├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature-request.md ├── chart-publish-config.yaml ├── dependabot.yml └── workflows │ ├── CI.yml │ ├── jslint.yml │ └── release.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yaml ├── .pre-commit-config.yaml ├── .swaggo ├── Dockerfile ├── LICENSE ├── Makefile ├── OWNERS ├── README.md ├── api └── types │ ├── auth.go │ ├── environment.go │ ├── image.go │ └── key.go ├── client ├── auth.go ├── client.go ├── const.go ├── environment_create.go ├── environment_get.go ├── environment_list.go ├── environment_remove.go ├── errors.go ├── image_get.go ├── image_list.go ├── key_create.go ├── options.go ├── request.go └── transport.go ├── cmd └── envd-server │ └── main.go ├── dashboard ├── .editorconfig ├── .env.development ├── .env.production ├── .eslintrc ├── .gitignore ├── .vscode │ └── extensions.json ├── README.md ├── cypress.config.ts ├── cypress │ ├── e2e │ │ └── basic.spec.ts │ └── tsconfig.json ├── embed.go ├── index.html ├── netlify.toml ├── package.json ├── pnpm-lock.yaml ├── postcss.config.cjs ├── public │ ├── _headers │ └── favicon.ico ├── src │ ├── App.vue │ ├── auto-imports.d.ts │ ├── components.d.ts │ ├── components │ │ ├── InfoModal.vue │ │ ├── LoginImg.vue │ │ ├── Navbar.vue │ │ ├── Sidebar.vue │ │ └── StatusTag.vue │ ├── composables │ │ ├── dark.ts │ │ ├── request.ts │ │ ├── types │ │ │ └── scheme.ts │ │ └── util.ts │ ├── layouts │ │ ├── README.md │ │ ├── dashboard.vue │ │ └── default.vue │ ├── main.ts │ ├── modules │ │ ├── README.md │ │ ├── dayjs.ts │ │ └── pinia.ts │ ├── pages │ │ ├── README.md │ │ ├── [...all].vue │ │ ├── about.vue │ │ ├── envs │ │ │ └── index.vue │ │ ├── images │ │ │ └── index.vue │ │ ├── index.vue │ │ ├── login │ │ │ └── index.vue │ │ └── signup │ │ │ └── index.vue │ ├── shims.d.ts │ ├── store │ │ ├── environment.ts │ │ ├── image.ts │ │ ├── nav.ts │ │ └── user.ts │ ├── styles │ │ └── tailwind.css │ └── types.ts ├── tailwind.config.cjs ├── test │ └── basic.test.ts ├── tsconfig.json └── vite.config.ts ├── errdefs ├── defs.go ├── doc.go ├── helpers.go ├── http_helpers.go └── is.go ├── go.mod ├── go.sum ├── manifests ├── .helmignore ├── Chart.yaml ├── secretkeys │ ├── backend_pod │ ├── backend_pod.pub │ ├── hostkey │ └── hostkey.pub ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── configmap.yaml │ ├── deployment.yaml │ ├── ingress.yaml │ ├── postgres.yaml │ ├── resourcequota.yaml │ ├── role.yaml │ ├── rolebinding.yaml │ ├── secret.yaml │ ├── service.yaml │ ├── serviceaccount.yaml │ └── tests │ │ └── test-connection.yaml └── values.yaml ├── pkg ├── app │ └── server.go ├── consts │ └── consts.go ├── docs │ └── docs.go ├── query │ ├── db.go │ ├── image.sql.go │ ├── key.sql.go │ ├── mock │ │ └── mock.go │ ├── models.go │ ├── querier.go │ └── user.sql.go ├── runtime │ ├── kubernetes │ │ ├── const.go │ │ ├── environment_create.go │ │ ├── environment_get.go │ │ ├── environment_list.go │ │ ├── environment_remove.go │ │ ├── kubernetes.go │ │ ├── label.go │ │ ├── label_test.go │ │ └── util.go │ └── provisioner.go ├── server │ ├── auth.go │ ├── auth_middleware.go │ ├── containerssh.go │ ├── environment_create.go │ ├── environment_get.go │ ├── environment_list.go │ ├── environment_remove.go │ ├── error.go │ ├── handler.go │ ├── image_get.go │ ├── image_list.go │ ├── key_create.go │ ├── ping.go │ ├── server.go │ └── types.go ├── service │ ├── image │ │ ├── image.go │ │ ├── metadata.go │ │ └── util.go │ └── user │ │ ├── error.go │ │ ├── jwt.go │ │ ├── salt.go │ │ ├── sshkey.go │ │ └── user.go ├── syncthing │ ├── config.go │ └── syncthing_test.go ├── version │ └── version.go └── web │ ├── static_serving.go │ └── static_serving_debug.go ├── sql ├── Dockerfile ├── README.md ├── atlas_schema.hcl ├── query │ ├── image.sql │ ├── key.sql │ └── user.sql └── schema │ ├── 20221206162738_create_user.sql │ ├── 20221206162846_create_image.sql │ ├── 20221207142402_add_users_name.sql │ ├── 20221221100643_rename_identity_token_and_add_packages.sql │ ├── 20221222034229_add_keys_table.sql │ ├── 20230119145105_alter_image_digest_index.sql │ └── atlas.sum ├── sqlc.yaml ├── sshname └── name.go └── test ├── environments ├── list_test.go └── suite_test.go ├── query ├── query_test.go └── suite_test.go └── util ├── environment.go └── server.go /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 4 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = false 12 | insert_final_newline = true 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: 'bug: ' 5 | labels: "type/bug \U0001F41B" 6 | 7 | --- 8 | 9 | ## Description 10 | 11 | ## Reproduction 12 | 13 | ## Additional Info 14 | 15 | <!-- It will be very helpful if you can provide the version info by running the command `envd version`. --> 16 | 17 | --- 18 | <!-- Issue Author: Don't delete this message to encourage other users to support your issue! --> 19 | **Message from the maintainers**: 20 | 21 | Impacted by this bug? Give it a 👍. We prioritise the issues with the most 👍. 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | 3 | contact_links: 4 | - name: Have you read the docs? 5 | url: https://envd.tensorchord.ai/docs/get-started/ 6 | about: Much help can be found in the docs 7 | - name: Ask a question 8 | url: https://github.com/tensorchord/envd/discussions/new 9 | about: Ask a question or start a discussion 10 | - name: Chat on Discord 11 | url: https://discord.gg/KqswhpVgdU 12 | about: Maybe chatting with the community can help 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: 'feat: <title>' 5 | labels: "type/feature \U0001F4A1" 6 | 7 | --- 8 | 9 | ## Description 10 | 11 | --- 12 | <!-- Issue Author: Don't delete this message to encourage other users to support your issue! --> 13 | **Message from the maintainers**: 14 | 15 | Love this enhancement proposal? Give it a 👍. We prioritise the proposals with the most 👍. 16 | -------------------------------------------------------------------------------- /.github/chart-publish-config.yaml: -------------------------------------------------------------------------------- 1 | owner: tensorchord 2 | git-repo: envd-server 3 | package-path: .deploy 4 | token: ENVIRONMENT_VARIABLE 5 | git-base-url: https://api.github.com/ 6 | git-upload-url: https://uploads.github.com/ 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | day: "monday" 8 | time: "10:00" 9 | timezone: "Asia/Shanghai" 10 | open-pull-requests-limit: 5 11 | 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | day: "monday" 17 | time: "10:00" 18 | timezone: "Asia/Shanghai" 19 | open-pull-requests-limit: 5 20 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - ".github/workflows/**" 9 | - "**.go" 10 | - "Makefile" 11 | - "go.**" 12 | pull_request: 13 | paths: 14 | - ".github/workflows/**" 15 | - "**.go" 16 | - "Makefile" 17 | - "go.**" 18 | 19 | concurrency: 20 | group: ${{ github.workflow }}-${{ github.ref }} 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | lint: 25 | name: lint 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/setup-go@v4 29 | with: 30 | go-version: 1.18 31 | - uses: actions/checkout@v3 32 | - name: Cache Go modules 33 | uses: actions/cache@preview 34 | with: 35 | path: ~/go/pkg/mod 36 | key: ${{ runner.os }}-build-${{ hashFiles('**/go.sum') }} 37 | restore-keys: | 38 | ${{ runner.OS }}-build-${{ env.cache-name }}- 39 | ${{ runner.OS }}-build- 40 | ${{ runner.OS }}- 41 | - run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.46.2 42 | - run: echo "${HOME}/.local/bin" >> $GITHUB_PATH 43 | - name: Add license 44 | run: | 45 | make addlicense && git add client api pkg cmd && 46 | git diff --cached --exit-code || (echo 'Please run "make addlicense" to verify license' && exit 1); 47 | - run: go mod tidy 48 | - name: Generate API documentation 49 | run: | 50 | make swag && git add pkg && 51 | git diff --cached --exit-code || (echo 'Please run "make swag" to verify api doc' && exit 1); 52 | - name: golangci-lint 53 | uses: golangci/golangci-lint-action@v3 54 | with: 55 | version: latest 56 | # Ref https://github.com/golangci/golangci-lint-action/issues/244 57 | skip-pkg-cache: true 58 | args: --timeout=3m 59 | build: 60 | name: build 61 | strategy: 62 | matrix: 63 | os: [ubuntu-latest, macos-latest] 64 | runs-on: ${{ matrix.os }} 65 | steps: 66 | - name: Check out code 67 | uses: actions/checkout@v3 68 | - name: Setup Go 69 | uses: actions/setup-go@v4 70 | with: 71 | go-version: 1.18 72 | - name: Cache Go modules 73 | uses: actions/cache@preview 74 | with: 75 | path: ~/go/pkg/mod 76 | key: ${{ runner.os }}-build-${{ hashFiles('**/go.sum') }} 77 | restore-keys: | 78 | ${{ runner.OS }}-build-${{ env.cache-name }}- 79 | ${{ runner.OS }}-build- 80 | ${{ runner.OS }}- 81 | - run: go mod tidy 82 | - name: Generate API documentation 83 | run: make swag 84 | - name: Build 85 | run: make 86 | test: 87 | name: test 88 | strategy: 89 | matrix: 90 | os: [ubuntu-latest, macos-latest] 91 | runs-on: ${{ matrix.os }} 92 | steps: 93 | - name: Check out code 94 | uses: actions/checkout@v3 95 | - name: Setup Go 96 | uses: actions/setup-go@v4 97 | with: 98 | go-version: 1.18 99 | - name: Cache Go modules 100 | uses: actions/cache@preview 101 | with: 102 | path: ~/go/pkg/mod 103 | key: ${{ runner.os }}-build-${{ hashFiles('**/go.sum') }} 104 | restore-keys: | 105 | ${{ runner.OS }}-build-${{ env.cache-name }}- 106 | ${{ runner.OS }}-build- 107 | ${{ runner.OS }}- 108 | - run: go mod tidy 109 | - name: Generate API documentation 110 | run: make swag 111 | - name: test 112 | run: make test 113 | -------------------------------------------------------------------------------- /.github/workflows/jslint.yml: -------------------------------------------------------------------------------- 1 | name: frontend lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - ".github/workflows/**" 9 | - "dashboard/**" 10 | pull_request: 11 | paths: 12 | - ".github/workflows/**" 13 | - "dashboard/**" 14 | 15 | concurrency: 16 | group: ${{ github.workflow }}-${{ github.ref }} 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | frontend-lint: 21 | name: frontend-lint 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Setup pnpm 26 | uses: pnpm/action-setup@v2.2.4 27 | with: 28 | version: 7.17.1 29 | run_install: | 30 | - cwd: "./dashboard" 31 | - name: Use Node.js 18 32 | uses: actions/setup-node@v3.7.0 33 | with: 34 | node-version: 18 35 | registry-url: https://registry.npmjs.org/ 36 | cache: pnpm 37 | cache-dependency-path: dashboard/pnpm-lock.yaml 38 | - name: ESLint 39 | run: npx eslint . 40 | working-directory: ./dashboard 41 | - name: build 42 | run: pnpm build 43 | working-directory: ./dashboard 44 | - name: check git status 45 | run: | 46 | if [[ $(git status --porcelain) ]]; then 47 | echo "git tree is not clean after build" 48 | exit 1 49 | else 50 | echo "git tree is clean after build" 51 | fi -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | *.report 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | vendor/ 20 | 21 | # Go workspace file 22 | go.work 23 | 24 | .vscode/* 25 | .idea 26 | 27 | # Local History for Visual Studio Code 28 | .history/ 29 | 30 | # Built Visual Studio Code Extensions 31 | *.vsix 32 | 33 | __debug_bin 34 | bin/ 35 | debug-bin/ 36 | /build.envd 37 | .ipynb_checkpoints/ 38 | cover.html 39 | 40 | cmd/test/ 41 | 42 | # Byte-compiled / optimized / DLL files 43 | __pycache__/ 44 | *.py[cod] 45 | *$py.class 46 | 47 | # Distribution / packaging 48 | .Python 49 | build/ 50 | develop-eggs/ 51 | dist/ 52 | downloads/ 53 | eggs/ 54 | .eggs/ 55 | lib/ 56 | lib64/ 57 | parts/ 58 | sdist/ 59 | var/ 60 | wheels/ 61 | wheelhouse/ 62 | pip-wheel-metadata/ 63 | share/python-wheels/ 64 | *.egg-info/ 65 | .installed.cfg 66 | *.egg 67 | MANIFEST 68 | .demo/ 69 | pkg/docs/swagger.* 70 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - gofmt 4 | - bodyclose 5 | - errcheck 6 | - goimports 7 | - errorlint 8 | - exportloopref 9 | - gosimple 10 | - govet 11 | - ineffassign 12 | - misspell 13 | - rowserrcheck 14 | - sqlclosecheck 15 | - staticcheck 16 | - typecheck 17 | - unparam 18 | - revive 19 | - stylecheck 20 | - unused 21 | - unconvert 22 | linters-settings: 23 | goimports: 24 | local-prefixes: github.com/tensorchord/envd-server/ 25 | run: 26 | timeout: "1m" 27 | issue-exit-code: 1 28 | tests: true 29 | skip-dirs-use-default: true 30 | allow-parallel-runners: false 31 | skip-dirs: 32 | - dashboard 33 | - client 34 | - errdefs 35 | - pkg/docs 36 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | before: 4 | hooks: 5 | - go mod tidy 6 | builds: 7 | - env: 8 | - CGO_ENABLED=0 9 | goos: 10 | - linux 11 | - darwin 12 | id: envd-server 13 | main: ./cmd/envd-server 14 | binary: envd-server 15 | ldflags: 16 | - -s -w 17 | - -X github.com/tensorchord/envd-server/pkg/version.version={{ .Version }} 18 | - -X github.com/tensorchord/envd-server/pkg/version.buildDate={{ .Date }} 19 | - -X github.com/tensorchord/envd-server/pkg/version.gitCommit={{ .Commit }} 20 | - -X github.com/tensorchord/envd-server/pkg/version.gitTreeState=clean 21 | - -X github.com/tensorchord/envd-server/pkg/version.gitTag={{ .Tag }} 22 | archives: 23 | - id: envd-server 24 | format: binary 25 | builds: 26 | - envd-server 27 | replacements: 28 | darwin: Darwin 29 | linux: Linux 30 | windows: Windows 31 | 386: i386 32 | amd64: x86_64 33 | checksum: 34 | name_template: 'checksums.txt' 35 | snapshot: 36 | name_template: "{{ incpatch .Version }}-next" 37 | changelog: 38 | sort: asc 39 | filters: 40 | exclude: 41 | - '^build:' 42 | - '^ci:' 43 | - '^docs:' 44 | - '^test:' 45 | - '^chore:' 46 | dockers: 47 | - image_templates: 48 | - "tensorchord/envd-server:{{ .Version }}-amd64" 49 | use: buildx 50 | dockerfile: Dockerfile 51 | ids: 52 | - envd-server 53 | build_flag_templates: 54 | - "--platform=linux/amd64" 55 | - image_templates: 56 | - "tensorchord/envd-server:{{ .Version }}-arm64v8" 57 | use: buildx 58 | goarch: arm64 59 | ids: 60 | - envd-server 61 | dockerfile: Dockerfile 62 | build_flag_templates: 63 | - "--platform=linux/arm64/v8" 64 | docker_manifests: 65 | - name_template: tensorchord/envd-server:{{ .Version }} 66 | image_templates: 67 | - tensorchord/envd-server:{{ .Version }}-amd64 68 | - tensorchord/envd-server:{{ .Version }}-arm64v8 69 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/dnephin/pre-commit-golang 3 | rev: v0.5.0 4 | hooks: 5 | - id: golangci-lint 6 | args: [--config=.golangci.yml, --timeout=3m] 7 | -------------------------------------------------------------------------------- /.swaggo: -------------------------------------------------------------------------------- 1 | replace go.containerssh.io/libcontainerssh/metadata.RemoteAddress string 2 | replace go.containerssh.io/libcontainerssh/config.SSHCipherList string 3 | replace go.containerssh.io/libcontainerssh/config.SSHKexList string 4 | replace go.containerssh.io/libcontainerssh/config.SSHMACList string 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 2 | RUN apt update; \ 3 | apt install -y --no-install-recommends \ 4 | ca-certificates \ 5 | curl; \ 6 | mkdir -p /usr/local/share/ca-certificates; \ 7 | rm -rf /var/lib/apt/lists/* 8 | COPY envd-server / 9 | -------------------------------------------------------------------------------- /OWNERS: -------------------------------------------------------------------------------- 1 | approvers: 2 | - kemingy 3 | - VoVAllen 4 | - gaocegege 5 | reviewers: 6 | - gaocegege 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # envd-server 2 | 3 | envd-server is the backend server for `envd`, which talks to Kubernetes and manages environments for users. 4 | 5 | ## Install 6 | 7 | ```bash 8 | helm install --debug envd-server ./manifests 9 | # skip 8080 if you're going to run the envd-server locally 10 | kubectl --namespace default port-forward service/envd-server 8080:8080 & 11 | kubectl --namespace default port-forward service/envd-server 2222:2222 & 12 | ``` 13 | 14 | To run the envd-server locally: 15 | 16 | ```bash 17 | make build-local 18 | ./bin/envd-server --kubeconfig $HOME/.kube/config --hostkey manifests/secretkeys/hostkey 19 | ``` 20 | 21 | ## Usage 22 | 23 | ```bash 24 | envd context create --name server --runner envd-server --runner-address http://localhost:8080 --use 25 | envd login 26 | envd create --image gaocegege/test-envd 27 | ``` 28 | 29 | # Development Guide of Dashboard 30 | 31 | ## Build 32 | 33 | Enter into [`./dashboard`](./dashboard) directory to develop just like normal vue application. 34 | 35 | If you want to build envd-server with dashboard 36 | 37 | ```bash 38 | pushd dashboard 39 | npm install 40 | npm run build 41 | popd 42 | DASHBOARD_BUILD=release make build-local 43 | ``` 44 | 45 | When envd-server is running, you can visit [http://localhost:8080/dashboard](http://localhost:8080/dashboard) to see it. 46 | 47 | ## Develop locally 48 | 49 | - Port forward the postgresql service to local 50 | 51 | ```bash 52 | kubectl port-forward svc/postgres-service 5432:5432 53 | ``` 54 | - Setup environment variable 55 | ```bash 56 | export KUBECONFIG=~/.kube/config 57 | export ENVD_DB_URL=postgres://postgresadmin:admin12345@localhost:5432/postgresdb?sslmode=disable 58 | ``` 59 | - Run envd-server locally 60 | ```bash 61 | ./bin/envd-server --debug --no-auth # Remove no-auth if auth is needed 62 | ``` 63 | -------------------------------------------------------------------------------- /api/types/auth.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package types 6 | 7 | // AuthNRequest contains authorization information for connecting to a envd server. 8 | type AuthNRequest struct { 9 | // LoginName is used to authenticate the user and get 10 | // an access token for the registry. 11 | LoginName string `json:"login_name,omitempty" example:"alice"` 12 | 13 | // Password stores the hashed password. 14 | Password string `json:"password,omitempty"` 15 | } 16 | 17 | type AuthStatus string 18 | 19 | const ( 20 | AuthSuccess AuthStatus = "Login succeeded" 21 | AuthFail AuthStatus = "Login Fail" 22 | Error AuthStatus = "Internal_Error" 23 | ) 24 | 25 | type AuthNResponse struct { 26 | // LoginName is used to authenticate the user and get 27 | // an access token for the registry. 28 | LoginName string `json:"login_name,omitempty" example:"alice"` 29 | // An opaque token used to authenticate a user after a successful login 30 | // Required: true 31 | IdentityToken string `json:"identity_token" example:"a332139d39b89a241400013700e665a3"` 32 | // The status of the authentication 33 | // Required: true 34 | Status AuthStatus `json:"status" example:"Login successfully"` 35 | } 36 | -------------------------------------------------------------------------------- /api/types/environment.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package types 6 | 7 | type Environment struct { 8 | ObjectMeta `json:",inline"` 9 | Spec EnvironmentSpec `json:"spec,omitempty"` 10 | Status EnvironmentStatus `json:"status,omitempty"` 11 | Resources ResourceSpec `json:"resource,omitempty"` 12 | CreatedAt int64 `json:"created_at,omitempty"` 13 | } 14 | 15 | type ResourceSpec struct { 16 | CPU string `json:"cpu,omitempty"` 17 | Memory string `json:"memory,omitempty"` 18 | GPU string `json:"gpu,omitempty"` 19 | Shm string `json:"shm,omitempty"` 20 | } 21 | 22 | type ObjectMeta struct { 23 | Name string `json:"name,omitempty"` 24 | 25 | Labels map[string]string `json:"labels,omitempty"` 26 | Annotations map[string]string `json:"annotations,omitempty"` 27 | } 28 | 29 | type EnvironmentSpec struct { 30 | Owner string `json:"owner,omitempty"` 31 | Image string `json:"image,omitempty"` 32 | Env []EnvVar `json:"env,omitempty"` 33 | Cmd []string `json:"cmd,omitempty"` 34 | Ports []EnvironmentPort `json:"ports,omitempty"` 35 | APTPackages []string `json:"apt_packages,omitempty"` 36 | PythonCommands []string `json:"pypi_commands,omitempty"` 37 | Sync bool `json:"sync,omitempty"` 38 | // TODO(gaocegege): Add volume specific spec. 39 | } 40 | 41 | type EnvVar struct { 42 | // Name of the environment variable. Must be a C_IDENTIFIER. 43 | Name string `json:"name"` 44 | 45 | // Optional: no more than one of the following may be specified. 46 | 47 | // Variable references $(VAR_NAME) are expanded 48 | // using the previously defined environment variables in the container and 49 | // any service environment variables. If a variable cannot be resolved, 50 | // the reference in the input string will be unchanged. Double $$ are reduced 51 | // to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. 52 | // "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". 53 | // Escaped references will never be expanded, regardless of whether the variable 54 | // exists or not. 55 | // Defaults to "". 56 | // +optional 57 | Value string `json:"value,omitempty"` 58 | } 59 | 60 | type EnvironmentStatus struct { 61 | Phase string `json:"phase,omitempty"` 62 | JupyterAddr *string `json:"jupyter_addr,omitempty"` 63 | RStudioServerAddr *string `json:"rstudio_server_addr,omitempty"` 64 | } 65 | 66 | type EnvironmentPort struct { 67 | Name string `json:"name,omitempty"` 68 | Port int32 `json:"port,omitempty"` 69 | } 70 | 71 | type EnvironmentRepoInfo struct { 72 | URL string `json:"url,omitempty"` 73 | Description string `json:"description,omitempty"` 74 | } 75 | 76 | type EnvironmentCreateRequest struct { 77 | Environment `json:",inline,omitempty"` 78 | } 79 | 80 | type EnvironmentCreateResponse struct { 81 | Created Environment `json:"environment,omitempty"` 82 | 83 | // Warnings encountered when creating the pod 84 | // Required: true 85 | Warnings []string `json:"warnings,omitempty"` 86 | } 87 | 88 | type EnvironmentListRequest struct { 89 | } 90 | 91 | type EnvironmentListResponse struct { 92 | Items []Environment `json:"items,omitempty"` 93 | } 94 | 95 | type EnvironmentRemoveRequest struct { 96 | Name string `uri:"name" example:"pytorch-example"` 97 | } 98 | 99 | type EnvironmentRemoveResponse struct { 100 | } 101 | 102 | type EnvironmentGetRequest struct { 103 | Name string `uri:"name" example:"pytorch-example"` 104 | } 105 | 106 | type EnvironmentGetResponse struct { 107 | Environment `json:",inline"` 108 | } 109 | -------------------------------------------------------------------------------- /api/types/image.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package types 6 | 7 | type ImageInfo struct { 8 | OwnerToken string 9 | ImageMeta 10 | } 11 | 12 | type ImageMeta struct { 13 | Name string `json:"name,omitempty" example:"pytorch-cuda:dev"` 14 | Digest string `json:"digest,omitempty"` 15 | Created int64 `json:"created,omitempty"` 16 | Size int64 `json:"size,omitempty"` 17 | Labels map[string]string `json:"labels,omitempty"` 18 | APTPackages []string `json:"apt_packages,omitempty"` 19 | PythonCommands []string `json:"python_commands,omitempty"` 20 | Ports []EnvironmentPort `json:"ports,omitempty"` 21 | } 22 | 23 | type ImageGetRequest struct { 24 | Name string `uri:"name" example:"pytorch-example"` 25 | } 26 | 27 | type ImageGetResponse struct { 28 | ImageMeta `json:",inline"` 29 | } 30 | 31 | type ImageListRequest struct { 32 | } 33 | 34 | type ImageListResponse struct { 35 | Items []ImageMeta `json:"items,omitempty"` 36 | } 37 | -------------------------------------------------------------------------------- /api/types/key.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package types 6 | 7 | type KeyCreateRequest struct { 8 | // Name is the key name. 9 | Name string `json:"name,omitempty" example:"mykey"` 10 | // PublicKey is the ssh public key 11 | PublicKey string `json:"public_key,omitempty"` 12 | } 13 | 14 | type KeyCreateResponse struct { 15 | // Name is the key name. 16 | Name string `json:"name,omitempty" example:"mykey"` 17 | // PublicKey is the ssh public key 18 | PublicKey string `json:"public_key,omitempty"` 19 | // LoginName is used to authenticate the user and get 20 | // an access token for the registry. 21 | LoginName string `json:"login_name,omitempty" example:"alice"` 22 | } 23 | -------------------------------------------------------------------------------- /client/auth.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package client 6 | 7 | import ( 8 | "context" 9 | "encoding/json" 10 | "net/url" 11 | 12 | "github.com/cockroachdb/errors" 13 | 14 | "github.com/tensorchord/envd-server/api/types" 15 | ) 16 | 17 | var ( 18 | ErrNoUser = errors.New("no user provided") 19 | ) 20 | 21 | // Register authenticates the envd server. 22 | // It returns unauthorizedError when the authentication fails. 23 | func (cli *Client) Register(ctx context.Context, auth types.AuthNRequest) (types.AuthNResponse, error) { 24 | resp, err := cli.post(ctx, "/register", url.Values{}, auth, nil) 25 | defer ensureReaderClosed(resp) 26 | 27 | if err != nil { 28 | return types.AuthNResponse{}, err 29 | } 30 | 31 | var response types.AuthNResponse 32 | err = json.NewDecoder(resp.body).Decode(&response) 33 | return response, err 34 | } 35 | 36 | // Login logins the envd server. 37 | // It returns unauthorizedError when the authentication fails. 38 | func (cli *Client) Login(ctx context.Context, auth types.AuthNRequest) (types.AuthNResponse, error) { 39 | resp, err := cli.post(ctx, "/login", url.Values{}, auth, nil) 40 | defer ensureReaderClosed(resp) 41 | 42 | if err != nil { 43 | return types.AuthNResponse{}, err 44 | } 45 | 46 | var response types.AuthNResponse 47 | err = json.NewDecoder(resp.body).Decode(&response) 48 | return response, err 49 | } 50 | 51 | func (cli Client) getUserAndHeaders() (string, map[string][]string, error) { 52 | if cli.user == "" { 53 | return "", nil, ErrNoUser 54 | } 55 | return cli.user, map[string][]string{ 56 | "Authorization": {cli.jwtToken}, 57 | }, nil 58 | } 59 | -------------------------------------------------------------------------------- /client/const.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package client 6 | 7 | const DefaultEnvdServerHost = "http://0.0.0.0:8080" 8 | 9 | const defaultProto = "http" 10 | const defaultAddr = "0.0.0.0:8080" 11 | 12 | // Base path for api, distinguish from frontend pages 13 | const apiBasePath = "/api" 14 | 15 | const ( 16 | // EnvOverrideHost is the name of the environment variable that can be used 17 | // to override the default host to connect to (DefaultEnvdServerHost). 18 | // 19 | // This env-var is read by FromEnv and WithHostFromEnv and when set to a 20 | // non-empty value, takes precedence over the default host (which is platform 21 | // specific), or any host already set. 22 | EnvOverrideHost = "ENVD_SERVER_HOST" 23 | 24 | // EnvOverrideCertPath is the name of the environment variable that can be 25 | // used to specify the directory from which to load the TLS certificates 26 | // (ca.pem, cert.pem, key.pem) from. These certificates are used to configure 27 | // the Client for a TCP connection protected by TLS client authentication. 28 | // 29 | // TLS certificate verification is enabled by default if the Client is configured 30 | // to use a TLS connection. Refer to EnvTLSVerify below to learn how to 31 | // disable verification for testing purposes. 32 | // 33 | // 34 | // For local access to the API, it is recommended to connect with the daemon 35 | // using the default local socket connection (on Linux), or the named pipe 36 | // (on Windows). 37 | // 38 | // If you need to access the API of a remote daemon, consider using an SSH 39 | // (ssh://) connection, which is easier to set up, and requires no additional 40 | // configuration if the host is accessible using ssh. 41 | EnvOverrideCertPath = "ENVD_SERVER_CERT_PATH" 42 | 43 | // EnvTLSVerify is the name of the environment variable that can be used to 44 | // enable or disable TLS certificate verification. When set to a non-empty 45 | // value, TLS certificate verification is enabled, and the client is configured 46 | // to use a TLS connection, using certificates from the default directories 47 | // (within `~/.envd`); refer to EnvOverrideCertPath above for additional 48 | // details. 49 | // 50 | // 51 | // Before setting up your client and daemon to use a TCP connection with TLS 52 | // client authentication, consider using one of the alternatives mentioned 53 | // in EnvOverrideCertPath above. 54 | // 55 | // Disabling TLS certificate verification (for testing purposes) 56 | // 57 | // TLS certificate verification is enabled by default if the Client is configured 58 | // to use a TLS connection, and it is highly recommended to keep verification 59 | // enabled to prevent machine-in-the-middle attacks. 60 | // 61 | // Set the "ENVD_SERVER_TLS_VERIFY" environment to an empty string ("") to 62 | // disable TLS certificate verification. Disabling verification is insecure, 63 | // so should only be done for testing purposes. From the Go documentation 64 | // (https://pkg.go.dev/crypto/tls#Config): 65 | // 66 | // InsecureSkipVerify controls whether a client verifies the server's 67 | // certificate chain and host name. If InsecureSkipVerify is true, crypto/tls 68 | // accepts any certificate presented by the server and any host name in that 69 | // certificate. In this mode, TLS is susceptible to machine-in-the-middle 70 | // attacks unless custom verification is used. This should be used only for 71 | // testing or in combination with VerifyConnection or VerifyPeerCertificate. 72 | EnvTLSVerify = "ENVD_SERVER_TLS_VERIFY" 73 | ) 74 | -------------------------------------------------------------------------------- /client/environment_create.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package client 6 | 7 | import ( 8 | "context" 9 | "encoding/json" 10 | "fmt" 11 | "net/url" 12 | 13 | "github.com/cockroachdb/errors" 14 | 15 | "github.com/tensorchord/envd-server/api/types" 16 | ) 17 | 18 | // EnvironmentCreate creates the environment. 19 | func (cli *Client) EnvironmentCreate(ctx context.Context, 20 | req types.EnvironmentCreateRequest) (types.EnvironmentCreateResponse, error) { 21 | 22 | username, headers, err := cli.getUserAndHeaders() 23 | if err != nil { 24 | return types.EnvironmentCreateResponse{}, 25 | errors.Wrap(err, "failed to get user and headers") 26 | } 27 | 28 | urlString := fmt.Sprintf("/users/%s/environments", username) 29 | resp, err := cli.post(ctx, urlString, url.Values{}, req, headers) 30 | defer ensureReaderClosed(resp) 31 | 32 | if err != nil { 33 | return types.EnvironmentCreateResponse{}, err 34 | } 35 | 36 | var response types.EnvironmentCreateResponse 37 | err = json.NewDecoder(resp.body).Decode(&response) 38 | return response, err 39 | } 40 | -------------------------------------------------------------------------------- /client/environment_get.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package client 6 | 7 | import ( 8 | "context" 9 | "encoding/json" 10 | "fmt" 11 | 12 | "github.com/cockroachdb/errors" 13 | 14 | "github.com/tensorchord/envd-server/api/types" 15 | ) 16 | 17 | // EnvironmentGet gets the environment. 18 | func (cli *Client) EnvironmentGet(ctx context.Context, name string) (types.EnvironmentGetResponse, error) { 19 | username, headers, err := cli.getUserAndHeaders() 20 | if err != nil { 21 | return types.EnvironmentGetResponse{}, 22 | errors.Wrap(err, "failed to get user and headers") 23 | } 24 | 25 | url := fmt.Sprintf("/users/%s/environments/%s", username, name) 26 | resp, err := cli.get(ctx, url, nil, headers) 27 | defer ensureReaderClosed(resp) 28 | 29 | if err != nil { 30 | return types.EnvironmentGetResponse{}, 31 | wrapResponseError(err, resp, "user", username) 32 | } 33 | 34 | var response types.EnvironmentGetResponse 35 | err = json.NewDecoder(resp.body).Decode(&response) 36 | return response, err 37 | } 38 | -------------------------------------------------------------------------------- /client/environment_list.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package client 6 | 7 | import ( 8 | "context" 9 | "encoding/json" 10 | "fmt" 11 | 12 | "github.com/cockroachdb/errors" 13 | 14 | "github.com/tensorchord/envd-server/api/types" 15 | ) 16 | 17 | // EnvironmentList lists the environment. 18 | func (cli *Client) EnvironmentList(ctx context.Context) (types.EnvironmentListResponse, error) { 19 | username, headers, err := cli.getUserAndHeaders() 20 | if err != nil { 21 | return types.EnvironmentListResponse{}, 22 | errors.Wrap(err, "failed to get user and headers") 23 | } 24 | 25 | url := fmt.Sprintf("/users/%s/environments", username) 26 | resp, err := cli.get(ctx, url, nil, headers) 27 | defer ensureReaderClosed(resp) 28 | 29 | if err != nil { 30 | return types.EnvironmentListResponse{}, 31 | wrapResponseError(err, resp, "username", username) 32 | } 33 | 34 | var response types.EnvironmentListResponse 35 | err = json.NewDecoder(resp.body).Decode(&response) 36 | return response, err 37 | } 38 | -------------------------------------------------------------------------------- /client/environment_remove.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package client 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | 11 | "github.com/cockroachdb/errors" 12 | ) 13 | 14 | // EnvironmentRemove the environment. 15 | func (cli *Client) EnvironmentRemove(ctx context.Context, 16 | name string) error { 17 | username, headers, err := cli.getUserAndHeaders() 18 | if err != nil { 19 | return errors.Wrap(err, "failed to get user and headers") 20 | } 21 | 22 | url := fmt.Sprintf("/users/%s/environments/%s", username, name) 23 | resp, err := cli.delete(ctx, url, nil, headers) 24 | defer ensureReaderClosed(resp) 25 | return wrapResponseError(err, resp, "environment", name) 26 | } 27 | -------------------------------------------------------------------------------- /client/errors.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package client // import "github.com/docker/docker/client" 6 | 7 | import ( 8 | "fmt" 9 | "net/http" 10 | 11 | "github.com/cockroachdb/errors" 12 | 13 | "github.com/tensorchord/envd-server/errdefs" 14 | ) 15 | 16 | // errConnectionFailed implements an error returned when connection failed. 17 | type errConnectionFailed struct { 18 | host string 19 | } 20 | 21 | // Error returns a string representation of an errConnectionFailed 22 | func (err errConnectionFailed) Error() string { 23 | if err.host == "" { 24 | return "Cannot connect to the envd server. Is the envd server running on this host?" 25 | } 26 | return fmt.Sprintf("Cannot connect to the envd server at %s. Is the envd server running?", err.host) 27 | } 28 | 29 | // IsErrConnectionFailed returns true if the error is caused by connection failed. 30 | func IsErrConnectionFailed(err error) bool { 31 | return errors.As(err, &errConnectionFailed{}) 32 | } 33 | 34 | // ErrorConnectionFailed returns an error with host in the error message when connection to docker daemon failed. 35 | func ErrorConnectionFailed(host string) error { 36 | return errConnectionFailed{host: host} 37 | } 38 | 39 | // Deprecated: use the errdefs.NotFound() interface instead. Kept for backward compatibility 40 | type notFound interface { 41 | error 42 | NotFound() bool 43 | } 44 | 45 | // IsErrNotFound returns true if the error is a NotFound error, which is returned 46 | // by the API when some object is not found. 47 | func IsErrNotFound(err error) bool { 48 | if errdefs.IsNotFound(err) { 49 | return true 50 | } 51 | var e notFound 52 | return errors.As(err, &e) 53 | } 54 | 55 | type objectNotFoundError struct { 56 | object string 57 | id string 58 | } 59 | 60 | func (e objectNotFoundError) NotFound() {} 61 | 62 | func (e objectNotFoundError) Error() string { 63 | return fmt.Sprintf("Error: No such %s: %s", e.object, e.id) 64 | } 65 | 66 | // IsErrUnauthorized returns true if the error is caused 67 | // when a remote registry authentication fails 68 | // 69 | // Deprecated: use errdefs.IsUnauthorized 70 | func IsErrUnauthorized(err error) bool { 71 | return errdefs.IsUnauthorized(err) 72 | } 73 | 74 | type pluginPermissionDenied struct { 75 | name string 76 | } 77 | 78 | func (e pluginPermissionDenied) Error() string { 79 | return "Permission denied while installing plugin " + e.name 80 | } 81 | 82 | // IsErrNotImplemented returns true if the error is a NotImplemented error. 83 | // This is returned by the API when a requested feature has not been 84 | // implemented. 85 | // 86 | // Deprecated: use errdefs.IsNotImplemented 87 | func IsErrNotImplemented(err error) bool { 88 | return errdefs.IsNotImplemented(err) 89 | } 90 | 91 | func wrapResponseError(err error, resp serverResponse, object, id string) error { 92 | switch { 93 | case err == nil: 94 | return nil 95 | case resp.statusCode == http.StatusNotFound: 96 | return objectNotFoundError{object: object, id: id} 97 | case resp.statusCode == http.StatusNotImplemented: 98 | return errdefs.NotImplemented(err) 99 | default: 100 | return err 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /client/image_get.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package client 6 | 7 | import ( 8 | "context" 9 | "encoding/json" 10 | "fmt" 11 | "net/url" 12 | 13 | "github.com/cockroachdb/errors" 14 | "github.com/sirupsen/logrus" 15 | 16 | "github.com/tensorchord/envd-server/api/types" 17 | ) 18 | 19 | // ImageGetByName gets the image info. 20 | func (cli *Client) ImageGetByName( 21 | ctx context.Context, name string) (types.ImageGetResponse, error) { 22 | username, headers, err := cli.getUserAndHeaders() 23 | if err != nil { 24 | return types.ImageGetResponse{}, 25 | errors.Wrap(err, "failed to get user and headers") 26 | } 27 | 28 | url := fmt.Sprintf("/users/%s/images/%s", username, url.PathEscape(name)) 29 | logrus.WithField("url", url).Debug("build image get url") 30 | resp, err := cli.get(ctx, url, nil, headers) 31 | defer ensureReaderClosed(resp) 32 | 33 | if err != nil { 34 | return types.ImageGetResponse{}, 35 | wrapResponseError(err, resp, "username", username) 36 | } 37 | 38 | var response types.ImageGetResponse 39 | err = json.NewDecoder(resp.body).Decode(&response) 40 | return response, err 41 | } 42 | -------------------------------------------------------------------------------- /client/image_list.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package client 6 | 7 | import ( 8 | "context" 9 | "encoding/json" 10 | "fmt" 11 | 12 | "github.com/cockroachdb/errors" 13 | 14 | "github.com/tensorchord/envd-server/api/types" 15 | ) 16 | 17 | // ImageList lists the images. 18 | func (cli *Client) ImageList(ctx context.Context) (types.ImageListResponse, error) { 19 | username, headers, err := cli.getUserAndHeaders() 20 | if err != nil { 21 | return types.ImageListResponse{}, 22 | errors.Wrap(err, "failed to get user and headers") 23 | } 24 | 25 | url := fmt.Sprintf("/users/%s/images", username) 26 | resp, err := cli.get(ctx, url, nil, headers) 27 | defer ensureReaderClosed(resp) 28 | 29 | if err != nil { 30 | return types.ImageListResponse{}, wrapResponseError(err, resp, "username", username) 31 | } 32 | 33 | var response types.ImageListResponse 34 | err = json.NewDecoder(resp.body).Decode(&response) 35 | return response, err 36 | } 37 | -------------------------------------------------------------------------------- /client/key_create.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package client 6 | 7 | import ( 8 | "context" 9 | "encoding/json" 10 | "fmt" 11 | "net/url" 12 | 13 | "github.com/cockroachdb/errors" 14 | 15 | "github.com/tensorchord/envd-server/api/types" 16 | ) 17 | 18 | // KeyCreate creates the ssh public key. 19 | func (cli *Client) KeyCreate(ctx context.Context, 20 | req types.KeyCreateRequest) (types.KeyCreateResponse, error) { 21 | 22 | username, headers, err := cli.getUserAndHeaders() 23 | if err != nil { 24 | return types.KeyCreateResponse{}, 25 | errors.Wrap(err, "failed to get user and headers") 26 | } 27 | 28 | urlString := fmt.Sprintf("/users/%s/keys", username) 29 | resp, err := cli.post(ctx, urlString, url.Values{}, req, headers) 30 | defer ensureReaderClosed(resp) 31 | 32 | if err != nil { 33 | return types.KeyCreateResponse{}, err 34 | } 35 | 36 | var response types.KeyCreateResponse 37 | err = json.NewDecoder(resp.body).Decode(&response) 38 | return response, err 39 | } 40 | -------------------------------------------------------------------------------- /client/transport.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package client // import "github.com/docker/docker/client" 6 | 7 | import ( 8 | "crypto/tls" 9 | "net/http" 10 | ) 11 | 12 | // resolveTLSConfig attempts to resolve the TLS configuration from the 13 | // RoundTripper. 14 | func resolveTLSConfig(transport http.RoundTripper) *tls.Config { 15 | switch tr := transport.(type) { 16 | case *http.Transport: 17 | return tr.TLSClientConfig 18 | default: 19 | return nil 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /cmd/envd-server/main.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | 11 | "github.com/sirupsen/logrus" 12 | "github.com/urfave/cli/v2" 13 | 14 | "github.com/tensorchord/envd-server/pkg/app" 15 | "github.com/tensorchord/envd-server/pkg/version" 16 | ) 17 | 18 | func run(args []string) error { 19 | cli.VersionPrinter = func(c *cli.Context) { 20 | fmt.Println(c.App.Name, version.Package, c.App.Version, version.Revision) 21 | } 22 | 23 | app := app.New() 24 | return app.Run(args) 25 | } 26 | 27 | func handleErr(err error) { 28 | if err == nil { 29 | return 30 | } 31 | 32 | logrus.Error(err) 33 | } 34 | 35 | // @title envd server API 36 | // @version v0.0.12 37 | // @description envd backend server 38 | 39 | // @contact.name envd maintainers 40 | // @contact.url https://github.com/tensorchord/envd 41 | // @contact.email envd-maintainers@tensorchord.ai 42 | 43 | // @license.name MPL 2.0 44 | // @license.url https://mozilla.org/MPL/2.0/ 45 | 46 | // @securitydefinitions.apikey Authentication 47 | // @in header 48 | // @name JWT 49 | 50 | // @host localhost:8080 51 | // @BasePath /api/v1 52 | // @in header 53 | // @name Authorization 54 | // @schemes http 55 | func main() { 56 | err := run(os.Args) 57 | handleErr(err) 58 | } 59 | -------------------------------------------------------------------------------- /dashboard/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /dashboard/.env.development: -------------------------------------------------------------------------------- 1 | VITE_BASE_HOST="http://localhost:8080" -------------------------------------------------------------------------------- /dashboard/.env.production: -------------------------------------------------------------------------------- 1 | VITE_BASE_HOST="" -------------------------------------------------------------------------------- /dashboard/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@antfu", 3 | "overrides": [ 4 | { 5 | "files": [ 6 | "src/composables/types/scheme.ts", 7 | "src/composables/types/client.ts", 8 | "*.spec.js" 9 | ], 10 | "rules": { 11 | "eslint-comments/no-unlimited-disable": "off" 12 | } 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /dashboard/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | -------------------------------------------------------------------------------- /dashboard/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "antfu.iconify", 4 | "antfu.vite", 5 | "antfu.goto-alias", 6 | "csstools.postcss", 7 | "dbaeumer.vscode-eslint", 8 | "vue.volar", 9 | "lokalise.i18n-ally", 10 | "streetsidesoftware.code-spell-checker", 11 | "bradlc.vscode-tailwindcss", 12 | "ellreka.tailwindcss-highlight" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /dashboard/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress' 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | baseUrl: 'http://localhost:3333', 6 | chromeWebSecurity: false, 7 | specPattern: 'cypress/e2e/**/*.spec.*', 8 | supportFile: false, 9 | }, 10 | }) 11 | -------------------------------------------------------------------------------- /dashboard/cypress/e2e/basic.spec.ts: -------------------------------------------------------------------------------- 1 | context('Basic', () => { 2 | beforeEach(() => { 3 | cy.visit('/') 4 | }) 5 | 6 | it('basic nav', () => { 7 | cy.url() 8 | .should('eq', 'http://localhost:3333/') 9 | 10 | cy.contains('[Home Layout]') 11 | .should('exist') 12 | 13 | cy.get('#input').should('be.visible').click() 14 | .type('ViteTail') 15 | 16 | cy.get('#go').click() 17 | .url().should('eq', 'http://localhost:3333/hi/ViteTail') 18 | 19 | cy.contains('[Default Layout]') 20 | .should('exist') 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /dashboard/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "types": [ 5 | "cypress" 6 | ] 7 | }, 8 | "exclude": [], 9 | "include": [ 10 | "**/*.ts" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /dashboard/embed.go: -------------------------------------------------------------------------------- 1 | //go:build !debug 2 | 3 | /* 4 | Copyright The TensorChord Inc. 5 | Copyright The BuildKit Authors. 6 | Copyright The containerd Authors. 7 | 8 | Licensed under the Apache License, Version 2.0 (the "License"); 9 | you may not use this file except in compliance with the License. 10 | You may obtain a copy of the License at 11 | 12 | http://www.apache.org/licenses/LICENSE-2.0 13 | 14 | Unless required by applicable law or agreed to in writing, software 15 | distributed under the License is distributed on an "AS IS" BASIS, 16 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | See the License for the specific language governing permissions and 18 | limitations under the License. 19 | */ 20 | 21 | package dashboard 22 | 23 | import "embed" 24 | 25 | //go:embed all:dist 26 | var DistFS embed.FS 27 | -------------------------------------------------------------------------------- /dashboard/index.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="UTF-8"> 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 | </head> 7 | <body class="scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-inherit scroll-smooth"> 8 | <div id="app"></div> 9 | <script type="module" src="/src/main.ts"></script> 10 | </body> 11 | </html> 12 | -------------------------------------------------------------------------------- /dashboard/netlify.toml: -------------------------------------------------------------------------------- 1 | [build.environment] 2 | NODE_VERSION = "18.8.0" 3 | 4 | [build] 5 | publish = "dist" 6 | command = "pnpm run build" 7 | 8 | [[redirects]] 9 | from = "/*" 10 | to = "/index.html" 11 | status = 200 12 | 13 | [[headers]] 14 | for = "/manifest.webmanifest" 15 | [headers.values] 16 | Content-Type = "application/manifest+json" 17 | -------------------------------------------------------------------------------- /dashboard/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "envd-dashboard", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "private": true, 6 | "packageManager": "pnpm@7.13.6", 7 | "description": "Dashboard for envd-server", 8 | "author": "envd-maintainer", 9 | "license": "Apache 2.0", 10 | "scripts": { 11 | "ssg-build": "vite-ssg build", 12 | "build": "vite build", 13 | "tbuild": "pnpm lint && pnpm typecheck && vite-ssg build", 14 | "dev": "vite --port 3333", 15 | "open": "vite --port 3333 --open", 16 | "lint": "eslint .", 17 | "preview": "vite preview", 18 | "preview-https": "serve dist", 19 | "test": "vitest", 20 | "test:e2e": "cypress open", 21 | "test:unit": "vitest", 22 | "typecheck": "vue-tsc --noEmit", 23 | "dep": "taze major -IR", 24 | "tsschema": "npx swagger-typescript-api -p ../pkg/docs/swagger.yaml -o src/composables/types/ -n scheme.ts --no-client" 25 | }, 26 | "dependencies": { 27 | "@headlessui/vue": "^1.7.7", 28 | "@tailwindcss/aspect-ratio": "^0.4.2", 29 | "@tailwindcss/typography": "^0.5.8", 30 | "@tanstack/vue-table": "^8.7.4", 31 | "@types/node": "18.11.9", 32 | "@vue/runtime-core": "^3.2.45", 33 | "@vueuse/core": "^9.6.0", 34 | "@vueuse/head": "^1.0.18", 35 | "critters": "^0.0.16", 36 | "daisyui": "^2.42.1", 37 | "dayjs": "^1.11.7", 38 | "pinia": "^2.0.27", 39 | "relativeTime": "link:dayjs/plugin/relativeTime", 40 | "tailwind-scrollbar": "2.1.0-preview.0", 41 | "theme-change": "^2.2.0", 42 | "unplugin-icons": "^0.14.14", 43 | "vue": "^3.2.45", 44 | "vue-demi": "^0.13.11", 45 | "vue-router": "^4.1.6" 46 | }, 47 | "devDependencies": { 48 | "@antfu/eslint-config": "^0.33.0", 49 | "@iconify/json": "^2.1.145", 50 | "@vitejs/plugin-vue": "^3.2.0", 51 | "@vitejs/plugin-vue-jsx": "^3.0.0", 52 | "@vue/runtime-dom": "^3.2.45", 53 | "@vue/test-utils": "^2.2.4", 54 | "autoprefixer": "^10.4.13", 55 | "cross-env": "^7.0.3", 56 | "cypress": "^11.2.0", 57 | "eslint": "^8.28.0", 58 | "eslint-plugin-cypress": "^2.12.1", 59 | "https-localhost": "^4.7.1", 60 | "pnpm": "^7.17.1", 61 | "postcss": "^8.4.19", 62 | "swagger-typescript-api": "^12.0.2", 63 | "tailwindcss": "3.2.4", 64 | "taze": "^0.8.4", 65 | "typescript": "^4.9.3", 66 | "unplugin-auto-import": "^0.12.0", 67 | "unplugin-vue-components": "^0.22.11", 68 | "vite": "3.2.4", 69 | "vite-plugin-inspect": "^0.7.9", 70 | "vite-plugin-pages": "^0.27.1", 71 | "vite-plugin-vue-component-preview": "^0.3.3", 72 | "vite-plugin-vue-layouts": "^0.7.0", 73 | "vite-ssg": "^0.22.0", 74 | "vite-ssg-sitemap": "^0.4.3", 75 | "vitest": "^0.25.3", 76 | "vue-tsc": "^1.0.10" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /dashboard/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /dashboard/public/_headers: -------------------------------------------------------------------------------- 1 | /assets/* 2 | cache-control: max-age=31536000 3 | cache-control: immutable 4 | -------------------------------------------------------------------------------- /dashboard/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tensorchord/envd-server/dd771e019f10028bfe76c9d577892cd4fbae5a68/dashboard/public/favicon.ico -------------------------------------------------------------------------------- /dashboard/src/App.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | // https://github.com/vueuse/head 3 | // you can use this to manipulate the document head in any components, 4 | // they will be rendered correctly in the html results with vite-ssg 5 | useHead({ 6 | title: 'envd dashboard', 7 | meta: [ 8 | { 9 | name: 'description', 10 | content: 'TensorChord envd', 11 | }, 12 | { 13 | name: 'theme-color', 14 | content: '#141824', 15 | }, 16 | ], 17 | link: [ 18 | { 19 | rel: 'icon', 20 | type: 'image/svg+xml', 21 | href: '/favicon.ico', 22 | }, 23 | ], 24 | }) 25 | </script> 26 | 27 | <template> 28 | <RouterView /> 29 | </template> 30 | -------------------------------------------------------------------------------- /dashboard/src/components.d.ts: -------------------------------------------------------------------------------- 1 | // generated by unplugin-vue-components 2 | // We suggest you to commit this file into source control 3 | // Read more: https://github.com/vuejs/core/pull/3399 4 | import '@vue/runtime-core' 5 | 6 | export {} 7 | 8 | declare module '@vue/runtime-core' { 9 | export interface GlobalComponents { 10 | IMdiAccountCircleOutline: typeof import('~icons/mdi/account-circle-outline')['default'] 11 | InfoModal: typeof import('./components/InfoModal.vue')['default'] 12 | LoginImg: typeof import('./components/LoginImg.vue')['default'] 13 | Navbar: typeof import('./components/Navbar.vue')['default'] 14 | RouterLink: typeof import('vue-router')['RouterLink'] 15 | RouterView: typeof import('vue-router')['RouterView'] 16 | Sidebar: typeof import('./components/Sidebar.vue')['default'] 17 | StatusTag: typeof import('./components/StatusTag.vue')['default'] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /dashboard/src/components/InfoModal.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import { ref } from 'vue' 3 | import { 4 | Dialog, 5 | DialogPanel, 6 | DialogTitle, 7 | TransitionChild, 8 | TransitionRoot, 9 | } from '@headlessui/vue' 10 | 11 | const isOpen = ref(false) 12 | 13 | function closeModal() { 14 | isOpen.value = false 15 | } 16 | function openModal() { 17 | isOpen.value = true 18 | } 19 | 20 | defineExpose({ 21 | openModal, 22 | closeModal, 23 | }) 24 | </script> 25 | 26 | <template> 27 | <TransitionRoot appear :show="isOpen" as="template"> 28 | <Dialog as="div" class="relative z-10" @close="closeModal"> 29 | <TransitionChild 30 | as="template" 31 | enter="duration-300 ease-out" 32 | enter-from="opacity-0" 33 | enter-to="opacity-100" 34 | leave="duration-200 ease-in" 35 | leave-from="opacity-100" 36 | leave-to="opacity-0" 37 | > 38 | <div class="fixed inset-0 bg-black bg-opacity-25" /> 39 | </TransitionChild> 40 | 41 | <div class="fixed inset-0 overflow-y-auto"> 42 | <div 43 | class="flex min-h-full items-center justify-center p-4 text-center" 44 | > 45 | <TransitionChild 46 | as="template" 47 | enter="duration-300 ease-out" 48 | enter-from="opacity-0 scale-95" 49 | enter-to="opacity-100 scale-100" 50 | leave="duration-200 ease-in" 51 | leave-from="opacity-100 scale-100" 52 | leave-to="opacity-0 scale-95" 53 | > 54 | <DialogPanel 55 | class="w-full max-w-2xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all" 56 | > 57 | <DialogTitle 58 | as="h3" 59 | class="text-lg font-medium leading-6 text-gray-900" 60 | > 61 | <slot name="header" /> 62 | </DialogTitle> 63 | <div class="mt-2"> 64 | <slot name="body" /> 65 | </div> 66 | 67 | <div class="mt-4"> 68 | <button 69 | type="button" 70 | class="inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2" 71 | @click="closeModal" 72 | > 73 | Close 74 | </button> 75 | </div> 76 | </DialogPanel> 77 | </TransitionChild> 78 | </div> 79 | </div> 80 | </Dialog> 81 | </TransitionRoot> 82 | </template> 83 | -------------------------------------------------------------------------------- /dashboard/src/components/Navbar.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import { storeToRefs } from 'pinia' 3 | 4 | const { header } = storeToRefs(useNav()) 5 | </script> 6 | 7 | <template> 8 | <nav class="bg-white border-gray-200 px-2 sm:px-4 py-2.5 rounded dark:bg-gray-900"> 9 | <div class="container flex flex-wrap justify-between mx-auto"> 10 | <span class="text-2xl font-bold">{{ header }}</span> 11 | <div id="navbar-default" class="w-auto"> 12 | <button 13 | type="button" 14 | class="text-white bg-blue-600 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 mr-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800" 15 | > 16 | + 17 |   Create New Environment 18 | </button> 19 | </div> 20 | </div> 21 | </nav> 22 | </template> 23 | -------------------------------------------------------------------------------- /dashboard/src/components/Sidebar.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | // import { CalendarIcon, ChartBarIcon, FolderIcon, HomeIcon, InboxIcon, UsersIcon } from '@heroicons/vue/outline' 3 | import IMdiDriveDocument from '~icons/mdi/drive-document' 4 | import IMaterialSymbolsInsertChartRounded from '~icons/material-symbols/insert-chart-rounded' 5 | import IMaterialSymbolsCalendarMonth from '~icons/material-symbols/calendar-month' 6 | import IMdiGear from '~icons/mdi/gear' 7 | 8 | const navigation = [ 9 | { name: 'Environments', icon: IMdiDriveDocument, href: '/envs', current: true }, 10 | { name: 'Images', icon: IMaterialSymbolsInsertChartRounded, href: '/images', current: true }, 11 | { name: 'Data', icon: IMaterialSymbolsCalendarMonth, href: '#', current: false }, 12 | { name: 'Settings', icon: IMdiGear, href: '#', current: false }, 13 | ] 14 | 15 | const index = ref(0) 16 | 17 | const { setNavHeader } = useNav() 18 | 19 | watch(index, (val) => { 20 | setNavHeader(navigation[val].name) 21 | }) 22 | </script> 23 | 24 | <template> 25 | <aside class="w-64 flex" aria-label="Sidebar"> 26 | <div class="overflow-y-auto flex-1 py-4 px-3 bg-gray-100 bg-opacity-50 rounded dark:bg-gray-800"> 27 | <a href="https://envd.tensorchord.ai" class="flex items-center pl-2.5 mb-5 h-20"> 28 | <img 29 | src="https://user-images.githubusercontent.com/12974685/200007223-cd94fe9a-266d-4bbd-ac23-e71043d0c3dc.svg" 30 | class="mr-3 h-10" alt="envd Logo" 31 | > 32 | <span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">envd</span> 33 | </a> 34 | <ul class="space-y-2"> 35 | <li 36 | v-for="(item, i) in navigation" :key="item.name" 37 | > 38 | <router-link 39 | :to="item.href" 40 | :class="[index === i ? 'bg-gray-200 text-gray-900' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900']" 41 | class="flex items-center px-2 py-3 text-base font-normalrounded-lg" 42 | @click="index = i" 43 | > 44 | <component :is="item.icon" class="mr-3 flex-shrink-0 h-6 w-6" :class="[index === i ? 'text-gray-500' : 'text-gray-400 group-hover:text-gray-500']" aria-hidden="true" /> 45 | <span class="ml-3">{{ item.name }}</span> 46 | </router-link> 47 | </li> 48 | </ul> 49 | </div> 50 | </aside> 51 | </template> 52 | -------------------------------------------------------------------------------- /dashboard/src/components/StatusTag.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const props = defineProps<{ status: string }>() 3 | 4 | const statusTagClass = computed(() => { 5 | switch (props.status) { 6 | case 'Running': 7 | return 'bg-green-100 text-green-800' 8 | case 'Pending': 9 | return 'bg-yellow-100 text-yellow-800' 10 | case 'Terminating': 11 | return 'bg-red-100 text-red-800' 12 | default: 13 | return 'bg-gray-100 text-gray-800' 14 | } 15 | }) 16 | </script> 17 | 18 | <template> 19 | <span 20 | class="text-xs font-semibold mr-2 px-2.5 py-0.5 rounded" 21 | :class="statusTagClass" 22 | > 23 | {{ $props.status }} 24 | </span> 25 | </template> 26 | -------------------------------------------------------------------------------- /dashboard/src/composables/dark.ts: -------------------------------------------------------------------------------- 1 | // these APIs are auto-imported from @vueuse/core 2 | export const isDark = useDark() 3 | export const toggleDark = useToggle(isDark) 4 | export const preferredDark = usePreferredDark() 5 | -------------------------------------------------------------------------------- /dashboard/src/composables/request.ts: -------------------------------------------------------------------------------- 1 | import { createFetch } from '@vueuse/core' 2 | 3 | export const useEnvdFetch = createFetch({ 4 | baseUrl: `${import.meta.env.VITE_BASE_HOST ? import.meta.env.VITE_BASE_HOST : ''}/api/v1`, 5 | options: { 6 | async beforeFetch({ options }) { 7 | const { getToken } = useUserStore() 8 | const myToken = await getToken() 9 | options.headers = { 10 | ...options.headers, 11 | Authorization: `Bearer ${myToken}`, 12 | } 13 | return { options } 14 | }, 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /dashboard/src/composables/util.ts: -------------------------------------------------------------------------------- 1 | export function formatBytes(bytes: number, decimals = 2) { 2 | if (!+bytes) 3 | return '0 Bytes' 4 | 5 | const k = 1024 6 | const dm = decimals < 0 ? 0 : decimals 7 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] 8 | 9 | const i = Math.floor(Math.log(bytes) / Math.log(k)) 10 | 11 | return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}` 12 | } 13 | -------------------------------------------------------------------------------- /dashboard/src/layouts/README.md: -------------------------------------------------------------------------------- 1 | ## Layouts 2 | 3 | Vue components in this dir are used as layouts. 4 | 5 | By default, `default.vue` will be used unless an alternative is specified in the route meta. 6 | 7 | With [`vite-plugin-pages`](https://github.com/hannoeru/vite-plugin-pages) and [`vite-plugin-vue-layouts`](https://github.com/JohnCampionJr/vite-plugin-vue-layouts), you can specify the layout in the page's SFCs like this: 8 | 9 | ```html 10 | <route lang="yaml"> 11 | meta: 12 | layout: home 13 | </route> 14 | ``` 15 | -------------------------------------------------------------------------------- /dashboard/src/layouts/dashboard.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | import Navbar from '~/components/Navbar.vue' 3 | </script> 4 | 5 | <template> 6 | <main> 7 | <div class="h-screen flex flex-nowrap w-screen overflow-hidden box-border"> 8 | <Sidebar class="flex-none z-40 h-screen w-80" /> 9 | <div class="container flex-1 py-5 mx-5"> 10 | <Navbar /> 11 | <RouterView /> 12 | </div> 13 | </div> 14 | </main> 15 | </template> 16 | 17 | -------------------------------------------------------------------------------- /dashboard/src/layouts/default.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <main> 3 | <RouterView /> 4 | </main> 5 | </template> 6 | -------------------------------------------------------------------------------- /dashboard/src/main.ts: -------------------------------------------------------------------------------- 1 | import './styles/tailwind.css' 2 | 3 | import { ViteSSG } from 'vite-ssg' 4 | import { setupLayouts } from 'virtual:generated-layouts' 5 | import Previewer from 'virtual:vue-component-preview' 6 | import App from './App.vue' 7 | import type { UserModule } from './types' 8 | import generatedRoutes from '~pages' 9 | 10 | const routes = setupLayouts(generatedRoutes) 11 | 12 | // https://github.com/antfu/vite-ssg 13 | export const createApp = ViteSSG( 14 | App, 15 | { routes, base: import.meta.env.BASE_URL }, 16 | (ctx) => { 17 | // install all modules under `modules/` 18 | Object.values(import.meta.glob<{ install: UserModule }>('./modules/*.ts', { eager: true })) 19 | .forEach(i => i.install?.(ctx)) 20 | ctx.app.use(Previewer) 21 | }, 22 | ) 23 | -------------------------------------------------------------------------------- /dashboard/src/modules/README.md: -------------------------------------------------------------------------------- 1 | ## Modules 2 | 3 | A custom user module system. Place a `.ts` file with the following template, it will be installed automatically. 4 | 5 | ```ts 6 | import { type UserModule } from '~/types' 7 | 8 | export const install: UserModule = ({ app, router, isClient }) => { 9 | // do something 10 | } 11 | ``` 12 | -------------------------------------------------------------------------------- /dashboard/src/modules/dayjs.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import relativeTime from 'dayjs/plugin/relativeTime' 3 | import { type UserModule } from '~/types' 4 | 5 | export const install: UserModule = () => { 6 | dayjs.extend(relativeTime) 7 | } 8 | 9 | -------------------------------------------------------------------------------- /dashboard/src/modules/pinia.ts: -------------------------------------------------------------------------------- 1 | import { createPinia } from 'pinia' 2 | import { type UserModule } from '~/types' 3 | 4 | // Setup Pinia 5 | // https://pinia.vuejs.org/ 6 | export const install: UserModule = ({ isClient, initialState, app }) => { 7 | const pinia = createPinia() 8 | app.use(pinia) 9 | // Refer to 10 | // https://github.com/antfu/vite-ssg/blob/main/README.md#state-serialization 11 | // for other serialization strategies. 12 | if (isClient) 13 | pinia.state.value = (initialState.pinia) || {} 14 | 15 | else 16 | initialState.pinia = pinia.state.value 17 | } 18 | -------------------------------------------------------------------------------- /dashboard/src/pages/README.md: -------------------------------------------------------------------------------- 1 | ## File-based Routing 2 | 3 | Routes will be auto-generated for Vue files in this dir with the same file structure. 4 | Check out [`vite-plugin-pages`](https://github.com/hannoeru/vite-plugin-pages) for more details. 5 | 6 | ### Path Aliasing 7 | 8 | `~/` is aliased to `./src/` folder. 9 | 10 | For example, instead of having 11 | 12 | ```ts 13 | import { isDark } from '../../../../composables' 14 | ``` 15 | 16 | now, you can use 17 | 18 | ```ts 19 | import { isDark } from '~/composables' 20 | ``` 21 | -------------------------------------------------------------------------------- /dashboard/src/pages/[...all].vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | </script> 3 | 4 | <template> 5 | <div class="container mx-auto flex justify-center py-10 text-5xl"> 6 | 404 Not Found 7 | </div> 8 | </template> 9 | -------------------------------------------------------------------------------- /dashboard/src/pages/about.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | const router = useRouter() 3 | </script> 4 | 5 | <template> 6 | <div class="flex justify-center"> 7 | <div class="hero min-h-screen"> 8 | <div class="hero-content text-center"> 9 | <div class="max-w-md space-y-3"> 10 | <h1 class="text-5xl font-bold pb-5"> 11 | ViteTail 12 | </h1> 13 | <div class="divider"> 14 | About 15 | </div> 16 | <p class="leading-relaxed text-left"> 17 | ViteTail is heavily inspired by <a class="link link-primary" href="https://github.com/antfu/vitesse">Vitesse</a>, including Vue3 and Vite3, file-based routing, component auto importing, icon auto importing, Pinia, Vite-SSG, and PWA support. 18 | </p> 19 | <p class="leading-relaxed text-left"> 20 | Styling out of the box with <a class="link" href="https://tailwindcss.com/">TailwindCSS</a>, <a class="link" href="https://postcss.org/">PostCSS</a>, and <a class="link" href="https://daisyui.com/">DaisyUI Components</a>. 21 | </p> 22 | <p class="leading-relaxed text-left"> 23 | Including <a class="link" href="https://github.com/adoxography/tailwind-scrollbar">TailwindCSS Scrollbars</a> and <a class="link" href="https://github.com/saadeghi/theme-change">Theme-Change</a>. 24 | </p> 25 | <p class="leading-relaxed text-left"> 26 | Check out the repo <a class="link link-primary" href="https://github.com/compilekaiten/ViteTail">here</a>, and give it a Star if you like it. 27 | </p> 28 | <p class=""> 29 | PR's welcome! 30 | </p> 31 | <button id="back" class="btn btn-wide btn-outline btn-error" @click="router.back()"> 32 | Back 33 | </button> 34 | </div> 35 | </div> 36 | </div> 37 | </div> 38 | </template> 39 | 40 | <route lang="yaml"> 41 | meta: 42 | layout: default 43 | </route> 44 | 45 | -------------------------------------------------------------------------------- /dashboard/src/pages/index.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | </script> 3 | 4 | <template> 5 | <router-link to="/login"> 6 | <div class="flex flex-col items-center justify-center h-screen"> 7 | <div class="text-4xl font-bold text-gray-500 dark:text-gray-400"> 8 | <i-mdi-account-circle-outline class="h-16 w-16" /> 9 | </div> 10 | <div class="text-2xl font-bold text-gray-500 dark:text-gray-400"> 11 | Login 12 | </div> 13 | </div> 14 | </router-link> 15 | </template> 16 | 17 | <route lang="yaml"> 18 | </route> 19 | -------------------------------------------------------------------------------- /dashboard/src/pages/signup/index.vue: -------------------------------------------------------------------------------- 1 | <script setup lang="ts"> 2 | // const props = defineProps<{ name: string }>() 3 | // const router = useRouter() 4 | // const user = useUserStore() 5 | 6 | // watchEffect(() => { 7 | // user.setNewName(props.name) 8 | // }) 9 | </script> 10 | 11 | <template> 12 | <div class="h-screen flex"> 13 | <div class="flex-1 flex flex-col justify-center py-2 px-4 sm:px-6 lg:flex-none lg:px-20 xl:px-24"> 14 | <div class="mx-auto w-full max-w-sm lg:w-96"> 15 | <div> 16 | <h2 class="mb-20 text-3xl font-extrabold text-gray-900 mx-auto text-center"> 17 | Sign up 18 | </h2> 19 | </div> 20 | 21 | <div class="mt-8"> 22 | <div class="mt-6"> 23 | <form action="#" method="POST" class="space-y-6"> 24 | <div> 25 | <label for="email" class="block text-sm font-medium text-gray-700 py-2"> Full Name 26 | </label> 27 | <div class="mt-1"> 28 | <input 29 | id="fullname" name="fullname" type="fullname" autocomplete="fullname" required 30 | class="appearance-none block w-full px-3 py-2 bg-[#F7F7F8] rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" 31 | > 32 | </div> 33 | </div> 34 | 35 | <div> 36 | <label for="email" class="block text-sm font-medium text-gray-700 py-2"> Email address 37 | </label> 38 | <div class="mt-1"> 39 | <input 40 | id="email" name="email" type="email" autocomplete="email" required 41 | class="appearance-none block w-full px-3 py-2 bg-[#F7F7F8] rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" 42 | > 43 | </div> 44 | </div> 45 | 46 | <div class="space-y-1"> 47 | <label for="password" class="block text-sm font-medium text-gray-700 py-2"> Password 48 | </label> 49 | <div class="mt-1"> 50 | <input 51 | id="password" name="password" type="password" autocomplete="current-password" required 52 | class="appearance-none block w-full px-3 py-2 bg-[#F7F7F8] rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" 53 | > 54 | </div> 55 | </div> 56 | 57 | <div class="pt-4"> 58 | <router-link to="/"> 59 | <button 60 | type="submit" 61 | class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white hover:bg-[#1949C5] bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" 62 | > 63 | Create 64 | Account 65 | </button> 66 | </router-link> 67 | </div> 68 | 69 | <div class="space-y-1"> 70 | <div class="mt-1 mx-auto text-center"> 71 | Already have an account? <router-link class="text-indigo-600" to="/login"> 72 | Log 73 | in 74 | </router-link> 75 | </div> 76 | </div> 77 | </form> 78 | </div> 79 | </div> 80 | </div> 81 | </div> 82 | <div class="h-screen flex w-0 flex-1 bg-gray-50"> 83 | <LoginImg class="m-auto" /> 84 | </div> 85 | </div> 86 | </template> 87 | 88 | <route lang="yaml"> 89 | meta: 90 | layout: default 91 | </route> 92 | 93 | -------------------------------------------------------------------------------- /dashboard/src/shims.d.ts: -------------------------------------------------------------------------------- 1 | declare interface Window { 2 | // extend the window 3 | } 4 | declare module '*.vue' { 5 | import { type DefineComponent } from 'vue' 6 | const component: DefineComponent<{}, {}, any> 7 | export default component 8 | } 9 | -------------------------------------------------------------------------------- /dashboard/src/store/environment.ts: -------------------------------------------------------------------------------- 1 | import type { Ref } from '@vue/reactivity' 2 | import { acceptHMRUpdate, defineStore } from 'pinia' 3 | 4 | import { useEnvdFetch } from '~/composables/request' 5 | import type { TypesEnvironment, TypesEnvironmentListResponse } from '~/composables/types/scheme' 6 | 7 | export const useEnvStore = defineStore('envs', () => { 8 | const { userInfo } = useUserStore() 9 | const { data, execute } = useEnvdFetch(`/users/${userInfo.username}/environments`).get().json<TypesEnvironmentListResponse>() 10 | const envs = ref<TypesEnvironment[]>([]) 11 | 12 | async function fetchEnvs(): Promise<TypesEnvironment[]> { 13 | await execute() 14 | return data.value!.items! 15 | } 16 | 17 | async function refreshEnvs(): Promise<void> { 18 | envs.value = await fetchEnvs() 19 | } 20 | 21 | function getEnvsRef(): Ref<TypesEnvironment[]> { 22 | return envs 23 | } 24 | 25 | async function deleteEnv(id: string): Promise<void> { 26 | await useEnvdFetch(`/users/${userInfo.username}/environments/${id}`).delete().json() 27 | await refreshEnvs() 28 | } 29 | 30 | async function getEnvInfo(name: string): Promise<TypesEnvironment> { 31 | const envs = getEnvsRef() 32 | const env = envs.value.find(env => env.name === name) 33 | return env! 34 | } 35 | 36 | return { getEnvsRef, refreshEnvs, deleteEnv, getEnvInfo } 37 | }) 38 | 39 | if (import.meta.hot) 40 | import.meta.hot.accept(acceptHMRUpdate(useEnvStore, import.meta.hot)) 41 | -------------------------------------------------------------------------------- /dashboard/src/store/image.ts: -------------------------------------------------------------------------------- 1 | import type { Ref } from '@vue/reactivity' 2 | import { acceptHMRUpdate, defineStore } from 'pinia' 3 | import type { TypesImageListResponse, TypesImageMeta } from '~/composables/types/scheme' 4 | 5 | export const useImageStore = defineStore('image', () => { 6 | const imgs = ref<TypesImageMeta[]>([]) 7 | const user = useUserStore() 8 | const { data, execute } = useEnvdFetch(`/users/${encodeURIComponent(user.userInfo.username)}/images`).get().json<TypesImageListResponse>() 9 | 10 | async function fetchImages(): Promise<TypesImageMeta[]> { 11 | await execute() 12 | return data.value!.items! 13 | } 14 | async function refreshImages(): Promise<void> { 15 | imgs.value = await fetchImages() 16 | } 17 | function getImages(): Ref<TypesImageMeta[]> { 18 | return imgs 19 | } 20 | async function getImageInfo(name: string): Promise<TypesImageMeta> { 21 | const imgs = getImages() 22 | const img = imgs.value.find(img => img.name === name) 23 | return img! 24 | } 25 | 26 | return { imgs, getImages, refreshImages, getImageInfo } 27 | }, 28 | ) 29 | 30 | if (import.meta.hot) 31 | import.meta.hot.accept(acceptHMRUpdate(useImageStore, import.meta.hot)) 32 | -------------------------------------------------------------------------------- /dashboard/src/store/nav.ts: -------------------------------------------------------------------------------- 1 | import { acceptHMRUpdate, defineStore } from 'pinia' 2 | 3 | export const useNav = defineStore('nav', () => { 4 | const header = ref('') 5 | function setNavHeader(newHeader: string) { 6 | header.value = newHeader 7 | } 8 | return { header, setNavHeader } 9 | }) 10 | 11 | if (import.meta.hot) 12 | import.meta.hot.accept(acceptHMRUpdate(useUserStore, import.meta.hot)) 13 | -------------------------------------------------------------------------------- /dashboard/src/store/user.ts: -------------------------------------------------------------------------------- 1 | import { acceptHMRUpdate, defineStore } from 'pinia' 2 | import type { TypesAuthNRequest, TypesAuthNResponse } from '~/composables/types/scheme' 3 | 4 | export const useUserStore = defineStore('user', () => { 5 | const userInfo = ref(useLocalStorage('envd/userInfo', { 6 | username: '', 7 | jwtToken: '', 8 | })) 9 | const isLogin = ref(false) 10 | 11 | async function getToken(): Promise<string> { 12 | return userInfo.value.jwtToken 13 | } 14 | 15 | async function login(username: string, password: string): Promise<boolean> { 16 | const request: TypesAuthNRequest = { 17 | login_name: username, 18 | password, 19 | } 20 | const { data, statusCode } = await useEnvdFetch('/login').post(request).json<TypesAuthNResponse>() 21 | if (statusCode.value !== 200) { 22 | return false 23 | } 24 | else { 25 | userInfo.value = { 26 | username, 27 | jwtToken: data.value!.identity_token!, 28 | } 29 | return true 30 | } 31 | } 32 | 33 | function setUser(username: string) { 34 | userInfo.value.username = username 35 | } 36 | return { 37 | userInfo, 38 | isLogin, 39 | login, 40 | setUser, 41 | getToken, 42 | } 43 | }) 44 | 45 | if (import.meta.hot) 46 | import.meta.hot.accept(acceptHMRUpdate(useUserStore, import.meta.hot)) 47 | -------------------------------------------------------------------------------- /dashboard/src/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /dashboard/src/types.ts: -------------------------------------------------------------------------------- 1 | import { type ViteSSGContext } from 'vite-ssg' 2 | 3 | export type UserModule = (ctx: ViteSSGContext) => void 4 | -------------------------------------------------------------------------------- /dashboard/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: 'class', 4 | content: [ 5 | './index.html', 6 | './src/**/*.{vue,js,ts,jsx,tsx}', 7 | ], 8 | theme: { 9 | extend: {}, 10 | }, 11 | plugins: [ 12 | require('daisyui'), 13 | require('@tailwindcss/aspect-ratio'), 14 | require('@tailwindcss/typography'), 15 | require('tailwind-scrollbar'), 16 | ], 17 | daisyui: {}, 18 | } 19 | -------------------------------------------------------------------------------- /dashboard/test/basic.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | 3 | describe('tests', () => { 4 | it('should works', () => { 5 | expect(1 + 1).toEqual(2) 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /dashboard/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "module": "ESNext", 5 | "target": "ESNext", 6 | "lib": ["DOM", "ESNext"], 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "jsx": "preserve", 10 | "skipLibCheck": true, 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "noUnusedLocals": true, 14 | "strictNullChecks": true, 15 | "allowJs": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "types": [ 18 | "vitest", 19 | "vite/client", 20 | "vue/ref-macros", 21 | "vite-plugin-pages/client", 22 | "vite-plugin-vue-component-preview/client", 23 | "vite-plugin-vue-layouts/client", 24 | "vite-plugin-pwa/client", 25 | "unplugin-icons/types/vue" 26 | ], 27 | "paths": { 28 | "~/*": ["src/*"] 29 | } 30 | }, 31 | "exclude": ["dist", "node_modules", "cypress"] 32 | } 33 | -------------------------------------------------------------------------------- /dashboard/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { defineConfig } from 'vite' 3 | import Preview from 'vite-plugin-vue-component-preview' 4 | import Vue from '@vitejs/plugin-vue' 5 | import Pages from 'vite-plugin-pages' 6 | import generateSitemap from 'vite-ssg-sitemap' 7 | import Layouts from 'vite-plugin-vue-layouts' 8 | import Components from 'unplugin-vue-components/vite' 9 | import AutoImport from 'unplugin-auto-import/vite' 10 | import Inspect from 'vite-plugin-inspect' 11 | import Icons from 'unplugin-icons/vite' 12 | import IconsResolver from 'unplugin-icons/resolver' 13 | import vueJsx from '@vitejs/plugin-vue-jsx' 14 | 15 | export default defineConfig({ 16 | base: process.env.NODE_ENV === 'production' ? '/dashboard/' : '/', 17 | resolve: { 18 | alias: { 19 | '~/': `${path.resolve(__dirname, 'src')}/`, 20 | }, 21 | }, 22 | 23 | plugins: [ 24 | Preview(), 25 | 26 | vueJsx(), 27 | 28 | Vue({ 29 | include: [/\.vue$/], 30 | }), 31 | 32 | // https://github.com/hannoeru/vite-plugin-pages 33 | Pages({ 34 | extensions: ['vue'], 35 | }), 36 | 37 | // https://github.com/JohnCampionJr/vite-plugin-vue-layouts 38 | Layouts(), 39 | 40 | // https://github.com/antfu/unplugin-auto-import 41 | AutoImport({ 42 | imports: [ 43 | 'vue', 44 | 'vue-router', 45 | 'vue/macros', 46 | '@vueuse/head', 47 | '@vueuse/core', 48 | ], 49 | dts: 'src/auto-imports.d.ts', 50 | dirs: [ 51 | 'src/composables', 52 | 'src/store', 53 | ], 54 | vueTemplate: true, 55 | }), 56 | 57 | // https://github.com/antfu/unplugin-vue-components 58 | Components({ 59 | // allow auto load markdown components under `./src/components/` 60 | extensions: ['vue'], 61 | // allow auto import and register components used in markdown 62 | include: [/\.vue$/, /\.vue\?vue/], 63 | dts: 'src/components.d.ts', 64 | resolvers: [ 65 | IconsResolver(), 66 | ], 67 | }), 68 | 69 | // https://github.com/antfu/unplugin-icons 70 | Icons({ 71 | compiler: 'vue3', 72 | autoInstall: true, 73 | }), 74 | 75 | // https://github.com/antfu/vite-plugin-inspect 76 | // Visit http://localhost:3333/__inspect/ to see the inspector 77 | Inspect(), 78 | ], 79 | 80 | // https://github.com/vitest-dev/vitest 81 | // test: { 82 | // include: ['test/**/*.test.ts'], 83 | // environment: 'jsdom', 84 | // deps: { 85 | // inline: ['@vue', '@vueuse', 'vue-demi'], 86 | // }, 87 | // }, 88 | 89 | // https://github.com/antfu/vite-ssg 90 | ssgOptions: { 91 | script: 'async', 92 | formatting: 'minify', 93 | onFinished() { generateSitemap() }, 94 | }, 95 | 96 | ssr: { 97 | // TODO: workaround until they support native ESM 98 | noExternal: ['workbox-window', /vue-i18n/], 99 | }, 100 | }) 101 | -------------------------------------------------------------------------------- /errdefs/defs.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package errdefs // import "github.com/docker/docker/errdefs" 6 | 7 | // ErrNotFound signals that the requested object doesn't exist 8 | type ErrNotFound interface { 9 | NotFound() 10 | } 11 | 12 | // ErrInvalidParameter signals that the user input is invalid 13 | type ErrInvalidParameter interface { 14 | InvalidParameter() 15 | } 16 | 17 | // ErrConflict signals that some internal state conflicts with the requested action and can't be performed. 18 | // A change in state should be able to clear this error. 19 | type ErrConflict interface { 20 | Conflict() 21 | } 22 | 23 | // ErrUnauthorized is used to signify that the user is not authorized to perform a specific action 24 | type ErrUnauthorized interface { 25 | Unauthorized() 26 | } 27 | 28 | // ErrUnavailable signals that the requested action/subsystem is not available. 29 | type ErrUnavailable interface { 30 | Unavailable() 31 | } 32 | 33 | // ErrForbidden signals that the requested action cannot be performed under any circumstances. 34 | // When a ErrForbidden is returned, the caller should never retry the action. 35 | type ErrForbidden interface { 36 | Forbidden() 37 | } 38 | 39 | // ErrSystem signals that some internal error occurred. 40 | // An example of this would be a failed mount request. 41 | type ErrSystem interface { 42 | System() 43 | } 44 | 45 | // ErrNotModified signals that an action can't be performed because it's already in the desired state 46 | type ErrNotModified interface { 47 | NotModified() 48 | } 49 | 50 | // ErrNotImplemented signals that the requested action/feature is not implemented on the system as configured. 51 | type ErrNotImplemented interface { 52 | NotImplemented() 53 | } 54 | 55 | // ErrUnknown signals that the kind of error that occurred is not known. 56 | type ErrUnknown interface { 57 | Unknown() 58 | } 59 | 60 | // ErrCancelled signals that the action was cancelled. 61 | type ErrCancelled interface { 62 | Cancelled() 63 | } 64 | 65 | // ErrDeadline signals that the deadline was reached before the action completed. 66 | type ErrDeadline interface { 67 | DeadlineExceeded() 68 | } 69 | 70 | // ErrDataLoss indicates that data was lost or there is data corruption. 71 | type ErrDataLoss interface { 72 | DataLoss() 73 | } 74 | -------------------------------------------------------------------------------- /errdefs/doc.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | // Package errdefs defines a set of error interfaces that packages should use for communicating classes of errors. 6 | // Errors that cross the package boundary should implement one (and only one) of these interfaces. 7 | // 8 | // Packages should not reference these interfaces directly, only implement them. 9 | // To check if a particular error implements one of these interfaces, there are helper 10 | // functions provided (e.g. `Is<SomeError>`) which can be used rather than asserting the interfaces directly. 11 | // If you must assert on these interfaces, be sure to check the causal chain (`err.Cause()`). 12 | package errdefs // import "github.com/docker/docker/errdefs" 13 | -------------------------------------------------------------------------------- /errdefs/http_helpers.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package errdefs // import "github.com/docker/docker/errdefs" 6 | 7 | import ( 8 | "net/http" 9 | 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | // FromStatusCode creates an errdef error, based on the provided HTTP status-code 14 | func FromStatusCode(err error, statusCode int) error { 15 | if err == nil { 16 | return err 17 | } 18 | switch statusCode { 19 | case http.StatusNotFound: 20 | err = NotFound(err) 21 | case http.StatusBadRequest: 22 | err = InvalidParameter(err) 23 | case http.StatusConflict: 24 | err = Conflict(err) 25 | case http.StatusUnauthorized: 26 | err = Unauthorized(err) 27 | case http.StatusServiceUnavailable: 28 | err = Unavailable(err) 29 | case http.StatusForbidden: 30 | err = Forbidden(err) 31 | case http.StatusNotModified: 32 | err = NotModified(err) 33 | case http.StatusNotImplemented: 34 | err = NotImplemented(err) 35 | case http.StatusInternalServerError: 36 | if !IsSystem(err) && !IsUnknown(err) && !IsDataLoss(err) && !IsDeadline(err) && !IsCancelled(err) { 37 | err = System(err) 38 | } 39 | default: 40 | logrus.WithError(err).WithFields(logrus.Fields{ 41 | "module": "api", 42 | "status_code": statusCode, 43 | }).Debug("FIXME: Got an status-code for which error does not match any expected type!!!") 44 | 45 | switch { 46 | case statusCode >= 200 && statusCode < 400: 47 | // it's a client error 48 | case statusCode >= 400 && statusCode < 500: 49 | err = InvalidParameter(err) 50 | case statusCode >= 500 && statusCode < 600: 51 | err = System(err) 52 | default: 53 | err = Unknown(err) 54 | } 55 | } 56 | return err 57 | } 58 | -------------------------------------------------------------------------------- /errdefs/is.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package errdefs // import "github.com/docker/docker/errdefs" 6 | 7 | type causer interface { 8 | Cause() error 9 | } 10 | 11 | func getImplementer(err error) error { 12 | switch e := err.(type) { 13 | case 14 | ErrNotFound, 15 | ErrInvalidParameter, 16 | ErrConflict, 17 | ErrUnauthorized, 18 | ErrUnavailable, 19 | ErrForbidden, 20 | ErrSystem, 21 | ErrNotModified, 22 | ErrNotImplemented, 23 | ErrCancelled, 24 | ErrDeadline, 25 | ErrDataLoss, 26 | ErrUnknown: 27 | return err 28 | case causer: 29 | return getImplementer(e.Cause()) 30 | default: 31 | return err 32 | } 33 | } 34 | 35 | // IsNotFound returns if the passed in error is an ErrNotFound 36 | func IsNotFound(err error) bool { 37 | _, ok := getImplementer(err).(ErrNotFound) 38 | return ok 39 | } 40 | 41 | // IsInvalidParameter returns if the passed in error is an ErrInvalidParameter 42 | func IsInvalidParameter(err error) bool { 43 | _, ok := getImplementer(err).(ErrInvalidParameter) 44 | return ok 45 | } 46 | 47 | // IsConflict returns if the passed in error is an ErrConflict 48 | func IsConflict(err error) bool { 49 | _, ok := getImplementer(err).(ErrConflict) 50 | return ok 51 | } 52 | 53 | // IsUnauthorized returns if the passed in error is an ErrUnauthorized 54 | func IsUnauthorized(err error) bool { 55 | _, ok := getImplementer(err).(ErrUnauthorized) 56 | return ok 57 | } 58 | 59 | // IsUnavailable returns if the passed in error is an ErrUnavailable 60 | func IsUnavailable(err error) bool { 61 | _, ok := getImplementer(err).(ErrUnavailable) 62 | return ok 63 | } 64 | 65 | // IsForbidden returns if the passed in error is an ErrForbidden 66 | func IsForbidden(err error) bool { 67 | _, ok := getImplementer(err).(ErrForbidden) 68 | return ok 69 | } 70 | 71 | // IsSystem returns if the passed in error is an ErrSystem 72 | func IsSystem(err error) bool { 73 | _, ok := getImplementer(err).(ErrSystem) 74 | return ok 75 | } 76 | 77 | // IsNotModified returns if the passed in error is a NotModified error 78 | func IsNotModified(err error) bool { 79 | _, ok := getImplementer(err).(ErrNotModified) 80 | return ok 81 | } 82 | 83 | // IsNotImplemented returns if the passed in error is an ErrNotImplemented 84 | func IsNotImplemented(err error) bool { 85 | _, ok := getImplementer(err).(ErrNotImplemented) 86 | return ok 87 | } 88 | 89 | // IsUnknown returns if the passed in error is an ErrUnknown 90 | func IsUnknown(err error) bool { 91 | _, ok := getImplementer(err).(ErrUnknown) 92 | return ok 93 | } 94 | 95 | // IsCancelled returns if the passed in error is an ErrCancelled 96 | func IsCancelled(err error) bool { 97 | _, ok := getImplementer(err).(ErrCancelled) 98 | return ok 99 | } 100 | 101 | // IsDeadline returns if the passed in error is an ErrDeadline 102 | func IsDeadline(err error) bool { 103 | _, ok := getImplementer(err).(ErrDeadline) 104 | return ok 105 | } 106 | 107 | // IsDataLoss returns if the passed in error is an ErrDataLoss 108 | func IsDataLoss(err error) bool { 109 | _, ok := getImplementer(err).(ErrDataLoss) 110 | return ok 111 | } 112 | -------------------------------------------------------------------------------- /manifests/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /manifests/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: envd-server 3 | description: Helm Chart for envd-server, the backend server for envd. 4 | 5 | # A chart can be either an 'application' or a 'library' chart. 6 | # 7 | # Application charts are a collection of templates that can be packaged into versioned archives 8 | # to be deployed. 9 | # 10 | # Library charts provide useful utilities or functions for the chart developer. They're included as 11 | # a dependency of application charts to inject those utilities and functions into the rendering 12 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 13 | type: application 14 | 15 | # This is the chart version. This version number should be incremented each time you make changes 16 | # to the chart and its templates, including the app version. 17 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 18 | version: 0.1.0 19 | 20 | maintainers: # (optional) 21 | - name: envd Maintainers 22 | email: envd-maintainers@tensorchord.ai 23 | 24 | home: https://github.com/tensorchord/envd-server 25 | 26 | sources: 27 | - https://github.com/tensorchord/envd-server 28 | -------------------------------------------------------------------------------- /manifests/secretkeys/backend_pod: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn 3 | NhAAAAAwEAAQAAAYEAx/g1yrSVARaYAHEw3SU3J7h9nNhUTFPk9jOaPN/tnpRGsA3YT5kO 4 | qyYuu6ssrb2zEBTzltx9STD3a6GLlrXRtPVJjuvnjkflDL9PJhy9JoBlRCLWK5OwrfdW9x 5 | mjFX+lhYAKLskfqHue0AqsAM/rjLl+jhq0TWQQghLd7WxziKtrOPnvo6lu6d4z2vGgqIsA 6 | Idb5U6AmBbPe0GGwV0+hH3u9rnqC37YQ1zVHJ8gqXT/LzbG8y5lhu5Zn6werzelg1Xgm57 7 | XTXoWYBTh4pkJ7NGv1985DpnNjBshWsXSvxnBsWdmzgcsjdg0VxazJQh9KgJ+W9zUnoQ8p 8 | LFdWpWARlgCcyIY5okOXdIBsocvzPnQSAuc8YP5VGG8qmF7q/11aZoS8LUwNxDLeXcj8KT 9 | 5+oQXA7R+AJ5m05JkQls9M52Gvjniubz1tk7/egVmr5w/Tg5suL0s1RdHw4zR74ep8qkF9 10 | 66WREKdoXTLiY88v1tN4ueVZ3Gm6oQXxz7ezUPDbAAAFiJw8JMGcPCTBAAAAB3NzaC1yc2 11 | EAAAGBAMf4Ncq0lQEWmABxMN0lNye4fZzYVExT5PYzmjzf7Z6URrAN2E+ZDqsmLrurLK29 12 | sxAU85bcfUkw92uhi5a10bT1SY7r545H5Qy/TyYcvSaAZUQi1iuTsK33VvcZoxV/pYWACi 13 | 7JH6h7ntAKrADP64y5fo4atE1kEIIS3e1sc4irazj576OpbuneM9rxoKiLACHW+VOgJgWz 14 | 3tBhsFdPoR97va56gt+2ENc1RyfIKl0/y82xvMuZYbuWZ+sHq83pYNV4Jue1016FmAU4eK 15 | ZCezRr9ffOQ6ZzYwbIVrF0r8ZwbFnZs4HLI3YNFcWsyUIfSoCflvc1J6EPKSxXVqVgEZYA 16 | nMiGOaJDl3SAbKHL8z50EgLnPGD+VRhvKphe6v9dWmaEvC1MDcQy3l3I/Ck+fqEFwO0fgC 17 | eZtOSZEJbPTOdhr454rm89bZO/3oFZq+cP04ObLi9LNUXR8OM0e+HqfKpBfeulkRCnaF0y 18 | 4mPPL9bTeLnlWdxpuqEF8c+3s1Dw2wAAAAMBAAEAAAGAPnZKnymvDWr4SdMRd1JjmxWmrv 19 | Jnynu+HiVaPT+ZIpqgRefdNGfTzCQeHuLGDvMdVp2kxO/UdqND9au9RXM/sO2Zb3pClw/f 20 | /Q5Y88ewUbFzcEgNbAky+/QxhvfMGDAKDNxE0f5i1CbhIYzj01Ee+5MJc+vle/MQsQChr8 21 | Lbh8o7sM1pTE7lZUnSGsa071CT1v4mXTe+CLP5mk+ZXHx0ELh/NFvyO1zMf9yVgFim2v/N 22 | ck/dcB9WBtlhVmnMAYKu/XABaHf06tFFD7+lMcNylHaPOuuA96eJxhnz39kgTbnsWpP2xp 23 | N/gkv2goICI8g/4qWCaUVvZibh5oyPncG4LEEjEZowm5l18+MeRLVM1Gs8C4Qh752kSWgb 24 | sPWh14i9h0ZaOET26MJYKAR6dos9jEss18xjGcoUDKpR6UuPSSVaMBgrV6bGcCsthbxx2r 25 | P+ogRK93j8V5tw520VC3izjBxXhmkzkMchCSsMJutuZfuJawOEUcnL7e2vEGWA14dhAAAA 26 | wDnrvVoiBQTrD7dH2mHWCT1WgjniYKNNnj3u1HVW8MnW4FRu1KTdhL0mIaEv5Ggmmcbi4+ 27 | KsUqREPqrnTScus06n/ulSbodYH0AIgyXLRNdGf9VQy2hOVbHnCAVTINxfh8YBo9rEhwQs 28 | VjA/rQWsfPlU+zEJlX1iEtp0MVll3vq7+4/KXDAC+SXOteEtFweIqqz3j+djiGp6rGYlqX 29 | FA+izl/3UoPa1n9zn30+re3o5fhRuxSQPrXhbFn5f/zu3qpwAAAMEA/Dpzj+/yc7/qOqA4 30 | zMZYwDyPlEwnnrSQCvfvG1ktFN8LhUtxjrQgH5VP/LbQntIdh801tyTUg96UroJZ/HY4pB 31 | yBwli2/MtTwHssW/W+jxTNRlGXI4bCZr/KArwwJP20rZF6kYMzLEazmfJ4kS6E1xm2BZfq 32 | HNMTLZNYOKauMhUjwY7zz28KJRrKJqEwa/1zu+w4SLIp1ejJ4us1jwk+J34+079rtnRTfQ 33 | O2Ex3EeMwrmvGJNX/SdanCMHP+RPaRAAAAwQDK9bVWevdBNTRTRV0bd+dHaerA3XarCS0t 34 | g6vU0pBmyD0lVlQgNNAEjQv/Wm6q2ZQ7K8fsUP6lAiizhlmvKXntr3DdeBpdnMLXnDpcIH 35 | 6FYL0Eg5HCexaZ+D7ebRV0CKu8x0StVwoEisZ6mQskCkWnJdD3fwvN3AFvaUgfkBIWa9Sm 36 | 4Rt035UUaPT6qpTT2aUfcqv4oaAx8uAPIWOHl/yb8zwOv5d9gaGCQSO2OvnTIXuVnixZ2n 37 | dV8jbsoP7HXqsAAAAPZ2FvY2VnZWdlQGNlZ2FvAQIDBA== 38 | -----END OPENSSH PRIVATE KEY----- 39 | -------------------------------------------------------------------------------- /manifests/secretkeys/backend_pod.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDH+DXKtJUBFpgAcTDdJTcnuH2c2FRMU+T2M5o83+2elEawDdhPmQ6rJi67qyytvbMQFPOW3H1JMPdroYuWtdG09UmO6+eOR+UMv08mHL0mgGVEItYrk7Ct91b3GaMVf6WFgAouyR+oe57QCqwAz+uMuX6OGrRNZBCCEt3tbHOIq2s4+e+jqW7p3jPa8aCoiwAh1vlToCYFs97QYbBXT6Efe72ueoLfthDXNUcnyCpdP8vNsbzLmWG7lmfrB6vN6WDVeCbntdNehZgFOHimQns0a/X3zkOmc2MGyFaxdK/GcGxZ2bOByyN2DRXFrMlCH0qAn5b3NSehDyksV1alYBGWAJzIhjmiQ5d0gGyhy/M+dBIC5zxg/lUYbyqYXur/XVpmhLwtTA3EMt5dyPwpPn6hBcDtH4AnmbTkmRCWz0znYa+OeK5vPW2Tv96BWavnD9ODmy4vSzVF0fDjNHvh6nyqQX3rpZEQp2hdMuJjzy/W03i55VncabqhBfHPt7NQ8Ns= gaocegege@cegao 2 | -------------------------------------------------------------------------------- /manifests/secretkeys/hostkey: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn 3 | NhAAAAAwEAAQAAAYEAziFTwkLm8VzADvqnXt+hc+gN2vVaHNABkgcYSX9AUe+OKUG+ivDv 4 | 8z5VWjp0Bj5AQlARoQ9mRIrk1KVvG38KiFat0b6McW84WaNEhfytikRpT7mJjznxO7Jndj 5 | 5BifF91Y2RbXl1LCoxe2JZyhC3Nj6Q2ONCi0oN54qp0ZtCrqoVN1IlZ6yujihEpgbCBigr 6 | KRacGCIK20JNMivCu5mRWllrnkDxqk8nLP5ki8L5r0O1hGKSpOUz5etNdFkUltq72q0I5B 7 | N4TB8pmgoECPHA1CF4iTJWPp3S7lBZCD0BVCZOtHR8i+HcU3nWhyMs8b50wlTlU/457NEA 8 | 7ZCquXgLVpgs4YcPHP425LSAPPUsidCfFbyCNT9W7uZycNKREm7quxwpo8D9HJMzUay4qC 9 | 7XPBKKf+bIfIekr5sNKIq0CE9zRnqc5JgM+cqa+KmP9WtVFWwEKk1Zj4E/KN6lK2Vxv+f+ 10 | a+QSKUI6PgbPPhynUWiNBLOYH44cwwINrlqXWc1FAAAFiHJpxqVyacalAAAAB3NzaC1yc2 11 | EAAAGBAM4hU8JC5vFcwA76p17foXPoDdr1WhzQAZIHGEl/QFHvjilBvorw7/M+VVo6dAY+ 12 | QEJQEaEPZkSK5NSlbxt/CohWrdG+jHFvOFmjRIX8rYpEaU+5iY858TuyZ3Y+QYnxfdWNkW 13 | 15dSwqMXtiWcoQtzY+kNjjQotKDeeKqdGbQq6qFTdSJWesro4oRKYGwgYoKykWnBgiCttC 14 | TTIrwruZkVpZa55A8apPJyz+ZIvC+a9DtYRikqTlM+XrTXRZFJbau9qtCOQTeEwfKZoKBA 15 | jxwNQheIkyVj6d0u5QWQg9AVQmTrR0fIvh3FN51ocjLPG+dMJU5VP+OezRAO2Qqrl4C1aY 16 | LOGHDxz+NuS0gDz1LInQnxW8gjU/Vu7mcnDSkRJu6rscKaPA/RyTM1GsuKgu1zwSin/myH 17 | yHpK+bDSiKtAhPc0Z6nOSYDPnKmvipj/VrVRVsBCpNWY+BPyjepStlcb/n/mvkEilCOj4G 18 | zz4cp1FojQSzmB+OHMMCDa5al1nNRQAAAAMBAAEAAAGAIMk8QVHS2eEey0MjC/wV+hGW4p 19 | TT2HFdTpTCUC5lVKL9waIrZH4eLFplyQwzGCsenW2O4EdKxOwyqYAGxCDY1Aa1bv8X55MB 20 | K4DEjWs7TxrChWPFdXqJ3CzsN+p/EinPEgCKeRcwg+3SIQXrsjAmdAJPl6/ODcmhnIp5qF 21 | VBrfZvmXT/bhYRTZsqEB5TDhelhcuK2GvRvj41eR1sw9oRPIWskGdUCPq+CHIOHAzPGgly 22 | /bOQD5pFPvTGRRNGKxhvL61iJyMSc5dlasDOd+NAHN2hTmOATd5bKyC+qnGCL+7xxgDHd+ 23 | wOiHsavIZgDvdyVcpVthKqin9Zn2Z7ytAa/7iKGPypxAjIcyck2HsZa13AVGWmcc1T/gsd 24 | m/5v078Y3kwu5dZkAA6Fp+yOV6wGbT1VN+upm7poTNSefL5QaHomibcV9mWjLtRFM4sWzU 25 | p22KN7F4QujvZOSeblvfanot5a7mK1JNxn6BtPWDIA3LkSwQL/dM46XQtz189lKKuhAAAA 26 | wA+ZmsTrXuUI158lCH4JmXfWQiePEENr+E7WzRepRoMw9vSq3sJQkXNQ0Aj5f6vB+T69gM 27 | IguH4S/dj/jOM+cuatr2xPgo9uIOSK5iYiO+KnLTvcvG8zpk3oGLvAIaQ45QpuZagm5FaJ 28 | g6lsxfpBv6OHfAuB+wO6GI0Y25WhK5kIZ1ia68Q3Pded+f5MMUiqt7hO88SN6uY/mvJxAJ 29 | eDbJw5wrRbzl9QSvS/j/z6DGsbRMRbzyN8iPY8v3kacyHSegAAAMEA5q3Eunzlem5REdHu 30 | tMVai8Pt6RIikVRCUHAzVfRmg0JXWyTwCeBgLdSOgpJy9dCrA+DvNJzvkO1CfjoRcX+sey 31 | hmTexof2DlCsx5k25/lNumaK8jj+ZWgqYQz0C51VNYhOW5gsr3ZSJthgZsXUi2gFp4ikst 32 | d1HRwwdVQN4D/a/ofceIQJVTqQyeQv+V15nSFQC37h7csFrQYOs6N0OYHWW039J/tFA0AI 33 | LYUjYxa8FTgC+mAw0LfdV0nhAtNrK3AAAAwQDkwbnu6XHYU00urCq6GVeLar/3b83fOTuC 34 | RhIq6WZSuqpoYE+Vwz3zgXHf/qMP+x20UX5EHFCE58TArJL2Vu0bAYL0GeVFuuBkOyH7D/ 35 | YUzi0UpDBK4QNJf44OHjkG6JJ442ZzvclwQa0UF7QfBy50gTHa3cc9seTSU2Qs6RLlOyuZ 36 | BAi3xALhwAyg1L/zvif1wKulT4q+aqzcQZ8YXwLbJDt7OvDF2lS3pACorKwnbEvvQPAmxw 37 | f0rN7tPVNYU+MAAAAPZ2FvY2VnZWdlQGNlZ2FvAQIDBA== 38 | -----END OPENSSH PRIVATE KEY----- 39 | -------------------------------------------------------------------------------- /manifests/secretkeys/hostkey.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDOIVPCQubxXMAO+qde36Fz6A3a9Voc0AGSBxhJf0BR744pQb6K8O/zPlVaOnQGPkBCUBGhD2ZEiuTUpW8bfwqIVq3RvoxxbzhZo0SF/K2KRGlPuYmPOfE7smd2PkGJ8X3VjZFteXUsKjF7YlnKELc2PpDY40KLSg3niqnRm0KuqhU3UiVnrK6OKESmBsIGKCspFpwYIgrbQk0yK8K7mZFaWWueQPGqTycs/mSLwvmvQ7WEYpKk5TPl6010WRSW2rvarQjkE3hMHymaCgQI8cDUIXiJMlY+ndLuUFkIPQFUJk60dHyL4dxTedaHIyzxvnTCVOVT/jns0QDtkKq5eAtWmCzhhw8c/jbktIA89SyJ0J8VvII1P1bu5nJw0pESbuq7HCmjwP0ckzNRrLioLtc8Eop/5sh8h6Svmw0oirQIT3NGepzkmAz5ypr4qY/1a1UVbAQqTVmPgT8o3qUrZXG/5/5r5BIpQjo+Bs8+HKdRaI0Es5gfjhzDAg2uWpdZzUU= gaocegege@cegao 2 | -------------------------------------------------------------------------------- /manifests/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "envd-server.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "envd-server.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "envd-server.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | Please run these commands: 19 | kubectl --namespace {{ .Release.Namespace }} port-forward svc/envd-server 8080:8080 2222:2222 20 | 21 | To get the pod name: 22 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "envd-server.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 23 | {{- end }} 24 | -------------------------------------------------------------------------------- /manifests/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "envd-server.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "envd-server.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "envd-server.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "envd-server.labels" -}} 37 | helm.sh/chart: {{ include "envd-server.chart" . }} 38 | {{ include "envd-server.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "envd-server.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "envd-server.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "envd-server.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "envd-server.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /manifests/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ include "envd-server.fullname" . }} 5 | data: 6 | config.yaml: | 7 | ssh: 8 | hostkeys: 9 | - /etc/containerssh/hostkey 10 | security: 11 | forwarding: 12 | reverseForwardingMode: enable 13 | forwardingMode: enable 14 | auth: 15 | url: http://127.0.0.1:8080/api/v1 16 | configserver: 17 | url: "http://127.0.0.1:8080/api/v1/config" 18 | metrics: 19 | enable: true 20 | listen: 0.0.0.0:9100 21 | path: /metrics 22 | log: 23 | level: debug 24 | backend: sshproxy 25 | sshproxy: 26 | privateKey: /etc/containerssh/privatekey 27 | -------------------------------------------------------------------------------- /manifests/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "envd-server.fullname" . -}} 3 | {{- $svcPort := .Values.service.serverPort -}} 4 | {{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} 5 | {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} 6 | {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} 7 | {{- end }} 8 | {{- end }} 9 | {{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} 10 | apiVersion: networking.k8s.io/v1 11 | {{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} 12 | apiVersion: networking.k8s.io/v1beta1 13 | {{- else -}} 14 | apiVersion: extensions/v1beta1 15 | {{- end }} 16 | kind: Ingress 17 | metadata: 18 | name: {{ $fullName }} 19 | labels: 20 | {{- include "envd-server.labels" . | nindent 4 }} 21 | {{- with .Values.ingress.annotations }} 22 | annotations: 23 | {{- toYaml . | nindent 4 }} 24 | {{- end }} 25 | spec: 26 | {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} 27 | ingressClassName: {{ .Values.ingress.className }} 28 | {{- end }} 29 | {{- if .Values.ingress.tls }} 30 | tls: 31 | {{- range .Values.ingress.tls }} 32 | - hosts: 33 | {{- range .hosts }} 34 | - {{ . | quote }} 35 | {{- end }} 36 | secretName: {{ .secretName }} 37 | {{- end }} 38 | {{- end }} 39 | rules: 40 | {{- range .Values.ingress.hosts }} 41 | - host: {{ .host | quote }} 42 | http: 43 | paths: 44 | {{- range .paths }} 45 | - path: {{ .path }} 46 | {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} 47 | pathType: {{ .pathType }} 48 | {{- end }} 49 | backend: 50 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 51 | service: 52 | name: {{ $fullName }} 53 | port: 54 | number: {{ $svcPort }} 55 | {{- else }} 56 | serviceName: {{ $fullName }} 57 | servicePort: {{ $svcPort }} 58 | {{- end }} 59 | {{- end }} 60 | {{- end }} 61 | {{- end }} 62 | -------------------------------------------------------------------------------- /manifests/templates/postgres.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.postgres.createdb -}} 2 | apiVersion: v1 3 | kind: ConfigMap 4 | metadata: 5 | name: postgres-config 6 | labels: 7 | app: postgres 8 | data: 9 | POSTGRES_DB: {{ .Values.postgres.dbname }} 10 | POSTGRES_USER: {{ .Values.postgres.username }} 11 | POSTGRES_PASSWORD: {{ .Values.postgres.password }} 12 | --- 13 | apiVersion: apps/v1 14 | kind: Deployment 15 | metadata: 16 | name: postgres-deployment 17 | spec: 18 | strategy: 19 | type: Recreate 20 | selector: 21 | matchLabels: 22 | app: postgres 23 | replicas: 1 24 | template: 25 | metadata: 26 | labels: 27 | app: postgres 28 | spec: 29 | containers: 30 | - name: postgres 31 | image: "postgres:{{ .Values.postgres.tag }}" 32 | imagePullPolicy: "IfNotPresent" 33 | ports: 34 | - containerPort: 5432 35 | envFrom: 36 | - configMapRef: 37 | name: postgres-config 38 | volumeMounts: 39 | - mountPath: /var/lib/postgresql/data 40 | name: postgredb 41 | {{- with .Values.postgres.resources }} 42 | resources: 43 | {{- toYaml . | nindent 12 }} 44 | {{- end }} 45 | volumes: 46 | - name: postgredb 47 | persistentVolumeClaim: 48 | claimName: postgres-pv-claim 49 | --- 50 | apiVersion: v1 51 | kind: Service 52 | metadata: 53 | name: postgres-service 54 | labels: 55 | app: postgres 56 | spec: 57 | type: NodePort 58 | ports: 59 | - port: 5432 60 | targetPort: 5432 61 | protocol: TCP 62 | selector: 63 | app: postgres 64 | --- 65 | kind: PersistentVolume 66 | apiVersion: v1 67 | metadata: 68 | name: postgres-pv-volume 69 | labels: 70 | type: local 71 | app: postgres 72 | spec: 73 | capacity: 74 | storage: 5Gi 75 | accessModes: 76 | - ReadWriteOnce 77 | hostPath: 78 | path: "/mnt/data" 79 | --- 80 | kind: PersistentVolumeClaim 81 | apiVersion: v1 82 | metadata: 83 | name: postgres-pv-claim 84 | labels: 85 | app: postgres 86 | spec: 87 | accessModes: 88 | - ReadWriteOnce 89 | resources: 90 | requests: 91 | storage: 5Gi 92 | --- 93 | {{- end -}} 94 | -------------------------------------------------------------------------------- /manifests/templates/resourcequota.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.resourceQuota.enabled -}} 2 | apiVersion: v1 3 | kind: ResourceQuota 4 | metadata: 5 | name: resource-quota 6 | labels: 7 | {{- include "envd-server.labels" . | nindent 4 }} 8 | spec: 9 | hard: 10 | limits.cpu: {{ .Values.resourceQuota.hard.limits.cpu }} 11 | limits.memory: {{ .Values.resourceQuota.hard.limits.memory }} 12 | limits.nvidia.com/gpu: {{ .Values.resourceQuota.hard.limits.gpu }} 13 | requests.cpu: {{ .Values.resourceQuota.hard.requests.cpu }} 14 | requests.memory: {{ .Values.resourceQuota.hard.requests.memory }} 15 | requests.nvidia.com/gpu: {{ .Values.resourceQuota.hard.requests.gpu }} 16 | limits.count/pods: {{ .Values.resourceQuota.hard.limits.pods }} 17 | {{- end }} 18 | -------------------------------------------------------------------------------- /manifests/templates/role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: Role 3 | metadata: 4 | name: {{ include "envd-server.fullname" . }} 5 | rules: 6 | - apiGroups: 7 | - "" 8 | resources: 9 | - pods 10 | - pods/logs 11 | - pods/exec 12 | - services 13 | - configmaps 14 | verbs: 15 | - '*' 16 | -------------------------------------------------------------------------------- /manifests/templates/rolebinding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | name: {{ include "envd-server.fullname" . }} 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: Role 8 | name: {{ include "envd-server.fullname" . }} 9 | subjects: 10 | - kind: ServiceAccount 11 | name: {{ include "envd-server.serviceAccountName" . }} 12 | -------------------------------------------------------------------------------- /manifests/templates/secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: {{ include "envd-server.fullname" . }} 5 | labels: 6 | {{- include "envd-server.labels" . | nindent 4 }} 7 | type: Opaque 8 | data: 9 | privatekey: |- 10 | {{ .Files.Get "secretkeys/backend_pod" | b64enc }} 11 | publickey: |- 12 | {{ .Files.Get "secretkeys/backend_pod.pub" | b64enc }} 13 | hostkey: |- 14 | {{ .Files.Get "secretkeys/hostkey" | b64enc }} 15 | -------------------------------------------------------------------------------- /manifests/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "envd-server.fullname" . }} 5 | labels: 6 | {{- include "envd-server.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.serverPort }} 11 | targetPort: envdserver 12 | protocol: TCP 13 | name: envd-server 14 | nodePort: {{ .Values.service.serverNodePort }} 15 | - port: {{ .Values.service.containersshPort }} 16 | targetPort: ssh 17 | protocol: TCP 18 | name: ssh 19 | nodePort: {{ .Values.service.containersshNodePort }} 20 | selector: 21 | {{- include "envd-server.selectorLabels" . | nindent 4 }} 22 | -------------------------------------------------------------------------------- /manifests/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "envd-server.serviceAccountName" . }} 6 | labels: 7 | {{- include "envd-server.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | automountServiceAccountToken: true 14 | -------------------------------------------------------------------------------- /manifests/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "envd-server.fullname" . }}-test-connection" 5 | labels: 6 | {{- include "envd-server.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test 9 | spec: 10 | containers: 11 | - name: wget 12 | image: busybox 13 | command: ['wget'] 14 | args: ['{{ include "envd-server.fullname" . }}:{{ .Values.service.port }}'] 15 | restartPolicy: Never 16 | -------------------------------------------------------------------------------- /manifests/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for envd-server. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: tensorchord/envd-server 9 | pullPolicy: Always 10 | # Overrides the image tag whose default is the chart appVersion. 11 | tag: "0.0.25" 12 | 13 | # Will generate from postgres info below if this field is empty 14 | # example: postgres://username:password@localhost:5432/database_name 15 | dbUrl: "" 16 | 17 | # envd server debug mode 18 | server: 19 | debug: false 20 | noauth: true 21 | imagePullSecret: "" 22 | # Whether to run the database migration process 23 | migration: true 24 | # Leave blank will use ${image.Repository}-migration:${image.tag} as the migration image 25 | migrationImage: "" 26 | resourcesEnabled: false 27 | resources: 28 | limits: 29 | cpu: 100m 30 | memory: 1G 31 | requests: 32 | cpu: 100m 33 | memory: 1G 34 | 35 | containerssh: 36 | repository: tensorchord/containerssh 37 | pullPolicy: IfNotPresent 38 | # Overrides the image tag whose default is the chart appVersion. 39 | tag: "0.0.6" 40 | resources: 41 | limits: 42 | cpu: 100m 43 | memory: 1G 44 | requests: 45 | cpu: 100m 46 | memory: 1G 47 | 48 | imagePullSecrets: [] 49 | nameOverride: "" 50 | fullnameOverride: "" 51 | 52 | postgres: 53 | createdb: true 54 | tag: "11.7" 55 | dbname: postgresdb 56 | username: postgresadmin 57 | password: admin12345 58 | resources: 59 | limits: 60 | cpu: 1 61 | memory: 2G 62 | requests: 63 | cpu: 1 64 | memory: 2G 65 | 66 | serviceAccount: 67 | # Specifies whether a service account should be created 68 | create: true 69 | # Annotations to add to the service account 70 | annotations: {} 71 | # The name of the service account to use. 72 | # If not set and create is true, a name is generated using the fullname template 73 | name: "" 74 | 75 | podAnnotations: {} 76 | 77 | podSecurityContext: {} 78 | # fsGroup: 2000 79 | 80 | securityContext: {} 81 | # capabilities: 82 | # drop: 83 | # - ALL 84 | # readOnlyRootFilesystem: true 85 | # runAsNonRoot: true 86 | # runAsUser: 1000 87 | 88 | service: 89 | type: ClusterIP 90 | serverPort: 8080 91 | containersshPort: 2222 92 | # type: NodePort 93 | # serverPort: 8080 94 | # containersshPort: 2222 95 | # serverNodePort: 30080 96 | # containersshNodePort: 32222 97 | 98 | resourceQuota: 99 | enabled: false 100 | hard: 101 | limits: 102 | cpu: 10 103 | memory: 50G 104 | gpu: 1 105 | pods: 15 106 | requests: 107 | cpu: 10 108 | memory: 50G 109 | gpu: 1 110 | 111 | ingress: 112 | enabled: false 113 | className: "" 114 | annotations: {} 115 | # kubernetes.io/ingress.class: nginx 116 | # kubernetes.io/tls-acme: "true" 117 | hosts: 118 | - host: chart-example.local 119 | paths: 120 | - path: / 121 | pathType: ImplementationSpecific 122 | tls: [] 123 | # - secretName: chart-example-tls 124 | # hosts: 125 | # - chart-example.local 126 | 127 | autoscaling: 128 | enabled: false 129 | minReplicas: 1 130 | maxReplicas: 100 131 | targetCPUUtilizationPercentage: 80 132 | # targetMemoryUtilizationPercentage: 80 133 | 134 | nodeSelector: {} 135 | 136 | tolerations: [] 137 | 138 | affinity: {} 139 | -------------------------------------------------------------------------------- /pkg/app/server.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package app 6 | 7 | import ( 8 | "time" 9 | 10 | "github.com/sirupsen/logrus" 11 | cli "github.com/urfave/cli/v2" 12 | 13 | "github.com/tensorchord/envd-server/pkg/server" 14 | "github.com/tensorchord/envd-server/pkg/version" 15 | ) 16 | 17 | type EnvdServerApp struct { 18 | *cli.App 19 | } 20 | 21 | func New() EnvdServerApp { 22 | internalApp := cli.NewApp() 23 | internalApp.EnableBashCompletion = true 24 | internalApp.Name = "envd-server" 25 | internalApp.Usage = "HTTP backend server for envd" 26 | internalApp.HideHelpCommand = true 27 | internalApp.HideVersion = true 28 | internalApp.Version = version.GetVersion().String() 29 | internalApp.Flags = []cli.Flag{ 30 | &cli.BoolFlag{ 31 | Name: "debug", 32 | Usage: "enable debug output in logs", 33 | }, 34 | &cli.PathFlag{ 35 | Name: "kubeconfig", 36 | Usage: "kubeconfig path", 37 | EnvVars: []string{"KUBE_CONFIG", "KUBECONFIG"}, 38 | }, 39 | &cli.PathFlag{ 40 | Name: "hostkey", 41 | Usage: "hostkey in the backend pod, used to generate fingerprint here", 42 | EnvVars: []string{"ENVD_SERVER_HOST_KEY"}, 43 | }, 44 | &cli.StringFlag{ 45 | Name: "dburl", 46 | Usage: "url for database. e.g. postgres://user:password@localhost:5432/dbname", 47 | EnvVars: []string{"ENVD_DB_URL"}, 48 | }, 49 | &cli.BoolFlag{ 50 | Name: "no-auth", 51 | Usage: "disable authentication. This is for development only. ", 52 | EnvVars: []string{"ENVD_NO_AUTH"}, 53 | Aliases: []string{"n"}, 54 | }, 55 | &cli.StringFlag{ 56 | Name: "jwt-secret", 57 | Usage: "secret for jwt token", 58 | Value: "envd-server", 59 | EnvVars: []string{"ENVD_JWT_SECRET"}, 60 | Aliases: []string{"js"}, 61 | }, 62 | &cli.DurationFlag{ 63 | Name: "jwt-expiration-timeout", 64 | Usage: "expiration timeout for the issued jwt token", 65 | Value: time.Hour * 24 * 365, 66 | EnvVars: []string{"ENVD_JWT_EXPIRATION_TIMEOUT"}, 67 | Aliases: []string{"jet"}, 68 | }, 69 | &cli.StringFlag{ 70 | Name: "image-pull-secret-name", 71 | Usage: "name of the image pull secret in the cluster", 72 | EnvVars: []string{"ENVD_IMAGE_PULL_SECRET_NAME"}, 73 | Aliases: []string{"ipsn"}, 74 | }, 75 | &cli.BoolFlag{ 76 | Name: "resource-quota-enabled", 77 | Usage: "enable resource quota", 78 | EnvVars: []string{"ENVD_RESOURCE_QUOTA_ENABLED"}, 79 | Aliases: []string{"rqe"}, 80 | }, 81 | } 82 | internalApp.Action = runServer 83 | 84 | // Deal with debug flag. 85 | var debugEnabled bool 86 | 87 | internalApp.Before = func(context *cli.Context) error { 88 | debugEnabled = context.Bool("debug") 89 | 90 | logrus.SetFormatter(&logrus.TextFormatter{FullTimestamp: true}) 91 | if debugEnabled { 92 | logrus.SetLevel(logrus.DebugLevel) 93 | } 94 | 95 | return nil 96 | } 97 | return EnvdServerApp{ 98 | App: internalApp, 99 | } 100 | } 101 | 102 | func runServer(clicontext *cli.Context) error { 103 | opt := server.Opt{ 104 | Debug: clicontext.Bool("debug"), 105 | KubeConfig: clicontext.Path("kubeconfig"), 106 | HostKeyPath: clicontext.Path("hostkey"), 107 | DBURL: clicontext.String("dburl"), 108 | NoAuth: clicontext.Bool("no-auth"), 109 | JWTSecret: clicontext.String("jwt-secret"), 110 | ImagePullSecretName: clicontext.String("image-pull-secret-name"), 111 | ResourceQuotaEnabled: clicontext.Bool("resource-quota-enabled"), 112 | } 113 | 114 | logrus.Debug("Starting server with options: ", opt) 115 | s, err := server.New(opt) 116 | if err != nil { 117 | return err 118 | } 119 | if err := s.Run(); err != nil { 120 | return err 121 | } 122 | return nil 123 | 124 | } 125 | -------------------------------------------------------------------------------- /pkg/consts/consts.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package consts 6 | 7 | const ( 8 | EnvdLabelPrefix = "ai.tensorchord.envd." 9 | 10 | PodLabelUID = EnvdLabelPrefix + "uid" 11 | PodLabelEnvironmentName = EnvdLabelPrefix + "environment-name" 12 | PodLabelJupyterAddr = EnvdLabelPrefix + "jupyter.address" 13 | PodLabelRStudioServerAddr = EnvdLabelPrefix + "rstudio.server.address" 14 | 15 | ImageLabelContainerName = EnvdLabelPrefix + "container.name" 16 | ImageLabelPorts = EnvdLabelPrefix + "ports" 17 | ImageLabelRepo = EnvdLabelPrefix + "repo" 18 | ImageLabelAPTPackages = EnvdLabelPrefix + "apt.packages" 19 | ImageLabelPythonCommands = EnvdLabelPrefix + "pypi.commands" 20 | ) 21 | -------------------------------------------------------------------------------- /pkg/query/db.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.16.0 4 | 5 | package query 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/jackc/pgconn" 11 | "github.com/jackc/pgx/v4" 12 | ) 13 | 14 | type DBTX interface { 15 | Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) 16 | Query(context.Context, string, ...interface{}) (pgx.Rows, error) 17 | QueryRow(context.Context, string, ...interface{}) pgx.Row 18 | } 19 | 20 | func New(db DBTX) *Queries { 21 | return &Queries{db: db} 22 | } 23 | 24 | type Queries struct { 25 | db DBTX 26 | } 27 | 28 | func (q *Queries) WithTx(tx pgx.Tx) *Queries { 29 | return &Queries{ 30 | db: tx, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /pkg/query/image.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.16.0 4 | // source: image.sql 5 | 6 | package query 7 | 8 | import ( 9 | "context" 10 | 11 | "github.com/jackc/pgtype" 12 | ) 13 | 14 | const createImageInfo = `-- name: CreateImageInfo :one 15 | INSERT INTO image_info ( 16 | login_name, name, digest, created, size, 17 | labels, apt_packages, pypi_commands, services 18 | ) VALUES ( 19 | $1, $2, $3, $4, $5, $6, $7, $8, $9 20 | ) 21 | RETURNING id, name, digest, created, size, labels, login_name, apt_packages, pypi_commands, services 22 | ` 23 | 24 | type CreateImageInfoParams struct { 25 | LoginName string `json:"login_name"` 26 | Name string `json:"name"` 27 | Digest string `json:"digest"` 28 | Created int64 `json:"created"` 29 | Size int64 `json:"size"` 30 | Labels pgtype.JSONB `json:"labels"` 31 | AptPackages pgtype.JSONB `json:"apt_packages"` 32 | PypiCommands pgtype.JSONB `json:"pypi_commands"` 33 | Services pgtype.JSONB `json:"services"` 34 | } 35 | 36 | func (q *Queries) CreateImageInfo(ctx context.Context, arg CreateImageInfoParams) (ImageInfo, error) { 37 | row := q.db.QueryRow(ctx, createImageInfo, 38 | arg.LoginName, 39 | arg.Name, 40 | arg.Digest, 41 | arg.Created, 42 | arg.Size, 43 | arg.Labels, 44 | arg.AptPackages, 45 | arg.PypiCommands, 46 | arg.Services, 47 | ) 48 | var i ImageInfo 49 | err := row.Scan( 50 | &i.ID, 51 | &i.Name, 52 | &i.Digest, 53 | &i.Created, 54 | &i.Size, 55 | &i.Labels, 56 | &i.LoginName, 57 | &i.AptPackages, 58 | &i.PypiCommands, 59 | &i.Services, 60 | ) 61 | return i, err 62 | } 63 | 64 | const getImageInfoByDigest = `-- name: GetImageInfoByDigest :one 65 | SELECT id, name, digest, created, size, labels, login_name, apt_packages, pypi_commands, services FROM image_info 66 | WHERE login_name = $1 AND digest = $2 LIMIT 1 67 | ` 68 | 69 | type GetImageInfoByDigestParams struct { 70 | LoginName string `json:"login_name"` 71 | Digest string `json:"digest"` 72 | } 73 | 74 | func (q *Queries) GetImageInfoByDigest(ctx context.Context, arg GetImageInfoByDigestParams) (ImageInfo, error) { 75 | row := q.db.QueryRow(ctx, getImageInfoByDigest, arg.LoginName, arg.Digest) 76 | var i ImageInfo 77 | err := row.Scan( 78 | &i.ID, 79 | &i.Name, 80 | &i.Digest, 81 | &i.Created, 82 | &i.Size, 83 | &i.Labels, 84 | &i.LoginName, 85 | &i.AptPackages, 86 | &i.PypiCommands, 87 | &i.Services, 88 | ) 89 | return i, err 90 | } 91 | 92 | const getImageInfoByName = `-- name: GetImageInfoByName :one 93 | SELECT id, name, digest, created, size, labels, login_name, apt_packages, pypi_commands, services FROM image_info 94 | WHERE login_name = $1 AND name = $2 LIMIT 1 95 | ` 96 | 97 | type GetImageInfoByNameParams struct { 98 | LoginName string `json:"login_name"` 99 | Name string `json:"name"` 100 | } 101 | 102 | func (q *Queries) GetImageInfoByName(ctx context.Context, arg GetImageInfoByNameParams) (ImageInfo, error) { 103 | row := q.db.QueryRow(ctx, getImageInfoByName, arg.LoginName, arg.Name) 104 | var i ImageInfo 105 | err := row.Scan( 106 | &i.ID, 107 | &i.Name, 108 | &i.Digest, 109 | &i.Created, 110 | &i.Size, 111 | &i.Labels, 112 | &i.LoginName, 113 | &i.AptPackages, 114 | &i.PypiCommands, 115 | &i.Services, 116 | ) 117 | return i, err 118 | } 119 | 120 | const listImageByOwner = `-- name: ListImageByOwner :many 121 | SELECT id, name, digest, created, size, labels, login_name, apt_packages, pypi_commands, services FROM image_info 122 | WHERE login_name = $1 123 | ` 124 | 125 | func (q *Queries) ListImageByOwner(ctx context.Context, loginName string) ([]ImageInfo, error) { 126 | rows, err := q.db.Query(ctx, listImageByOwner, loginName) 127 | if err != nil { 128 | return nil, err 129 | } 130 | defer rows.Close() 131 | var items []ImageInfo 132 | for rows.Next() { 133 | var i ImageInfo 134 | if err := rows.Scan( 135 | &i.ID, 136 | &i.Name, 137 | &i.Digest, 138 | &i.Created, 139 | &i.Size, 140 | &i.Labels, 141 | &i.LoginName, 142 | &i.AptPackages, 143 | &i.PypiCommands, 144 | &i.Services, 145 | ); err != nil { 146 | return nil, err 147 | } 148 | items = append(items, i) 149 | } 150 | if err := rows.Err(); err != nil { 151 | return nil, err 152 | } 153 | return items, nil 154 | } 155 | -------------------------------------------------------------------------------- /pkg/query/key.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.16.0 4 | // source: key.sql 5 | 6 | package query 7 | 8 | import ( 9 | "context" 10 | ) 11 | 12 | const createKey = `-- name: CreateKey :one 13 | INSERT INTO keys ( 14 | login_name, name, public_key 15 | ) VALUES ( 16 | $1, $2, $3 17 | ) 18 | RETURNING login_name, name, public_key 19 | ` 20 | 21 | type CreateKeyParams struct { 22 | LoginName string `json:"login_name"` 23 | Name string `json:"name"` 24 | PublicKey []byte `json:"public_key"` 25 | } 26 | 27 | type CreateKeyRow struct { 28 | LoginName string `json:"login_name"` 29 | Name string `json:"name"` 30 | PublicKey []byte `json:"public_key"` 31 | } 32 | 33 | func (q *Queries) CreateKey(ctx context.Context, arg CreateKeyParams) (CreateKeyRow, error) { 34 | row := q.db.QueryRow(ctx, createKey, arg.LoginName, arg.Name, arg.PublicKey) 35 | var i CreateKeyRow 36 | err := row.Scan(&i.LoginName, &i.Name, &i.PublicKey) 37 | return i, err 38 | } 39 | 40 | const getKey = `-- name: GetKey :one 41 | SELECT id, name, login_name, public_key FROM keys 42 | WHERE login_name = $1 AND name = $2 LIMIT 1 43 | ` 44 | 45 | type GetKeyParams struct { 46 | LoginName string `json:"login_name"` 47 | Name string `json:"name"` 48 | } 49 | 50 | func (q *Queries) GetKey(ctx context.Context, arg GetKeyParams) (Key, error) { 51 | row := q.db.QueryRow(ctx, getKey, arg.LoginName, arg.Name) 52 | var i Key 53 | err := row.Scan( 54 | &i.ID, 55 | &i.Name, 56 | &i.LoginName, 57 | &i.PublicKey, 58 | ) 59 | return i, err 60 | } 61 | 62 | const listKeys = `-- name: ListKeys :many 63 | SELECT id, name, login_name, public_key FROM keys 64 | WHERE login_name = $1 65 | ` 66 | 67 | func (q *Queries) ListKeys(ctx context.Context, loginName string) ([]Key, error) { 68 | rows, err := q.db.Query(ctx, listKeys, loginName) 69 | if err != nil { 70 | return nil, err 71 | } 72 | defer rows.Close() 73 | var items []Key 74 | for rows.Next() { 75 | var i Key 76 | if err := rows.Scan( 77 | &i.ID, 78 | &i.Name, 79 | &i.LoginName, 80 | &i.PublicKey, 81 | ); err != nil { 82 | return nil, err 83 | } 84 | items = append(items, i) 85 | } 86 | if err := rows.Err(); err != nil { 87 | return nil, err 88 | } 89 | return items, nil 90 | } 91 | -------------------------------------------------------------------------------- /pkg/query/models.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.16.0 4 | 5 | package query 6 | 7 | import ( 8 | "github.com/jackc/pgtype" 9 | ) 10 | 11 | type ImageInfo struct { 12 | ID int64 `json:"id"` 13 | Name string `json:"name"` 14 | Digest string `json:"digest"` 15 | Created int64 `json:"created"` 16 | Size int64 `json:"size"` 17 | Labels pgtype.JSONB `json:"labels"` 18 | LoginName string `json:"login_name"` 19 | AptPackages pgtype.JSONB `json:"apt_packages"` 20 | PypiCommands pgtype.JSONB `json:"pypi_commands"` 21 | Services pgtype.JSONB `json:"services"` 22 | } 23 | 24 | type Key struct { 25 | ID int64 `json:"id"` 26 | Name string `json:"name"` 27 | LoginName string `json:"login_name"` 28 | PublicKey []byte `json:"public_key"` 29 | } 30 | 31 | type User struct { 32 | ID int64 `json:"id"` 33 | LoginName string `json:"login_name"` 34 | PasswordHash string `json:"password_hash"` 35 | } 36 | -------------------------------------------------------------------------------- /pkg/query/querier.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.16.0 4 | 5 | package query 6 | 7 | import ( 8 | "context" 9 | ) 10 | 11 | type Querier interface { 12 | CreateImageInfo(ctx context.Context, arg CreateImageInfoParams) (ImageInfo, error) 13 | CreateKey(ctx context.Context, arg CreateKeyParams) (CreateKeyRow, error) 14 | CreateUser(ctx context.Context, arg CreateUserParams) (string, error) 15 | DeleteUser(ctx context.Context, id int64) error 16 | GetImageInfoByDigest(ctx context.Context, arg GetImageInfoByDigestParams) (ImageInfo, error) 17 | GetImageInfoByName(ctx context.Context, arg GetImageInfoByNameParams) (ImageInfo, error) 18 | GetKey(ctx context.Context, arg GetKeyParams) (Key, error) 19 | GetUser(ctx context.Context, loginName string) (User, error) 20 | ListImageByOwner(ctx context.Context, loginName string) ([]ImageInfo, error) 21 | ListKeys(ctx context.Context, loginName string) ([]Key, error) 22 | ListUsers(ctx context.Context) ([]User, error) 23 | } 24 | 25 | var _ Querier = (*Queries)(nil) 26 | -------------------------------------------------------------------------------- /pkg/query/user.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.16.0 4 | // source: user.sql 5 | 6 | package query 7 | 8 | import ( 9 | "context" 10 | ) 11 | 12 | const createUser = `-- name: CreateUser :one 13 | INSERT INTO users ( 14 | login_name, password_hash 15 | ) VALUES ( 16 | $1, $2 17 | ) 18 | RETURNING login_name 19 | ` 20 | 21 | type CreateUserParams struct { 22 | LoginName string `json:"login_name"` 23 | PasswordHash string `json:"password_hash"` 24 | } 25 | 26 | func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (string, error) { 27 | row := q.db.QueryRow(ctx, createUser, arg.LoginName, arg.PasswordHash) 28 | var login_name string 29 | err := row.Scan(&login_name) 30 | return login_name, err 31 | } 32 | 33 | const deleteUser = `-- name: DeleteUser :exec 34 | DELETE FROM users 35 | WHERE id = $1 36 | ` 37 | 38 | func (q *Queries) DeleteUser(ctx context.Context, id int64) error { 39 | _, err := q.db.Exec(ctx, deleteUser, id) 40 | return err 41 | } 42 | 43 | const getUser = `-- name: GetUser :one 44 | SELECT id, login_name, password_hash FROM users 45 | WHERE login_name = $1 LIMIT 1 46 | ` 47 | 48 | func (q *Queries) GetUser(ctx context.Context, loginName string) (User, error) { 49 | row := q.db.QueryRow(ctx, getUser, loginName) 50 | var i User 51 | err := row.Scan(&i.ID, &i.LoginName, &i.PasswordHash) 52 | return i, err 53 | } 54 | 55 | const listUsers = `-- name: ListUsers :many 56 | SELECT id, login_name, password_hash FROM users 57 | ORDER BY id 58 | ` 59 | 60 | func (q *Queries) ListUsers(ctx context.Context) ([]User, error) { 61 | rows, err := q.db.Query(ctx, listUsers) 62 | if err != nil { 63 | return nil, err 64 | } 65 | defer rows.Close() 66 | var items []User 67 | for rows.Next() { 68 | var i User 69 | if err := rows.Scan(&i.ID, &i.LoginName, &i.PasswordHash); err != nil { 70 | return nil, err 71 | } 72 | items = append(items, i) 73 | } 74 | if err := rows.Err(); err != nil { 75 | return nil, err 76 | } 77 | return items, nil 78 | } 79 | -------------------------------------------------------------------------------- /pkg/runtime/kubernetes/const.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package kubernetes 6 | 7 | const ( 8 | ResourceNvidiaGPU = "nvidia.com/gpu" 9 | ResourceShm = "shm" 10 | ResourceShmPath = "/dev/shm" 11 | ) 12 | -------------------------------------------------------------------------------- /pkg/runtime/kubernetes/environment_get.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package kubernetes 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/cockroachdb/errors" 11 | "github.com/sirupsen/logrus" 12 | k8serrors "k8s.io/apimachinery/pkg/api/errors" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | 15 | "github.com/tensorchord/envd-server/api/types" 16 | "github.com/tensorchord/envd-server/errdefs" 17 | "github.com/tensorchord/envd-server/pkg/consts" 18 | ) 19 | 20 | func (p generalProvisioner) EnvironmentGet(ctx context.Context, 21 | owner, envName string) (*types.Environment, error) { 22 | 23 | pod, err := p.client.CoreV1(). 24 | Pods(p.namespace).Get(ctx, envName, metav1.GetOptions{}) 25 | if err != nil { 26 | if k8serrors.IsNotFound(err) { 27 | return nil, errdefs.NotFound(err) 28 | } 29 | return nil, errors.Wrap(err, "failed to get pod") 30 | } 31 | if pod.Labels[consts.PodLabelUID] != owner { 32 | logrus.WithFields(logrus.Fields{ 33 | "loginname_in_pod": pod.Labels[consts.PodLabelUID], 34 | "loginname_in_request": owner, 35 | }).Debug("mismatch loginname") 36 | return nil, errdefs.Unauthorized(errors.New("mismatch loginname")) 37 | } 38 | 39 | if pod == nil { 40 | return nil, errdefs.NotFound(errors.New("pod not found")) 41 | } 42 | 43 | e, err := environmentFromKubernetesPod(pod) 44 | if err != nil { 45 | return nil, errors.Wrap(err, "failed to convert pod to environment") 46 | } 47 | return e, nil 48 | } 49 | -------------------------------------------------------------------------------- /pkg/runtime/kubernetes/environment_list.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package kubernetes 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/pkg/errors" 11 | k8serrors "k8s.io/apimachinery/pkg/api/errors" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/apimachinery/pkg/labels" 14 | 15 | "github.com/tensorchord/envd-server/api/types" 16 | "github.com/tensorchord/envd-server/errdefs" 17 | "github.com/tensorchord/envd-server/pkg/consts" 18 | ) 19 | 20 | func (p generalProvisioner) EnvironmentList(ctx context.Context, 21 | owner string) ([]types.Environment, error) { 22 | ls := labels.Set{ 23 | consts.PodLabelUID: owner, 24 | } 25 | 26 | pods, err := p.client.CoreV1().Pods( 27 | p.namespace).List(ctx, metav1.ListOptions{ 28 | LabelSelector: ls.String(), 29 | }) 30 | if err != nil { 31 | if k8serrors.IsNotFound(err) { 32 | return nil, errdefs.NotFound(err) 33 | } 34 | return nil, errors.Wrap(err, "failed to get pods") 35 | } 36 | 37 | res := []types.Environment{} 38 | 39 | for _, pod := range pods.Items { 40 | e, err := environmentFromKubernetesPod(&pod) 41 | if err != nil { 42 | return nil, errors.Wrap(err, "failed to convert pod to environment") 43 | } 44 | res = append(res, *e) 45 | } 46 | 47 | return res, nil 48 | } 49 | -------------------------------------------------------------------------------- /pkg/runtime/kubernetes/environment_remove.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package kubernetes 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/pkg/errors" 11 | "github.com/sirupsen/logrus" 12 | k8serrors "k8s.io/apimachinery/pkg/api/errors" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | 15 | "github.com/tensorchord/envd-server/errdefs" 16 | "github.com/tensorchord/envd-server/pkg/consts" 17 | ) 18 | 19 | func (p generalProvisioner) EnvironmentRemove(ctx context.Context, 20 | owner, envName string) error { 21 | logger := logrus.WithField("env", envName) 22 | 23 | pod, err := p.client.CoreV1().Pods(p.namespace). 24 | Get(ctx, envName, metav1.GetOptions{}) 25 | 26 | if !k8serrors.IsNotFound(err) { 27 | if err != nil { 28 | return errors.Wrap(err, "failed to get pod") 29 | } 30 | if pod.Labels[consts.PodLabelUID] != owner { 31 | logger.WithFields(logrus.Fields{ 32 | "loginname_in_pod": pod.Labels[consts.PodLabelUID], 33 | "loginname_in_request": owner, 34 | }).Debug("mismatch loginname") 35 | return errdefs.Unauthorized(errors.New("mismatch loginname")) 36 | } 37 | 38 | err = p.client.CoreV1().Pods( 39 | p.namespace).Delete(ctx, envName, metav1.DeleteOptions{}) 40 | if err != nil && !k8serrors.IsNotFound(err) { 41 | return errors.Wrap(err, "failed to delete pod") 42 | } 43 | logger.Debugf("pod %s is deleted", envName) 44 | } 45 | 46 | configMapName := "st-config" 47 | configMap, err := p.client.CoreV1().ConfigMaps(p.namespace).Get(ctx, configMapName, metav1.GetOptions{}) 48 | 49 | if !k8serrors.IsNotFound(err) { 50 | if err != nil { 51 | return errors.Wrap(err, "failed to get configmap") 52 | } 53 | if configMap.Labels[consts.PodLabelUID] != owner { 54 | logger.WithFields(logrus.Fields{ 55 | "loginname_in_pod": pod.Labels[consts.PodLabelUID], 56 | "loginname_in_request": owner, 57 | }).Debug("mismatch loginname") 58 | return errdefs.Unauthorized(errors.New("mismatch loginname")) 59 | } 60 | 61 | err = p.client.CoreV1().ConfigMaps(p.namespace).Delete(ctx, configMapName, metav1.DeleteOptions{}) 62 | if err != nil && !k8serrors.IsNotFound(err) { 63 | return errors.Wrap(err, "failed to delete configmap") 64 | } 65 | logger.Debugf("configmap %s is deleted", envName) 66 | } 67 | 68 | service, err := p.client.CoreV1().Services(p.namespace). 69 | Get(ctx, envName, metav1.GetOptions{}) 70 | 71 | if !k8serrors.IsNotFound(err) { 72 | if err != nil { 73 | return errors.Wrap(err, "failed to get service") 74 | } 75 | if service.Labels[consts.PodLabelUID] != owner { 76 | logger.WithFields(logrus.Fields{ 77 | "loginname_in_pod": pod.Labels[consts.PodLabelUID], 78 | "loginname_in_request": owner, 79 | }).Debug("mismatch loginname") 80 | return errdefs.Unauthorized(errors.New("mismatch loginname")) 81 | } 82 | err = p.client.CoreV1().Services(p.namespace). 83 | Delete(ctx, envName, metav1.DeleteOptions{}) 84 | if err != nil && !k8serrors.IsNotFound(err) { 85 | return errors.Wrap(err, "failed to delete service") 86 | } 87 | logger.Debugf("service %s is deleted", envName) 88 | } 89 | 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /pkg/runtime/kubernetes/kubernetes.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package kubernetes 6 | 7 | import ( 8 | "github.com/sirupsen/logrus" 9 | "k8s.io/client-go/kubernetes" 10 | 11 | "github.com/tensorchord/envd-server/pkg/runtime" 12 | ) 13 | 14 | type generalProvisioner struct { 15 | client kubernetes.Interface 16 | logger *logrus.Entry 17 | 18 | namespace string 19 | imagePullSecretName *string 20 | resourceQuotaEnabled bool 21 | } 22 | 23 | func NewProvisioner(client kubernetes.Interface, 24 | namespace, imagePullSecretName string, resourceQuotaEnabled bool) runtime.Provisioner { 25 | p := &generalProvisioner{ 26 | client: client, 27 | namespace: namespace, 28 | logger: logrus.WithFields(logrus.Fields{ 29 | "namespace": namespace, 30 | "image-pull-secret-name": imagePullSecretName, 31 | "resource-quota-enabled": resourceQuotaEnabled, 32 | }), 33 | } 34 | if imagePullSecretName != "" { 35 | p.imagePullSecretName = &imagePullSecretName 36 | } 37 | p.resourceQuotaEnabled = resourceQuotaEnabled 38 | return p 39 | } 40 | -------------------------------------------------------------------------------- /pkg/runtime/kubernetes/label.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package kubernetes 6 | 7 | import ( 8 | "encoding/json" 9 | 10 | "github.com/tensorchord/envd-server/api/types" 11 | ) 12 | 13 | func portsFromLabel(label string) ([]types.EnvironmentPort, error) { 14 | var ports []types.EnvironmentPort 15 | if err := json.Unmarshal([]byte(label), &ports); err != nil { 16 | return nil, err 17 | } 18 | 19 | return ports, nil 20 | } 21 | 22 | func aptPackagesFromLabel(label string) ([]string, error) { 23 | var packages []string 24 | if err := json.Unmarshal([]byte(label), &packages); err != nil { 25 | return nil, err 26 | } 27 | return packages, nil 28 | } 29 | 30 | func pythonCommandsFromLabel(label string) ([]string, error) { 31 | var commands []string 32 | if err := json.Unmarshal([]byte(label), &commands); err != nil { 33 | return nil, err 34 | } 35 | return commands, nil 36 | } 37 | 38 | func repoInfoFromLabel(label string) (*types.EnvironmentRepoInfo, error) { 39 | var repo *types.EnvironmentRepoInfo 40 | if err := json.Unmarshal([]byte(label), &repo); err != nil { 41 | return nil, err 42 | } 43 | return repo, nil 44 | } 45 | -------------------------------------------------------------------------------- /pkg/runtime/kubernetes/label_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package kubernetes 6 | 7 | import ( 8 | "reflect" 9 | "testing" 10 | 11 | "github.com/tensorchord/envd-server/api/types" 12 | ) 13 | 14 | func TestPortsFromLabel(t *testing.T) { 15 | tcs := []struct { 16 | label string 17 | expectedErr bool 18 | port []types.EnvironmentPort 19 | }{ 20 | { 21 | label: `[{"name": "test", "port": 2222}]`, 22 | expectedErr: false, 23 | port: []types.EnvironmentPort{ 24 | { 25 | Name: "test", 26 | Port: 2222, 27 | }, 28 | }, 29 | }, 30 | { 31 | label: ``, 32 | expectedErr: false, 33 | port: nil, 34 | }, 35 | { 36 | label: `[{"name": "test", "port": 2222},{"name": "jupyter", "port": 8080}]`, 37 | expectedErr: false, 38 | port: []types.EnvironmentPort{ 39 | { 40 | Name: "test", 41 | Port: 2222, 42 | }, 43 | { 44 | Name: "jupyter", 45 | Port: 8080, 46 | }, 47 | }, 48 | }, 49 | } 50 | 51 | for _, tc := range tcs { 52 | p, err := portsFromLabel(tc.label) 53 | if tc.expectedErr { 54 | if err == nil { 55 | t.Errorf("Expected err, got nil") 56 | } 57 | continue 58 | } 59 | 60 | if e := reflect.DeepEqual(tc.port, p); e != true { 61 | t.Errorf("Expected ports %v, got %v", tc.port, p) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /pkg/runtime/provisioner.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package runtime 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/tensorchord/envd-server/api/types" 11 | ) 12 | 13 | type Provisioner interface { 14 | EnvironmentCreate(ctx context.Context, 15 | owner string, env types.Environment, 16 | meta types.ImageMeta) (*types.Environment, error) 17 | EnvironmentGet(ctx context.Context, 18 | owner, envName string) (*types.Environment, error) 19 | EnvironmentRemove(ctx context.Context, 20 | owner, envName string) error 21 | EnvironmentList(ctx context.Context, 22 | username string) ([]types.Environment, error) 23 | } 24 | -------------------------------------------------------------------------------- /pkg/server/auth.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package server 6 | 7 | import ( 8 | "net/http" 9 | 10 | "github.com/gin-gonic/gin" 11 | 12 | "github.com/tensorchord/envd-server/api/types" 13 | "github.com/tensorchord/envd-server/errdefs" 14 | ) 15 | 16 | // @Summary register the user. 17 | // @Description register the user for the given public key. 18 | // @Tags user 19 | // @Accept json 20 | // @Produce json 21 | // @Param request body types.AuthNRequest true "query params" 22 | // @Success 200 {object} types.AuthNResponse 23 | // @Router /register [post] 24 | func (s Server) register(c *gin.Context) error { 25 | var req types.AuthNRequest 26 | if err := c.BindJSON(&req); err != nil { 27 | return NewError(http.StatusInternalServerError, err, "gin.bind-json") 28 | } 29 | 30 | token, err := s.UserService.Register(c.Request.Context(), req.LoginName, req.Password) 31 | if err != nil { 32 | if errdefs.IsConflict(err) { 33 | return NewError(http.StatusConflict, err, "user.register") 34 | } 35 | return NewError(http.StatusInternalServerError, err, "user.register") 36 | } 37 | res := types.AuthNResponse{ 38 | LoginName: req.LoginName, 39 | IdentityToken: token, 40 | Status: types.AuthSuccess, 41 | } 42 | c.JSON(http.StatusOK, res) 43 | return nil 44 | } 45 | 46 | // @Summary login the user. 47 | // @Description login to the server. 48 | // @Tags user 49 | // @Accept json 50 | // @Produce json 51 | // @Param request body types.AuthNRequest true "query params" 52 | // @Success 200 {object} types.AuthNResponse 53 | // @Router /login [post] 54 | func (s Server) login(c *gin.Context) error { 55 | var req types.AuthNRequest 56 | if err := c.BindJSON(&req); err != nil { 57 | return NewError(http.StatusInternalServerError, err, "gin.bind-json") 58 | } 59 | 60 | succeeded, token, err := s.UserService.Login(c.Request.Context(), 61 | req.LoginName, req.Password, s.Auth) 62 | if err != nil { 63 | return NewError(http.StatusUnauthorized, err, "user.login") 64 | } 65 | if !succeeded { 66 | return NewError(http.StatusUnauthorized, err, "user.login") 67 | } 68 | 69 | res := types.AuthNResponse{ 70 | LoginName: req.LoginName, 71 | IdentityToken: token, 72 | Status: types.AuthSuccess, 73 | } 74 | c.JSON(200, res) 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /pkg/server/auth_middleware.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package server 6 | 7 | import ( 8 | "net/http" 9 | 10 | "github.com/cockroachdb/errors" 11 | "github.com/gin-gonic/gin" 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | func (s *Server) AuthMiddleware() gin.HandlerFunc { 16 | return func(c *gin.Context) { 17 | amURI := AuthMiddlewareURIRequest{} 18 | if err := c.BindUri(&amURI); err != nil { 19 | respondWithError(c, NewError(http.StatusUnauthorized, err, "auth.middleware.bind-uri")) 20 | return 21 | } 22 | 23 | logrus.WithFields(logrus.Fields{ 24 | "login-name-in-uri": amURI.LoginName, 25 | }).Debug("debug") 26 | 27 | amr := AuthMiddlewareHeaderRequest{} 28 | if err := c.BindHeader(&amr); err != nil { 29 | respondWithError(c, NewError(http.StatusUnauthorized, err, "auth.middleware")) 30 | return 31 | } 32 | 33 | loginName, err := s.UserService.ValidateJWT(amr.JWTToken) 34 | if err != nil { 35 | respondWithError(c, NewError(http.StatusUnauthorized, err, "user.validateJWT")) 36 | return 37 | } 38 | if loginName != amURI.LoginName { 39 | logrus.WithFields(logrus.Fields{ 40 | "login-name": loginName, 41 | "login-name-in-uri": amURI.LoginName, 42 | }).Debug("login name in JWT does not match the login name in URI") 43 | respondWithError(c, NewError(http.StatusUnauthorized, err, "user.validateJWT")) 44 | return 45 | } 46 | c.Set(ContextLoginName, loginName) 47 | c.Next() 48 | } 49 | } 50 | 51 | // NoAuthMiddleware is a middleware that does not auth the user. 52 | func (s *Server) NoAuthMiddleware() gin.HandlerFunc { 53 | return func(c *gin.Context) { 54 | amURI := AuthMiddlewareURIRequest{} 55 | if err := c.BindUri(&amURI); err != nil { 56 | respondWithError(c, NewError(http.StatusUnauthorized, err, "auth.middleware.bind-uri")) 57 | return 58 | } 59 | 60 | c.Set(ContextLoginName, amURI.LoginName) 61 | c.Next() 62 | } 63 | } 64 | 65 | // nolint:unparam 66 | func respondWithError(c *gin.Context, err error) { 67 | var serverErr *Error 68 | if errors.As(err, &serverErr) { 69 | c.AbortWithStatusJSON(serverErr.HTTPStatusCode, serverErr) 70 | return 71 | } 72 | c.AbortWithStatusJSON(http.StatusInternalServerError, err) 73 | } 74 | -------------------------------------------------------------------------------- /pkg/server/containerssh.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package server 6 | 7 | import ( 8 | "crypto/subtle" 9 | "net/http" 10 | 11 | "github.com/gin-gonic/gin" 12 | "github.com/sirupsen/logrus" 13 | "go.containerssh.io/libcontainerssh/auth" 14 | "go.containerssh.io/libcontainerssh/config" 15 | "golang.org/x/crypto/ssh" 16 | 17 | "github.com/tensorchord/envd-server/sshname" 18 | ) 19 | 20 | // @Summary Update the config of containerssh. 21 | // @Description It is called by the containerssh webhook. It is not expected to be used externally. 22 | // @Tags ssh-internal 23 | // @Accept json 24 | // @Produce json 25 | // @Param request body config.Request true "query params" 26 | // @Success 200 27 | // @Router /config [post] 28 | func (s Server) OnConfig(c *gin.Context) { 29 | var req config.Request 30 | if err := c.BindJSON(&req); err != nil { 31 | logrus.Debugf("gin.bind err: %v", err) 32 | c.Status(http.StatusBadRequest) 33 | return 34 | } 35 | 36 | _, name, err := sshname.GetInfo(req.Username) 37 | if err != nil { 38 | logrus.Debugf("sshname.get err: %v", err) 39 | c.Status(http.StatusBadRequest) 40 | return 41 | } 42 | 43 | cfg := config.AppConfig{ 44 | Backend: "sshproxy", 45 | SSHProxy: config.SSHProxyConfig{ 46 | Server: name, 47 | Port: 2222, 48 | Username: "envd", 49 | }, 50 | } 51 | fingerprints := s.serverFingerPrints 52 | cfg.SSHProxy.AllowedHostKeyFingerprints = fingerprints 53 | res := config.ResponseBody{ 54 | Config: cfg, 55 | } 56 | c.JSON(http.StatusOK, res) 57 | } 58 | 59 | // @Summary authenticate the public key. 60 | // @Description It is called by the containerssh webhook. It is not expected to be used externally. 61 | // @Tags ssh-internal 62 | // @Accept json 63 | // @Produce json 64 | // @Param request body auth.PublicKeyAuthRequest true "query params" 65 | // @Success 200 {object} auth.ResponseBody 66 | // @Router /pubkey [post] 67 | func (s Server) OnPubKey(c *gin.Context) { 68 | var req auth.PublicKeyAuthRequest 69 | if err := c.BindJSON(&req); err != nil { 70 | logrus.Debugf("bind.json err: %v", err) 71 | c.JSON(http.StatusBadRequest, auth.ResponseBody{Success: false}) 72 | return 73 | } 74 | 75 | owner, _, err := sshname.GetInfo(req.Username) 76 | if err != nil { 77 | logrus.Debugf("sshname.get-info err: %v", err) 78 | c.JSON(http.StatusBadRequest, auth.ResponseBody{Success: false}) 79 | return 80 | } 81 | 82 | skeys, err := s.UserService.ListPubKeys(c.Request.Context(), owner) 83 | if err != nil { 84 | logrus.Debugf("db.get-pubkey err: %v", err) 85 | c.JSON(http.StatusBadRequest, auth.ResponseBody{Success: false}) 86 | return 87 | } 88 | if len(skeys) == 0 { 89 | logrus.Debugf("db.get-pubkey err: %v", err) 90 | c.JSON(http.StatusBadRequest, auth.ResponseBody{Success: false}) 91 | return 92 | } 93 | key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(req.PublicKey.PublicKey)) 94 | if err != nil { 95 | logrus.Debugf("ssh.parse err: %v", err) 96 | c.JSON(http.StatusBadRequest, auth.ResponseBody{Success: false}) 97 | return 98 | } 99 | 100 | for _, skey := range skeys { 101 | logger := logrus.WithFields(logrus.Fields{ 102 | "username": req.Username, 103 | "remote-addr": req.RemoteAddress, 104 | "key-name": skey.Name, 105 | }) 106 | if subtle.ConstantTimeCompare(key.Marshal(), skey.PublicKey) == 1 { 107 | logger.Debug("auth success") 108 | res := auth.ResponseBody{ 109 | Success: true, 110 | } 111 | c.JSON(http.StatusOK, res) 112 | return 113 | } else { 114 | logger.Debug("trying next ssh key") 115 | } 116 | } 117 | 118 | logrus.WithFields(logrus.Fields{ 119 | "username": req.Username, 120 | "remote-addr": req.RemoteAddress, 121 | }).Debug("auth failed") 122 | res := auth.ResponseBody{ 123 | Success: false, 124 | } 125 | c.JSON(http.StatusOK, res) 126 | } 127 | -------------------------------------------------------------------------------- /pkg/server/environment_create.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package server 6 | 7 | import ( 8 | "net/http" 9 | 10 | "github.com/cockroachdb/errors" 11 | "github.com/gin-gonic/gin" 12 | 13 | "github.com/tensorchord/envd-server/api/types" 14 | ) 15 | 16 | // @Summary Create the environment. 17 | // @Description Create the environment. 18 | // @Security Authentication 19 | // @Tags environment 20 | // @Accept json 21 | // @Produce json 22 | // @Param login_name path string true "login name" example("alice") 23 | // @Param request body types.EnvironmentCreateRequest true "query params" 24 | // @Success 201 {object} types.EnvironmentCreateResponse 25 | // @Router /users/{login_name}/environments [post] 26 | func (s Server) environmentCreate(c *gin.Context) error { 27 | owner := c.GetString(ContextLoginName) 28 | 29 | var req types.EnvironmentCreateRequest 30 | if err := c.BindJSON(&req); err != nil { 31 | return NewError(http.StatusInternalServerError, err, "gin.bind-json") 32 | } 33 | 34 | // Create the image in DB if it does not exist. 35 | meta, err := s.ImageService.CreateImageIfNotExist( 36 | c.Request.Context(), owner, req.Spec.Image) 37 | if err != nil { 38 | return NewError(http.StatusInternalServerError, 39 | err, "image-service.create-image") 40 | } 41 | if meta == nil { 42 | return NewError(http.StatusInternalServerError, 43 | errors.New("image is not found"), "image-service.create-image") 44 | } 45 | 46 | // Create the environment. 47 | env, err := s.Runtime.EnvironmentCreate(c.Request.Context(), 48 | owner, req.Environment, *meta) 49 | if err != nil { 50 | return NewError(http.StatusInternalServerError, 51 | err, "runtime.create-environment") 52 | } 53 | 54 | resp := types.EnvironmentCreateResponse{ 55 | Created: *env, 56 | } 57 | c.JSON(http.StatusCreated, resp) 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /pkg/server/environment_get.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package server 6 | 7 | import ( 8 | "net/http" 9 | 10 | "github.com/gin-gonic/gin" 11 | 12 | "github.com/tensorchord/envd-server/api/types" 13 | "github.com/tensorchord/envd-server/errdefs" 14 | ) 15 | 16 | // @Summary Get the environment. 17 | // @Description Get the environment with the given environment name. 18 | // @Security Authentication 19 | // @Tags environment 20 | // @Accept json 21 | // @Produce json 22 | // @Param login_name path string true "login name" example("alice") 23 | // @Param name path string true "environment name" example("pytorch-example") 24 | // @Success 200 {object} types.EnvironmentGetResponse 25 | // @Router /users/{login_name}/environments/{name} [get] 26 | func (s Server) environmentGet(c *gin.Context) error { 27 | owner := c.GetString(ContextLoginName) 28 | 29 | var req types.EnvironmentGetRequest 30 | if err := c.BindUri(&req); err != nil { 31 | return NewError(http.StatusInternalServerError, err, "gin.bind-json") 32 | } 33 | 34 | e, err := s.Runtime.EnvironmentGet(c.Request.Context(), owner, req.Name) 35 | if err != nil { 36 | if errdefs.IsNotFound(err) { 37 | return NewError(http.StatusNotFound, err, "runtime.get-environment") 38 | } else if errdefs.IsUnauthorized(err) { 39 | return NewError(http.StatusUnauthorized, err, "runtime.get-environment") 40 | } 41 | return NewError(http.StatusInternalServerError, err, "runtime.get-environment") 42 | } 43 | 44 | c.JSON(http.StatusOK, types.EnvironmentGetResponse{ 45 | Environment: *e, 46 | }) 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /pkg/server/environment_list.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package server 6 | 7 | import ( 8 | "net/http" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/sirupsen/logrus" 12 | 13 | "github.com/tensorchord/envd-server/api/types" 14 | "github.com/tensorchord/envd-server/errdefs" 15 | ) 16 | 17 | // @Summary List the environment. 18 | // @Description List the environment. 19 | // @Security Authentication 20 | // @Tags environment 21 | // @Accept json 22 | // @Produce json 23 | // @Param login_name path string true "login name" example("alice") 24 | // @Success 200 {object} types.EnvironmentListResponse 25 | // @Router /users/{login_name}/environments [get] 26 | func (s Server) environmentList(c *gin.Context) error { 27 | owner := c.GetString(ContextLoginName) 28 | logger := logrus.WithField(ContextLoginName, owner) 29 | 30 | items, err := s.Runtime.EnvironmentList(c.Request.Context(), owner) 31 | if err != nil { 32 | if errdefs.IsNotFound(err) { 33 | return NewError(http.StatusNotFound, err, "runtime.list-environment") 34 | } 35 | return NewError(http.StatusInternalServerError, err, "runtime.list-environment") 36 | } 37 | 38 | res := types.EnvironmentListResponse{ 39 | Items: items, 40 | } 41 | 42 | logger.WithField("count", len(res.Items)). 43 | Debug("list the environments successfully") 44 | c.JSON(http.StatusOK, res) 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /pkg/server/environment_remove.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package server 6 | 7 | import ( 8 | "net/http" 9 | 10 | "github.com/gin-gonic/gin" 11 | k8serrors "k8s.io/apimachinery/pkg/api/errors" 12 | 13 | "github.com/tensorchord/envd-server/api/types" 14 | ) 15 | 16 | // @Summary Remove the environment. 17 | // @Description Remove the environment. 18 | // @Security Authentication 19 | // @Tags environment 20 | // @Accept json 21 | // @Produce json 22 | // @Param login_name path string true "login name" example("alice") 23 | // @Param name path string true "environment name" example("pytorch-example") 24 | // @Success 200 {object} types.EnvironmentRemoveResponse 25 | // @Router /users/{login_name}/environments/{name} [delete] 26 | func (s *Server) environmentRemove(c *gin.Context) error { 27 | owner := c.GetString(ContextLoginName) 28 | 29 | var req types.EnvironmentRemoveRequest 30 | if err := c.BindUri(&req); err != nil { 31 | return NewError(http.StatusInternalServerError, err, "gin.bind-json") 32 | } 33 | 34 | if err := s.Runtime.EnvironmentRemove(c.Request.Context(), owner, req.Name); err != nil { 35 | if k8serrors.IsNotFound(err) { 36 | return NewError(http.StatusNotFound, err, "runtime.remove-environment") 37 | } else if k8serrors.IsUnauthorized(err) { 38 | return NewError(http.StatusUnauthorized, err, "runtime.remove-environment") 39 | } 40 | return NewError(http.StatusInternalServerError, err, "runtime.remove-environment") 41 | } 42 | 43 | c.JSON(http.StatusOK, types.EnvironmentRemoveResponse{}) 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /pkg/server/error.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package server 6 | 7 | import ( 8 | "bytes" 9 | "fmt" 10 | "net/http" 11 | ) 12 | 13 | // Error defines a standard application error. 14 | type Error struct { 15 | // Machine-readable error code. 16 | HTTPStatusCode int `json:"http_status_code,omitempty"` 17 | 18 | // Human-readable message. 19 | Message string `json:"message,omitempty"` 20 | Request string `json:"request,omitempty"` 21 | 22 | // Logical operation and nested error. 23 | Op string `json:"op,omitempty"` 24 | Err error `json:"error,omitempty"` 25 | } 26 | 27 | // Error returns the string representation of the error message. 28 | func (e *Error) Error() string { 29 | var buf bytes.Buffer 30 | 31 | // Print the current operation in our stack, if any. 32 | if e.Op != "" { 33 | fmt.Fprintf(&buf, "%s: ", e.Op) 34 | } 35 | 36 | // If wrapping an error, print its Error() message. 37 | // Otherwise print the error code & message. 38 | if e.Err != nil { 39 | buf.WriteString(e.Err.Error()) 40 | } else { 41 | if e.HTTPStatusCode != 0 { 42 | fmt.Fprintf(&buf, "<%s> ", http.StatusText(e.HTTPStatusCode)) 43 | } 44 | buf.WriteString(e.Message) 45 | } 46 | return buf.String() 47 | } 48 | 49 | func NewError(code int, err error, op string) error { 50 | return &Error{ 51 | HTTPStatusCode: code, 52 | Err: err, 53 | Message: err.Error(), 54 | Op: op, 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /pkg/server/handler.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package server 6 | 7 | import ( 8 | "net/http" 9 | 10 | "github.com/cockroachdb/errors" 11 | "github.com/gin-gonic/gin" 12 | "github.com/sirupsen/logrus" 13 | swaggerfiles "github.com/swaggo/files" 14 | ginSwagger "github.com/swaggo/gin-swagger" 15 | 16 | "github.com/tensorchord/envd-server/pkg/web" 17 | ) 18 | 19 | func (s *Server) BindHandlers() { 20 | engine := s.Router 21 | web.RegisterRoute(engine) 22 | 23 | engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) 24 | 25 | v1 := engine.Group("/api/v1") 26 | 27 | v1.GET("/", WrapHandler(s.handlePing)) 28 | v1.POST("/register", WrapHandler(s.register)) 29 | v1.POST("/login", WrapHandler(s.login)) 30 | v1.POST("/config", s.OnConfig) 31 | v1.POST("/pubkey", s.OnPubKey) 32 | 33 | authorized := engine.Group("/api/v1/users") 34 | if s.Auth { 35 | authorized.Use(s.AuthMiddleware()) 36 | } else { 37 | authorized.Use(s.NoAuthMiddleware()) 38 | } 39 | 40 | // env 41 | authorized.POST("/:login_name/environments", WrapHandler(s.environmentCreate)) 42 | authorized.GET("/:login_name/environments", WrapHandler(s.environmentList)) 43 | authorized.GET("/:login_name/environments/:name", WrapHandler(s.environmentGet)) 44 | authorized.DELETE("/:login_name/environments/:name", WrapHandler(s.environmentRemove)) 45 | // image 46 | authorized.GET("/:login_name/images/:name", WrapHandler(s.imageGet)) 47 | authorized.GET("/:login_name/images", WrapHandler(s.imageList)) 48 | // key 49 | authorized.POST("/:login_name/keys", WrapHandler(s.keyCreate)) 50 | } 51 | 52 | type HandlerFunc func(c *gin.Context) error 53 | 54 | func WrapHandler(handler HandlerFunc) gin.HandlerFunc { 55 | return func(c *gin.Context) { 56 | err := handler(c) 57 | if err != nil { 58 | var serverErr *Error 59 | if !errors.As(err, &serverErr) { 60 | serverErr = &Error{ 61 | HTTPStatusCode: http.StatusInternalServerError, 62 | Err: err, 63 | Message: err.Error(), 64 | } 65 | } 66 | serverErr.Request = c.Request.Method + " " + c.Request.URL.String() 67 | 68 | if gin.Mode() == "debug" { 69 | logrus.Debugf("error: %+v", err) 70 | } else { 71 | // Remove detailed info when in the release mode 72 | serverErr.Op = "" 73 | serverErr.Err = nil 74 | } 75 | 76 | c.JSON(serverErr.HTTPStatusCode, serverErr) 77 | return 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /pkg/server/image_get.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package server 6 | 7 | import ( 8 | "net/http" 9 | 10 | "github.com/cockroachdb/errors" 11 | "github.com/gin-gonic/gin" 12 | 13 | "github.com/tensorchord/envd-server/api/types" 14 | "github.com/tensorchord/envd-server/errdefs" 15 | ) 16 | 17 | // @Summary Get the image. 18 | // @Description Get the image with the given image name. 19 | // @Security Authentication 20 | // @Tags image 21 | // @Accept json 22 | // @Produce json 23 | // @Param login_name path string true "login name" example("alice") 24 | // @Param name path string true "digest" example("python-example") 25 | // @Success 200 {object} types.ImageGetResponse 26 | // @Router /users/{login_name}/images/{name} [get] 27 | func (s *Server) imageGet(c *gin.Context) error { 28 | owner := c.GetString(ContextLoginName) 29 | 30 | var req types.ImageGetRequest 31 | if err := c.BindUri(&req); err != nil { 32 | return NewError(http.StatusInternalServerError, err, "gin.bind-json") 33 | } 34 | 35 | meta, err := s.ImageService.GetImageByName(c, owner, req.Name) 36 | if err != nil { 37 | if errdefs.IsNotFound(err) { 38 | return NewError(http.StatusNotFound, err, "image.get") 39 | } else if errdefs.IsUnauthorized(err) { 40 | return NewError(http.StatusUnauthorized, err, "image.get") 41 | } 42 | return NewError(http.StatusInternalServerError, err, "image.get") 43 | } 44 | if meta == nil { 45 | return NewError(http.StatusNotFound, 46 | errors.New("image metadata is not found"), "image.get") 47 | } 48 | 49 | c.JSON(http.StatusOK, types.ImageGetResponse{ 50 | ImageMeta: *meta, 51 | }) 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /pkg/server/image_list.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package server 6 | 7 | import ( 8 | "net/http" 9 | 10 | "github.com/gin-gonic/gin" 11 | 12 | "github.com/tensorchord/envd-server/api/types" 13 | "github.com/tensorchord/envd-server/errdefs" 14 | ) 15 | 16 | // @Summary List the images. 17 | // @Description List the images. 18 | // @Security Authentication 19 | // @Tags image 20 | // @Accept json 21 | // @Produce json 22 | // @Param login_name path string true "login name" example("alice") 23 | // @Success 200 {object} types.ImageListResponse 24 | // @Router /users/{login_name}/images [get] 25 | func (s *Server) imageList(c *gin.Context) error { 26 | owner := c.GetString(ContextLoginName) 27 | 28 | resp := types.ImageListResponse{} 29 | images, err := s.ImageService.ListImages(c.Request.Context(), owner) 30 | if err != nil { 31 | if errdefs.IsNotFound(err) { 32 | return NewError(http.StatusNotFound, err, "image.list") 33 | } else { 34 | return NewError(http.StatusInternalServerError, err, "image.list") 35 | } 36 | } 37 | 38 | resp.Items = images 39 | c.JSON(http.StatusOK, resp) 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /pkg/server/key_create.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package server 6 | 7 | import ( 8 | "net/http" 9 | 10 | "github.com/gin-gonic/gin" 11 | "golang.org/x/crypto/ssh" 12 | 13 | "github.com/tensorchord/envd-server/api/types" 14 | "github.com/tensorchord/envd-server/errdefs" 15 | ) 16 | 17 | // @Summary Create the key. 18 | // @Description Create the key. 19 | // @Security Authentication 20 | // @Tags key 21 | // @Accept json 22 | // @Produce json 23 | // @Param login_name path string true "login name" example("alice") 24 | // @Param request body types.KeyCreateRequest true "query params" 25 | // @Success 201 {object} types.KeyCreateResponse 26 | // @Router /users/{login_name}/keys [post] 27 | func (s Server) keyCreate(c *gin.Context) error { 28 | owner := c.GetString(ContextLoginName) 29 | 30 | var req types.KeyCreateRequest 31 | if err := c.BindJSON(&req); err != nil { 32 | return NewError(http.StatusInternalServerError, err, "gin.bind-json") 33 | } 34 | 35 | key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(req.PublicKey)) 36 | if err != nil { 37 | return NewError(http.StatusInternalServerError, err, "ssh.parse-auth-key") 38 | } 39 | 40 | if err := s.UserService.CreatePubKey(c.Request.Context(), 41 | owner, req.Name, key.Marshal()); err != nil { 42 | if errdefs.IsConflict(err) { 43 | return NewError(http.StatusConflict, err, "user.create-pubkey") 44 | } 45 | return NewError(http.StatusInternalServerError, err, "user.create-pubkey") 46 | } 47 | 48 | c.JSON(http.StatusOK, types.KeyCreateResponse{ 49 | Name: req.Name, 50 | PublicKey: req.PublicKey, 51 | LoginName: owner, 52 | }) 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /pkg/server/ping.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package server 6 | 7 | import ( 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | // handlePing is the handler for ping requrets. 12 | // @Summary Show the status of server. 13 | // @Description get the status of server. 14 | // @Tags root 15 | // @Accept */* 16 | // @Produce json 17 | // @Success 200 {object} map[string]interface{} 18 | // @Router / [get] 19 | func (s Server) handlePing(c *gin.Context) error { 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /pkg/server/server.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package server 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "os" 11 | "time" 12 | 13 | "github.com/cockroachdb/errors" 14 | "github.com/gin-contrib/cors" 15 | "github.com/gin-gonic/gin" 16 | "github.com/jackc/pgx/v4/pgxpool" 17 | "github.com/sirupsen/logrus" 18 | ginlogrus "github.com/toorop/gin-logrus" 19 | "golang.org/x/crypto/ssh" 20 | "k8s.io/client-go/kubernetes" 21 | "k8s.io/client-go/tools/clientcmd" 22 | 23 | _ "github.com/tensorchord/envd-server/pkg/docs" 24 | "github.com/tensorchord/envd-server/pkg/query" 25 | "github.com/tensorchord/envd-server/pkg/runtime" 26 | runtimek8s "github.com/tensorchord/envd-server/pkg/runtime/kubernetes" 27 | "github.com/tensorchord/envd-server/pkg/service/image" 28 | "github.com/tensorchord/envd-server/pkg/service/user" 29 | ) 30 | 31 | type Server struct { 32 | Router *gin.Engine 33 | AdminRouter *gin.Engine 34 | Runtime runtime.Provisioner 35 | 36 | serverFingerPrints []string 37 | 38 | // Auth shows if the auth is enabled. 39 | Auth bool 40 | 41 | UserService user.Service 42 | ImageService image.Service 43 | } 44 | 45 | type Opt struct { 46 | Debug bool 47 | KubeConfig string 48 | HostKeyPath string 49 | DBURL string 50 | 51 | NoAuth bool 52 | JWTSecret string 53 | JWTExpirationTimeout time.Duration 54 | ImagePullSecretName string 55 | ResourceQuotaEnabled bool 56 | } 57 | 58 | func New(opt Opt) (*Server, error) { 59 | // use the current context in kubeconfig 60 | k8sConfig, err := clientcmd.BuildConfigFromFlags( 61 | "", opt.KubeConfig) 62 | if err != nil { 63 | return nil, err 64 | } 65 | cli, err := kubernetes.NewForConfig(k8sConfig) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | // Connect to database 71 | conn, err := pgxpool.Connect(context.Background(), opt.DBURL) 72 | if err != nil { 73 | fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) 74 | os.Exit(1) 75 | } 76 | 77 | queries := query.New(conn) 78 | 79 | router := gin.New() 80 | router.Use(ginlogrus.Logger(logrus.StandardLogger())) 81 | router.Use(gin.Recovery()) 82 | if gin.Mode() == gin.DebugMode { 83 | logrus.SetLevel(logrus.DebugLevel) 84 | logrus.Debug("Allow CORS") 85 | router.Use(cors.New(cors.Config{ 86 | AllowOrigins: []string{"*"}, 87 | AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, 88 | AllowHeaders: []string{"*"}, 89 | })) 90 | } 91 | admin := gin.New() 92 | 93 | userService := user.NewService(queries, opt.JWTSecret, opt.JWTExpirationTimeout) 94 | imageService := image.NewService(queries) 95 | s := &Server{ 96 | Router: router, 97 | AdminRouter: admin, 98 | serverFingerPrints: make([]string, 0), 99 | Runtime: runtimek8s.NewProvisioner(cli, "default", 100 | opt.ImagePullSecretName, opt.ResourceQuotaEnabled), 101 | UserService: userService, 102 | ImageService: imageService, 103 | Auth: !opt.NoAuth, 104 | } 105 | 106 | // Load host key. 107 | if opt.HostKeyPath != "" { 108 | // read private key file 109 | pemBytes, err := os.ReadFile(opt.HostKeyPath) 110 | if err != nil { 111 | return nil, errors.Wrapf( 112 | err, "reading private key %s failed", opt.HostKeyPath) 113 | } 114 | if privateKey, err := ssh.ParsePrivateKey(pemBytes); err != nil { 115 | return nil, err 116 | } else { 117 | logrus.Debugf("load host key from %s", opt.HostKeyPath) 118 | fingerPrint := ssh.FingerprintSHA256(privateKey.PublicKey()) 119 | s.serverFingerPrints = append(s.serverFingerPrints, fingerPrint) 120 | } 121 | } 122 | 123 | // Bind the HTTP handlers. 124 | s.BindHandlers() 125 | return s, nil 126 | } 127 | 128 | func (s *Server) Run() error { 129 | return s.Router.Run() 130 | } 131 | -------------------------------------------------------------------------------- /pkg/server/types.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package server 6 | 7 | const ( 8 | ContextLoginName = "login-name" 9 | ) 10 | 11 | type AuthMiddlewareHeaderRequest struct { 12 | JWTToken string `header:"Authorization"` 13 | } 14 | 15 | type AuthMiddlewareURIRequest struct { 16 | LoginName string `uri:"login_name" example:"alice"` 17 | } 18 | -------------------------------------------------------------------------------- /pkg/service/image/metadata.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package image 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | 11 | "github.com/cockroachdb/errors" 12 | "github.com/containers/image/v5/docker" 13 | "github.com/containers/image/v5/image" 14 | containertypes "github.com/containers/image/v5/types" 15 | "github.com/sirupsen/logrus" 16 | 17 | "github.com/tensorchord/envd-server/api/types" 18 | "github.com/tensorchord/envd-server/pkg/consts" 19 | ) 20 | 21 | func (g generalService) fetchMetadata(ctx context.Context, imageName string) ( 22 | meta types.ImageMeta, err error) { 23 | ref, err := docker.ParseReference(fmt.Sprintf("//%s", imageName)) 24 | if err != nil { 25 | return meta, errors.Wrap(err, "docker.ParseReference") 26 | } 27 | sys := &containertypes.SystemContext{} 28 | src, err := ref.NewImageSource(ctx, sys) 29 | if err != nil { 30 | return meta, errors.Wrap(err, "ref.NewImageSource") 31 | } 32 | defer src.Close() 33 | digest, err := docker.GetDigest(ctx, sys, ref) 34 | if err != nil { 35 | return meta, errors.Wrap(err, "docker.GetDigest") 36 | } 37 | image, err := image.FromUnparsedImage(ctx, sys, image.UnparsedInstance(src, &digest)) 38 | if err != nil { 39 | return meta, errors.Wrap(err, "image.FromUnparsedImage") 40 | } 41 | inspect, err := image.Inspect(ctx) 42 | if err != nil { 43 | return meta, errors.Wrap(err, "image.Inspect") 44 | } 45 | 46 | // correct the image size 47 | var size int64 48 | for _, layer := range inspect.LayersData { 49 | size += layer.Size 50 | } 51 | 52 | meta = types.ImageMeta{ 53 | Name: imageName, 54 | Created: inspect.Created.Unix(), 55 | Digest: string(digest), 56 | Labels: inspect.Labels, 57 | Size: size, 58 | } 59 | 60 | // Get the apt packages 61 | if aptLabel, ok := inspect.Labels[consts.ImageLabelAPTPackages]; ok { 62 | packages, err := aptPackagesFromLabel(aptLabel) 63 | if err != nil { 64 | return meta, errors.Wrap(err, "apt packages") 65 | } 66 | meta.APTPackages = packages 67 | } 68 | 69 | // Get the pip packages 70 | if pipLabel, ok := inspect.Labels[consts.ImageLabelPythonCommands]; ok { 71 | commands, err := pythonCommandsFromLabel(pipLabel) 72 | if err != nil { 73 | return meta, errors.Wrap(err, "pip packages") 74 | } 75 | meta.PythonCommands = commands 76 | } 77 | 78 | // Get the services 79 | if servicesLabel, ok := inspect.Labels[consts.ImageLabelPorts]; ok { 80 | ports, err := portsFromLabel(servicesLabel) 81 | if err != nil { 82 | return meta, errors.Wrap(err, "services") 83 | } 84 | meta.Ports = ports 85 | } 86 | 87 | logrus.WithField("image meta", meta).Debug("get image meta before creating env") 88 | return meta, nil 89 | } 90 | -------------------------------------------------------------------------------- /pkg/service/image/util.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package image 6 | 7 | import ( 8 | "encoding/json" 9 | 10 | "github.com/tensorchord/envd-server/api/types" 11 | "github.com/tensorchord/envd-server/pkg/query" 12 | ) 13 | 14 | func daoToImageMeta(dao query.ImageInfo) (*types.ImageMeta, error) { 15 | var label map[string]string 16 | if err := dao.Labels.AssignTo(&label); err != nil { 17 | return nil, err 18 | } 19 | 20 | var aptPackages []string 21 | if err := dao.AptPackages.AssignTo(&aptPackages); err != nil { 22 | return nil, err 23 | } 24 | 25 | var pythonCommands []string 26 | if err := dao.PypiCommands.AssignTo(&pythonCommands); err != nil { 27 | return nil, err 28 | } 29 | 30 | var ports []types.EnvironmentPort 31 | if err := dao.Services.AssignTo(&ports); err != nil { 32 | return nil, err 33 | } 34 | 35 | meta := types.ImageMeta{ 36 | Name: dao.Name, 37 | Digest: dao.Digest, 38 | Created: dao.Created, 39 | Size: dao.Size, 40 | Labels: label, 41 | APTPackages: aptPackages, 42 | PythonCommands: pythonCommands, 43 | Ports: ports, 44 | } 45 | return &meta, nil 46 | } 47 | 48 | func portsFromLabel(label string) ([]types.EnvironmentPort, error) { 49 | var ports []types.EnvironmentPort 50 | if err := json.Unmarshal([]byte(label), &ports); err != nil { 51 | return nil, err 52 | } 53 | 54 | return ports, nil 55 | } 56 | 57 | func aptPackagesFromLabel(label string) ([]string, error) { 58 | var packages []string 59 | if err := json.Unmarshal([]byte(label), &packages); err != nil { 60 | return nil, err 61 | } 62 | return packages, nil 63 | } 64 | 65 | func pythonCommandsFromLabel(label string) ([]string, error) { 66 | var commands []string 67 | if err := json.Unmarshal([]byte(label), &commands); err != nil { 68 | return nil, err 69 | } 70 | return commands, nil 71 | } 72 | -------------------------------------------------------------------------------- /pkg/service/user/error.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package user 6 | -------------------------------------------------------------------------------- /pkg/service/user/jwt.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package user 6 | 7 | import ( 8 | "fmt" 9 | "time" 10 | 11 | "github.com/cockroachdb/errors" 12 | jwt "github.com/golang-jwt/jwt/v4" 13 | ) 14 | 15 | // Create a struct that will be encoded to a JWT. 16 | // We add jwt.RegisteredClaims as an embedded type, to provide fields like expiry time 17 | type Claims struct { 18 | Username string `json:"username"` 19 | jwt.RegisteredClaims 20 | } 21 | 22 | type JWTIssuer struct { 23 | ExpirationTime time.Duration 24 | Key string 25 | } 26 | 27 | func newJWTIssuer(expirationTime time.Duration, key string) *JWTIssuer { 28 | return &JWTIssuer{ 29 | ExpirationTime: expirationTime, 30 | Key: key, 31 | } 32 | } 33 | 34 | func (j *JWTIssuer) Issue(username string) (string, error) { 35 | // Create the Claims 36 | claims := Claims{ 37 | username, 38 | jwt.RegisteredClaims{ 39 | ExpiresAt: jwt.NewNumericDate(time.Now().Add(j.ExpirationTime)), 40 | }, 41 | } 42 | 43 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 44 | return token.SignedString([]byte(j.Key)) 45 | } 46 | 47 | func (j *JWTIssuer) Validate(token string) (string, error) { 48 | // Initialize a new instance of `Claims` 49 | claims := &Claims{} 50 | 51 | // Parse the JWT string and store the result in `claims`. 52 | // Note that we are passing the key in this method as well. This method will return an error 53 | // if the token is invalid (if it has expired according to the expiry time we set on sign in), 54 | // or if the signature does not match 55 | tkn, err := jwt.ParseWithClaims(token, claims, func(token *jwt.Token) (interface{}, error) { 56 | return []byte(j.Key), nil 57 | }) 58 | if err != nil { 59 | return "", errors.Wrap(err, "failed to parse token") 60 | } 61 | if !tkn.Valid { 62 | return "", fmt.Errorf("failed to parse the claims") 63 | } 64 | 65 | return claims.Username, nil 66 | } 67 | -------------------------------------------------------------------------------- /pkg/service/user/salt.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package user 6 | 7 | import "golang.org/x/crypto/bcrypt" 8 | 9 | func GenerateHashedSaltPassword(pwd []byte) ([]byte, error) { 10 | bcryptedPwd, err := bcrypt.GenerateFromPassword(pwd, bcrypt.DefaultCost) 11 | if err != nil { 12 | return []byte{}, err 13 | } 14 | return bcryptedPwd, nil 15 | } 16 | 17 | func CompareHashAndPassword(hashedPwd, pwd []byte) error { 18 | return bcrypt.CompareHashAndPassword(hashedPwd, pwd) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/service/user/sshkey.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package user 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/cockroachdb/errors" 11 | "github.com/jackc/pgconn" 12 | "github.com/jackc/pgerrcode" 13 | 14 | "github.com/tensorchord/envd-server/errdefs" 15 | "github.com/tensorchord/envd-server/pkg/query" 16 | ) 17 | 18 | func (u *generalService) ListPubKeys(ctx context.Context, loginName string) ([]query.Key, error) { 19 | return u.querier.ListKeys(ctx, loginName) 20 | } 21 | 22 | func (u *generalService) CreatePubKey(ctx context.Context, loginName, name string, pubKey []byte) error { 23 | _, err := u.querier.CreateKey(ctx, query.CreateKeyParams{ 24 | LoginName: loginName, 25 | PublicKey: pubKey, 26 | }) 27 | if err != nil { 28 | var pgErr *pgconn.PgError 29 | if errors.As(err, &pgErr) { 30 | switch pgErr.Code { 31 | case pgerrcode.UniqueViolation: 32 | return errdefs.Conflict(errors.New("key already exists")) 33 | } 34 | } 35 | return err 36 | } 37 | return err 38 | } 39 | 40 | func (u *generalService) GetPubKey(ctx context.Context, loginName, name string) ([]byte, error) { 41 | k, err := u.querier.GetKey(ctx, query.GetKeyParams{ 42 | LoginName: loginName, 43 | Name: name, 44 | }) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | return k.PublicKey, nil 50 | } 51 | -------------------------------------------------------------------------------- /pkg/service/user/user.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package user 6 | 7 | import ( 8 | "context" 9 | "time" 10 | 11 | "github.com/cockroachdb/errors" 12 | "github.com/jackc/pgconn" 13 | "github.com/jackc/pgerrcode" 14 | "github.com/jackc/pgx/v4" 15 | 16 | "github.com/tensorchord/envd-server/errdefs" 17 | "github.com/tensorchord/envd-server/pkg/query" 18 | ) 19 | 20 | type Service interface { 21 | Register(ctx context.Context, loginName, pwd string) (string, error) 22 | Login(ctx context.Context, loginName, pwd string, auth bool) (bool, string, error) 23 | 24 | GetPubKey(ctx context.Context, loginName string, keyName string) ([]byte, error) 25 | ListPubKeys(ctx context.Context, loginName string) ([]query.Key, error) 26 | CreatePubKey(ctx context.Context, loginName, keyName string, pubKey []byte) error 27 | 28 | ValidateJWT(token string) (string, error) 29 | } 30 | 31 | type generalService struct { 32 | querier query.Querier 33 | jwtIssuer *JWTIssuer 34 | } 35 | 36 | func NewService(querier query.Querier, 37 | secret string, expirationTimeDefault time.Duration) Service { 38 | return &generalService{ 39 | querier: querier, 40 | jwtIssuer: newJWTIssuer(expirationTimeDefault, secret), 41 | } 42 | } 43 | 44 | func (u *generalService) Register(ctx context.Context, 45 | loginName, pwd string) (string, error) { 46 | hashed, err := GenerateHashedSaltPassword([]byte(pwd)) 47 | if err != nil { 48 | return "", err 49 | } 50 | _, err = u.querier.CreateUser( 51 | ctx, query.CreateUserParams{ 52 | LoginName: loginName, 53 | PasswordHash: string(hashed), 54 | }) 55 | 56 | if err != nil { 57 | var pgErr *pgconn.PgError 58 | if errors.As(err, &pgErr) { 59 | switch pgErr.Code { 60 | case pgerrcode.UniqueViolation: 61 | return "", errdefs.Conflict(errors.New("login name already exists")) 62 | } 63 | } 64 | return "", err 65 | } 66 | 67 | token, err := u.jwtIssuer.Issue(loginName) 68 | if err != nil { 69 | return "", err 70 | } 71 | return token, nil 72 | } 73 | 74 | func (u *generalService) Login(ctx context.Context, 75 | loginName, pwd string, auth bool) (bool, string, error) { 76 | if auth { 77 | rawUser, err := u.querier.GetUser(ctx, loginName) 78 | if err != nil { 79 | if errors.Is(err, pgx.ErrNoRows) { 80 | return false, "", nil 81 | } 82 | return false, "", err 83 | } 84 | 85 | if err := CompareHashAndPassword( 86 | []byte(rawUser.PasswordHash), []byte(pwd)); err != nil { 87 | return false, "", err 88 | } 89 | } 90 | 91 | token, err := u.jwtIssuer.Issue(loginName) 92 | if err != nil { 93 | return false, "", err 94 | } 95 | return true, token, nil 96 | } 97 | 98 | func (u *generalService) ValidateJWT(token string) (string, error) { 99 | return u.jwtIssuer.Validate(token) 100 | } 101 | -------------------------------------------------------------------------------- /pkg/syncthing/config.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package syncthing 6 | 7 | import ( 8 | "encoding/xml" 9 | 10 | "github.com/syncthing/syncthing/lib/config" 11 | ) 12 | 13 | // @source: https://docs.syncthing.net/users/config.html 14 | func InitConfig() *config.Configuration { 15 | return &config.Configuration{ 16 | Version: 37, 17 | GUI: config.GUIConfiguration{ 18 | Enabled: true, 19 | RawAddress: "127.0.0.1:8384", 20 | APIKey: "envd", 21 | Theme: "default", 22 | }, 23 | Options: config.OptionsConfiguration{ 24 | GlobalAnnEnabled: false, 25 | LocalAnnEnabled: false, 26 | ReconnectIntervalS: 1, 27 | StartBrowser: true, // TODO: disable later 28 | NATEnabled: false, 29 | URAccepted: -1, // Disallow telemetry 30 | URPostInsecurely: false, 31 | URInitialDelayS: 1800, 32 | AutoUpgradeIntervalH: 0, // Disable auto upgrade 33 | StunKeepaliveStartS: 0, // Disable STUN keepalive 34 | }, 35 | } 36 | } 37 | 38 | func GetConfigByte(cfg *config.Configuration) ([]byte, error) { 39 | tmp := struct { 40 | XMLName xml.Name `xml:"configuration"` 41 | *config.Configuration 42 | }{ 43 | Configuration: cfg, 44 | } 45 | 46 | configByte, err := xml.MarshalIndent(tmp, "", " ") 47 | if err != nil { 48 | return []byte{}, err 49 | } 50 | 51 | return configByte, nil 52 | } 53 | -------------------------------------------------------------------------------- /pkg/syncthing/syncthing_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package syncthing_test 6 | 7 | import ( 8 | "testing" 9 | 10 | . "github.com/onsi/ginkgo/v2" 11 | . "github.com/onsi/gomega" 12 | 13 | "github.com/tensorchord/envd-server/pkg/syncthing" 14 | ) 15 | 16 | func TestSyncthing(t *testing.T) { 17 | RegisterFailHandler(Fail) 18 | RunSpecs(t, "Syncthing Suite") 19 | } 20 | 21 | var _ = Describe("Syncthing", func() { 22 | BeforeEach(func() { 23 | }) 24 | 25 | Describe("Syncthing", func() { 26 | It("Generates configuration string", func() { 27 | configByte, err := (syncthing.GetConfigByte(syncthing.InitConfig())) 28 | configStr := string(configByte) 29 | Expect(err).To(BeNil()) 30 | Expect(configStr).To(ContainSubstring("<configuration")) 31 | Expect(configStr).To(ContainSubstring("<apikey>envd</apikey>")) 32 | Expect(configStr).To(ContainSubstring("<address>127.0.0.1:8384</address>")) 33 | Expect(configStr).To(ContainSubstring("<globalAnnounceEnabled>false</globalAnnounceEnabled>")) 34 | }) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright The TensorChord Inc. 3 | Copyright The BuildKit Authors. 4 | Copyright The containerd Authors. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | package version 20 | 21 | import ( 22 | "fmt" 23 | "regexp" 24 | "runtime" 25 | "strings" 26 | "sync" 27 | ) 28 | 29 | var ( 30 | // Package is filled at linking time 31 | Package = "github.com/tensorchord/envd-server" 32 | 33 | // Revision is filled with the VCS (e.g. git) revision being used to build 34 | // the program at linking time. 35 | Revision = "" 36 | 37 | version = "0.0.0+unknown" 38 | buildDate = "1970-01-01T00:00:00Z" // output from `date -u +'%Y-%m-%dT%H:%M:%SZ'` 39 | gitCommit = "" // output from `git rev-parse HEAD` 40 | gitTag = "" // output from `git describe --exact-match --tags HEAD` (if clean tree state) 41 | gitTreeState = "" // determined from `git status --porcelain`. either 'clean' or 'dirty' 42 | developmentFlag = "false" 43 | ) 44 | 45 | // Version contains envd version information 46 | type Version struct { 47 | Version string 48 | BuildDate string 49 | GitCommit string 50 | GitTag string 51 | GitTreeState string 52 | GoVersion string 53 | Compiler string 54 | Platform string 55 | } 56 | 57 | func (v Version) String() string { 58 | return v.Version 59 | } 60 | 61 | // SetGitTagForE2ETest sets the gitTag for test purpose. 62 | func SetGitTagForE2ETest(tag string) { 63 | gitTag = tag 64 | } 65 | 66 | // GetEnvdVersion gets Envd version information 67 | func GetEnvdVersion() string { 68 | var versionStr string 69 | 70 | if gitCommit != "" && gitTag != "" && 71 | gitTreeState == "clean" && developmentFlag == "false" { 72 | // if we have a clean tree state and the current commit is tagged, 73 | // this is an official release. 74 | versionStr = gitTag 75 | } else { 76 | // otherwise formulate a version string based on as much metadata 77 | // information we have available. 78 | if strings.HasPrefix(version, "v") { 79 | versionStr = version 80 | } else { 81 | versionStr = "v" + version 82 | } 83 | if len(gitCommit) >= 7 { 84 | versionStr += "+" + gitCommit[0:7] 85 | if gitTreeState != "clean" { 86 | versionStr += ".dirty" 87 | } 88 | } else { 89 | versionStr += "+unknown" 90 | } 91 | } 92 | return versionStr 93 | } 94 | 95 | // GetVersion returns the version information 96 | func GetVersion() Version { 97 | return Version{ 98 | Version: GetEnvdVersion(), 99 | BuildDate: buildDate, 100 | GitCommit: gitCommit, 101 | GitTag: gitTag, 102 | GitTreeState: gitTreeState, 103 | GoVersion: runtime.Version(), 104 | Compiler: runtime.Compiler, 105 | Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), 106 | } 107 | } 108 | 109 | var ( 110 | reRelease *regexp.Regexp 111 | reDev *regexp.Regexp 112 | reOnce sync.Once 113 | ) 114 | 115 | func UserAgent() string { 116 | version := GetVersion().String() 117 | 118 | reOnce.Do(func() { 119 | reRelease = regexp.MustCompile(`^(v[0-9]+\.[0-9]+)\.[0-9]+$`) 120 | reDev = regexp.MustCompile(`^(v[0-9]+\.[0-9]+)\.[0-9]+`) 121 | }) 122 | 123 | if matches := reRelease.FindAllStringSubmatch(version, 1); len(matches) > 0 { 124 | version = matches[0][1] 125 | } else if matches := reDev.FindAllStringSubmatch(version, 1); len(matches) > 0 { 126 | version = matches[0][1] + "-dev" 127 | } 128 | 129 | return "envd/" + version 130 | } 131 | -------------------------------------------------------------------------------- /pkg/web/static_serving.go: -------------------------------------------------------------------------------- 1 | //go:build !debug 2 | 3 | /* 4 | Copyright The TensorChord Inc. 5 | Copyright The BuildKit Authors. 6 | Copyright The containerd Authors. 7 | 8 | Licensed under the Apache License, Version 2.0 (the "License"); 9 | you may not use this file except in compliance with the License. 10 | You may obtain a copy of the License at 11 | 12 | http://www.apache.org/licenses/LICENSE-2.0 13 | 14 | Unless required by applicable law or agreed to in writing, software 15 | distributed under the License is distributed on an "AS IS" BASIS, 16 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | See the License for the specific language governing permissions and 18 | limitations under the License. 19 | */ 20 | 21 | package web 22 | 23 | import ( 24 | "io/fs" 25 | "net/http" 26 | "strings" 27 | 28 | "github.com/gin-gonic/gin" 29 | 30 | "github.com/tensorchord/envd-server/dashboard" 31 | ) 32 | 33 | func RegisterRoute(route *gin.Engine) { 34 | webRoot, _ := fs.Sub(dashboard.DistFS, "dist") 35 | route.StaticFS("/dashboard", http.FS(webRoot)) 36 | route.StaticFileFS("/favicon.ico", "favicon.ico", http.FS(webRoot)) 37 | route.NoRoute(func(c *gin.Context) { 38 | if strings.HasPrefix(c.Request.URL.Path, "/dashboard") { 39 | c.FileFromFS("/", http.FS(webRoot)) 40 | } else { 41 | c.String(404, "404 not found") 42 | } 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /pkg/web/static_serving_debug.go: -------------------------------------------------------------------------------- 1 | //go:build debug 2 | 3 | /* 4 | Copyright The TensorChord Inc. 5 | Copyright The BuildKit Authors. 6 | Copyright The containerd Authors. 7 | 8 | Licensed under the Apache License, Version 2.0 (the "License"); 9 | you may not use this file except in compliance with the License. 10 | You may obtain a copy of the License at 11 | 12 | http://www.apache.org/licenses/LICENSE-2.0 13 | 14 | Unless required by applicable law or agreed to in writing, software 15 | distributed under the License is distributed on an "AS IS" BASIS, 16 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | See the License for the specific language governing permissions and 18 | limitations under the License. 19 | */ 20 | 21 | package web 22 | 23 | import ( 24 | "net/http" 25 | 26 | "github.com/gin-gonic/gin" 27 | ) 28 | 29 | func RegisterRoute(route *gin.Engine) { 30 | // Do nothing, we don't need dashboard in this case 31 | route.GET("/dashboard", func(c *gin.Context) { 32 | c.String(http.StatusOK, "Hello, dashboard!") 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /sql/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM arigaio/atlas:latest 2 | 3 | COPY schema /migrations -------------------------------------------------------------------------------- /sql/README.md: -------------------------------------------------------------------------------- 1 | # SQL 2 | 3 | We use `goose` file format for migrations 4 | 5 | ## Generate golang code from SQL 6 | 7 | We use [sqlc](https://github.com/kyleconroy/sqlc) for the SQL related codes. To generate the golang code: 8 | - Install sqlc: `go install github.com/kyleconroy/sqlc/cmd/sqlc@latest` 9 | - Execute `sqlc generate` at the project root 10 | 11 | ## Apply migration files 12 | 13 | Run `atlas migrate apply --dir=file://schema?format=goose --url <dburl> --allow-dirty`. dburl is the database connection string for the database you want to change, such as `postgres://username:password@localhost:5432/database_name`. 14 | 15 | ## Write new migration tools 16 | 17 | ### Install atlas 18 | 19 | Please find the guide at https://atlasgo.io/getting-started/ 20 | 21 | ### Generate hcl file from current db schema 22 | 23 | Run `atlas schema inspect --url "postgres://postgres:atlasdev@localhost:5432/postgres?sslmode=disable" --schema=public > atlas_schema.hcl` 24 | 25 | ### Rehash the atlas migration 26 | 27 | Run `atlas migrate hash --dir file://schema` 28 | 29 | ### Author a new migration 30 | 31 | - `docker run --name atlas-dev-postgres --rm -e POSTGRES_PASSWORD=atlasdev -p5432:5432 -d postgres` 32 | - Update the `atlas_schema.hcl` file 33 | - Run `atlas migrate diff --dir file://schema/ --dir-format goose <summary> --to "file://atlas_schema.hcl" --dev-url "postgres://postgres:atlasdev@localhost:5432/postgres?sslmode=disable"`. This will generate a migration file from the existing schema files to the state described in `atlas_schema.hcl` file. The migration file will be created at `schema/<timestamp>_<summary>.sql`. 34 | -------------------------------------------------------------------------------- /sql/atlas_schema.hcl: -------------------------------------------------------------------------------- 1 | table "image_info" { 2 | schema = schema.public 3 | column "id" { 4 | null = false 5 | type = bigserial 6 | } 7 | column "name" { 8 | null = false 9 | type = text 10 | } 11 | column "digest" { 12 | null = false 13 | type = text 14 | } 15 | column "created" { 16 | null = false 17 | type = bigint 18 | } 19 | column "size" { 20 | null = false 21 | type = bigint 22 | } 23 | column "labels" { 24 | null = true 25 | type = jsonb 26 | } 27 | column "login_name" { 28 | null = false 29 | type = character_varying(100) 30 | } 31 | column "apt_packages" { 32 | null = false 33 | type = jsonb 34 | } 35 | column "pypi_commands" { 36 | null = false 37 | type = jsonb 38 | } 39 | column "services" { 40 | null = false 41 | type = jsonb 42 | } 43 | primary_key { 44 | columns = [column.id] 45 | } 46 | index "unique_login_name_and_digest" { 47 | unique = true 48 | columns = [column.digest, column.login_name] 49 | } 50 | } 51 | table "users" { 52 | schema = schema.public 53 | column "id" { 54 | null = false 55 | type = bigserial 56 | } 57 | column "login_name" { 58 | null = false 59 | type = character_varying(100) 60 | } 61 | column "password_hash" { 62 | null = false 63 | type = text 64 | } 65 | primary_key { 66 | columns = [column.id] 67 | } 68 | index "unique_login_name" { 69 | unique = true 70 | columns = [column.login_name] 71 | } 72 | } 73 | table "keys" { 74 | schema = schema.public 75 | column "id" { 76 | null = false 77 | type = bigserial 78 | } 79 | column "name" { 80 | null = false 81 | type = text 82 | } 83 | column "login_name" { 84 | null = false 85 | type = character_varying(100) 86 | } 87 | column "public_key" { 88 | null = false 89 | type = bytea 90 | } 91 | primary_key { 92 | columns = [column.id] 93 | } 94 | index "unique_login_name_and_key" { 95 | unique = true 96 | columns = [column.login_name, column.name] 97 | } 98 | } 99 | schema "public" { 100 | } 101 | -------------------------------------------------------------------------------- /sql/query/image.sql: -------------------------------------------------------------------------------- 1 | -- name: GetImageInfoByName :one 2 | SELECT * FROM image_info 3 | WHERE login_name = $1 AND name = $2 LIMIT 1; 4 | 5 | -- name: GetImageInfoByDigest :one 6 | SELECT * FROM image_info 7 | WHERE login_name = $1 AND digest = $2 LIMIT 1; 8 | 9 | -- name: ListImageByOwner :many 10 | SELECT * FROM image_info 11 | WHERE login_name = $1; 12 | 13 | -- name: CreateImageInfo :one 14 | INSERT INTO image_info ( 15 | login_name, name, digest, created, size, 16 | labels, apt_packages, pypi_commands, services 17 | ) VALUES ( 18 | $1, $2, $3, $4, $5, $6, $7, $8, $9 19 | ) 20 | RETURNING *; 21 | -------------------------------------------------------------------------------- /sql/query/key.sql: -------------------------------------------------------------------------------- 1 | -- name: ListKeys :many 2 | SELECT * FROM keys 3 | WHERE login_name = $1; 4 | 5 | -- name: GetKey :one 6 | SELECT * FROM keys 7 | WHERE login_name = $1 AND name = $2 LIMIT 1; 8 | 9 | -- name: CreateKey :one 10 | INSERT INTO keys ( 11 | login_name, name, public_key 12 | ) VALUES ( 13 | $1, $2, $3 14 | ) 15 | RETURNING login_name, name, public_key; 16 | -------------------------------------------------------------------------------- /sql/query/user.sql: -------------------------------------------------------------------------------- 1 | -- name: GetUser :one 2 | SELECT * FROM users 3 | WHERE login_name = $1 LIMIT 1; 4 | 5 | -- name: ListUsers :many 6 | SELECT * FROM users 7 | ORDER BY id; 8 | 9 | -- name: CreateUser :one 10 | INSERT INTO users ( 11 | login_name, password_hash 12 | ) VALUES ( 13 | $1, $2 14 | ) 15 | RETURNING login_name; 16 | 17 | -- name: DeleteUser :exec 18 | DELETE FROM users 19 | WHERE id = $1; 20 | -------------------------------------------------------------------------------- /sql/schema/20221206162738_create_user.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE TABLE IF NOT EXISTS public.users ( 3 | id BIGSERIAL PRIMARY KEY, 4 | identity_token text NOT NULL, 5 | public_key bytea NOT NULL 6 | ); 7 | 8 | -- +goose Down 9 | DROP TABLE users -------------------------------------------------------------------------------- /sql/schema/20221206162846_create_image.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE TABLE IF NOT EXISTS image_info ( 3 | id BIGSERIAL PRIMARY KEY, 4 | owner_token text NOT NULL, 5 | name text NOT NULL, 6 | digest text NOT NULL, 7 | created bigint NOT NULL, 8 | size bigint NOT NULL, 9 | labels JSONB 10 | ); 11 | 12 | -- +goose Down 13 | DROP TABLE image_info -------------------------------------------------------------------------------- /sql/schema/20221207142402_add_users_name.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- modify "users" table 3 | -- atlas:nolint 4 | ALTER TABLE "public"."users" DROP COLUMN "identity_token", ADD COLUMN "login_name" character varying(100) NOT NULL, ADD COLUMN "password_hash" text NOT NULL; 5 | -- create index "unique_login_name" to table: "users" 6 | -- atlas:nolint 7 | CREATE UNIQUE INDEX "unique_login_name" ON "public"."users" ("login_name"); 8 | 9 | -- +goose Down 10 | -- reverse: create index "unique_login_name" to table: "users" 11 | -- atlas:nolint 12 | DROP INDEX "public"."unique_login_name"; 13 | -- reverse: modify "users" table 14 | -- atlas:nolint 15 | ALTER TABLE "public"."users" DROP COLUMN "password_hash", DROP COLUMN "login_name", ADD COLUMN "identity_token" text NOT NULL; 16 | -------------------------------------------------------------------------------- /sql/schema/20221221100643_rename_identity_token_and_add_packages.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- modify "image_info" table 3 | ALTER TABLE "public"."image_info" DROP COLUMN "owner_token", ADD COLUMN "login_name" text NOT NULL, ADD COLUMN "apt_packages" jsonb NOT NULL, ADD COLUMN "pypi_commands" jsonb NOT NULL, ADD COLUMN "services" jsonb NOT NULL; 4 | -- create index "unique_digest" to table: "image_info" 5 | CREATE UNIQUE INDEX "unique_digest" ON "public"."image_info" ("digest"); 6 | 7 | -- +goose Down 8 | -- reverse: create index "unique_digest" to table: "image_info" 9 | DROP INDEX "public"."unique_digest"; 10 | -- reverse: modify "image_info" table 11 | ALTER TABLE "public"."image_info" DROP COLUMN "services", DROP COLUMN "pypi_commands", DROP COLUMN "apt_packages", DROP COLUMN "login_name", ADD COLUMN "owner_token" text NOT NULL; 12 | -------------------------------------------------------------------------------- /sql/schema/20221222034229_add_keys_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- modify "image_info" table 3 | ALTER TABLE "public"."image_info" ALTER COLUMN "login_name" TYPE character varying(100); 4 | -- modify "users" table 5 | ALTER TABLE "public"."users" DROP COLUMN "public_key"; 6 | -- create "keys" table 7 | CREATE TABLE "public"."keys" ("id" bigserial NOT NULL, "name" text NOT NULL, "login_name" character varying(100) NOT NULL, "public_key" bytea NOT NULL, PRIMARY KEY ("id")); 8 | -- create index "unique_login_name_and_key" to table: "keys" 9 | CREATE UNIQUE INDEX "unique_login_name_and_key" ON "public"."keys" ("login_name", "name"); 10 | 11 | -- +goose Down 12 | -- reverse: create index "unique_login_name_and_key" to table: "keys" 13 | DROP INDEX "public"."unique_login_name_and_key"; 14 | -- reverse: create "keys" table 15 | DROP TABLE "public"."keys"; 16 | -- reverse: modify "users" table 17 | ALTER TABLE "public"."users" ADD COLUMN "public_key" bytea NOT NULL; 18 | -- reverse: modify "image_info" table 19 | ALTER TABLE "public"."image_info" ALTER COLUMN "login_name" TYPE text; 20 | -------------------------------------------------------------------------------- /sql/schema/20230119145105_alter_image_digest_index.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- drop index "unique_digest" from table: "image_info" 3 | DROP INDEX "public"."unique_digest"; 4 | -- create index "unique_login_name_and_digest" to table: "image_info" 5 | CREATE UNIQUE INDEX "unique_login_name_and_digest" ON "public"."image_info" ("digest", "login_name"); 6 | 7 | -- +goose Down 8 | -- reverse: create index "unique_login_name_and_digest" to table: "image_info" 9 | DROP INDEX "public"."unique_login_name_and_digest"; 10 | -- reverse: drop index "unique_digest" from table: "image_info" 11 | CREATE UNIQUE INDEX "unique_digest" ON "public"."image_info" ("digest"); 12 | -------------------------------------------------------------------------------- /sql/schema/atlas.sum: -------------------------------------------------------------------------------- 1 | h1:eWpNeIF1ebjwIYsNDDp39uaDGXFN8s/TBQawxUJurzE= 2 | 20221206162738_create_user.sql h1:0GtqhnZp7B19DFTPxOmW3DXKMQiksuvaA2ibjG3Fy3s= 3 | 20221206162846_create_image.sql h1:o/RlrpWmd/VyHQ+zuCBarkE/xT0m5XNt/gsOd81+OTk= 4 | 20221207142402_add_users_name.sql h1:2QRCMcNzoVnrgFosueAdvI1XrNfQVZQxgf74aM1MRlU= 5 | 20221221100643_rename_identity_token_and_add_packages.sql h1:H97Tpz5mv/79Odp13oDrvxn8PrtBKxEYPRNAtzMQQZc= 6 | 20221222034229_add_keys_table.sql h1:hyPYBiEGHAdvZ7zmo11mYni2SrxUcZFBfI+PTfirjgo= 7 | 20230119145105_alter_image_digest_index.sql h1:XrpABf32urN7ePuSs/ecEFRxr3oJh+WZo4LPFSqpIWA= 8 | -------------------------------------------------------------------------------- /sqlc.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | sql: 3 | - engine: "postgresql" 4 | queries: "sql/query/" 5 | schema: "sql/schema/" 6 | gen: 7 | go: 8 | package: "query" 9 | sql_package: "pgx/v4" 10 | out: "pkg/query" 11 | emit_prepared_queries: true 12 | emit_interface: true 13 | emit_exact_table_names: false 14 | emit_json_tags: true -------------------------------------------------------------------------------- /sshname/name.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package sshname 6 | 7 | import ( 8 | "fmt" 9 | "strings" 10 | ) 11 | 12 | func Username(owner, envName string) (string, error) { 13 | return fmt.Sprintf("%s/%s", owner, envName), nil 14 | } 15 | 16 | func GetInfo(username string) (string, string, error) { 17 | s := strings.Split(username, "/") 18 | if len(s) != 2 { 19 | return "", "", 20 | fmt.Errorf("failed to get owner and environment name from the ssh username") 21 | } 22 | return s[0], s[1], nil 23 | } 24 | -------------------------------------------------------------------------------- /test/environments/list_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package environments 6 | 7 | import ( 8 | "context" 9 | 10 | . "github.com/onsi/ginkgo/v2" 11 | . "github.com/onsi/gomega" 12 | "github.com/sirupsen/logrus" 13 | 14 | "github.com/google/uuid" 15 | 16 | "github.com/tensorchord/envd-server/client" 17 | "github.com/tensorchord/envd-server/test/util" 18 | ) 19 | 20 | var _ = Describe("environments", Ordered, func() { 21 | identityToken := uuid.New().String() 22 | logger := logrus.WithField("test-case", "environment-list"). 23 | WithField("identity-token", identityToken) 24 | logger.Debug("Running test cases") 25 | // TODO(gaocegege): Add creation test case. 26 | Describe("with newly created environments", func() { 27 | logger.Debug(identityToken) 28 | srv, err := util.NewServer(util.NewPod("test", identityToken)) 29 | Expect(err).Should(BeNil()) 30 | cli, err := client.NewClientWithOpts(client.WithJWTToken(identityToken, "")) 31 | Expect(err).Should(BeNil()) 32 | 33 | go func() { 34 | err := srv.Run() 35 | Expect(err).Should(BeNil()) 36 | }() 37 | It("should get the newly created environments", func() { 38 | resp, err := cli.EnvironmentList(context.TODO()) 39 | Expect(err).Should(BeNil()) 40 | Expect(len(resp.Items)).Should(Equal(1)) 41 | }) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /test/environments/suite_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package environments 6 | 7 | import ( 8 | "testing" 9 | 10 | . "github.com/onsi/ginkgo/v2" 11 | . "github.com/onsi/gomega" 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | func TestEnvdServer(t *testing.T) { 16 | logrus.SetLevel(logrus.DebugLevel) 17 | RegisterFailHandler(Fail) 18 | RunSpecs(t, "Environment Suite") 19 | } 20 | -------------------------------------------------------------------------------- /test/query/query_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package query 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/golang/mock/gomock" 11 | . "github.com/onsi/ginkgo/v2" 12 | . "github.com/onsi/gomega" 13 | 14 | "github.com/tensorchord/envd-server/pkg/query" 15 | "github.com/tensorchord/envd-server/pkg/query/mock" 16 | "github.com/tensorchord/envd-server/pkg/service/user" 17 | ) 18 | 19 | var _ = Describe("Mock test for db", func() { 20 | When("When change user data", func() { 21 | It("should work", func() { 22 | username := "test" 23 | pwd := "pwd" 24 | 25 | hashed, err := user.GenerateHashedSaltPassword([]byte(pwd)) 26 | Expect(err).Should(BeNil()) 27 | ctrl := gomock.NewController(GinkgoT()) 28 | m := mock.NewMockQuerier(ctrl) 29 | m.EXPECT().CreateUser( 30 | context.Background(), 31 | gomock.All(), 32 | ).Return( 33 | username, nil, 34 | ) 35 | m.EXPECT().GetUser( 36 | context.Background(), username).Return( 37 | query.User{ 38 | LoginName: username, 39 | PasswordHash: string(hashed), 40 | }, nil, 41 | ) 42 | userService := user.NewService(m, "secret", 0) 43 | _, err = userService.Register(context.Background(), username, pwd) 44 | Expect(err).NotTo(HaveOccurred()) 45 | exists, _, err := userService.Login(context.Background(), username, pwd, true) 46 | Expect(exists).To(BeTrue()) 47 | Expect(err).NotTo(HaveOccurred()) 48 | }) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /test/query/suite_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package query 6 | 7 | import ( 8 | "testing" 9 | 10 | . "github.com/onsi/ginkgo/v2" 11 | . "github.com/onsi/gomega" 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | func TestEnvdServer(t *testing.T) { 16 | logrus.SetLevel(logrus.DebugLevel) 17 | RegisterFailHandler(Fail) 18 | RunSpecs(t, "query mock Suite") 19 | } 20 | -------------------------------------------------------------------------------- /test/util/environment.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package util 6 | 7 | import ( 8 | "fmt" 9 | 10 | v1 "k8s.io/api/core/v1" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | 13 | "github.com/tensorchord/envd-server/pkg/consts" 14 | ) 15 | 16 | func NewPod(name, it string) *v1.Pod { 17 | var defaultPermMode int32 = 0666 18 | 19 | p := v1.Pod{ 20 | ObjectMeta: metav1.ObjectMeta{ 21 | Name: name, 22 | Namespace: "default", 23 | Labels: map[string]string{ 24 | consts.PodLabelUID: it, 25 | }, 26 | Annotations: map[string]string{ 27 | consts.ImageLabelPorts: ` 28 | [{"name": "ssh", "port": 2222}]`, 29 | }, 30 | }, 31 | Spec: v1.PodSpec{ 32 | Containers: []v1.Container{ 33 | { 34 | Name: "envd", 35 | Image: "test", 36 | Ports: []v1.ContainerPort{ 37 | { 38 | Name: "ssh", 39 | ContainerPort: 2222, 40 | }, 41 | }, 42 | Env: []v1.EnvVar{ 43 | { 44 | Name: "ENVD_HOST_KEY", 45 | Value: "test", 46 | }, 47 | { 48 | Name: "ENVD_AUTHORIZED_KEYS_PATH", 49 | Value: "test", 50 | }, 51 | { 52 | Name: "ENVD_WORKDIR", 53 | Value: fmt.Sprintf("/home/envd/%s", "test"), 54 | }, 55 | }, 56 | VolumeMounts: []v1.VolumeMount{ 57 | { 58 | Name: "secret", 59 | ReadOnly: true, 60 | MountPath: "test", 61 | SubPath: "hostkey", 62 | }, 63 | { 64 | Name: "secret", 65 | ReadOnly: true, 66 | MountPath: "test", 67 | SubPath: "publickey", 68 | }, 69 | }, 70 | }, 71 | }, 72 | Volumes: []v1.Volume{ 73 | { 74 | Name: "secret", 75 | VolumeSource: v1.VolumeSource{ 76 | Secret: &v1.SecretVolumeSource{ 77 | SecretName: "envd-server", 78 | DefaultMode: &defaultPermMode, 79 | }, 80 | }, 81 | }, 82 | }, 83 | }, 84 | } 85 | return &p 86 | } 87 | -------------------------------------------------------------------------------- /test/util/server.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | package util 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/sirupsen/logrus" 12 | ginlogrus "github.com/toorop/gin-logrus" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/apimachinery/pkg/runtime" 15 | kubernetes "k8s.io/client-go/kubernetes/fake" 16 | 17 | runtimek8s "github.com/tensorchord/envd-server/pkg/runtime/kubernetes" 18 | "github.com/tensorchord/envd-server/pkg/server" 19 | ) 20 | 21 | func NewServer(objects ...runtime.Object) (*server.Server, error) { 22 | cli := kubernetes.NewSimpleClientset(objects...) 23 | 24 | router := gin.New() 25 | router.Use(ginlogrus.Logger(logrus.StandardLogger())) 26 | router.Use(gin.Recovery()) 27 | if gin.Mode() == gin.DebugMode { 28 | logrus.SetLevel(logrus.DebugLevel) 29 | } 30 | admin := gin.New() 31 | s := &server.Server{ 32 | Router: router, 33 | AdminRouter: admin, 34 | Runtime: runtimek8s.NewProvisioner(cli, "default", "", false), 35 | Auth: false, 36 | } 37 | logrus.Debug(cli.CoreV1().Pods("default").List(context.Background(), metav1.ListOptions{})) 38 | 39 | s.BindHandlers() 40 | return s, nil 41 | } 42 | --------------------------------------------------------------------------------