mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
feat: Add templates to create working release (#422)
* Add templates
* Move API structs to codersdk
* Back to green tests!
* It all works, but now with tea! 🧋
* It works!
* Add cancellation to provisionerd
* Tests pass!
* Add deletion of workspaces and projects
* Fix agent lock
* Add clog
* Fix linting errors
* Remove unused CLI tests
* Rename daemon to start
* Fix leaking command
* Fix promptui test
* Update agent connection frequency
* Skip login tests on Windows
* Increase tunnel connect timeout
* Fix templater
* Lower test requirements
* Fix embed
* Disable promptui tests for Windows
* Fix write newline
* Fix PTY write newline
* Fix CloseReader
* Fix compilation on Windows
* Fix linting error
* Remove bubbletea
* Cleanup readwriter
* Use embedded templates instead of serving over API
* Move templates to examples
* Improve workspace create flow
* Fix Windows build
* Fix tests
* Fix linting errors
* Fix untar with extracting max size
* Fix newline char
This commit is contained in:
50
cli/cliui/cliui.go
Normal file
50
cli/cliui/cliui.go
Normal file
@ -0,0 +1,50 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/charm/ui/common"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
var (
|
||||
Canceled = xerrors.New("canceled")
|
||||
|
||||
defaultStyles = common.DefaultStyles()
|
||||
)
|
||||
|
||||
// ValidateNotEmpty is a helper function to disallow empty inputs!
|
||||
func ValidateNotEmpty(s string) error {
|
||||
if s == "" {
|
||||
return xerrors.New("Must be provided!")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Styles compose visual elements of the UI!
|
||||
var Styles = struct {
|
||||
Bold,
|
||||
Code,
|
||||
Field,
|
||||
Keyword,
|
||||
Paragraph,
|
||||
Placeholder,
|
||||
Prompt,
|
||||
FocusedPrompt,
|
||||
Fuschia,
|
||||
Logo,
|
||||
Warn,
|
||||
Wrap lipgloss.Style
|
||||
}{
|
||||
Bold: lipgloss.NewStyle().Bold(true),
|
||||
Code: defaultStyles.Code,
|
||||
Field: defaultStyles.Code.Copy().Foreground(lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}),
|
||||
Keyword: defaultStyles.Keyword,
|
||||
Paragraph: defaultStyles.Paragraph,
|
||||
Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
|
||||
Prompt: defaultStyles.Prompt.Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}),
|
||||
FocusedPrompt: defaultStyles.FocusedPrompt.Foreground(lipgloss.Color("#651fff")),
|
||||
Fuschia: defaultStyles.SelectedMenuItem.Copy(),
|
||||
Logo: defaultStyles.Logo.SetString("Coder"),
|
||||
Warn: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"}),
|
||||
Wrap: defaultStyles.Wrap,
|
||||
}
|
150
cli/cliui/job.go
Normal file
150
cli/cliui/job.go
Normal file
@ -0,0 +1,150 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/briandowns/spinner"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/database"
|
||||
)
|
||||
|
||||
type JobOptions struct {
|
||||
Title string
|
||||
Fetch func() (codersdk.ProvisionerJob, error)
|
||||
Cancel func() error
|
||||
Logs func() (<-chan codersdk.ProvisionerJobLog, error)
|
||||
}
|
||||
|
||||
// Job renders a provisioner job.
|
||||
func Job(cmd *cobra.Command, opts JobOptions) (codersdk.ProvisionerJob, error) {
|
||||
var (
|
||||
spin = spinner.New(spinner.CharSets[5], 100*time.Millisecond, spinner.WithColor("fgGreen"))
|
||||
|
||||
started = false
|
||||
completed = false
|
||||
job codersdk.ProvisionerJob
|
||||
)
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s%s %s\n", Styles.FocusedPrompt, opts.Title, Styles.Placeholder.Render("(ctrl+c to cancel)"))
|
||||
|
||||
spin.Writer = cmd.OutOrStdout()
|
||||
defer spin.Stop()
|
||||
|
||||
// Refreshes the job state!
|
||||
refresh := func() {
|
||||
var err error
|
||||
job, err = opts.Fetch()
|
||||
if err != nil {
|
||||
// If a single fetch fails, it could be a one-off.
|
||||
return
|
||||
}
|
||||
|
||||
if !started && job.StartedAt != nil {
|
||||
spin.Stop()
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), Styles.Prompt.String()+"Started "+Styles.Placeholder.Render("[%dms]")+"\n", job.StartedAt.Sub(job.CreatedAt).Milliseconds())
|
||||
spin.Start()
|
||||
started = true
|
||||
}
|
||||
if !completed && job.CompletedAt != nil {
|
||||
spin.Stop()
|
||||
msg := ""
|
||||
switch job.Status {
|
||||
case codersdk.ProvisionerJobCanceled:
|
||||
msg = "Canceled"
|
||||
case codersdk.ProvisionerJobFailed:
|
||||
msg = "Completed"
|
||||
case codersdk.ProvisionerJobSucceeded:
|
||||
msg = "Built"
|
||||
}
|
||||
started := job.CreatedAt
|
||||
if job.StartedAt != nil {
|
||||
started = *job.StartedAt
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStderr(), Styles.Prompt.String()+msg+" "+Styles.Placeholder.Render("[%dms]")+"\n", job.CompletedAt.Sub(started).Milliseconds())
|
||||
spin.Start()
|
||||
completed = true
|
||||
}
|
||||
|
||||
switch job.Status {
|
||||
case codersdk.ProvisionerJobPending:
|
||||
spin.Suffix = " Queued"
|
||||
case codersdk.ProvisionerJobRunning:
|
||||
spin.Suffix = " Running"
|
||||
case codersdk.ProvisionerJobCanceling:
|
||||
spin.Suffix = " Canceling"
|
||||
}
|
||||
}
|
||||
refresh()
|
||||
spin.Start()
|
||||
|
||||
stopChan := make(chan os.Signal, 1)
|
||||
defer signal.Stop(stopChan)
|
||||
go func() {
|
||||
signal.Notify(stopChan, os.Interrupt)
|
||||
select {
|
||||
case <-cmd.Context().Done():
|
||||
return
|
||||
case _, ok := <-stopChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
spin.Stop()
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), Styles.FocusedPrompt.String()+"Gracefully canceling... wait for exit or data loss may occur!\n")
|
||||
spin.Start()
|
||||
err := opts.Cancel()
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Failed to cancel %s...\n", err)
|
||||
}
|
||||
refresh()
|
||||
}()
|
||||
|
||||
logs, err := opts.Logs()
|
||||
if err != nil {
|
||||
return job, err
|
||||
}
|
||||
|
||||
firstLog := false
|
||||
ticker := time.NewTicker(time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-cmd.Context().Done():
|
||||
return job, cmd.Context().Err()
|
||||
case <-ticker.C:
|
||||
refresh()
|
||||
if job.CompletedAt != nil {
|
||||
return job, nil
|
||||
}
|
||||
case log, ok := <-logs:
|
||||
if !ok {
|
||||
refresh()
|
||||
continue
|
||||
}
|
||||
if !firstLog {
|
||||
refresh()
|
||||
firstLog = true
|
||||
}
|
||||
spin.Stop()
|
||||
var style lipgloss.Style
|
||||
switch log.Level {
|
||||
case database.LogLevelTrace:
|
||||
style = defaultStyles.Error
|
||||
case database.LogLevelDebug:
|
||||
style = defaultStyles.Error
|
||||
case database.LogLevelError:
|
||||
style = defaultStyles.Error
|
||||
case database.LogLevelWarn:
|
||||
style = Styles.Warn
|
||||
case database.LogLevelInfo:
|
||||
style = defaultStyles.Note
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s %s %s\n", Styles.Placeholder.Render("|"), style.Render(string(log.Level)), log.Output)
|
||||
spin.Start()
|
||||
}
|
||||
}
|
||||
}
|
45
cli/cliui/parameter.go
Normal file
45
cli/cliui/parameter.go
Normal file
@ -0,0 +1,45 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/coderd/parameter"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func ParameterSchema(cmd *cobra.Command, parameterSchema codersdk.ProjectVersionParameterSchema) (string, error) {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), Styles.Bold.Render("var."+parameterSchema.Name))
|
||||
if parameterSchema.Description != "" {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+strings.TrimSpace(strings.Join(strings.Split(parameterSchema.Description, "\n"), "\n "))+"\n")
|
||||
}
|
||||
|
||||
var err error
|
||||
var options []string
|
||||
if parameterSchema.ValidationCondition != "" {
|
||||
options, _, err = parameter.Contains(parameterSchema.ValidationCondition)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
var value string
|
||||
if len(options) > 0 {
|
||||
// Move the cursor up a single line for nicer display!
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), "\033[1A")
|
||||
value, err = Select(cmd, SelectOptions{
|
||||
Options: options,
|
||||
HideSearch: true,
|
||||
})
|
||||
if err == nil {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+Styles.Prompt.String()+Styles.Field.Render(value))
|
||||
}
|
||||
} else {
|
||||
value, err = Prompt(cmd, PromptOptions{
|
||||
Text: Styles.Bold.Render("Enter a value:"),
|
||||
})
|
||||
}
|
||||
return value, err
|
||||
}
|
90
cli/cliui/prompt.go
Normal file
90
cli/cliui/prompt.go
Normal file
@ -0,0 +1,90 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
|
||||
"github.com/bgentry/speakeasy"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// PromptOptions supply a set of options to the prompt.
|
||||
type PromptOptions struct {
|
||||
Text string
|
||||
Default string
|
||||
Secret bool
|
||||
IsConfirm bool
|
||||
Validate func(string) error
|
||||
}
|
||||
|
||||
// Prompt asks the user for input.
|
||||
func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.FocusedPrompt.String()+opts.Text+" ")
|
||||
if opts.IsConfirm {
|
||||
opts.Default = "yes"
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.Placeholder.Render("("+Styles.Bold.Render("yes")+Styles.Placeholder.Render("/no) ")))
|
||||
} else if opts.Default != "" {
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.Placeholder.Render("("+opts.Default+") "))
|
||||
}
|
||||
interrupt := make(chan os.Signal, 1)
|
||||
signal.Notify(interrupt, os.Interrupt)
|
||||
defer signal.Stop(interrupt)
|
||||
|
||||
errCh := make(chan error)
|
||||
lineCh := make(chan string)
|
||||
go func() {
|
||||
var line string
|
||||
var err error
|
||||
inFile, valid := cmd.InOrStdin().(*os.File)
|
||||
if opts.Secret && valid && isatty.IsTerminal(inFile.Fd()) {
|
||||
line, err = speakeasy.Ask("")
|
||||
} else {
|
||||
reader := bufio.NewReader(cmd.InOrStdin())
|
||||
line, err = reader.ReadString('\n')
|
||||
// Multiline with single quotes!
|
||||
if err == nil && strings.HasPrefix(line, "'") {
|
||||
rest, err := reader.ReadString('\'')
|
||||
if err == nil {
|
||||
line += rest
|
||||
line = strings.Trim(line, "'")
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" {
|
||||
line = opts.Default
|
||||
}
|
||||
lineCh <- line
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return "", err
|
||||
case line := <-lineCh:
|
||||
if opts.IsConfirm && line != "yes" && line != "y" {
|
||||
return line, Canceled
|
||||
}
|
||||
if opts.Validate != nil {
|
||||
err := opts.Validate(line)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), defaultStyles.Error.Render(err.Error()))
|
||||
return Prompt(cmd, opts)
|
||||
}
|
||||
}
|
||||
return line, nil
|
||||
case <-cmd.Context().Done():
|
||||
return "", cmd.Context().Err()
|
||||
case <-interrupt:
|
||||
// Print a newline so that any further output starts properly on a new line.
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
return "", Canceled
|
||||
}
|
||||
}
|
84
cli/cliui/prompt_test.go
Normal file
84
cli/cliui/prompt_test.go
Normal file
@ -0,0 +1,84 @@
|
||||
package cliui_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
func TestPrompt(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ptty := ptytest.New(t)
|
||||
msgChan := make(chan string)
|
||||
go func() {
|
||||
resp, err := newPrompt(ptty, cliui.PromptOptions{
|
||||
Text: "Example",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
msgChan <- resp
|
||||
}()
|
||||
ptty.ExpectMatch("Example")
|
||||
ptty.WriteLine("hello")
|
||||
require.Equal(t, "hello", <-msgChan)
|
||||
})
|
||||
|
||||
t.Run("Confirm", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ptty := ptytest.New(t)
|
||||
doneChan := make(chan string)
|
||||
go func() {
|
||||
resp, err := newPrompt(ptty, cliui.PromptOptions{
|
||||
Text: "Example",
|
||||
IsConfirm: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
doneChan <- resp
|
||||
}()
|
||||
ptty.ExpectMatch("Example")
|
||||
ptty.WriteLine("yes")
|
||||
require.Equal(t, "yes", <-doneChan)
|
||||
})
|
||||
|
||||
t.Run("Multiline", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ptty := ptytest.New(t)
|
||||
doneChan := make(chan string)
|
||||
go func() {
|
||||
resp, err := newPrompt(ptty, cliui.PromptOptions{
|
||||
Text: "Example",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
doneChan <- resp
|
||||
}()
|
||||
ptty.ExpectMatch("Example")
|
||||
ptty.WriteLine("'this is a")
|
||||
ptty.WriteLine("test'")
|
||||
newline := "\n"
|
||||
if runtime.GOOS == "windows" {
|
||||
newline = "\r\n"
|
||||
}
|
||||
require.Equal(t, "this is a"+newline+"test", <-doneChan)
|
||||
})
|
||||
}
|
||||
|
||||
func newPrompt(ptty *ptytest.PTY, opts cliui.PromptOptions) (string, error) {
|
||||
value := ""
|
||||
cmd := &cobra.Command{
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var err error
|
||||
value, err = cliui.Prompt(cmd, opts)
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.SetOutput(ptty.Output())
|
||||
cmd.SetIn(ptty.Input())
|
||||
return value, cmd.ExecuteContext(context.Background())
|
||||
}
|
73
cli/cliui/select.go
Normal file
73
cli/cliui/select.go
Normal file
@ -0,0 +1,73 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type SelectOptions struct {
|
||||
Options []string
|
||||
Size int
|
||||
HideSearch bool
|
||||
}
|
||||
|
||||
// Select displays a list of user options.
|
||||
func Select(cmd *cobra.Command, opts SelectOptions) (string, error) {
|
||||
selector := promptui.Select{
|
||||
Label: "",
|
||||
Items: opts.Options,
|
||||
Size: opts.Size,
|
||||
Searcher: func(input string, index int) bool {
|
||||
option := opts.Options[index]
|
||||
name := strings.Replace(strings.ToLower(option), " ", "", -1)
|
||||
input = strings.Replace(strings.ToLower(input), " ", "", -1)
|
||||
|
||||
return strings.Contains(name, input)
|
||||
},
|
||||
HideHelp: opts.HideSearch,
|
||||
Stdin: io.NopCloser(cmd.InOrStdin()),
|
||||
Stdout: &writeCloser{cmd.OutOrStdout()},
|
||||
Templates: &promptui.SelectTemplates{
|
||||
FuncMap: template.FuncMap{
|
||||
"faint": func(value interface{}) string {
|
||||
return Styles.Placeholder.Render(value.(string))
|
||||
},
|
||||
"subtle": func(value interface{}) string {
|
||||
return defaultStyles.Subtle.Render(value.(string))
|
||||
},
|
||||
"selected": func(value interface{}) string {
|
||||
return defaultStyles.Keyword.Render("> " + value.(string))
|
||||
// return defaultStyles.SelectedMenuItem.Render("> " + value.(string))
|
||||
},
|
||||
},
|
||||
Active: "{{ . | selected }}",
|
||||
Inactive: " {{ . }}",
|
||||
Label: "{{.}}",
|
||||
Selected: "{{ \"\" }}",
|
||||
Help: `{{ "Use" | faint }} {{ .SearchKey | faint }} {{ "to toggle search" | faint }}`,
|
||||
},
|
||||
HideSelected: true,
|
||||
}
|
||||
|
||||
_, result, err := selector.Run()
|
||||
if errors.Is(err, promptui.ErrAbort) || errors.Is(err, promptui.ErrInterrupt) {
|
||||
return result, Canceled
|
||||
}
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type writeCloser struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func (*writeCloser) Close() error {
|
||||
return nil
|
||||
}
|
47
cli/cliui/select_test.go
Normal file
47
cli/cliui/select_test.go
Normal file
@ -0,0 +1,47 @@
|
||||
package cliui_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
func TestSelect(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Select", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ptty := ptytest.New(t)
|
||||
msgChan := make(chan string)
|
||||
go func() {
|
||||
resp, err := newSelect(ptty, cliui.SelectOptions{
|
||||
Options: []string{"First", "Second"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
msgChan <- resp
|
||||
}()
|
||||
ptty.ExpectMatch("Second")
|
||||
ptty.Write(promptui.KeyNext)
|
||||
ptty.WriteLine("")
|
||||
require.Equal(t, "Second", <-msgChan)
|
||||
})
|
||||
}
|
||||
|
||||
func newSelect(ptty *ptytest.PTY, opts cliui.SelectOptions) (string, error) {
|
||||
value := ""
|
||||
cmd := &cobra.Command{
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var err error
|
||||
value, err = cliui.Select(cmd, opts)
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.SetOutput(ptty.Output())
|
||||
cmd.SetIn(ptty.Input())
|
||||
return value, cmd.ExecuteContext(context.Background())
|
||||
}
|
26
cli/configssh.go
Normal file
26
cli/configssh.go
Normal file
@ -0,0 +1,26 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// const sshStartToken = "# ------------START-CODER-----------"
|
||||
// const sshStartMessage = `# This was generated by "coder config-ssh".
|
||||
// #
|
||||
// # To remove this blob, run:
|
||||
// #
|
||||
// # coder config-ssh --remove
|
||||
// #
|
||||
// # You should not hand-edit this section, unless you are deleting it.`
|
||||
// const sshEndToken = "# ------------END-CODER------------"
|
||||
|
||||
func configSSH() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config-ssh",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
112
cli/daemon.go
112
cli/daemon.go
@ -1,112 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/database"
|
||||
"github.com/coder/coder/database/databasefake"
|
||||
"github.com/coder/coder/provisioner/terraform"
|
||||
"github.com/coder/coder/provisionerd"
|
||||
"github.com/coder/coder/provisionersdk"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
)
|
||||
|
||||
func daemon() *cobra.Command {
|
||||
var (
|
||||
address string
|
||||
)
|
||||
root := &cobra.Command{
|
||||
Use: "daemon",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
logger := slog.Make(sloghuman.Sink(os.Stderr))
|
||||
accessURL := &url.URL{
|
||||
Scheme: "http",
|
||||
Host: address,
|
||||
}
|
||||
handler, closeCoderd := coderd.New(&coderd.Options{
|
||||
AccessURL: accessURL,
|
||||
Logger: logger,
|
||||
Database: databasefake.New(),
|
||||
Pubsub: database.NewPubsubInMemory(),
|
||||
})
|
||||
|
||||
listener, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("listen %q: %w", address, err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
logger.Info(cmd.Context(), "daemon started", slog.F("url", accessURL.String()))
|
||||
|
||||
client := codersdk.New(accessURL)
|
||||
daemonClose, err := newProvisionerDaemon(cmd.Context(), client, logger)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create provisioner daemon: %w", err)
|
||||
}
|
||||
defer daemonClose.Close()
|
||||
|
||||
errCh := make(chan error)
|
||||
go func() {
|
||||
defer close(errCh)
|
||||
errCh <- http.Serve(listener, handler)
|
||||
}()
|
||||
|
||||
closeCoderd()
|
||||
select {
|
||||
case <-cmd.Context().Done():
|
||||
return cmd.Context().Err()
|
||||
case err := <-errCh:
|
||||
return err
|
||||
}
|
||||
},
|
||||
}
|
||||
defaultAddress, ok := os.LookupEnv("ADDRESS")
|
||||
if !ok {
|
||||
defaultAddress = "127.0.0.1:3000"
|
||||
}
|
||||
root.Flags().StringVarP(&address, "address", "a", defaultAddress, "The address to serve the API and dashboard.")
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func newProvisionerDaemon(ctx context.Context, client *codersdk.Client, logger slog.Logger) (io.Closer, error) {
|
||||
terraformClient, terraformServer := provisionersdk.TransportPipe()
|
||||
go func() {
|
||||
err := terraform.Serve(ctx, &terraform.ServeOptions{
|
||||
ServeOptions: &provisionersdk.ServeOptions{
|
||||
Listener: terraformServer,
|
||||
},
|
||||
Logger: logger,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
tempDir, err := ioutil.TempDir("", "provisionerd")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provisionerd.New(client.ListenProvisionerDaemon, &provisionerd.Options{
|
||||
Logger: logger,
|
||||
PollInterval: 50 * time.Millisecond,
|
||||
UpdateInterval: 50 * time.Millisecond,
|
||||
Provisioners: provisionerd.Provisioners{
|
||||
string(database.ProvisionerTypeTerraform): proto.NewDRPCProvisionerClient(provisionersdk.Conn(terraformClient)),
|
||||
},
|
||||
WorkDirectory: tempDir,
|
||||
}), nil
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
)
|
||||
|
||||
func TestDaemon(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
go cancelFunc()
|
||||
root, _ := clitest.New(t, "daemon")
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}
|
64
cli/login.go
64
cli/login.go
@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/url"
|
||||
@ -9,14 +10,12 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/pkg/browser"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
@ -67,38 +66,36 @@ func login() *cobra.Command {
|
||||
if !isTTY(cmd) {
|
||||
return xerrors.New("the initial user cannot be created in non-interactive mode. use the API")
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Your Coder deployment hasn't been set up!\n", caret)
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), caret+"Your Coder deployment hasn't been set up!\n")
|
||||
|
||||
_, err := prompt(cmd, &promptui.Prompt{
|
||||
Label: "Would you like to create the first user?",
|
||||
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Would you like to create the first user?",
|
||||
Default: "yes",
|
||||
IsConfirm: true,
|
||||
Default: "y",
|
||||
})
|
||||
if errors.Is(err, cliui.Canceled) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create user prompt: %w", err)
|
||||
return err
|
||||
}
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get current user: %w", err)
|
||||
}
|
||||
username, err := prompt(cmd, &promptui.Prompt{
|
||||
Label: "What username would you like?",
|
||||
username, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "What " + cliui.Styles.Field.Render("username") + " would you like?",
|
||||
Default: currentUser.Username,
|
||||
})
|
||||
if errors.Is(err, cliui.Canceled) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return xerrors.Errorf("pick username prompt: %w", err)
|
||||
}
|
||||
|
||||
organization, err := prompt(cmd, &promptui.Prompt{
|
||||
Label: "What is the name of your organization?",
|
||||
Default: "acme-corp",
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("pick organization prompt: %w", err)
|
||||
}
|
||||
|
||||
email, err := prompt(cmd, &promptui.Prompt{
|
||||
Label: "What's your email?",
|
||||
email, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "What's your " + cliui.Styles.Field.Render("email") + "?",
|
||||
Validate: func(s string) error {
|
||||
err := validator.New().Var(s, "email")
|
||||
if err != nil {
|
||||
@ -111,24 +108,25 @@ func login() *cobra.Command {
|
||||
return xerrors.Errorf("specify email prompt: %w", err)
|
||||
}
|
||||
|
||||
password, err := prompt(cmd, &promptui.Prompt{
|
||||
Label: "Enter a password:",
|
||||
Mask: '*',
|
||||
password, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Enter a " + cliui.Styles.Field.Render("password") + ":",
|
||||
Secret: true,
|
||||
Validate: cliui.ValidateNotEmpty,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("specify password prompt: %w", err)
|
||||
}
|
||||
|
||||
_, err = client.CreateFirstUser(cmd.Context(), coderd.CreateFirstUserRequest{
|
||||
_, err = client.CreateFirstUser(cmd.Context(), codersdk.CreateFirstUserRequest{
|
||||
Email: email,
|
||||
Username: username,
|
||||
Organization: username,
|
||||
Password: password,
|
||||
Organization: organization,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create initial user: %w", err)
|
||||
}
|
||||
resp, err := client.LoginWithPassword(cmd.Context(), coderd.LoginWithPasswordRequest{
|
||||
resp, err := client.LoginWithPassword(cmd.Context(), codersdk.LoginWithPasswordRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
})
|
||||
@ -147,7 +145,11 @@ func login() *cobra.Command {
|
||||
return xerrors.Errorf("write server url: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", caret, color.HiCyanString(username))
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
|
||||
cliui.Styles.Paragraph.Render(fmt.Sprintf("Welcome to Coder, %s! You're authenticated.", cliui.Styles.Keyword.Render(username)))+"\n")
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
|
||||
cliui.Styles.Paragraph.Render("Get started by creating a project: "+cliui.Styles.Code.Render("coder projects create"))+"\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -159,9 +161,9 @@ func login() *cobra.Command {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Your browser has been opened to visit:\n\n\t%s\n\n", authURL.String())
|
||||
}
|
||||
|
||||
sessionToken, err := prompt(cmd, &promptui.Prompt{
|
||||
Label: "Paste your token here:",
|
||||
Mask: '*',
|
||||
sessionToken, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Paste your token here:",
|
||||
Secret: true,
|
||||
Validate: func(token string) error {
|
||||
client.SessionToken = token
|
||||
_, err := client.User(cmd.Context(), "me")
|
||||
@ -192,7 +194,7 @@ func login() *cobra.Command {
|
||||
return xerrors.Errorf("write server url: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", caret, color.HiCyanString(resp.Username))
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), caret+"Welcome to Coder, %s! You're authenticated.\n", cliui.Styles.Keyword.Render(resp.Username))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@ -26,19 +27,20 @@ func TestLogin(t *testing.T) {
|
||||
// The --force-tty flag is required on Windows, because the `isatty` library does not
|
||||
// accurately detect Windows ptys when they are not attached to a process:
|
||||
// https://github.com/mattn/go-isatty/issues/59
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--force-tty")
|
||||
doneChan := make(chan struct{})
|
||||
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String())
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.Execute()
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"first user?", "y",
|
||||
"first user?", "yes",
|
||||
"username", "testuser",
|
||||
"organization", "testorg",
|
||||
"email", "user@coder.com",
|
||||
"password", "password",
|
||||
}
|
||||
@ -49,6 +51,7 @@ func TestLogin(t *testing.T) {
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("ExistingUserValidTokenTTY", func(t *testing.T) {
|
||||
@ -56,11 +59,13 @@ func TestLogin(t *testing.T) {
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--force-tty", "--no-open")
|
||||
doneChan := make(chan struct{})
|
||||
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String(), "--no-open")
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.Execute()
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
@ -68,6 +73,7 @@ func TestLogin(t *testing.T) {
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(client.SessionToken)
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("ExistingUserInvalidTokenTTY", func(t *testing.T) {
|
||||
@ -75,12 +81,16 @@ func TestLogin(t *testing.T) {
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--force-tty", "--no-open")
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
doneChan := make(chan struct{})
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--no-open")
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
go func() {
|
||||
err := root.Execute()
|
||||
defer close(doneChan)
|
||||
err := root.ExecuteContext(ctx)
|
||||
// An error is expected in this case, since the login wasn't successful:
|
||||
require.Error(t, err)
|
||||
}()
|
||||
@ -88,5 +98,7 @@ func TestLogin(t *testing.T) {
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine("an-invalid-token")
|
||||
pty.ExpectMatch("That's not a valid token!")
|
||||
cancelFunc()
|
||||
<-doneChan
|
||||
})
|
||||
}
|
||||
|
75
cli/parametercreate.go
Normal file
75
cli/parametercreate.go
Normal file
@ -0,0 +1,75 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/database"
|
||||
)
|
||||
|
||||
func parameterCreate() *cobra.Command {
|
||||
var (
|
||||
name string
|
||||
value string
|
||||
scheme string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <scope> [name]",
|
||||
Aliases: []string{"mk"},
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
organization, err := currentOrganization(cmd, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scopeName := ""
|
||||
if len(args) >= 2 {
|
||||
scopeName = args[1]
|
||||
}
|
||||
scope, scopeID, err := parseScopeAndID(cmd.Context(), client, organization, args[0], scopeName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scheme, err := parseParameterScheme(scheme)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = client.CreateParameter(cmd.Context(), scope, scopeID, codersdk.CreateParameterRequest{
|
||||
Name: name,
|
||||
SourceValue: value,
|
||||
SourceScheme: database.ParameterSourceSchemeData,
|
||||
DestinationScheme: scheme,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Printf("Created!\n")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&name, "name", "n", "", "Name for a parameter.")
|
||||
_ = cmd.MarkFlagRequired("name")
|
||||
cmd.Flags().StringVarP(&value, "value", "v", "", "Value for a parameter.")
|
||||
_ = cmd.MarkFlagRequired("value")
|
||||
cmd.Flags().StringVarP(&scheme, "scheme", "s", "var", `Scheme for the parameter ("var" or "env").`)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func parseParameterScheme(scheme string) (database.ParameterDestinationScheme, error) {
|
||||
switch scheme {
|
||||
case "env":
|
||||
return database.ParameterDestinationSchemeEnvironmentVariable, nil
|
||||
case "var":
|
||||
return database.ParameterDestinationSchemeProvisionerVariable, nil
|
||||
}
|
||||
return database.ParameterDestinationSchemeNone, xerrors.Errorf("scheme %q not recognized", scheme)
|
||||
}
|
13
cli/parameterdelete.go
Normal file
13
cli/parameterdelete.go
Normal file
@ -0,0 +1,13 @@
|
||||
package cli
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func parameterDelete() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete",
|
||||
Aliases: []string{"rm"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
50
cli/parameterlist.go
Normal file
50
cli/parameterlist.go
Normal file
@ -0,0 +1,50 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func parameterList() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list <scope> <scope-id>",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
organization, err := currentOrganization(cmd, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
name := ""
|
||||
if len(args) >= 2 {
|
||||
name = args[1]
|
||||
}
|
||||
scope, scopeID, err := parseScopeAndID(cmd.Context(), client, organization, args[0], name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params, err := client.Parameters(cmd.Context(), scope, scopeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 4, ' ', 0)
|
||||
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\n",
|
||||
color.HiBlackString("Parameter"),
|
||||
color.HiBlackString("Created"),
|
||||
color.HiBlackString("Scheme"))
|
||||
for _, param := range params {
|
||||
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\n",
|
||||
color.New(color.FgHiCyan).Sprint(param.Name),
|
||||
color.WhiteString(param.UpdatedAt.Format("January 2, 2006")),
|
||||
color.New(color.FgHiWhite).Sprint(param.DestinationScheme))
|
||||
}
|
||||
return writer.Flush()
|
||||
},
|
||||
}
|
||||
}
|
74
cli/parameters.go
Normal file
74
cli/parameters.go
Normal file
@ -0,0 +1,74 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func parameters() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "parameters",
|
||||
Aliases: []string{"params"},
|
||||
}
|
||||
|
||||
cmd.AddCommand(parameterCreate(), parameterList(), parameterDelete())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func parseScopeAndID(ctx context.Context, client *codersdk.Client, organization codersdk.Organization, rawScope string, name string) (codersdk.ParameterScope, string, error) {
|
||||
scope, err := parseParameterScope(rawScope)
|
||||
if err != nil {
|
||||
return scope, "", err
|
||||
}
|
||||
var scopeID string
|
||||
switch scope {
|
||||
case codersdk.ParameterOrganization:
|
||||
if name == "" {
|
||||
scopeID = organization.ID
|
||||
} else {
|
||||
org, err := client.OrganizationByName(ctx, "", name)
|
||||
if err != nil {
|
||||
return scope, "", err
|
||||
}
|
||||
scopeID = org.ID
|
||||
}
|
||||
case codersdk.ParameterProject:
|
||||
project, err := client.ProjectByName(ctx, organization.ID, name)
|
||||
if err != nil {
|
||||
return scope, "", err
|
||||
}
|
||||
scopeID = project.ID.String()
|
||||
case codersdk.ParameterUser:
|
||||
user, err := client.User(ctx, name)
|
||||
if err != nil {
|
||||
return scope, "", err
|
||||
}
|
||||
scopeID = user.ID
|
||||
case codersdk.ParameterWorkspace:
|
||||
workspace, err := client.WorkspaceByName(ctx, "", name)
|
||||
if err != nil {
|
||||
return scope, "", err
|
||||
}
|
||||
scopeID = workspace.ID.String()
|
||||
}
|
||||
return scope, scopeID, nil
|
||||
}
|
||||
|
||||
func parseParameterScope(scope string) (codersdk.ParameterScope, error) {
|
||||
switch scope {
|
||||
case string(codersdk.ParameterOrganization):
|
||||
return codersdk.ParameterOrganization, nil
|
||||
case string(codersdk.ParameterProject):
|
||||
return codersdk.ParameterProject, nil
|
||||
case string(codersdk.ParameterUser):
|
||||
return codersdk.ParameterUser, nil
|
||||
case string(codersdk.ParameterWorkspace):
|
||||
return codersdk.ParameterWorkspace, nil
|
||||
}
|
||||
return codersdk.ParameterOrganization, xerrors.Errorf("no scope found by name %q", scope)
|
||||
}
|
@ -1,35 +1,34 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/briandowns/spinner"
|
||||
"github.com/fatih/color"
|
||||
"github.com/google/uuid"
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/database"
|
||||
"github.com/coder/coder/provisionerd"
|
||||
"github.com/coder/coder/provisionersdk"
|
||||
)
|
||||
|
||||
func projectCreate() *cobra.Command {
|
||||
var (
|
||||
yes bool
|
||||
directory string
|
||||
provisioner string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Use: "create [name]",
|
||||
Short: "Create a project from the current directory",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
@ -40,70 +39,66 @@ func projectCreate() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = prompt(cmd, &promptui.Prompt{
|
||||
Default: "y",
|
||||
IsConfirm: true,
|
||||
Label: fmt.Sprintf("Set up %s in your organization?", color.New(color.FgHiCyan).Sprintf("%q", directory)),
|
||||
})
|
||||
|
||||
var projectName string
|
||||
if len(args) == 0 {
|
||||
projectName = filepath.Base(directory)
|
||||
} else {
|
||||
projectName = args[0]
|
||||
}
|
||||
_, err = client.ProjectByName(cmd.Context(), organization.ID, projectName)
|
||||
if err == nil {
|
||||
return xerrors.Errorf("A project already exists named %q!", projectName)
|
||||
}
|
||||
|
||||
spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond)
|
||||
spin.Writer = cmd.OutOrStdout()
|
||||
spin.Suffix = cliui.Styles.Keyword.Render(" Uploading current directory...")
|
||||
spin.Start()
|
||||
defer spin.Stop()
|
||||
archive, err := provisionersdk.Tar(directory)
|
||||
if err != nil {
|
||||
if errors.Is(err, promptui.ErrAbort) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
name, err := prompt(cmd, &promptui.Prompt{
|
||||
Default: filepath.Base(directory),
|
||||
Label: "What's your project's name?",
|
||||
Validate: func(s string) error {
|
||||
project, _ := client.ProjectByName(cmd.Context(), organization.ID, s)
|
||||
if project.ID.String() != uuid.Nil.String() {
|
||||
return xerrors.New("A project already exists with that name!")
|
||||
resp, err := client.Upload(cmd.Context(), codersdk.ContentTypeTar, archive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spin.Stop()
|
||||
|
||||
spin = spinner.New(spinner.CharSets[5], 100*time.Millisecond)
|
||||
spin.Writer = cmd.OutOrStdout()
|
||||
spin.Suffix = cliui.Styles.Keyword.Render("Something")
|
||||
job, parameters, err := createValidProjectVersion(cmd, client, organization, database.ProvisionerType(provisioner), resp.Hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !yes {
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Create project?",
|
||||
IsConfirm: true,
|
||||
Default: "yes",
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, promptui.ErrAbort) {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
job, err := validateProjectVersionSource(cmd, client, organization, database.ProvisionerType(provisioner), directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := client.CreateProject(cmd.Context(), organization.ID, coderd.CreateProjectRequest{
|
||||
Name: name,
|
||||
VersionID: job.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = prompt(cmd, &promptui.Prompt{
|
||||
Label: "Create project?",
|
||||
IsConfirm: true,
|
||||
Default: "y",
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, promptui.ErrAbort) {
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s The %s project has been created!\n", caret, color.HiCyanString(project.Name))
|
||||
_, err = prompt(cmd, &promptui.Prompt{
|
||||
Label: "Create a new workspace?",
|
||||
IsConfirm: true,
|
||||
Default: "y",
|
||||
_, err = client.CreateProject(cmd.Context(), organization.ID, codersdk.CreateProjectRequest{
|
||||
Name: projectName,
|
||||
VersionID: job.ID,
|
||||
ParameterValues: parameters,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, promptui.ErrAbort) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "The %s project has been created!\n", projectName)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@ -115,146 +110,93 @@ func projectCreate() *cobra.Command {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Bypass prompts")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func validateProjectVersionSource(cmd *cobra.Command, client *codersdk.Client, organization coderd.Organization, provisioner database.ProvisionerType, directory string, parameters ...coderd.CreateParameterRequest) (*coderd.ProjectVersion, error) {
|
||||
spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond)
|
||||
spin.Writer = cmd.OutOrStdout()
|
||||
spin.Suffix = " Uploading current directory..."
|
||||
err := spin.Color("fgHiGreen")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spin.Start()
|
||||
defer spin.Stop()
|
||||
|
||||
tarData, err := tarDirectory(directory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Upload(cmd.Context(), codersdk.ContentTypeTar, tarData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func createValidProjectVersion(cmd *cobra.Command, client *codersdk.Client, organization codersdk.Organization, provisioner database.ProvisionerType, hash string, parameters ...codersdk.CreateParameterRequest) (*codersdk.ProjectVersion, []codersdk.CreateParameterRequest, error) {
|
||||
before := time.Now()
|
||||
version, err := client.CreateProjectVersion(cmd.Context(), organization.ID, coderd.CreateProjectVersionRequest{
|
||||
version, err := client.CreateProjectVersion(cmd.Context(), organization.ID, codersdk.CreateProjectVersionRequest{
|
||||
StorageMethod: database.ProvisionerStorageMethodFile,
|
||||
StorageSource: resp.Hash,
|
||||
StorageSource: hash,
|
||||
Provisioner: provisioner,
|
||||
ParameterValues: parameters,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spin.Suffix = " Waiting for the import to complete..."
|
||||
logs, err := client.ProjectVersionLogsAfter(cmd.Context(), version.ID, before)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logBuffer := make([]coderd.ProvisionerJobLog, 0, 64)
|
||||
for {
|
||||
log, ok := <-logs
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
logBuffer = append(logBuffer, log)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
_, err = cliui.Job(cmd, cliui.JobOptions{
|
||||
Title: "Building project...",
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
version, err := client.ProjectVersion(cmd.Context(), version.ID)
|
||||
return version.Job, err
|
||||
},
|
||||
Cancel: func() error {
|
||||
return client.CancelProjectVersion(cmd.Context(), version.ID)
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
|
||||
return client.ProjectVersionLogsAfter(cmd.Context(), version.ID, before)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
version, err = client.ProjectVersion(cmd.Context(), version.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
parameterSchemas, err := client.ProjectVersionSchema(cmd.Context(), version.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
parameterValues, err := client.ProjectVersionParameters(cmd.Context(), version.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
spin.Stop()
|
||||
|
||||
if provisionerd.IsMissingParameterError(version.Job.Error) {
|
||||
valuesBySchemaID := map[string]coderd.ProjectVersionParameter{}
|
||||
valuesBySchemaID := map[string]codersdk.ProjectVersionParameter{}
|
||||
for _, parameterValue := range parameterValues {
|
||||
valuesBySchemaID[parameterValue.SchemaID.String()] = parameterValue
|
||||
}
|
||||
sort.Slice(parameterSchemas, func(i, j int) bool {
|
||||
return parameterSchemas[i].Name < parameterSchemas[j].Name
|
||||
})
|
||||
missingSchemas := make([]codersdk.ProjectVersionParameterSchema, 0)
|
||||
for _, parameterSchema := range parameterSchemas {
|
||||
_, ok := valuesBySchemaID[parameterSchema.ID.String()]
|
||||
if ok {
|
||||
continue
|
||||
}
|
||||
value, err := prompt(cmd, &promptui.Prompt{
|
||||
Label: fmt.Sprintf("Enter value for %s:", color.HiCyanString(parameterSchema.Name)),
|
||||
})
|
||||
missingSchemas = append(missingSchemas, parameterSchema)
|
||||
}
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("This project has required variables! They are scoped to the project, and not viewable after being set.")+"\r\n")
|
||||
for _, parameterSchema := range missingSchemas {
|
||||
value, err := cliui.ParameterSchema(cmd, parameterSchema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
parameters = append(parameters, coderd.CreateParameterRequest{
|
||||
parameters = append(parameters, codersdk.CreateParameterRequest{
|
||||
Name: parameterSchema.Name,
|
||||
SourceValue: value,
|
||||
SourceScheme: database.ParameterSourceSchemeData,
|
||||
DestinationScheme: parameterSchema.DefaultDestinationScheme,
|
||||
})
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
}
|
||||
return validateProjectVersionSource(cmd, client, organization, provisioner, directory, parameters...)
|
||||
return createValidProjectVersion(cmd, client, organization, provisioner, hash, parameters...)
|
||||
}
|
||||
|
||||
if version.Job.Status != coderd.ProvisionerJobSucceeded {
|
||||
for _, log := range logBuffer {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s %s\n", color.HiGreenString("[tf]"), log.Output)
|
||||
}
|
||||
return nil, xerrors.New(version.Job.Error)
|
||||
if version.Job.Status != codersdk.ProvisionerJobSucceeded {
|
||||
return nil, nil, xerrors.New(version.Job.Error)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Successfully imported project source!\n", color.HiGreenString("✓"))
|
||||
|
||||
resources, err := client.ProjectVersionResources(cmd.Context(), version.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
return &version, displayProjectImportInfo(cmd, parameterSchemas, parameterValues, resources)
|
||||
}
|
||||
|
||||
func tarDirectory(directory string) ([]byte, error) {
|
||||
var buffer bytes.Buffer
|
||||
tarWriter := tar.NewWriter(&buffer)
|
||||
err := filepath.Walk(directory, func(file string, fileInfo os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header, err := tar.FileInfoHeader(fileInfo, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(directory, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = rel
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
if fileInfo.IsDir() {
|
||||
return nil
|
||||
}
|
||||
data, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(tarWriter, data); err != nil {
|
||||
return err
|
||||
}
|
||||
return data.Close()
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = tarWriter.Flush()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buffer.Bytes(), nil
|
||||
return &version, parameters, displayProjectVersionInfo(cmd, resources)
|
||||
}
|
||||
|
@ -9,13 +9,12 @@ import (
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/database"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
func TestProjectCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("NoParameters", func(t *testing.T) {
|
||||
t.Run("Create", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
@ -23,24 +22,20 @@ func TestProjectCreate(t *testing.T) {
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: echo.ProvisionComplete,
|
||||
})
|
||||
cmd, root := clitest.New(t, "projects", "create", "--directory", source, "--provisioner", string(database.ProvisionerTypeEcho))
|
||||
cmd, root := clitest.New(t, "projects", "create", "my-project", "--directory", source, "--provisioner", string(database.ProvisionerTypeEcho))
|
||||
clitest.SetupConfig(t, client, root)
|
||||
_ = coderdtest.NewProvisionerDaemon(t, client)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
closeChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
close(closeChan)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"organization?", "y",
|
||||
"name?", "test-project",
|
||||
"project?", "y",
|
||||
"created!", "n",
|
||||
"Create project?", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
@ -48,54 +43,6 @@ func TestProjectCreate(t *testing.T) {
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
<-closeChan
|
||||
})
|
||||
|
||||
t.Run("Parameter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
source := clitest.CreateProjectVersionSource(t, &echo.Responses{
|
||||
Parse: []*proto.Parse_Response{{
|
||||
Type: &proto.Parse_Response_Complete{
|
||||
Complete: &proto.Parse_Complete{
|
||||
ParameterSchemas: []*proto.ParameterSchema{{
|
||||
Name: "somevar",
|
||||
DefaultDestination: &proto.ParameterDestination{
|
||||
Scheme: proto.ParameterDestination_PROVISIONER_VARIABLE,
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
Provision: echo.ProvisionComplete,
|
||||
})
|
||||
cmd, root := clitest.New(t, "projects", "create", "--directory", source, "--provisioner", string(database.ProvisionerTypeEcho))
|
||||
clitest.SetupConfig(t, client, root)
|
||||
coderdtest.NewProvisionerDaemon(t, client)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
closeChan := make(chan struct{})
|
||||
go func() {
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
close(closeChan)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"organization?", "y",
|
||||
"name?", "test-project",
|
||||
"somevar", "value",
|
||||
"project?", "y",
|
||||
"created!", "n",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
<-closeChan
|
||||
<-doneChan
|
||||
})
|
||||
}
|
||||
|
12
cli/projectedit.go
Normal file
12
cli/projectedit.go
Normal file
@ -0,0 +1,12 @@
|
||||
package cli
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func projectEdit() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "edit",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
73
cli/projectinit.go
Normal file
73
cli/projectinit.go
Normal file
@ -0,0 +1,73 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/examples"
|
||||
"github.com/coder/coder/provisionersdk"
|
||||
)
|
||||
|
||||
func projectInit() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "init [directory]",
|
||||
Short: "Get started with a templated project.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
exampleList, err := examples.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exampleNames := []string{}
|
||||
exampleByName := map[string]examples.Example{}
|
||||
for _, example := range exampleList {
|
||||
exampleNames = append(exampleNames, example.Name)
|
||||
exampleByName[example.Name] = example
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render("Projects contain Infrastructure as Code that works with Coder to provision development workspaces. Get started by selecting an example:\n"))
|
||||
option, err := cliui.Select(cmd, cliui.SelectOptions{
|
||||
Options: exampleNames,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
selectedTemplate := exampleByName[option]
|
||||
archive, err := examples.Archive(selectedTemplate.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workingDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var directory string
|
||||
if len(args) > 0 {
|
||||
directory = args[0]
|
||||
} else {
|
||||
directory = filepath.Join(workingDir, selectedTemplate.ID)
|
||||
}
|
||||
relPath, err := filepath.Rel(workingDir, directory)
|
||||
if err != nil {
|
||||
relPath = directory
|
||||
} else {
|
||||
relPath = "./" + relPath
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%sExtracting %s to %s...\n", cliui.Styles.Prompt, cliui.Styles.Field.Render(selectedTemplate.ID), cliui.Styles.Keyword.Render(relPath))
|
||||
err = os.MkdirAll(directory, 0700)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = provisionersdk.Untar(directory, archive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Prompt.String()+"Inside that directory, get started by running:")
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Code.Render("coder projects create"))+"\n")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
35
cli/projectinit_test.go
Normal file
35
cli/projectinit_test.go
Normal file
@ -0,0 +1,35 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
func TestProjectInit(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Extract", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tempDir := t.TempDir()
|
||||
cmd, _ := clitest.New(t, "projects", "init", tempDir)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
pty.ExpectMatch("Develop in Linux")
|
||||
pty.WriteLine("")
|
||||
<-doneChan
|
||||
files, err := os.ReadDir(tempDir)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, len(files), 0)
|
||||
})
|
||||
}
|
@ -1,56 +0,0 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
func TestProjectList(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("None", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
cmd, root := clitest.New(t, "projects", "list")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
closeChan := make(chan struct{})
|
||||
go func() {
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
close(closeChan)
|
||||
}()
|
||||
pty.ExpectMatch("No projects found")
|
||||
<-closeChan
|
||||
})
|
||||
t.Run("List", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
daemon := coderdtest.NewProvisionerDaemon(t, client)
|
||||
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
|
||||
_ = daemon.Close()
|
||||
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
|
||||
cmd, root := clitest.New(t, "projects", "list")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
closeChan := make(chan struct{})
|
||||
go func() {
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
close(closeChan)
|
||||
}()
|
||||
pty.ExpectMatch(project.Name)
|
||||
<-closeChan
|
||||
})
|
||||
}
|
@ -2,14 +2,13 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sort"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/xlab/treeprint"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/database"
|
||||
)
|
||||
|
||||
@ -32,53 +31,53 @@ func projects() *cobra.Command {
|
||||
}
|
||||
cmd.AddCommand(
|
||||
projectCreate(),
|
||||
projectEdit(),
|
||||
projectInit(),
|
||||
projectList(),
|
||||
projectPlan(),
|
||||
projectUpdate(),
|
||||
projectVersions(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func displayProjectImportInfo(cmd *cobra.Command, parameterSchemas []coderd.ProjectVersionParameterSchema, parameterValues []coderd.ProjectVersionParameter, resources []coderd.WorkspaceResource) error {
|
||||
schemaByID := map[string]coderd.ProjectVersionParameterSchema{}
|
||||
for _, schema := range parameterSchemas {
|
||||
schemaByID[schema.ID.String()] = schema
|
||||
}
|
||||
func displayProjectVersionInfo(cmd *cobra.Command, resources []codersdk.WorkspaceResource) error {
|
||||
sort.Slice(resources, func(i, j int) bool {
|
||||
return fmt.Sprintf("%s.%s", resources[i].Type, resources[i].Name) < fmt.Sprintf("%s.%s", resources[j].Type, resources[j].Name)
|
||||
})
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\n %s\n\n", color.HiBlackString("Parameters"))
|
||||
for _, value := range parameterValues {
|
||||
schema, ok := schemaByID[value.SchemaID.String()]
|
||||
if !ok {
|
||||
return xerrors.Errorf("schema not found: %s", value.Name)
|
||||
}
|
||||
displayValue := value.SourceValue
|
||||
if !schema.RedisplayValue {
|
||||
displayValue = "<redacted>"
|
||||
}
|
||||
output := fmt.Sprintf("%s %s %s", color.HiCyanString(value.Name), color.HiBlackString("="), displayValue)
|
||||
if value.DefaultSourceValue {
|
||||
output += " (default value)"
|
||||
} else if value.Scope != database.ParameterScopeImportJob {
|
||||
output += fmt.Sprintf(" (inherited from %s)", value.Scope)
|
||||
}
|
||||
|
||||
root := treeprint.NewWithRoot(output)
|
||||
if schema.Description != "" {
|
||||
root.AddBranch(fmt.Sprintf("%s\n%s", color.HiBlackString("Description"), schema.Description))
|
||||
}
|
||||
if schema.AllowOverrideSource {
|
||||
root.AddBranch(fmt.Sprintf("%s Users can customize this value!", color.HiYellowString("+")))
|
||||
}
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+strings.Join(strings.Split(root.String(), "\n"), "\n "))
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " %s\n\n", color.HiBlackString("Resources"))
|
||||
addressOnStop := map[string]codersdk.WorkspaceResource{}
|
||||
for _, resource := range resources {
|
||||
transition := color.HiGreenString("start")
|
||||
if resource.Transition == database.WorkspaceTransitionStop {
|
||||
transition = color.HiRedString("stop")
|
||||
if resource.Transition != database.WorkspaceTransitionStop {
|
||||
continue
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " %s %s on %s\n\n", color.HiCyanString(resource.Type), color.HiCyanString(resource.Name), transition)
|
||||
addressOnStop[resource.Address] = resource
|
||||
}
|
||||
|
||||
displayed := map[string]struct{}{}
|
||||
for _, resource := range resources {
|
||||
if resource.Type == "random_string" {
|
||||
// Hide resources that aren't substantial to a user!
|
||||
continue
|
||||
}
|
||||
_, alreadyShown := displayed[resource.Address]
|
||||
if alreadyShown {
|
||||
continue
|
||||
}
|
||||
displayed[resource.Address] = struct{}{}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Bold.Render("resource."+resource.Type+"."+resource.Name))
|
||||
_, existsOnStop := addressOnStop[resource.Address]
|
||||
if existsOnStop {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Warn.Render("~ persistent"))
|
||||
} else {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Keyword.Render("+ start")+cliui.Styles.Placeholder.Render(" (deletes on stop)"))
|
||||
}
|
||||
if resource.Agent != nil {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Fuschia.Render("▲ allows ssh"))
|
||||
}
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -1,13 +1,94 @@
|
||||
package cli
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/database"
|
||||
"github.com/coder/coder/provisionersdk"
|
||||
)
|
||||
|
||||
func projectUpdate() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "update <name>",
|
||||
Use: "update <project> [directory]",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Short: "Update a project from the current directory",
|
||||
Short: "Update the source-code of a project from a directory.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
organization, err := currentOrganization(cmd, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := client.ProjectByName(cmd.Context(), organization.ID, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
directory, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) >= 2 {
|
||||
directory, err = filepath.Abs(args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
content, err := provisionersdk.Tar(directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := client.Upload(cmd.Context(), codersdk.ContentTypeTar, content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
projectVersion, err := client.CreateProjectVersion(cmd.Context(), organization.ID, codersdk.CreateProjectVersionRequest{
|
||||
ProjectID: project.ID,
|
||||
StorageMethod: database.ProvisionerStorageMethodFile,
|
||||
StorageSource: resp.Hash,
|
||||
Provisioner: database.ProvisionerTypeTerraform,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logs, err := client.ProjectVersionLogsAfter(cmd.Context(), projectVersion.ID, before)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
log, ok := <-logs
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
_, _ = fmt.Printf("terraform (%s): %s\n", log.Level, log.Output)
|
||||
}
|
||||
projectVersion, err = client.ProjectVersion(cmd.Context(), projectVersion.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if projectVersion.Job.Status != codersdk.ProvisionerJobSucceeded {
|
||||
return xerrors.New("job failed")
|
||||
}
|
||||
|
||||
err = client.UpdateActiveProjectVersion(cmd.Context(), project.ID, codersdk.UpdateActiveProjectVersion{
|
||||
ID: projectVersion.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Printf("Updated version!\n")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
15
cli/projectversions.go
Normal file
15
cli/projectversions.go
Normal file
@ -0,0 +1,15 @@
|
||||
package cli
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func projectVersions() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "versions",
|
||||
Aliases: []string{"version"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// coder project versions
|
133
cli/root.go
133
cli/root.go
@ -1,25 +1,22 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/kirsle/configdir"
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/cli/config"
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
var (
|
||||
caret = color.HiBlackString(">")
|
||||
caret = cliui.Styles.Prompt.String()
|
||||
)
|
||||
|
||||
const (
|
||||
@ -30,47 +27,48 @@ const (
|
||||
|
||||
func Root() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "coder",
|
||||
Use: "coder",
|
||||
SilenceUsage: true,
|
||||
Long: ` ▄█▀ ▀█▄
|
||||
▄▄ ▀▀▀ █▌ ██▀▀█▄ ▐█
|
||||
▄▄██▀▀█▄▄▄ ██ ██ █▀▀█ ▐█▀▀██ ▄█▀▀█ █▀▀
|
||||
█▌ ▄▌ ▐█ █▌ ▀█▄▄▄█▌ █ █ ▐█ ██ ██▀▀ █
|
||||
██████▀▄█ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀ ▀
|
||||
` + color.New(color.Underline).Sprint("Self-hosted developer workspaces on your infra") + `
|
||||
` + lipgloss.NewStyle().Underline(true).Render("Self-hosted developer workspaces on your infra") + `
|
||||
|
||||
`,
|
||||
Example: `
|
||||
- Create a project for developers to create workspaces
|
||||
Example: cliui.Styles.Paragraph.Render(`Start Coder in "dev" mode. This dev-mode requires no further setup, and your local `+cliui.Styles.Code.Render("coder")+` CLI will be authenticated to talk to it. This makes it easy to experiment with Coder.`) + `
|
||||
|
||||
` + color.New(color.FgHiMagenta).Sprint("$ coder projects create <directory>") + `
|
||||
` + cliui.Styles.Code.Render("$ coder start --dev") + `
|
||||
` + cliui.Styles.Paragraph.Render("Get started by creating a project from an example.") + `
|
||||
|
||||
- Create a workspace for a specific project
|
||||
|
||||
` + color.New(color.FgHiMagenta).Sprint("$ coder workspaces create <project>") + `
|
||||
|
||||
- Maintain consistency by updating a workspace
|
||||
|
||||
` + color.New(color.FgHiMagenta).Sprint("$ coder workspaces update <workspace>"),
|
||||
` + cliui.Styles.Code.Render("$ coder projects init"),
|
||||
}
|
||||
// Customizes the color of headings to make subcommands
|
||||
// more visually appealing.
|
||||
header := color.New(color.FgHiBlack)
|
||||
header := cliui.Styles.Placeholder
|
||||
cmd.SetUsageTemplate(strings.NewReplacer(
|
||||
`Usage:`, header.Sprint("Usage:"),
|
||||
`Examples:`, header.Sprint("Examples:"),
|
||||
`Available Commands:`, header.Sprint("Commands:"),
|
||||
`Global Flags:`, header.Sprint("Global Flags:"),
|
||||
`Flags:`, header.Sprint("Flags:"),
|
||||
`Additional help topics:`, header.Sprint("Additional help:"),
|
||||
`Usage:`, header.Render("Usage:"),
|
||||
`Examples:`, header.Render("Examples:"),
|
||||
`Available Commands:`, header.Render("Commands:"),
|
||||
`Global Flags:`, header.Render("Global Flags:"),
|
||||
`Flags:`, header.Render("Flags:"),
|
||||
`Additional help topics:`, header.Render("Additional help:"),
|
||||
).Replace(cmd.UsageTemplate()))
|
||||
|
||||
cmd.AddCommand(daemon())
|
||||
cmd.AddCommand(login())
|
||||
cmd.AddCommand(projects())
|
||||
cmd.AddCommand(workspaces())
|
||||
cmd.AddCommand(users())
|
||||
cmd.AddCommand(
|
||||
configSSH(),
|
||||
start(),
|
||||
login(),
|
||||
parameters(),
|
||||
projects(),
|
||||
users(),
|
||||
workspaces(),
|
||||
workspaceSSH(),
|
||||
workspaceTunnel(),
|
||||
)
|
||||
|
||||
cmd.PersistentFlags().String(varGlobalConfig, configdir.LocalConfig("coder"), "Path to the global `coder` config directory")
|
||||
cmd.PersistentFlags().String(varGlobalConfig, configdir.LocalConfig("coderv2"), "Path to the global `coder` config directory")
|
||||
cmd.PersistentFlags().Bool(varForceTty, false, "Force the `coder` command to run as if connected to a TTY")
|
||||
err := cmd.PersistentFlags().MarkHidden(varForceTty)
|
||||
if err != nil {
|
||||
@ -108,10 +106,10 @@ func createClient(cmd *cobra.Command) (*codersdk.Client, error) {
|
||||
}
|
||||
|
||||
// currentOrganization returns the currently active organization for the authenticated user.
|
||||
func currentOrganization(cmd *cobra.Command, client *codersdk.Client) (coderd.Organization, error) {
|
||||
func currentOrganization(cmd *cobra.Command, client *codersdk.Client) (codersdk.Organization, error) {
|
||||
orgs, err := client.OrganizationsByUser(cmd.Context(), "me")
|
||||
if err != nil {
|
||||
return coderd.Organization{}, nil
|
||||
return codersdk.Organization{}, nil
|
||||
}
|
||||
// For now, we won't use the config to set this.
|
||||
// Eventually, we will support changing using "coder switch <org>"
|
||||
@ -138,76 +136,9 @@ func isTTY(cmd *cobra.Command) bool {
|
||||
if forceTty && err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
reader := cmd.InOrStdin()
|
||||
file, ok := reader.(*os.File)
|
||||
file, ok := cmd.InOrStdin().(*os.File)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return isatty.IsTerminal(file.Fd())
|
||||
}
|
||||
|
||||
func prompt(cmd *cobra.Command, prompt *promptui.Prompt) (string, error) {
|
||||
prompt.Stdin = io.NopCloser(cmd.InOrStdin())
|
||||
prompt.Stdout = readWriteCloser{
|
||||
Writer: cmd.OutOrStdout(),
|
||||
}
|
||||
|
||||
// The prompt library displays defaults in a jarring way for the user
|
||||
// by attempting to autocomplete it. This sets no default enabling us
|
||||
// to customize the display.
|
||||
defaultValue := prompt.Default
|
||||
if !prompt.IsConfirm {
|
||||
prompt.Default = ""
|
||||
}
|
||||
|
||||
// Rewrite the confirm template to remove bold, and fit to the Coder style.
|
||||
confirmEnd := fmt.Sprintf("[y/%s] ", color.New(color.Bold).Sprint("N"))
|
||||
if prompt.Default == "y" {
|
||||
confirmEnd = fmt.Sprintf("[%s/n] ", color.New(color.Bold).Sprint("Y"))
|
||||
}
|
||||
confirm := color.HiBlackString("?") + ` {{ . }} ` + confirmEnd
|
||||
|
||||
// Customize to remove bold.
|
||||
valid := color.HiBlackString("?") + " {{ . }} "
|
||||
if defaultValue != "" {
|
||||
valid += fmt.Sprintf("(%s) ", defaultValue)
|
||||
}
|
||||
|
||||
success := valid
|
||||
invalid := valid
|
||||
if prompt.IsConfirm {
|
||||
success = confirm
|
||||
invalid = confirm
|
||||
}
|
||||
|
||||
prompt.Templates = &promptui.PromptTemplates{
|
||||
Confirm: confirm,
|
||||
Success: success,
|
||||
Invalid: invalid,
|
||||
Valid: valid,
|
||||
}
|
||||
oldValidate := prompt.Validate
|
||||
if oldValidate != nil {
|
||||
// Override the validate function to pass our default!
|
||||
prompt.Validate = func(s string) error {
|
||||
if s == "" {
|
||||
s = defaultValue
|
||||
}
|
||||
return oldValidate(s)
|
||||
}
|
||||
}
|
||||
value, err := prompt.Run()
|
||||
if value == "" && !prompt.IsConfirm {
|
||||
value = defaultValue
|
||||
}
|
||||
|
||||
return value, err
|
||||
}
|
||||
|
||||
// readWriteCloser fakes reads, writes, and closing!
|
||||
type readWriteCloser struct {
|
||||
io.Reader
|
||||
io.Writer
|
||||
io.Closer
|
||||
}
|
||||
|
87
cli/ssh.go
87
cli/ssh.go
@ -1 +1,88 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/pion/webrtc/v3"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/peer"
|
||||
"github.com/coder/coder/peerbroker"
|
||||
)
|
||||
|
||||
func workspaceSSH() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "ssh <workspace> [resource]",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace, err := client.WorkspaceByName(cmd.Context(), "", args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resources, err := client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, resource := range resources {
|
||||
_, _ = fmt.Printf("Got resource: %+v\n", resource)
|
||||
if resource.Agent == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
dialed, err := client.DialWorkspaceAgent(cmd.Context(), resource.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stream, err := dialed.NegotiateConnection(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn, err := peerbroker.Dial(stream, []webrtc.ICEServer{{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
}}, &peer.ConnOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := agent.DialSSHClient(conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = term.MakeRaw(int(os.Stdin.Fd()))
|
||||
err = session.RequestPty("xterm-256color", 128, 128, ssh.TerminalModes{
|
||||
ssh.OCRNL: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
session.Stdin = os.Stdin
|
||||
session.Stdout = os.Stdout
|
||||
session.Stderr = os.Stderr
|
||||
err = session.Shell()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = session.Wait()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
190
cli/start.go
Normal file
190
cli/start.go
Normal file
@ -0,0 +1,190 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
"google.golang.org/api/idtoken"
|
||||
"google.golang.org/api/option"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/coderd/tunnel"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/database"
|
||||
"github.com/coder/coder/database/databasefake"
|
||||
"github.com/coder/coder/provisioner/terraform"
|
||||
"github.com/coder/coder/provisionerd"
|
||||
"github.com/coder/coder/provisionersdk"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
)
|
||||
|
||||
func start() *cobra.Command {
|
||||
var (
|
||||
address string
|
||||
dev bool
|
||||
useTunnel bool
|
||||
)
|
||||
root := &cobra.Command{
|
||||
Use: "start",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
logger := slog.Make(sloghuman.Sink(os.Stderr))
|
||||
listener, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("listen %q: %w", address, err)
|
||||
}
|
||||
defer listener.Close()
|
||||
tcpAddr, valid := listener.Addr().(*net.TCPAddr)
|
||||
if !valid {
|
||||
return xerrors.New("must be listening on tcp")
|
||||
}
|
||||
if tcpAddr.IP.IsUnspecified() {
|
||||
tcpAddr.IP = net.IPv4(127, 0, 0, 1)
|
||||
}
|
||||
|
||||
localURL := &url.URL{
|
||||
Scheme: "http",
|
||||
Host: tcpAddr.String(),
|
||||
}
|
||||
accessURL := localURL
|
||||
var tunnelErr <-chan error
|
||||
if dev {
|
||||
if useTunnel {
|
||||
var accessURLRaw string
|
||||
accessURLRaw, tunnelErr, err = tunnel.New(cmd.Context(), localURL.String())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tunnel: %w", err)
|
||||
}
|
||||
accessURL, err = url.Parse(accessURLRaw)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), ` ▄█▀ ▀█▄
|
||||
▄▄ ▀▀▀ █▌ ██▀▀█▄ ▐█
|
||||
▄▄██▀▀█▄▄▄ ██ ██ █▀▀█ ▐█▀▀██ ▄█▀▀█ █▀▀
|
||||
█▌ ▄▌ ▐█ █▌ ▀█▄▄▄█▌ █ █ ▐█ ██ ██▀▀ █
|
||||
██████▀▄█ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀ ▀
|
||||
|
||||
`+cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Started in `+
|
||||
cliui.Styles.Field.Render("dev")+` mode. All data is in-memory! Learn how to setup and manage a production Coder deployment here: `+cliui.Styles.Prompt.Render("https://coder.com/docs/TODO")))+
|
||||
`
|
||||
`+
|
||||
cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.FocusedPrompt.String()+`Run `+cliui.Styles.Code.Render("coder projects init")+" in a new terminal to get started.\n"))+`
|
||||
`)
|
||||
}
|
||||
|
||||
validator, err := idtoken.NewValidator(cmd.Context(), option.WithoutAuthentication())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
handler, closeCoderd := coderd.New(&coderd.Options{
|
||||
AccessURL: accessURL,
|
||||
Logger: logger,
|
||||
Database: databasefake.New(),
|
||||
Pubsub: database.NewPubsubInMemory(),
|
||||
GoogleTokenValidator: validator,
|
||||
})
|
||||
client := codersdk.New(localURL)
|
||||
|
||||
daemonClose, err := newProvisionerDaemon(cmd.Context(), client, logger)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create provisioner daemon: %w", err)
|
||||
}
|
||||
defer daemonClose.Close()
|
||||
|
||||
errCh := make(chan error)
|
||||
go func() {
|
||||
defer close(errCh)
|
||||
errCh <- http.Serve(listener, handler)
|
||||
}()
|
||||
|
||||
if dev {
|
||||
config := createConfig(cmd)
|
||||
_, err = client.CreateFirstUser(cmd.Context(), codersdk.CreateFirstUserRequest{
|
||||
Email: "dev@coder.com",
|
||||
Username: "developer",
|
||||
Password: "password",
|
||||
Organization: "coder",
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create first user: %w\n", err)
|
||||
}
|
||||
token, err := client.LoginWithPassword(cmd.Context(), codersdk.LoginWithPasswordRequest{
|
||||
Email: "dev@coder.com",
|
||||
Password: "password",
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("login with first user: %w", err)
|
||||
}
|
||||
err = config.URL().Write(localURL.String())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write local url: %w", err)
|
||||
}
|
||||
err = config.Session().Write(token.SessionToken)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write session token: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
closeCoderd()
|
||||
select {
|
||||
case <-cmd.Context().Done():
|
||||
return cmd.Context().Err()
|
||||
case err := <-tunnelErr:
|
||||
return err
|
||||
case err := <-errCh:
|
||||
return err
|
||||
}
|
||||
},
|
||||
}
|
||||
defaultAddress, ok := os.LookupEnv("ADDRESS")
|
||||
if !ok {
|
||||
defaultAddress = "127.0.0.1:3000"
|
||||
}
|
||||
root.Flags().StringVarP(&address, "address", "a", defaultAddress, "The address to serve the API and dashboard.")
|
||||
root.Flags().BoolVarP(&dev, "dev", "", false, "Serve Coder in dev mode for tinkering.")
|
||||
root.Flags().BoolVarP(&useTunnel, "tunnel", "", true, `Serve "dev" mode through a Cloudflare Tunnel for easy setup.`)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func newProvisionerDaemon(ctx context.Context, client *codersdk.Client, logger slog.Logger) (io.Closer, error) {
|
||||
terraformClient, terraformServer := provisionersdk.TransportPipe()
|
||||
go func() {
|
||||
err := terraform.Serve(ctx, &terraform.ServeOptions{
|
||||
ServeOptions: &provisionersdk.ServeOptions{
|
||||
Listener: terraformServer,
|
||||
},
|
||||
Logger: logger,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
tempDir, err := ioutil.TempDir("", "provisionerd")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provisionerd.New(client.ListenProvisionerDaemon, &provisionerd.Options{
|
||||
Logger: logger,
|
||||
PollInterval: 50 * time.Millisecond,
|
||||
UpdateInterval: 50 * time.Millisecond,
|
||||
Provisioners: provisionerd.Provisioners{
|
||||
string(database.ProvisionerTypeTerraform): proto.NewDRPCProvisionerClient(provisionersdk.Conn(terraformClient)),
|
||||
},
|
||||
WorkDirectory: tempDir,
|
||||
}), nil
|
||||
}
|
50
cli/start_test.go
Normal file
50
cli/start_test.go
Normal file
@ -0,0 +1,50 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func TestStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Production", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
go cancelFunc()
|
||||
root, _ := clitest.New(t, "start", "--address", ":0")
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
})
|
||||
t.Run("Development", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
root, cfg := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0")
|
||||
go func() {
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}()
|
||||
var accessURL string
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
accessURL, err = cfg.URL().Read()
|
||||
return err == nil
|
||||
}, 15*time.Second, 25*time.Millisecond)
|
||||
// Verify that authentication was properly set in dev-mode.
|
||||
token, err := cfg.Session().Read()
|
||||
require.NoError(t, err)
|
||||
parsed, err := url.Parse(accessURL)
|
||||
require.NoError(t, err)
|
||||
client := codersdk.New(parsed)
|
||||
client.SessionToken = token
|
||||
_, err = client.User(ctx, "")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
12
cli/tunnel.go
Normal file
12
cli/tunnel.go
Normal file
@ -0,0 +1,12 @@
|
||||
package cli
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func workspaceTunnel() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "tunnel",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
@ -4,12 +4,15 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"github.com/powersj/whatsthis/pkg/cloud"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/peer"
|
||||
)
|
||||
|
||||
func workspaceAgent() *cobra.Command {
|
||||
@ -30,26 +33,28 @@ func workspaceAgent() *cobra.Command {
|
||||
client := codersdk.New(coderURL)
|
||||
sessionToken, exists := os.LookupEnv("CODER_TOKEN")
|
||||
if !exists {
|
||||
probe, err := cloud.New()
|
||||
// probe, err := cloud.New()
|
||||
// if err != nil {
|
||||
// return xerrors.Errorf("probe cloud: %w", err)
|
||||
// }
|
||||
// if !probe.Detected {
|
||||
// return xerrors.Errorf("no valid authentication method found; set \"CODER_TOKEN\"")
|
||||
// }
|
||||
// switch {
|
||||
// case probe.GCP():
|
||||
response, err := client.AuthWorkspaceGoogleInstanceIdentity(cmd.Context(), "", nil)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("probe cloud: %w", err)
|
||||
}
|
||||
if !probe.Detected {
|
||||
return xerrors.Errorf("no valid authentication method found; set \"CODER_TOKEN\"")
|
||||
}
|
||||
switch {
|
||||
case probe.GCP():
|
||||
response, err := client.AuthWorkspaceGoogleInstanceIdentity(cmd.Context(), "", nil)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("authenticate workspace with gcp: %w", err)
|
||||
}
|
||||
sessionToken = response.SessionToken
|
||||
default:
|
||||
return xerrors.Errorf("%q authentication not supported; set \"CODER_TOKEN\" instead", probe.Name)
|
||||
return xerrors.Errorf("authenticate workspace with gcp: %w", err)
|
||||
}
|
||||
sessionToken = response.SessionToken
|
||||
// default:
|
||||
// return xerrors.Errorf("%q authentication not supported; set \"CODER_TOKEN\" instead", probe.Name)
|
||||
// }
|
||||
}
|
||||
client.SessionToken = sessionToken
|
||||
closer := agent.New(client.ListenWorkspaceAgent, nil)
|
||||
closer := agent.New(client.ListenWorkspaceAgent, &peer.ConnOptions{
|
||||
Logger: slog.Make(sloghuman.Sink(cmd.OutOrStdout())),
|
||||
})
|
||||
<-cmd.Context().Done()
|
||||
return closer.Close()
|
||||
},
|
||||
|
@ -3,21 +3,27 @@ package cli
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/briandowns/spinner"
|
||||
"github.com/fatih/color"
|
||||
"github.com/google/uuid"
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/database"
|
||||
)
|
||||
|
||||
func workspaceCreate() *cobra.Command {
|
||||
var (
|
||||
projectName string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <project> [name]",
|
||||
Use: "create <name>",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Short: "Create a workspace from a project",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
@ -29,37 +35,51 @@ func workspaceCreate() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
var name string
|
||||
if len(args) >= 2 {
|
||||
name = args[1]
|
||||
} else {
|
||||
name, err = prompt(cmd, &promptui.Prompt{
|
||||
Label: "What's your workspace's name?",
|
||||
Validate: func(s string) error {
|
||||
if s == "" {
|
||||
return xerrors.Errorf("You must provide a name!")
|
||||
}
|
||||
workspace, _ := client.WorkspaceByName(cmd.Context(), "", s)
|
||||
if workspace.ID.String() != uuid.Nil.String() {
|
||||
return xerrors.New("A workspace already exists with that name!")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
var project codersdk.Project
|
||||
if projectName == "" {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render("Select a project:"))
|
||||
|
||||
projectNames := []string{}
|
||||
projectByName := map[string]codersdk.Project{}
|
||||
projects, err := client.ProjectsByOrganization(cmd.Context(), organization.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, project := range projects {
|
||||
projectNames = append(projectNames, project.Name)
|
||||
projectByName[project.Name] = project
|
||||
}
|
||||
sort.Slice(projectNames, func(i, j int) bool {
|
||||
return projectByName[projectNames[i]].WorkspaceOwnerCount > projectByName[projectNames[j]].WorkspaceOwnerCount
|
||||
})
|
||||
// Move the cursor up a single line for nicer display!
|
||||
option, err := cliui.Select(cmd, cliui.SelectOptions{
|
||||
Options: projectNames,
|
||||
HideSearch: true,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, promptui.ErrAbort) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
project = projectByName[option]
|
||||
} else {
|
||||
project, err = client.ProjectByName(cmd.Context(), organization.ID, projectName)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get project by name: %w", err)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Previewing project create...\n", caret)
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Prompt.String()+"Creating with the "+cliui.Styles.Field.Render(project.Name)+" project...")
|
||||
|
||||
project, err := client.ProjectByName(cmd.Context(), organization.ID, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
workspaceName := args[0]
|
||||
_, err = client.WorkspaceByName(cmd.Context(), "", workspaceName)
|
||||
if err == nil {
|
||||
return xerrors.Errorf("A workspace already exists named %q!", workspaceName)
|
||||
}
|
||||
|
||||
projectVersion, err := client.ProjectVersion(cmd.Context(), project.ActiveVersionID)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -68,22 +88,46 @@ func workspaceCreate() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parameterValues, err := client.ProjectVersionParameters(cmd.Context(), projectVersion.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
printed := false
|
||||
parameters := make([]codersdk.CreateParameterRequest, 0)
|
||||
for _, parameterSchema := range parameterSchemas {
|
||||
if !parameterSchema.AllowOverrideSource {
|
||||
continue
|
||||
}
|
||||
if !printed {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("This project has customizable parameters! These can be changed after create, but may have unintended side effects (like data loss).")+"\r\n")
|
||||
printed = true
|
||||
}
|
||||
|
||||
value, err := cliui.ParameterSchema(cmd, parameterSchema)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parameters = append(parameters, codersdk.CreateParameterRequest{
|
||||
Name: parameterSchema.Name,
|
||||
SourceValue: value,
|
||||
SourceScheme: database.ParameterSourceSchemeData,
|
||||
DestinationScheme: parameterSchema.DefaultDestinationScheme,
|
||||
})
|
||||
}
|
||||
if printed {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.FocusedPrompt.String()+"Previewing resources...")
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
}
|
||||
resources, err := client.ProjectVersionResources(cmd.Context(), projectVersion.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = displayProjectImportInfo(cmd, parameterSchemas, parameterValues, resources)
|
||||
err = displayProjectVersionInfo(cmd, resources)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = prompt(cmd, &promptui.Prompt{
|
||||
Label: fmt.Sprintf("Create workspace %s?", color.HiCyanString(name)),
|
||||
Default: "y",
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Create workspace %s?", color.HiCyanString(workspaceName)),
|
||||
Default: "yes",
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err != nil {
|
||||
@ -93,39 +137,70 @@ func workspaceCreate() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
workspace, err := client.CreateWorkspace(cmd.Context(), "", coderd.CreateWorkspaceRequest{
|
||||
ProjectID: project.ID,
|
||||
Name: name,
|
||||
before := time.Now()
|
||||
workspace, err := client.CreateWorkspace(cmd.Context(), "", codersdk.CreateWorkspaceRequest{
|
||||
ProjectID: project.ID,
|
||||
Name: workspaceName,
|
||||
ParameterValues: parameters,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
|
||||
ProjectVersionID: projectVersion.ID,
|
||||
Transition: database.WorkspaceTransitionStart,
|
||||
_, err = cliui.Job(cmd, cliui.JobOptions{
|
||||
Title: "Building workspace...",
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
build, err := client.WorkspaceBuild(cmd.Context(), workspace.LatestBuild.ID)
|
||||
return build.Job, err
|
||||
},
|
||||
Cancel: func() error {
|
||||
return client.CancelWorkspaceBuild(cmd.Context(), workspace.LatestBuild.ID)
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
|
||||
return client.WorkspaceBuildLogsAfter(cmd.Context(), workspace.LatestBuild.ID, before)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logs, err := client.WorkspaceBuildLogsAfter(cmd.Context(), version.ID, time.Time{})
|
||||
resources, err = client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
log, ok := <-logs
|
||||
if !ok {
|
||||
spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond, spinner.WithColor("fgGreen"))
|
||||
spin.Writer = cmd.OutOrStdout()
|
||||
spin.Suffix = " Waiting for agent to connect..."
|
||||
spin.Start()
|
||||
defer spin.Stop()
|
||||
for _, resource := range resources {
|
||||
if resource.Agent == nil {
|
||||
continue
|
||||
}
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-cmd.Context().Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
}
|
||||
resource, err := client.WorkspaceResource(cmd.Context(), resource.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resource.Agent.FirstConnectedAt == nil {
|
||||
continue
|
||||
}
|
||||
spin.Stop()
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace has been created!\n\n", cliui.Styles.Keyword.Render(workspace.Name))
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Code.Render("coder ssh "+workspace.Name))
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
break
|
||||
}
|
||||
_, _ = fmt.Printf("Terraform: %s\n", log.Output)
|
||||
}
|
||||
|
||||
// This command is WIP, and output will change!
|
||||
|
||||
_, _ = fmt.Printf("Created workspace! %s\n", name)
|
||||
return nil
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&projectName, "project", "p", "", "Specify a project name.")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
@ -1,63 +0,0 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
func TestWorkspaceCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Create", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
_ = coderdtest.NewProvisionerDaemon(t, client)
|
||||
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
|
||||
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
|
||||
cmd, root := clitest.New(t, "workspaces", "create", project.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
closeChan := make(chan struct{})
|
||||
go func() {
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
close(closeChan)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"name?", "workspace-name",
|
||||
"Create workspace", "y",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
pty.ExpectMatch("Create")
|
||||
<-closeChan
|
||||
})
|
||||
}
|
51
cli/workspacedelete.go
Normal file
51
cli/workspacedelete.go
Normal file
@ -0,0 +1,51 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/database"
|
||||
)
|
||||
|
||||
func workspaceDelete() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete <workspace>",
|
||||
Aliases: []string{"rm"},
|
||||
ValidArgsFunction: validArgsWorkspaceName,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace, err := client.WorkspaceByName(cmd.Context(), "", args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := time.Now()
|
||||
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: database.WorkspaceTransitionDelete,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cliui.Job(cmd, cliui.JobOptions{
|
||||
Title: "Deleting workspace...",
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
build, err := client.WorkspaceBuild(cmd.Context(), build.ID)
|
||||
return build.Job, err
|
||||
},
|
||||
Cancel: func() error {
|
||||
return client.CancelWorkspaceBuild(cmd.Context(), build.ID)
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
|
||||
return client.WorkspaceBuildLogsAfter(cmd.Context(), build.ID, before)
|
||||
},
|
||||
})
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
59
cli/workspacelist.go
Normal file
59
cli/workspacelist.go
Normal file
@ -0,0 +1,59 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
)
|
||||
|
||||
func workspaceList() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
start := time.Now()
|
||||
workspaces, err := client.WorkspacesByUser(cmd.Context(), "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(workspaces) == 0 {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Prompt.String()+"No workspaces found! Create one:")
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Code.Render("coder workspaces create <name>"))
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Workspaces found %s\n\n",
|
||||
caret,
|
||||
color.HiBlackString("[%dms]",
|
||||
time.Since(start).Milliseconds()))
|
||||
|
||||
writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 4, ' ', 0)
|
||||
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\n",
|
||||
color.HiBlackString("Workspace"),
|
||||
color.HiBlackString("Project"),
|
||||
color.HiBlackString("Status"),
|
||||
color.HiBlackString("Last Built"),
|
||||
color.HiBlackString("Outdated"))
|
||||
for _, workspace := range workspaces {
|
||||
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%+v\n",
|
||||
color.New(color.FgHiCyan).Sprint(workspace.Name),
|
||||
color.WhiteString(workspace.ProjectName),
|
||||
color.WhiteString(string(workspace.LatestBuild.Transition)),
|
||||
color.WhiteString(workspace.LatestBuild.Job.CompletedAt.Format("January 2, 2006")),
|
||||
workspace.Outdated)
|
||||
}
|
||||
return writer.Flush()
|
||||
},
|
||||
}
|
||||
}
|
@ -1,13 +1,44 @@
|
||||
package cli
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func workspaces() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "workspaces",
|
||||
Use: "workspaces",
|
||||
Aliases: []string{"ws"},
|
||||
}
|
||||
cmd.AddCommand(workspaceAgent())
|
||||
cmd.AddCommand(workspaceCreate())
|
||||
cmd.AddCommand(workspaceDelete())
|
||||
cmd.AddCommand(workspaceList())
|
||||
cmd.AddCommand(workspaceShow())
|
||||
cmd.AddCommand(workspaceStop())
|
||||
cmd.AddCommand(workspaceStart())
|
||||
cmd.AddCommand(workspaceSSH())
|
||||
cmd.AddCommand(workspaceUpdate())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func validArgsWorkspaceName(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveError
|
||||
}
|
||||
workspaces, err := client.WorkspacesByUser(cmd.Context(), "")
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveError
|
||||
}
|
||||
names := make([]string, 0)
|
||||
for _, workspace := range workspaces {
|
||||
if !strings.HasPrefix(workspace.Name, toComplete) {
|
||||
continue
|
||||
}
|
||||
names = append(names, workspace.Name)
|
||||
}
|
||||
return names, cobra.ShellCompDirectiveDefault
|
||||
}
|
||||
|
35
cli/workspaceshow.go
Normal file
35
cli/workspaceshow.go
Normal file
@ -0,0 +1,35 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func workspaceShow() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "show",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace, err := client.WorkspaceByName(cmd.Context(), "", args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resources, err := client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, resource := range resources {
|
||||
if resource.Agent == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
_, _ = fmt.Printf("Agent: %+v\n", resource.Agent)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
50
cli/workspacestart.go
Normal file
50
cli/workspacestart.go
Normal file
@ -0,0 +1,50 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/database"
|
||||
)
|
||||
|
||||
func workspaceStart() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "start <workspace>",
|
||||
ValidArgsFunction: validArgsWorkspaceName,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace, err := client.WorkspaceByName(cmd.Context(), "", args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := time.Now()
|
||||
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: database.WorkspaceTransitionStart,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cliui.Job(cmd, cliui.JobOptions{
|
||||
Title: "Starting workspace...",
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
build, err := client.WorkspaceBuild(cmd.Context(), build.ID)
|
||||
return build.Job, err
|
||||
},
|
||||
Cancel: func() error {
|
||||
return client.CancelWorkspaceBuild(cmd.Context(), build.ID)
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
|
||||
return client.WorkspaceBuildLogsAfter(cmd.Context(), build.ID, before)
|
||||
},
|
||||
})
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
53
cli/workspacestop.go
Normal file
53
cli/workspacestop.go
Normal file
@ -0,0 +1,53 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/database"
|
||||
)
|
||||
|
||||
func workspaceStop() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "stop <workspace>",
|
||||
ValidArgsFunction: validArgsWorkspaceName,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace, err := client.WorkspaceByName(cmd.Context(), "", args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := time.Now()
|
||||
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: database.WorkspaceTransitionStop,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cliui.Job(cmd, cliui.JobOptions{
|
||||
Title: "Stopping workspace...",
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
build, err := client.WorkspaceBuild(cmd.Context(), build.ID)
|
||||
return build.Job, err
|
||||
},
|
||||
Cancel: func() error {
|
||||
return client.CancelWorkspaceBuild(cmd.Context(), build.ID)
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
|
||||
return client.WorkspaceBuildLogsAfter(cmd.Context(), build.ID, before)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
54
cli/workspaceupdate.go
Normal file
54
cli/workspaceupdate.go
Normal file
@ -0,0 +1,54 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func workspaceUpdate() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "update",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace, err := client.WorkspaceByName(cmd.Context(), "", args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !workspace.Outdated {
|
||||
_, _ = fmt.Printf("Workspace isn't outdated!\n")
|
||||
return nil
|
||||
}
|
||||
project, err := client.Project(cmd.Context(), workspace.ProjectID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
before := time.Now()
|
||||
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
ProjectVersionID: project.ActiveVersionID,
|
||||
Transition: workspace.LatestBuild.Transition,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logs, err := client.WorkspaceBuildLogsAfter(cmd.Context(), build.ID, before)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
log, ok := <-logs
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
_, _ = fmt.Printf("Output: %s\n", log.Output)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user