Repository: tensorchord/envd (language: python)

Issue:
feat(CLI): envd create command
## Description

`envd create` will be introduced as the new command. It builds the image and launches the container/pod. If the runner is `envd-server`, the image needs to be pushed first.

Here, the endpoint from `envd-server` needs to be designed carefully.

```
POST /v1/environments
To be determined.
```

And, the pod name should be searchable. The envd-server needs to find the correct pod from the limited information (identity token and project name) when users run `ssh`.

Ref #303 #939 

---
<!-- Issue Author: Don't delete this message to encourage other users to support your issue! -->
**Message from the maintainers**:

Love this enhancement proposal? Give it a 👍. We prioritise the proposals with the most 👍.



Discussion / hints:
And, in the issue, we need to refactor the func StartEnvd() in the engine interface.
We will implement pod creation first:

`envd create --image=xxx`

It's optional.

Fix patch:
diff --git a/go.mod b/go.mod
index df9560cac..a4633e6ec 100644
--- a/go.mod
+++ b/go.mod
@@ -28,7 +28,7 @@ require (
 	github.com/sirupsen/logrus v1.9.0
 	github.com/spf13/viper v1.13.0
 	github.com/stretchr/testify v1.8.0
-	github.com/tensorchord/envd-server v0.0.2
+	github.com/tensorchord/envd-server v0.0.3
 	github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea
 	github.com/tonistiigi/vt100 v0.0.0-20210615222946-8066bb97264f
 	github.com/urfave/cli/v2 v2.19.2
@@ -37,11 +37,22 @@ require (
 	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
 	golang.org/x/term v0.0.0-20220919170432-7a66f970e087
 	golang.org/x/time v0.0.0-20220920022843-2ce7c2934d45
+	k8s.io/api v0.25.2
 )
 
 require (
+	github.com/google/gofuzz v1.2.0 // indirect
+	github.com/json-iterator/go v1.1.12 // indirect
+	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/op/go-logging v0.0.0-20160211212156-b2cb9fa56473 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
+	gopkg.in/inf.v0 v0.9.1 // indirect
+	k8s.io/apimachinery v0.25.2 // indirect
+	k8s.io/klog/v2 v2.80.1 // indirect
+	k8s.io/utils v0.0.0-20220823124924-e9cbc92d1a73 // indirect
+	sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 // indirect
+	sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect
 )
 
 require (
diff --git a/go.sum b/go.sum
index 66de66af8..4d6a53d8f 100644
--- a/go.sum
+++ b/go.sum
@@ -236,6 +236,8 @@ github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2
 github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
 github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
 github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
+github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
+github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
 github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
 github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -312,6 +314,8 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
+github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
 github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
 github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -374,6 +378,8 @@ github.com/jgautheron/codename-generator v0.0.0-20150829203204-16d037c7cc3c/go.m
 github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
 github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
 github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
@@ -463,9 +469,12 @@ github.com/moby/term v0.0.0-20201110203204-bea5bbe245bf/go.mod h1:FBS0z0QWA44HXy
 github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc=
 github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
 github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
 github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
@@ -574,6 +583,7 @@ github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9
 github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
 github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
 github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
+github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
 github.com/spf13/afero v1.8.2 h1:xehSyVa0YnHWsJ49JFljMpg1HX19V6NDZ1fkm1Xznbo=
 github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
 github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
@@ -607,8 +617,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
 github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
 github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
 github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
-github.com/tensorchord/envd-server v0.0.2 h1:GJbRydV3xfo1TxUWGreoY3nECgUDOEpVTHdTPpAhH2g=
-github.com/tensorchord/envd-server v0.0.2/go.mod h1:GXzQ2sZX5zxRODSDW9Fcdp6VltEprgyVp7HSug2t7bM=
+github.com/tensorchord/envd-server v0.0.3 h1:4QzvkM6f3nJRqFN/aVsDkEp1F/vxAEJ3xF1wNhDF9qc=
+github.com/tensorchord/envd-server v0.0.3/go.mod h1:AWUk101rs4xl7wHmQmafZ8X5TSV/ZR9AD7BysLkIMIo=
 github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
 github.com/tonistiigi/fsutil v0.0.0-20220115021204-b19f7f9cb274 h1:wbyZxD6IPFp0sl5uscMOJRsz5UKGFiNiD16e+MVfKZY=
 github.com/tonistiigi/fsutil v0.0.0-20220115021204-b19f7f9cb274/go.mod h1:oPAfvw32vlUJSjyDcQ3Bu0nb2ON2B+G0dtVN/SZNJiA=
@@ -1088,6 +1098,8 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
 gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
 gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
+gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
+gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
 gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
 gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
@@ -1123,6 +1135,20 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
 honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+k8s.io/api v0.25.2 h1:v6G8RyFcwf0HR5jQGIAYlvtRNrxMJQG1xJzaSeVnIS8=
+k8s.io/api v0.25.2/go.mod h1:qP1Rn4sCVFwx/xIhe+we2cwBLTXNcheRyYXwajonhy0=
+k8s.io/apimachinery v0.25.2 h1:WbxfAjCx+AeN8Ilp9joWnyJ6xu9OMeS/fsfjK/5zaQs=
+k8s.io/apimachinery v0.25.2/go.mod h1:hqqA1X0bsgsxI6dXsJ4HnNTBOmJNxyPp8dw3u2fSHwA=
+k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE=
+k8s.io/klog/v2 v2.80.1 h1:atnLQ121W371wYYFawwYx1aEY2eUfs4l3J72wtgAwV4=
+k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
+k8s.io/utils v0.0.0-20220823124924-e9cbc92d1a73 h1:H9TCJUUx+2VA0ZiD9lvtaX8fthFsMoD+Izn93E/hm8U=
+k8s.io/utils v0.0.0-20220823124924-e9cbc92d1a73/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
 rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
 rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
 rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
+sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 h1:iXTIw73aPyC+oRdyqqvVJuloN1p0AC/kzH07hu3NE+k=
+sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
+sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kFxnAMREiWFE=
+sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E=
+sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
diff --git a/pkg/app/app.go b/pkg/app/app.go
index 722ab0d10..c08f33370 100644
--- a/pkg/app/app.go
+++ b/pkg/app/app.go
@@ -61,6 +61,7 @@ func New() EnvdApp {
 
 	internalApp.Commands = []*cli.Command{
 		CommandBootstrap,
+		CommandCreate,
 		CommandContext,
 		CommandBuild,
 		CommandDestroy,
@@ -68,8 +69,6 @@ func New() EnvdApp {
 		CommandImage,
 		CommandInit,
 		CommandLogin,
-		CommandK8s,
-		CommandSSH,
 		CommandPause,
 		CommandPrune,
 		CommandRun,
diff --git a/pkg/app/create.go b/pkg/app/create.go
new file mode 100644
index 000000000..131e37bfb
--- /dev/null
+++ b/pkg/app/create.go
@@ -0,0 +1,123 @@
+// Copyright 2022 The envd Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"time"
+
+	"github.com/cockroachdb/errors"
+	"github.com/sirupsen/logrus"
+	"github.com/urfave/cli/v2"
+
+	"github.com/tensorchord/envd/pkg/envd"
+	"github.com/tensorchord/envd/pkg/home"
+	"github.com/tensorchord/envd/pkg/ssh"
+	sshconfig "github.com/tensorchord/envd/pkg/ssh/config"
+	"github.com/tensorchord/envd/pkg/types"
+)
+
+var CommandCreate = &cli.Command{
+	Name:        "create",
+	Category:    CategoryBasic,
+	Aliases:     []string{"c"},
+	Usage:       "Create the envd environment from the existing image",
+	Hidden:      true,
+	Description: ``,
+	Flags: []cli.Flag{
+		&cli.StringFlag{
+			Name:        "image",
+			Usage:       "image name",
+			DefaultText: "PROJECT:dev",
+			Required:    true,
+		},
+		&cli.DurationFlag{
+			Name:  "timeout",
+			Usage: "Timeout of container creation",
+			Value: time.Second * 30,
+		},
+		&cli.BoolFlag{
+			Name:  "detach",
+			Usage: "Detach from the container",
+			Value: false,
+		},
+		&cli.PathFlag{
+			Name:    "private-key",
+			Usage:   "Path to the private key",
+			Aliases: []string{"k"},
+			Value:   sshconfig.GetPrivateKeyOrPanic(),
+			Hidden:  true,
+		},
+	},
+	Action: create,
+}
+
+func create(clicontext *cli.Context) error {
+	c, err := home.GetManager().ContextGetCurrent()
+	if err != nil {
+		return err
+	}
+
+	engine, err := envd.New(clicontext.Context, envd.Options{
+		Context: c,
+	})
+	if err != nil {
+		return err
+	}
+
+	opt := envd.StartOptions{
+		Image:   clicontext.String("image"),
+		Timeout: clicontext.Duration("timeout"),
+	}
+	if c.Runner == types.RunnerTypeEnvdServer {
+		opt.EnvdServerSource = &envd.EnvdServerSource{}
+	}
+	res, err := engine.StartEnvd(clicontext.Context, opt)
+	if err != nil {
+		return err
+	}
+
+	logrus.Debugf("container %s is running", res.Name)
+
+	logrus.Debugf("add entry %s to SSH config.", res.Name)
+	eo := sshconfig.EntryOptions{
+		Name:               res.Name,
+		IFace:              localhost,
+		Port:               res.SSHPort,
+		PrivateKeyPath:     clicontext.Path("private-key"),
+		EnableHostKeyCheck: false,
+		EnableAgentForward: false,
+		User:               res.Name,
+	}
+	if err = sshconfig.AddEntry(eo); err != nil {
+		logrus.Infof("failed to add entry %s to your SSH config file: %s", res.Name, err)
+		return errors.Wrap(err, "failed to add entry to your SSH config file")
+	}
+
+	if !clicontext.Bool("detach") {
+		opt := ssh.DefaultOptions()
+		opt.PrivateKeyPath = clicontext.Path("private-key")
+		opt.Port = res.SSHPort
+		opt.AgentForwarding = false
+		opt.User = res.Name
+		sshClient, err := ssh.NewClient(opt)
+		if err != nil {
+			return errors.Wrap(err, "failed to create the ssh client")
+		}
+		if err := sshClient.Attach(); err != nil {
+			return errors.Wrap(err, "failed to attach to the container")
+		}
+	}
+	return nil
+}
diff --git a/pkg/app/k8s.go b/pkg/app/k8s.go
deleted file mode 100644
index 5356bdaf0..000000000
--- a/pkg/app/k8s.go
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright 2022 The envd Authors
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package app
-
-import (
-	servertypes "github.com/tensorchord/envd-server/api/types"
-	"github.com/tensorchord/envd-server/client"
-	"github.com/urfave/cli/v2"
-
-	"github.com/tensorchord/envd/pkg/home"
-)
-
-var CommandK8s = &cli.Command{
-	Name:     "k8s",
-	Category: CategoryBasic,
-	Hidden:   true,
-	Usage:    "TestK8s",
-	Action:   k8s,
-}
-
-func k8s(clicontext *cli.Context) error {
-	ac, err := home.GetManager().AuthGetCurrent()
-	if err != nil {
-		return err
-	}
-	it := ac.IdentityToken
-	c, err := client.NewClientWithOpts(client.FromEnv)
-	if err != nil {
-		return err
-	}
-	req := servertypes.EnvironmentCreateRequest{
-		IdentityToken: it,
-		Image:         "gaocegege/test-envd",
-	}
-	_, err = c.EnvironmentCreate(clicontext.Context, req)
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
diff --git a/pkg/app/ssh.go b/pkg/app/ssh.go
deleted file mode 100644
index 9f3562ffe..000000000
--- a/pkg/app/ssh.go
+++ /dev/null
@@ -1,70 +0,0 @@
-// Copyright 2022 The envd Authors
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package app
-
-import (
-	"github.com/cockroachdb/errors"
-	"github.com/urfave/cli/v2"
-
-	"github.com/tensorchord/envd/pkg/home"
-	"github.com/tensorchord/envd/pkg/ssh"
-	sshconfig "github.com/tensorchord/envd/pkg/ssh/config"
-)
-
-var CommandSSH = &cli.Command{
-	Name:     "ssh",
-	Category: CategoryBasic,
-	Hidden:   true,
-	Usage:    "TestK8s",
-	Flags: []cli.Flag{
-		&cli.PathFlag{
-			Name:    "private-key",
-			Usage:   "Path to the private key",
-			Aliases: []string{"k"},
-			Value:   sshconfig.GetPrivateKeyOrPanic(),
-			Hidden:  true,
-		},
-		&cli.PathFlag{
-			Name:    "public-key",
-			Usage:   "Path to the public key",
-			Aliases: []string{"pubk"},
-			Value:   sshconfig.GetPublicKeyOrPanic(),
-			Hidden:  true,
-		},
-	},
-	Action: sshc,
-}
-
-func sshc(clicontext *cli.Context) error {
-	ac, err := home.GetManager().AuthGetCurrent()
-	if err != nil {
-		return err
-	}
-	it := ac.IdentityToken
-
-	opt := ssh.DefaultOptions()
-	opt.User = it
-	opt.PrivateKeyPath = clicontext.Path("private-key")
-	opt.Port = 2222
-	opt.AgentForwarding = false
-	sshClient, err := ssh.NewClient(opt)
-	if err != nil {
-		return errors.Wrap(err, "failed to create the ssh client")
-	}
-	if err := sshClient.Attach(); err != nil {
-		return errors.Wrap(err, "failed to attach to the container")
-	}
-	return nil
-}
diff --git a/pkg/app/up.go b/pkg/app/up.go
index bb7eb8358..dd9f3dfd2 100644
--- a/pkg/app/up.go
+++ b/pkg/app/up.go
@@ -22,13 +22,12 @@ import (
 	"github.com/sirupsen/logrus"
 	"github.com/urfave/cli/v2"
 
-	"github.com/tensorchord/envd/pkg/builder"
 	"github.com/tensorchord/envd/pkg/envd"
 	"github.com/tensorchord/envd/pkg/home"
 	"github.com/tensorchord/envd/pkg/lang/ir"
 	"github.com/tensorchord/envd/pkg/ssh"
 	sshconfig "github.com/tensorchord/envd/pkg/ssh/config"
-	"github.com/tensorchord/envd/pkg/util/netutil"
+	"github.com/tensorchord/envd/pkg/types"
 )
 
 const (
@@ -157,77 +156,72 @@ func up(clicontext *cli.Context) error {
 	} else {
 		gpu = builder.GPUEnabled()
 	}
-	numGPUs := builder.NumGPUs()
-
-	sshPortInHost, error := StartEnvd(clicontext, buildOpt, gpu, numGPUs)
-	if error != nil {
-		return error
-	}
-
-	if !detach {
-		opt := ssh.DefaultOptions()
-		opt.PrivateKeyPath = clicontext.Path("private-key")
-		opt.Port = sshPortInHost
-		sshClient, err := ssh.NewClient(opt)
-		if err != nil {
-			return errors.Wrap(err, "failed to create the ssh client")
-		}
-		if err := sshClient.Attach(); err != nil {
-			return errors.Wrap(err, "failed to attach to the container")
-		}
+	numGPU := 0
+	if gpu {
+		numGPU = 1
 	}
 
-	return nil
-}
-
-func StartEnvd(clicontext *cli.Context, buildOpt builder.Options, gpu bool, numGPUs int) (int, error) {
 	context, err := home.GetManager().ContextGetCurrent()
 	if err != nil {
-		return 0, errors.Wrap(err, "failed to get the current context")
+		return errors.Wrap(err, "failed to get the current context")
 	}
 	opt := envd.Options{
 		Context: context,
 	}
 	engine, err := envd.New(clicontext.Context, opt)
 	if err != nil {
-		return 0, errors.Wrap(err, "failed to create the docker client")
-	}
-
-	if gpu {
-		nvruntimeExists, err := engine.GPUEnabled(clicontext.Context)
-		if err != nil {
-			return 0, errors.Wrap(err, "failed to check if nvidia-runtime is installed")
-		}
-		if !nvruntimeExists {
-			return 0, errors.New("GPU is required but nvidia container runtime is not installed, please refer to https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html#docker")
+		return errors.Wrap(err, "failed to create the docker client")
+	}
+
+	startOptions := envd.StartOptions{
+		EnvironmentName: filepath.Base(buildOpt.BuildContextDir),
+		BuildContext:    buildOpt.BuildContextDir,
+		Image:           buildOpt.Tag,
+		NumGPU:          numGPU,
+		Forced:          clicontext.Bool("force"),
+		Timeout:         clicontext.Duration("timeout"),
+	}
+	if context.Runner != types.RunnerTypeEnvdServer {
+		startOptions.EngineSource = envd.EngineSource{
+			DockerSource: &envd.DockerSource{
+				Graph:        *ir.DefaultGraph,
+				MountOptions: clicontext.StringSlice("volume"),
+			},
 		}
 	}
 
-	sshPortInHost, err := netutil.GetFreePort()
+	res, err := engine.StartEnvd(clicontext.Context, startOptions)
 	if err != nil {
-		return 0, errors.Wrap(err, "failed to get a free port")
+		return errors.Wrap(err, "failed to start the envd environment")
 	}
+	logrus.Debugf("container %s is running", res.Name)
 
-	ctr := filepath.Base(buildOpt.BuildContextDir)
-	force := clicontext.Bool("force")
-	err = engine.CleanEnvdIfExists(clicontext.Context, ctr, force)
-	if err != nil {
-		return 0, errors.Wrap(err, "failed to clean the envd environment")
+	logrus.Debugf("add entry %s to SSH config.", ctr)
+	eo := sshconfig.EntryOptions{
+		Name:               ctr,
+		IFace:              localhost,
+		Port:               res.SSHPort,
+		PrivateKeyPath:     clicontext.Path("private-key"),
+		EnableHostKeyCheck: false,
+		EnableAgentForward: true,
 	}
-	containerID, containerIP, err := engine.StartEnvd(clicontext.Context,
-		buildOpt.Tag, ctr, buildOpt.BuildContextDir, gpu, numGPUs, sshPortInHost, *ir.DefaultGraph, clicontext.Duration("timeout"),
-		clicontext.StringSlice("volume"))
-	if err != nil {
-		return 0, errors.Wrap(err, "failed to start the envd environment")
+	if err = sshconfig.AddEntry(eo); err != nil {
+		logrus.Infof("failed to add entry %s to your SSH config file: %s", ctr, err)
+		return errors.Wrap(err, "failed to add entry to your SSH config file")
 	}
-	logrus.Debugf("container %s is running", containerID)
 
-	logrus.Debugf("Add entry %s to SSH config. at %s", buildOpt.BuildContextDir, containerIP)
-	if err = sshconfig.AddEntry(
-		ctr, localhost, sshPortInHost, clicontext.Path("private-key")); err != nil {
-		logrus.Infof("failed to add entry %s to your SSH config file: %s", ctr, err)
-		return 0, errors.Wrap(err, "failed to add entry to your SSH config file")
+	if !detach {
+		opt := ssh.DefaultOptions()
+		opt.PrivateKeyPath = clicontext.Path("private-key")
+		opt.Port = res.SSHPort
+		sshClient, err := ssh.NewClient(opt)
+		if err != nil {
+			return errors.Wrap(err, "failed to create the ssh client")
+		}
+		if err := sshClient.Attach(); err != nil {
+			return errors.Wrap(err, "failed to attach to the container")
+		}
 	}
-	return sshPortInHost, nil
 
+	return nil
 }
diff --git a/pkg/envd/docker.go b/pkg/envd/docker.go
index 8c5ce6ed9..5b820e538 100644
--- a/pkg/envd/docker.go
+++ b/pkg/envd/docker.go
@@ -39,14 +39,6 @@ import (
 	"github.com/tensorchord/envd/pkg/util/netutil"
 )
 
-const (
-	localhost = "127.0.0.1"
-)
-
-var (
-	waitingInternal = 1 * time.Second
-)
-
 type dockerEngine struct {
 	*client.Client
 }
@@ -246,29 +238,51 @@ func (e dockerEngine) IsRunning(ctx context.Context, cname string) (bool, error)
 }
 
 // StartEnvd creates the container for the given tag and container name.
-func (e dockerEngine) StartEnvd(ctx context.Context, tag, name, buildContext string,
-	gpuEnabled bool, numGPUs int, sshPortInHost int, g ir.Graph, timeout time.Duration,
-	mountOptionsStr []string) (string, string, error) {
+func (e dockerEngine) StartEnvd(ctx context.Context, so StartOptions) (*StartResult, error) {
 	logger := logrus.WithFields(logrus.Fields{
-		"tag":           tag,
-		"container":     name,
-		"gpu":           gpuEnabled,
-		"numGPUs":       numGPUs,
-		"build-context": buildContext,
+		"tag":           so.Image,
+		"environment":   so.EnvironmentName,
+		"gpu":           so.NumGPU,
+		"build-context": so.BuildContext,
 	})
+	if so.NumGPU != 0 {
+		nvruntimeExists, err := e.GPUEnabled(ctx)
+		if err != nil {
+			return nil, errors.Wrap(err, "failed to check if nvidia-runtime is installed")
+		}
+		if !nvruntimeExists {
+			return nil, errors.New("GPU is required but nvidia container runtime is not installed, please refer to https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html#docker")
+		}
+	}
+
+	sshPortInHost, err := netutil.GetFreePort()
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get a free port")
+	}
+
+	err = e.CleanEnvdIfExists(ctx, so.EnvironmentName, so.Forced)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to clean the envd environment")
+	}
 	config := &container.Config{
-		Image:        tag,
+		Image:        so.Image,
 		User:         "envd",
 		ExposedPorts: nat.PortSet{},
 	}
-	base := fileutil.EnvdHomeDir(filepath.Base(buildContext))
+	base := fileutil.EnvdHomeDir(filepath.Base(so.BuildContext))
 	config.WorkingDir = base
 
-	mountOption := make([]mount.Mount, 0, len(mountOptionsStr)+len(g.Mount)+1)
-	for _, option := range mountOptionsStr {
+	if so.DockerSource == nil {
+		return nil, errors.New("failed to get the docker-specific options")
+	}
+	g := so.DockerSource.Graph
+
+	mountOption := make([]mount.Mount, 0,
+		len(so.DockerSource.MountOptions)+len(g.Mount)+1)
+	for _, option := range so.DockerSource.MountOptions {
 		mStr := strings.Split(option, ":")
 		if len(mStr) != 2 {
-			return "", "", errors.Newf("Invalid mount options %s", option)
+			return nil, errors.Newf("Invalid mount options %s", option)
 		}
 
 		logger.WithFields(logrus.Fields{
@@ -295,12 +309,12 @@ func (e dockerEngine) StartEnvd(ctx context.Context, tag, name, buildContext str
 
 	mountOption = append(mountOption, mount.Mount{
 		Type:   mount.TypeBind,
-		Source: buildContext,
+		Source: so.BuildContext,
 		Target: base,
 	})
 
 	logger.WithFields(logrus.Fields{
-		"mount-path":  buildContext,
+		"mount-path":  so.BuildContext,
 		"working-dir": base,
 	}).Debug("setting up container working directory")
 
@@ -324,6 +338,7 @@ func (e dockerEngine) StartEnvd(ctx context.Context, tag, name, buildContext str
 
 	var jupyterPortInHost int
 	// TODO(gaocegege): Avoid specific logic to set the port.
+	// Add a func to builder to generate all the ports from the build process.
 	if g.JupyterConfig != nil {
 		if g.JupyterConfig.Port != 0 {
 			jupyterPortInHost = int(g.JupyterConfig.Port)
@@ -331,7 +346,7 @@ func (e dockerEngine) StartEnvd(ctx context.Context, tag, name, buildContext str
 			var err error
 			jupyterPortInHost, err = netutil.GetFreePort()
 			if err != nil {
-				return "", "", errors.Wrap(err, "failed to get a free port")
+				return nil, errors.Wrap(err, "failed to get a free port")
 			}
 		}
 		natPort := nat.Port(fmt.Sprintf("%d/tcp", envdconfig.JupyterPortInContainer))
@@ -348,7 +363,7 @@ func (e dockerEngine) StartEnvd(ctx context.Context, tag, name, buildContext str
 		var err error
 		rStudioPortInHost, err = netutil.GetFreePort()
 		if err != nil {
-			return "", "", errors.Wrap(err, "failed to get a free port")
+			return nil, errors.Wrap(err, "failed to get a free port")
 		}
 		natPort := nat.Port(fmt.Sprintf("%d/tcp", envdconfig.RStudioServerPortInContainer))
 		hostConfig.PortBindings[natPort] = []nat.PortBinding{
@@ -366,7 +381,7 @@ func (e dockerEngine) StartEnvd(ctx context.Context, tag, name, buildContext str
 			if item.HostPort == 0 {
 				item.HostPort, err = netutil.GetFreePort()
 				if err != nil {
-					return "", "", errors.Wrap(err, "failed to get a free port")
+					return nil, errors.Wrap(err, "failed to get a free port")
 				}
 			}
 			natPort := nat.Port(fmt.Sprintf("%d/tcp", item.EnvdPort))
@@ -380,23 +395,23 @@ func (e dockerEngine) StartEnvd(ctx context.Context, tag, name, buildContext str
 		}
 	}
 
-	if gpuEnabled {
+	if so.NumGPU != 0 {
 		logger.Debug("GPU is enabled.")
-		hostConfig.DeviceRequests = deviceRequests(numGPUs)
+		hostConfig.DeviceRequests = deviceRequests(so.NumGPU)
 	}
 
-	config.Labels = labels(name, g,
+	config.Labels = labels(so.EnvironmentName, g,
 		sshPortInHost, jupyterPortInHost, rStudioPortInHost)
 
 	logger = logger.WithFields(logrus.Fields{
 		"entrypoint":  config.Entrypoint,
 		"working-dir": config.WorkingDir,
 	})
-	logger.Debugf("starting %s container", name)
+	logger.Debugf("starting %s container", so.EnvironmentName)
 
-	resp, err := e.ContainerCreate(ctx, config, hostConfig, nil, nil, name)
+	resp, err := e.ContainerCreate(ctx, config, hostConfig, nil, nil, so.EnvironmentName)
 	if err != nil {
-		return "", "", errors.Wrap(err, "failed to create the container")
+		return nil, errors.Wrap(err, "failed to create the container")
 	}
 
 	for _, w := range resp.Warnings {
@@ -409,22 +424,27 @@ func (e dockerEngine) StartEnvd(ctx context.Context, tag, name, buildContext str
 		// Hack to check if the port is already allocated.
 		if strings.Contains(errCause.Error(), "port is already allocated") {
 			logrus.Debugf("failed to allocate the port: %s", err)
-			return "", "", errors.New("port is already allocated in the host")
+			return nil, errors.New("port is already allocated in the host")
 		}
-		return "", "", errors.Wrap(err, "failed to run the container")
+		return nil, errors.Wrap(err, "failed to run the container")
 	}
 
 	container, err := e.ContainerInspect(ctx, resp.ID)
 	if err != nil {
-		return "", "", errors.Wrap(err, "failed to inspect the container")
+		return nil, errors.Wrap(err, "failed to inspect the container")
 	}
 
 	if err := e.WaitUntilRunning(
-		ctx, container.Name, timeout); err != nil {
-		return "", "", errors.Wrap(err, "failed to wait until the container is running")
+		ctx, container.Name, so.Timeout); err != nil {
+		return nil, errors.Wrap(err, "failed to wait until the container is running")
 	}
 
-	return container.Name, container.NetworkSettings.IPAddress, nil
+	result := &StartResult{
+		SSHPort: sshPortInHost,
+		Address: container.NetworkSettings.IPAddress,
+		Name:    container.Name,
+	}
+	return result, nil
 }
 
 func (e dockerEngine) WaitUntilRunning(ctx context.Context,
@@ -437,7 +457,7 @@ func (e dockerEngine) WaitUntilRunning(ctx context.Context,
 	defer cancel()
 	for {
 		select {
-		case <-time.After(waitingInternal):
+		case <-time.After(waitingInterval):
 			isRunning, err := e.IsRunning(ctxTimeout, name)
 			if err != nil {
 				// Has not yet started. Keep waiting.
diff --git a/pkg/envd/engine.go b/pkg/envd/engine.go
index a6a771048..82d022044 100644
--- a/pkg/envd/engine.go
+++ b/pkg/envd/engine.go
@@ -20,16 +20,9 @@ import (
 
 	dockertypes "github.com/docker/docker/api/types"
 
-	"github.com/tensorchord/envd/pkg/lang/ir"
 	"github.com/tensorchord/envd/pkg/types"
 )
 
-type Engine interface {
-	ImageClient
-	EnvironmentClient
-	VersionClient
-}
-
 type EnvironmentClient interface {
 	PauseEnvironment(ctx context.Context, env string) (string, error)
 	ResumeEnvironment(ctx context.Context, env string) (string, error)
@@ -39,9 +32,7 @@ type EnvironmentClient interface {
 
 	CleanEnvdIfExists(ctx context.Context, name string, force bool) error
 	// StartEnvd creates the container for the given tag and container name.
-	StartEnvd(ctx context.Context, tag, name, buildContext string,
-		gpuEnabled bool, numGPUs int, sshPort int, g ir.Graph, timeout time.Duration,
-		mountOptionsStr []string) (string, string, error)
+	StartEnvd(ctx context.Context, so StartOptions) (*StartResult, error)
 
 	IsRunning(ctx context.Context, name string) (bool, error)
 	Exists(ctx context.Context, name string) (bool, error)
diff --git a/pkg/envd/envdserver.go b/pkg/envd/envdserver.go
index dc273c9aa..fed8e3690 100644
--- a/pkg/envd/envdserver.go
+++ b/pkg/envd/envdserver.go
@@ -20,14 +20,17 @@ import (
 
 	"github.com/cockroachdb/errors"
 	dockertypes "github.com/docker/docker/api/types"
+	"github.com/sirupsen/logrus"
+	servertypes "github.com/tensorchord/envd-server/api/types"
 	"github.com/tensorchord/envd-server/client"
+	v1 "k8s.io/api/core/v1"
 
-	"github.com/tensorchord/envd/pkg/lang/ir"
 	"github.com/tensorchord/envd/pkg/types"
 )
 
 type envdServerEngine struct {
 	*client.Client
+	IdentityToken string
 }
 
 func (e *envdServerEngine) ListImage(ctx context.Context) ([]types.EnvdImage, error) {
@@ -75,14 +78,44 @@ func (e *envdServerEngine) CleanEnvdIfExists(ctx context.Context, name string, f
 }
 
 // StartEnvd creates the container for the given tag and container name.
-func (e *envdServerEngine) StartEnvd(ctx context.Context, tag, name, buildContext string,
-	gpuEnabled bool, numGPUs int, sshPort int, g ir.Graph, timeout time.Duration,
-	mountOptionsStr []string) (string, string, error) {
-	return "", "", errors.New("not implemented")
+func (e *envdServerEngine) StartEnvd(ctx context.Context, so StartOptions) (*StartResult, error) {
+	if so.EnvdServerSource == nil {
+		return nil, errors.New("failed to get the envd server specific options")
+	}
+
+	req := servertypes.EnvironmentCreateRequest{
+		IdentityToken: e.IdentityToken,
+		Image:         so.Image,
+	}
+
+	resp, err := e.EnvironmentCreate(ctx, req)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to create the environment")
+	}
+
+	if err := e.WaitUntilRunning(
+		ctx, resp.ID, so.Timeout); err != nil {
+		return nil, errors.Wrap(err, "failed to wait until the container is running")
+	}
+
+	result := &StartResult{
+		SSHPort: 2222,
+		Address: "",
+		Name:    resp.ID,
+	}
+	return result, nil
 }
 
 func (e *envdServerEngine) IsRunning(ctx context.Context, name string) (bool, error) {
-	return false, errors.New("not implemented")
+	req := servertypes.EnvironmentListRequest{
+		IdentityToken: e.IdentityToken,
+	}
+
+	resp, err := e.EnvironmentList(ctx, req)
+	if err != nil {
+		return false, errors.Wrap(err, "failed to list the environment")
+	}
+	return resp.Pod.Status.Phase == v1.PodRunning, nil
 }
 
 func (e *envdServerEngine) Exists(ctx context.Context, name string) (bool, error) {
@@ -90,5 +123,27 @@ func (e *envdServerEngine) Exists(ctx context.Context, name string) (bool, error
 }
 
 func (e *envdServerEngine) WaitUntilRunning(ctx context.Context, name string, timeout time.Duration) error {
-	return errors.New("not implemented")
+	logger := logrus.WithField("container", name)
+	logger.Debug("waiting to start")
+
+	// First, wait for the container to be marked as started.
+	ctxTimeout, cancel := context.WithTimeout(ctx, timeout)
+	defer cancel()
+	for {
+		select {
+		case <-time.After(waitingInterval):
+			isRunning, err := e.IsRunning(ctxTimeout, name)
+			if err != nil {
+				// Has not yet started. Keep waiting.
+				return errors.Wrap(err, "failed to check if environment is running")
+			}
+			if isRunning {
+				logger.Debug("the environment is running")
+				return nil
+			}
+
+		case <-ctxTimeout.Done():
+			return errors.Errorf("timeout %s: environment did not start", timeout)
+		}
+	}
 }
diff --git a/pkg/envd/factory.go b/pkg/envd/factory.go
index 2053493b4..56c932093 100644
--- a/pkg/envd/factory.go
+++ b/pkg/envd/factory.go
@@ -21,6 +21,7 @@ import (
 	"github.com/docker/docker/client"
 	envdclient "github.com/tensorchord/envd-server/client"
 
+	"github.com/tensorchord/envd/pkg/home"
 	"github.com/tensorchord/envd/pkg/types"
 )
 
@@ -33,12 +34,18 @@ func New(ctx context.Context, opt Options) (Engine, error) {
 		return nil, errors.New("failed to get the context")
 	}
 	if opt.Context.Runner == types.RunnerTypeEnvdServer {
+		ac, err := home.GetManager().AuthGetCurrent()
+		if err != nil {
+			return nil, err
+		}
+
 		cli, err := envdclient.NewClientWithOpts(envdclient.FromEnv)
 		if err != nil {
 			return nil, err
 		}
 		return &envdServerEngine{
-			Client: cli,
+			Client:        cli,
+			IdentityToken: ac.IdentityToken,
 		}, nil
 	} else {
 		cli, err := client.NewClientWithOpts(
diff --git a/pkg/envd/types.go b/pkg/envd/types.go
new file mode 100644
index 000000000..887df97c1
--- /dev/null
+++ b/pkg/envd/types.go
@@ -0,0 +1,65 @@
+// Copyright 2022 The envd Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package envd
+
+import (
+	"time"
+
+	"github.com/tensorchord/envd/pkg/lang/ir"
+)
+
+const (
+	localhost = "127.0.0.1"
+)
+
+var (
+	waitingInterval = 1 * time.Second
+)
+
+type StartOptions struct {
+	Image           string
+	EnvironmentName string
+	BuildContext    string
+	NumGPU          int
+	Timeout         time.Duration
+	Forced          bool
+
+	EngineSource
+}
+
+type EngineSource struct {
+	DockerSource     *DockerSource
+	EnvdServerSource *EnvdServerSource
+}
+
+type DockerSource struct {
+	Graph        ir.Graph
+	MountOptions []string
+}
+
+type EnvdServerSource struct{}
+
+type Engine interface {
+	ImageClient
+	EnvironmentClient
+	VersionClient
+}
+
+type StartResult struct {
+	// TODO(gaocegege): Make result a chan, to send running status to the receiver.
+	SSHPort int
+	Address string
+	Name    string
+}
diff --git a/pkg/lang/ir/compile.go b/pkg/lang/ir/compile.go
index 9a5ec4ece..2f6d148eb 100644
--- a/pkg/lang/ir/compile.go
+++ b/pkg/lang/ir/compile.go
@@ -50,7 +50,7 @@ func NewGraph() *Graph {
 		},
 		CUDA:    nil,
 		CUDNN:   CUDNNVersionDefault,
-		NumGPUs: -1,
+		NumGPUs: 0,
 
 		PyPIPackages:    []string{},
 		RPackages:       []string{},
diff --git a/pkg/ssh/config/entry.go b/pkg/ssh/config/entry.go
new file mode 100644
index 000000000..752431718
--- /dev/null
+++ b/pkg/ssh/config/entry.go
@@ -0,0 +1,117 @@
+// Copyright 2022 The envd Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package config
+
+import (
+	"strconv"
+
+	"github.com/sirupsen/logrus"
+
+	"github.com/tensorchord/envd/pkg/util/osutil"
+)
+
+type EntryOptions struct {
+	Name           string
+	IFace          string
+	Port           int
+	PrivateKeyPath string
+
+	EnableHostKeyCheck bool
+	EnableAgentForward bool
+	User               string
+}
+
+// AddEntry adds an entry to the user's sshconfig
+func AddEntry(eo EntryOptions) error {
+	eo.Name = buildHostname(eo.Name)
+	err := add(getSSHConfigPath(), eo)
+	if err != nil {
+		return err
+	}
+	if osutil.IsWsl() {
+		logrus.Debug("Try adding entry to WSL's ssh-agent")
+		winSshConfig, err := osutil.GetWslHostSshConfig()
+		if err != nil {
+			return err
+		}
+		winKeyPath, err := osutil.CopyToWinEnvdHome(eo.PrivateKeyPath, 0600)
+		if err != nil {
+			return err
+		}
+		// Add the entry to the WSL host SSH config
+		logrus.Debugf("Adding entry to WSL's ssh-agent: %s", winSshConfig)
+		eo.PrivateKeyPath = winKeyPath
+		err = add(winSshConfig, eo)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func add(path string, eo EntryOptions) error {
+	cfg, err := getConfig(path)
+	if err != nil {
+		return err
+	}
+
+	_ = removeHost(cfg, eo.Name)
+
+	host := newHost([]string{eo.Name}, []string{"entry generated by envd"})
+	host.params = []*param{
+		newParam(forwardAgentKeyword, []string{"yes"}, nil),
+		newParam(pubkeyAcceptedKeyTypesKeyword, []string{"+ssh-rsa"}, nil),
+		newParam(hostKeyAlgorithms, []string{"+ssh-rsa"}, nil),
+		newParam(hostNameKeyword, []string{eo.IFace}, nil),
+		newParam(portKeyword, []string{strconv.Itoa(eo.Port)}, nil),
+		newParam(userKnownHostsFileKeyword, []string{"/dev/null"}, nil),
+		newParam(identityFile, []string{"\"" + eo.PrivateKeyPath + "\""}, nil),
+	}
+	if !eo.EnableHostKeyCheck {
+		host.params = append(host.params,
+			newParam(strictHostKeyCheckingKeyword, []string{"no"}, nil))
+	}
+	if eo.EnableAgentForward {
+		host.params = append(host.params,
+			newParam(forwardAgentKeyword, []string{"yes"}, nil))
+	}
+	if eo.User != "" {
+		host.params = append(host.params,
+			newParam(userKeyword, []string{eo.User}, nil))
+	}
+
+	cfg.hosts = append(cfg.hosts, host)
+	return save(cfg, path)
+}
+
+// RemoveEntry removes the entry to the user's sshconfig if found
+func RemoveEntry(name string) error {
+	err := remove(getSSHConfigPath(), buildHostname(name))
+	if err != nil {
+		return err
+	}
+	if osutil.IsWsl() {
+		logrus.Debug("Try removing entry from WSL's ssh-agent")
+		winSshConfig, err := osutil.GetWslHostSshConfig()
+		if err != nil {
+			return err
+		}
+		err = remove(winSshConfig, buildHostname(name))
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/pkg/ssh/config/ssh_config.go b/pkg/ssh/config/ssh_config.go
index 4865d36e5..418757255 100644
--- a/pkg/ssh/config/ssh_config.go
+++ b/pkg/ssh/config/ssh_config.go
@@ -61,6 +61,7 @@ const (
 	hostKeyAlgorithms             = "HostKeyAlgorithms"
 	userKnownHostsFileKeyword     = "UserKnownHostsFile"
 	identityFile                  = "IdentityFile"
+	userKeyword                   = "User"
 )
 
 func newHost(hostnames, comments []string) *host {
@@ -304,32 +305,6 @@ func buildHostname(name string) string {
 	return fmt.Sprintf("%s.envd", name)
 }
 
-// AddEntry adds an entry to the user's sshconfig
-func AddEntry(name, iface string, port int, privateKeyPath string) error {
-	err := add(getSSHConfigPath(), buildHostname(name), iface, port, privateKeyPath)
-	if err != nil {
-		return err
-	}
-	if osutil.IsWsl() {
-		logrus.Debug("Try adding entry to WSL's ssh-agent")
-		winSshConfig, err := osutil.GetWslHostSshConfig()
-		if err != nil {
-			return err
-		}
-		winKeyPath, err := osutil.CopyToWinEnvdHome(privateKeyPath, 0600)
-		if err != nil {
-			return err
-		}
-		// Add the entry to the WSL host SSH config
-		logrus.Debugf("Adding entry to WSL's ssh-agent: %s", winSshConfig)
-		err = add(winSshConfig, buildHostname(name), iface, port, winKeyPath)
-		if err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
 func ReplaceKeyManagedByEnvd(oldKey string, newKey string) error {
 	cfg, err := getConfig(getSSHConfigPath())
 	if err != nil {
@@ -404,53 +379,6 @@ func ReplaceKeyManagedByEnvd(oldKey string, newKey string) error {
 	return nil
 }
 
-func add(path, name, iface string, port int, privateKeyPath string) error {
-	cfg, err := getConfig(path)
-	if err != nil {
-		return err
-	}
-
-	_ = removeHost(cfg, name)
-
-	// TODO: Use private key to authenticate ssh
-	// _, privateKey := getKeyPaths()
-
-	host := newHost([]string{name}, []string{"entry generated by envd"})
-	host.params = []*param{
-		newParam(forwardAgentKeyword, []string{"yes"}, nil),
-		newParam(pubkeyAcceptedKeyTypesKeyword, []string{"+ssh-rsa"}, nil),
-		newParam(hostKeyAlgorithms, []string{"+ssh-rsa"}, nil),
-		newParam(hostNameKeyword, []string{iface}, nil),
-		newParam(portKeyword, []string{strconv.Itoa(port)}, nil),
-		newParam(strictHostKeyCheckingKeyword, []string{"no"}, nil),
-		newParam(userKnownHostsFileKeyword, []string{"/dev/null"}, nil),
-		newParam(identityFile, []string{"\"" + privateKeyPath + "\""}, nil),
-	}
-
-	cfg.hosts = append(cfg.hosts, host)
-	return save(cfg, path)
-}
-
-// RemoveEntry removes the entry to the user's sshconfig if found
-func RemoveEntry(name string) error {
-	err := remove(getSSHConfigPath(), buildHostname(name))
-	if err != nil {
-		return err
-	}
-	if osutil.IsWsl() {
-		logrus.Debug("Try removing entry from WSL's ssh-agent")
-		winSshConfig, err := osutil.GetWslHostSshConfig()
-		if err != nil {
-			return err
-		}
-		err = remove(winSshConfig, buildHostname(name))
-		if err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
 // GetPort returns the corresponding SSH port for the dev env
 func GetPort(name string) (int, error) {
 	cfg, err := getConfig(getSSHConfigPath())


Test patch:
diff --git a/pkg/ssh/config/ssh_config_test.go b/pkg/ssh/config/ssh_config_test.go
index eeffe127b..5e731e1b2 100644
--- a/pkg/ssh/config/ssh_config_test.go
+++ b/pkg/ssh/config/ssh_config_test.go
@@ -26,7 +26,15 @@ var _ = Describe("ssh config", func() {
 			iface := "localhost"
 			port := 8888
 			keyPath := "key"
-			err := add(getSSHConfigPath(), buildHostname(env), iface, port, keyPath)
+			eo := EntryOptions{
+				Name:               buildHostname(env),
+				IFace:              iface,
+				Port:               port,
+				PrivateKeyPath:     keyPath,
+				EnableHostKeyCheck: false,
+				EnableAgentForward: true,
+			}
+			err := add(getSSHConfigPath(), eo)
 			Expect(err).NotTo(HaveOccurred())
 
 			actual, err := GetPort(env)