Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/local-app/cmd/workspace-up.go
2497 views
1
// Copyright (c) 2023 Gitpod GmbH. All rights reserved.
2
// Licensed under the GNU Affero General Public License (AGPL).
3
// See License.AGPL.txt in the project root for license information.
4
5
package cmd
6
7
import (
8
"bytes"
9
"context"
10
"errors"
11
"fmt"
12
"log/slog"
13
"os"
14
"os/exec"
15
"path/filepath"
16
"strings"
17
"time"
18
19
"github.com/bufbuild/connect-go"
20
v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1"
21
"github.com/gitpod-io/local-app/pkg/config"
22
"github.com/gitpod-io/local-app/pkg/helper"
23
"github.com/gitpod-io/local-app/pkg/prettyprint"
24
"github.com/go-git/go-git/v5"
25
gitcfg "github.com/go-git/go-git/v5/config"
26
"github.com/gookit/color"
27
"github.com/melbahja/goph"
28
"github.com/spf13/cobra"
29
"golang.org/x/crypto/ssh"
30
)
31
32
// workspaceUpCmd creates a new workspace
33
var workspaceUpCmd = &cobra.Command{
34
Use: "up [path/to/git/working-copy]",
35
Hidden: true,
36
Short: "Creates a new workspace, pushes the Git working copy and adds it as remote",
37
Args: cobra.MaximumNArgs(1),
38
RunE: func(cmd *cobra.Command, args []string) error {
39
cmd.SilenceUsage = true
40
41
workingDir := "."
42
if len(args) != 0 {
43
workingDir = args[0]
44
}
45
46
cfg := config.FromContext(cmd.Context())
47
gpctx, err := cfg.GetActiveContext()
48
if err != nil {
49
return err
50
}
51
gitpod, err := getGitpodClient(cmd.Context())
52
if err != nil {
53
return err
54
}
55
56
if workspaceCreateOpts.WorkspaceClass != "" {
57
resp, err := gitpod.Workspaces.ListWorkspaceClasses(cmd.Context(), connect.NewRequest(&v1.ListWorkspaceClassesRequest{}))
58
if err != nil {
59
return prettyprint.MarkExceptional(prettyprint.AddResolution(fmt.Errorf("cannot list workspace classes: %w", err),
60
"don't pass an explicit workspace class, i.e. omit the --class flag",
61
))
62
}
63
var (
64
classes []string
65
found bool
66
)
67
for _, cls := range resp.Msg.GetResult() {
68
classes = append(classes, cls.Id)
69
if cls.Id == workspaceCreateOpts.WorkspaceClass {
70
found = true
71
}
72
}
73
if !found {
74
return prettyprint.AddResolution(fmt.Errorf("workspace class %s not found", workspaceCreateOpts.WorkspaceClass),
75
fmt.Sprintf("use one of the available workspace classes: %s", strings.Join(classes, ", ")),
76
)
77
}
78
}
79
80
if workspaceCreateOpts.Editor != "" {
81
resp, err := gitpod.Editors.ListEditorOptions(cmd.Context(), connect.NewRequest(&v1.ListEditorOptionsRequest{}))
82
if err != nil {
83
return prettyprint.MarkExceptional(prettyprint.AddResolution(fmt.Errorf("cannot list editor options: %w", err),
84
"don't pass an explicit editor, i.e. omit the --editor flag",
85
))
86
}
87
var (
88
editors []string
89
found bool
90
)
91
for _, editor := range resp.Msg.GetResult() {
92
editors = append(editors, editor.Id)
93
if editor.Id == workspaceCreateOpts.Editor {
94
found = true
95
}
96
}
97
if !found {
98
return prettyprint.AddResolution(fmt.Errorf("editor %s not found", workspaceCreateOpts.Editor),
99
fmt.Sprintf("use one of the available editor options: %s", strings.Join(editors, ", ")),
100
)
101
}
102
}
103
104
var (
105
orgId = gpctx.OrganizationID
106
ctx = cmd.Context()
107
)
108
109
defer func() {
110
// If the error doesn't have a resolution, assume it's a system error and add an apology
111
if err != nil && !errors.Is(err, &prettyprint.ErrResolution{}) {
112
err = prettyprint.MarkExceptional(err)
113
}
114
}()
115
116
currentDir, err := filepath.Abs(workingDir)
117
if err != nil {
118
return err
119
}
120
for {
121
// Check if current directory contains .git folder
122
_, err := os.Stat(filepath.Join(currentDir, ".git"))
123
if err == nil {
124
break
125
}
126
if !os.IsNotExist(err) {
127
return err
128
}
129
130
// Move to the parent directory
131
parentDir := filepath.Dir(currentDir)
132
if parentDir == currentDir {
133
// No more parent directories
134
return prettyprint.AddResolution(fmt.Errorf("no Git repository found"),
135
fmt.Sprintf("make sure %s is a valid Git repository", workingDir),
136
"run `git clone` to clone an existing repository",
137
"open a remote repository using `{gitpod} workspace create <repo-url>`",
138
)
139
}
140
currentDir = parentDir
141
}
142
143
slog.Debug("found Git working copy", "dir", currentDir)
144
repo, err := git.PlainOpen(currentDir)
145
if err != nil {
146
return prettyprint.MarkExceptional(fmt.Errorf("cannot open Git working copy at %s: %w", currentDir, err))
147
}
148
_ = repo.DeleteRemote("gitpod")
149
head, err := repo.Head()
150
if err != nil {
151
return prettyprint.MarkExceptional(fmt.Errorf("cannot get HEAD: %w", err))
152
}
153
branch := head.Name().Short()
154
155
newWorkspace, err := gitpod.Workspaces.CreateAndStartWorkspace(ctx, connect.NewRequest(
156
&v1.CreateAndStartWorkspaceRequest{
157
Source: &v1.CreateAndStartWorkspaceRequest_ContextUrl{ContextUrl: "GITPODCLI_CONTENT_INIT=push/https://github.com/gitpod-io/empty"},
158
OrganizationId: orgId,
159
StartSpec: &v1.StartWorkspaceSpec{
160
IdeSettings: &v1.IDESettings{
161
DefaultIde: workspaceCreateOpts.Editor,
162
UseLatestVersion: false,
163
},
164
WorkspaceClass: workspaceCreateOpts.WorkspaceClass,
165
},
166
},
167
))
168
if err != nil {
169
return err
170
}
171
workspaceID := newWorkspace.Msg.WorkspaceId
172
if len(workspaceID) == 0 {
173
return prettyprint.MarkExceptional(prettyprint.AddResolution(fmt.Errorf("workspace was not created"),
174
"try to create the workspace again",
175
))
176
}
177
ws, err := helper.ObserveWorkspaceUntilStarted(ctx, gitpod, workspaceID)
178
if err != nil {
179
return err
180
}
181
slog.Debug("workspace started", "workspaceID", workspaceID)
182
183
token, err := gitpod.Workspaces.GetOwnerToken(ctx, connect.NewRequest(&v1.GetOwnerTokenRequest{WorkspaceId: workspaceID}))
184
if err != nil {
185
return err
186
}
187
var (
188
ownerToken = token.Msg.Token
189
host = strings.TrimPrefix(strings.ReplaceAll(ws.Instance.Status.Url, workspaceID, workspaceID+".ssh"), "https://")
190
)
191
sess, err := goph.NewConn(&goph.Config{
192
User: fmt.Sprintf("%s#%s", workspaceID, ownerToken),
193
Addr: host,
194
Callback: ssh.InsecureIgnoreHostKey(),
195
Timeout: 10 * time.Second,
196
Port: 22,
197
})
198
if err != nil {
199
return prettyprint.AddResolution(fmt.Errorf("cannot connect to workspace: %w", err),
200
"make sure you can connect to SSH servers on port 22",
201
)
202
}
203
defer sess.Close()
204
205
slog.Debug("initializing remote workspace Git repository")
206
err = runSSHCommand(ctx, sess, "rm", "-r", "/workspace/empty/.git")
207
if err != nil {
208
return err
209
}
210
err = runSSHCommand(ctx, sess, "git", "init", "/workspace/remote")
211
if err != nil {
212
return err
213
}
214
215
slog.Debug("pushing to workspace")
216
sshRemote := fmt.Sprintf("%s#%s@%s:/workspace/remote", workspaceID, ownerToken, helper.WorkspaceSSHHost(&v1.Workspace{WorkspaceId: workspaceID, Status: ws}))
217
_, err = repo.CreateRemote(&gitcfg.RemoteConfig{
218
Name: "gitpod",
219
URLs: []string{sshRemote},
220
})
221
if err != nil {
222
return fmt.Errorf("cannot create remote: %w", err)
223
}
224
225
// Pushing using Go git is tricky because of the SSH host verification. Shelling out to git is easier.
226
slog.Info("pushing to local working copy to remote workspace")
227
pushcmd := exec.Command("git", "push", "--progress", "gitpod")
228
pushcmd.Stdout = os.Stdout
229
pushcmd.Stderr = os.Stderr
230
pushcmd.Dir = currentDir
231
pushcmd.Env = append(os.Environ(), "GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null")
232
err = pushcmd.Run()
233
if err != nil {
234
return fmt.Errorf("cannot push to remote: %w", err)
235
}
236
237
slog.Debug("checking out branch in workspace")
238
err = runSSHCommand(ctx, sess, "sh -c 'cd /workspace/empty && git clone /workspace/remote .'")
239
if err != nil {
240
return err
241
}
242
err = runSSHCommand(ctx, sess, "sh -c 'cd /workspace/empty && git checkout "+branch+"'")
243
if err != nil {
244
return err
245
}
246
err = runSSHCommand(ctx, sess, "sh -c 'cd /workspace/empty && git config receive.denyCurrentBranch ignore'")
247
if err != nil {
248
return err
249
}
250
251
doneBanner := fmt.Sprintf("\n\n%s\n\nDon't forget to pull your changes to your local working copy before stopping the workspace.\nUse `cd %s && git pull gitpod %s`\n\n", color.New(color.FgGreen, color.Bold).Sprintf("Workspace ready!"), currentDir, branch)
252
slog.Info(doneBanner)
253
254
switch {
255
case workspaceCreateOpts.StartOpts.OpenSSH:
256
err = helper.SSHConnectToWorkspace(ctx, gitpod, workspaceID, false)
257
if err != nil && err.Error() == "exit status 255" {
258
err = nil
259
} else if err != nil {
260
return err
261
}
262
case workspaceCreateOpts.StartOpts.OpenEditor:
263
return helper.OpenWorkspaceInPreferredEditor(ctx, gitpod, workspaceID)
264
default:
265
slog.Info("Access your workspace at", "url", ws.Instance.Status.Url)
266
}
267
return nil
268
},
269
}
270
271
func runSSHCommand(ctx context.Context, sess *goph.Client, name string, args ...string) error {
272
cmd, err := sess.Command(name, args...)
273
if err != nil {
274
return err
275
}
276
out := bytes.NewBuffer(nil)
277
cmd.Stdout = out
278
cmd.Stderr = out
279
slog.Debug("running remote command", "cmd", name, "args", args)
280
281
err = cmd.Run()
282
if err != nil {
283
return fmt.Errorf("%w: %s", err, out.String())
284
}
285
return nil
286
}
287
288
func init() {
289
workspaceCmd.AddCommand(workspaceUpCmd)
290
addWorkspaceStartOptions(workspaceUpCmd, &workspaceCreateOpts.StartOpts)
291
292
workspaceUpCmd.Flags().StringVar(&workspaceCreateOpts.WorkspaceClass, "class", "", "the workspace class")
293
workspaceUpCmd.Flags().StringVar(&workspaceCreateOpts.Editor, "editor", "code", "the editor to use")
294
295
_ = workspaceUpCmd.RegisterFlagCompletionFunc("class", classCompletionFunc)
296
_ = workspaceUpCmd.RegisterFlagCompletionFunc("editor", editorCompletionFunc)
297
}
298
299