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:
Kyle Carberry 2022-03-22 13:17:50 -06:00 committed by GitHub
parent 2818b3ce6d
commit c451f4e685
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
138 changed files with 7317 additions and 2334 deletions

View File

@ -157,7 +157,7 @@ jobs:
- name: Test with Mock Database
shell: bash
env:
GOCOUNT: ${{ runner.os == 'Windows' && 3 || 5 }}
GOCOUNT: ${{ runner.os == 'Windows' && 1 || 2 }}
GOMAXPROCS: ${{ runner.os == 'Windows' && 1 || 2 }}
run: gotestsum --junitfile="gotests.xml" --packages="./..." --
-covermode=atomic -coverprofile="gotests.coverage"
@ -178,7 +178,7 @@ jobs:
run: DB=true gotestsum --junitfile="gotests.xml" --packages="./..." --
-covermode=atomic -coverprofile="gotests.coverage" -timeout=3m
-coverpkg=./...,github.com/coder/coder/codersdk
-count=1 -race -parallel=2 -failfast
-count=1 -parallel=2 -failfast
- name: Upload DataDog Trace
if: (success() || failure()) && github.actor != 'dependabot[bot]' && runner.os == 'Linux'

5
.gitignore vendored
View File

@ -28,3 +28,8 @@ site/**/*.typegen.ts
# Build
dist/
site/out/
*.tfstate
*.tfplan
*.lock.hcl
.terraform/

View File

@ -1,5 +1,6 @@
archives:
- builds:
- id: coder
builds:
- coder
files:
- README.md
@ -12,7 +13,6 @@ before:
builds:
- id: coder-slim
dir: cmd/coder
flags: [-tags=slim]
ldflags: ["-s -w"]
env: [CGO_ENABLED=0]
goos: [darwin, linux, windows]
@ -20,10 +20,11 @@ builds:
hooks:
# The "trimprefix" appends ".exe" on Windows.
post: |
cp {{.Path}} site/out/bin/coder_{{ .Os }}_{{ .Arch }}{{ trimprefix .Name "coder" }}
cp {{.Path}} site/out/bin/coder-{{ .Os }}-{{ .Arch }}{{ trimprefix .Name "coder" }}
- id: coder
dir: cmd/coder
flags: [-tags=embed]
ldflags: ["-s -w"]
env: [CGO_ENABLED=0]
goos: [darwin, linux, windows]

View File

@ -1,5 +1,6 @@
{
"cSpell.words": [
"cliui",
"coderd",
"coderdtest",
"codersdk",
@ -12,8 +13,10 @@
"goleak",
"gossh",
"hashicorp",
"hclsyntax",
"httpmw",
"idtoken",
"Iflag",
"incpatch",
"isatty",
"Jobf",
@ -40,11 +43,14 @@
"retrier",
"sdkproto",
"stretchr",
"TCGETS",
"tcpip",
"TCSETS",
"tfexec",
"tfstate",
"trimprefix",
"unconvert",
"Untar",
"webrtc",
"xerrors",
"yamux"
@ -69,7 +75,7 @@
"go.coverOnSave": true,
// The codersdk is used by coderd another other packages extensively.
// To reduce redundancy in tests, it's covered by other packages.
"go.testFlags": ["-coverpkg=./.,github.com/coder/coder/codersdk"],
"go.testFlags": ["-short", "-coverpkg=./.,github.com/coder/coder/codersdk"],
"go.coverageDecorator": {
"type": "gutter",
"coveredHighlightColor": "rgba(64,128,128,0.5)",

View File

@ -55,6 +55,10 @@ install: bin
@echo "-- CLI available at $(shell ls $(INSTALL_DIR)/coder*)"
.PHONY: install
package:
goreleaser release --snapshot --rm-dist
.PHONY: package
peerbroker/proto: peerbroker/proto/peerbroker.proto
protoc \
--go_out=. \
@ -89,7 +93,3 @@ site/out:
# Restores GITKEEP files!
git checkout HEAD site/out
.PHONY: site/out
snapshot:
goreleaser release --snapshot --rm-dist
.PHONY: snapshot

View File

@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net"
"os"
"os/exec"
"os/user"
"sync"
@ -85,6 +86,76 @@ type server struct {
sshServer *ssh.Server
}
func (s *server) run(ctx context.Context) {
var peerListener *peerbroker.Listener
var err error
// An exponential back-off occurs when the connection is failing to dial.
// This is to prevent server spam in case of a coderd outage.
for retrier := retry.New(50*time.Millisecond, 10*time.Second); retrier.Wait(ctx); {
peerListener, err = s.clientDialer(ctx, s.options)
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
if s.isClosed() {
return
}
s.options.Logger.Warn(context.Background(), "failed to dial", slog.Error(err))
continue
}
s.options.Logger.Debug(context.Background(), "connected")
break
}
select {
case <-ctx.Done():
return
default:
}
for {
conn, err := peerListener.Accept()
if err != nil {
if s.isClosed() {
return
}
s.options.Logger.Debug(ctx, "peer listener accept exited; restarting connection", slog.Error(err))
s.run(ctx)
return
}
s.closeMutex.Lock()
s.connCloseWait.Add(1)
s.closeMutex.Unlock()
go s.handlePeerConn(ctx, conn)
}
}
func (s *server) handlePeerConn(ctx context.Context, conn *peer.Conn) {
go func() {
<-conn.Closed()
s.connCloseWait.Done()
}()
for {
channel, err := conn.Accept(ctx)
if err != nil {
if errors.Is(err, peer.ErrClosed) || s.isClosed() {
return
}
s.options.Logger.Debug(ctx, "accept channel from peer connection", slog.Error(err))
return
}
switch channel.Protocol() {
case "ssh":
s.sshServer.HandleConn(channel.NetConn())
default:
s.options.Logger.Warn(ctx, "unhandled protocol from channel",
slog.F("protocol", channel.Protocol()),
slog.F("label", channel.Label()),
)
}
}
}
func (s *server) init(ctx context.Context) {
// Clients' should ignore the host key when connecting.
// The agent needs to authenticate with coderd to SSH,
@ -197,7 +268,7 @@ func (*server) handleSSHSession(session ssh.Session) error {
}()
cmd := exec.CommandContext(session.Context(), command, args...)
cmd.Env = session.Environ()
cmd.Env = append(os.Environ(), session.Environ()...)
sshPty, windowSize, isPty := session.Pty()
if isPty {
@ -244,76 +315,6 @@ func (*server) handleSSHSession(session ssh.Session) error {
return nil
}
func (s *server) run(ctx context.Context) {
var peerListener *peerbroker.Listener
var err error
// An exponential back-off occurs when the connection is failing to dial.
// This is to prevent server spam in case of a coderd outage.
for retrier := retry.New(50*time.Millisecond, 10*time.Second); retrier.Wait(ctx); {
peerListener, err = s.clientDialer(ctx, s.options)
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
if s.isClosed() {
return
}
s.options.Logger.Warn(context.Background(), "failed to dial", slog.Error(err))
continue
}
s.options.Logger.Debug(context.Background(), "connected")
break
}
select {
case <-ctx.Done():
return
default:
}
for {
conn, err := peerListener.Accept()
if err != nil {
if s.isClosed() {
return
}
s.options.Logger.Debug(ctx, "peer listener accept exited; restarting connection", slog.Error(err))
s.run(ctx)
return
}
s.closeMutex.Lock()
s.connCloseWait.Add(1)
s.closeMutex.Unlock()
go s.handlePeerConn(ctx, conn)
}
}
func (s *server) handlePeerConn(ctx context.Context, conn *peer.Conn) {
go func() {
<-conn.Closed()
s.connCloseWait.Done()
}()
for {
channel, err := conn.Accept(ctx)
if err != nil {
if errors.Is(err, peer.ErrClosed) || s.isClosed() {
return
}
s.options.Logger.Debug(ctx, "accept channel from peer connection", slog.Error(err))
return
}
switch channel.Protocol() {
case "ssh":
s.sshServer.HandleConn(channel.NetConn())
default:
s.options.Logger.Warn(ctx, "unhandled protocol from channel",
slog.F("protocol", channel.Protocol()),
slog.F("label", channel.Label()),
)
}
}
}
// isClosed returns whether the API is closed or not.
func (s *server) isClosed() bool {
select {

View File

@ -1,6 +1,12 @@
package usershell
import "os/exec"
// Get returns the command prompt binary name.
func Get(username string) (string, error) {
_, err := exec.LookPath("powershell.exe")
if err == nil {
return "powershell.exe", nil
}
return "cmd.exe", nil
}

50
cli/cliui/cliui.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
},
}

View File

@ -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
View 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
View 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
View 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
View 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)
}

View File

@ -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)
}

View File

@ -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
View 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
View 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
View 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)
})
}

View File

@ -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
})
}

View File

@ -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
}

View File

@ -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
View 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

View File

@ -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
}

View File

@ -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
View 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
View 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
View 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
},
}
}

View File

@ -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()
},

View File

@ -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
}

View File

@ -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
View 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
View 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()
},
}
}

View File

@ -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
View 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
View 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
View 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
View 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
},
}
}

138
cmd/cliui/main.go Normal file
View File

@ -0,0 +1,138 @@
package main
import (
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
)
func main() {
root := &cobra.Command{
Use: "cliui",
Short: "Used for visually testing UI components for the CLI.",
}
root.AddCommand(&cobra.Command{
Use: "prompt",
RunE: func(cmd *cobra.Command, args []string) error {
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
Text: "What is our " + cliui.Styles.Field.Render("company name") + "?",
Default: "acme-corp",
Validate: func(s string) error {
if !strings.EqualFold(s, "coder") {
return xerrors.New("Err... nope!")
}
return nil
},
})
if errors.Is(err, cliui.Canceled) {
return nil
}
if err != nil {
return err
}
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
Text: "Do you want to accept?",
Default: "yes",
IsConfirm: true,
})
if errors.Is(err, cliui.Canceled) {
return nil
}
if err != nil {
return err
}
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
Text: "Enter password",
Secret: true,
})
return err
},
})
root.AddCommand(&cobra.Command{
Use: "select",
RunE: func(cmd *cobra.Command, args []string) error {
_, err := cliui.Select(cmd, cliui.SelectOptions{
Options: []string{"Tomato", "Banana", "Onion", "Grape", "Lemon"},
Size: 3,
})
return err
},
})
root.AddCommand(&cobra.Command{
Use: "job",
RunE: func(cmd *cobra.Command, args []string) error {
job := codersdk.ProvisionerJob{
Status: codersdk.ProvisionerJobPending,
CreatedAt: database.Now(),
}
go func() {
time.Sleep(time.Second)
if job.Status != codersdk.ProvisionerJobPending {
return
}
started := database.Now()
job.StartedAt = &started
job.Status = codersdk.ProvisionerJobRunning
time.Sleep(3 * time.Second)
if job.Status != codersdk.ProvisionerJobRunning {
return
}
completed := database.Now()
job.CompletedAt = &completed
job.Status = codersdk.ProvisionerJobSucceeded
}()
_, err := cliui.Job(cmd, cliui.JobOptions{
Fetch: func() (codersdk.ProvisionerJob, error) {
return job, nil
},
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
logs := make(chan codersdk.ProvisionerJobLog)
go func() {
defer close(logs)
ticker := time.NewTicker(500 * time.Millisecond)
for {
select {
case <-cmd.Context().Done():
return
case <-ticker.C:
logs <- codersdk.ProvisionerJobLog{
Output: "Some log",
Level: database.LogLevelInfo,
}
}
}
}()
return logs, nil
},
Cancel: func() error {
job.Status = codersdk.ProvisionerJobCanceling
time.Sleep(time.Second)
job.Status = codersdk.ProvisionerJobCanceled
completed := database.Now()
job.CompletedAt = &completed
return nil
},
})
return err
},
})
err := root.Execute()
if err != nil {
_, _ = fmt.Println(err.Error())
os.Exit(1)
}
}

279
cmd/templater/main.go Normal file
View File

@ -0,0 +1,279 @@
package main
import (
"context"
"fmt"
"io"
"io/ioutil"
"net"
"net/http/httptest"
"net/url"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"google.golang.org/api/idtoken"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"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 main() {
var rawParameters []string
cmd := &cobra.Command{
Use: "templater",
RunE: func(cmd *cobra.Command, args []string) error {
parameters := make([]codersdk.CreateParameterRequest, 0)
for _, parameter := range rawParameters {
parts := strings.SplitN(parameter, "=", 2)
parameters = append(parameters, codersdk.CreateParameterRequest{
Name: parts[0],
SourceValue: parts[1],
SourceScheme: database.ParameterSourceSchemeData,
DestinationScheme: database.ParameterDestinationSchemeProvisionerVariable,
})
}
return parse(cmd, parameters)
},
}
cmd.Flags().StringArrayVarP(&rawParameters, "parameter", "p", []string{}, "Specify parameters to pass in a template.")
err := cmd.Execute()
if err != nil {
panic(err)
}
}
func parse(cmd *cobra.Command, parameters []codersdk.CreateParameterRequest) error {
srv := httptest.NewUnstartedServer(nil)
srv.Config.BaseContext = func(_ net.Listener) context.Context {
return cmd.Context()
}
srv.Start()
serverURL, err := url.Parse(srv.URL)
if err != nil {
return err
}
accessURL, errCh, err := tunnel.New(cmd.Context(), srv.URL)
if err != nil {
return err
}
go func() {
err := <-errCh
if err != nil {
panic(err)
}
}()
accessURLParsed, err := url.Parse(accessURL)
if err != nil {
return err
}
var closeWait func()
validator, err := idtoken.NewValidator(cmd.Context())
if err != nil {
return err
}
logger := slog.Make(sloghuman.Sink(cmd.OutOrStdout()))
srv.Config.Handler, closeWait = coderd.New(&coderd.Options{
AccessURL: accessURLParsed,
Logger: logger,
Database: databasefake.New(),
Pubsub: database.NewPubsubInMemory(),
GoogleTokenValidator: validator,
})
client := codersdk.New(serverURL)
daemonClose, err := newProvisionerDaemon(cmd.Context(), client, logger)
if err != nil {
return err
}
defer daemonClose.Close()
created, err := client.CreateFirstUser(cmd.Context(), codersdk.CreateFirstUserRequest{
Email: "templater@coder.com",
Username: "templater",
Organization: "templater",
Password: "insecure",
})
if err != nil {
return err
}
auth, err := client.LoginWithPassword(cmd.Context(), codersdk.LoginWithPasswordRequest{
Email: "templater@coder.com",
Password: "insecure",
})
if err != nil {
return err
}
client.SessionToken = auth.SessionToken
dir, err := os.Getwd()
if err != nil {
return err
}
content, err := provisionersdk.Tar(dir)
if err != nil {
return err
}
resp, err := client.Upload(cmd.Context(), codersdk.ContentTypeTar, content)
if err != nil {
return err
}
before := time.Now()
version, err := client.CreateProjectVersion(cmd.Context(), created.OrganizationID, codersdk.CreateProjectVersionRequest{
StorageMethod: database.ProvisionerStorageMethodFile,
StorageSource: resp.Hash,
Provisioner: database.ProvisionerTypeTerraform,
ParameterValues: parameters,
})
if err != nil {
return err
}
logs, err := client.ProjectVersionLogsAfter(cmd.Context(), version.ID, before)
if err != nil {
return err
}
for {
log, ok := <-logs
if !ok {
break
}
_, _ = fmt.Printf("terraform (%s): %s\n", log.Level, log.Output)
}
version, err = client.ProjectVersion(cmd.Context(), version.ID)
if err != nil {
return err
}
if version.Job.Status != codersdk.ProvisionerJobSucceeded {
return xerrors.Errorf("Job wasn't successful, it was %q. Check the logs!", version.Job.Status)
}
_, err = client.ProjectVersionResources(cmd.Context(), version.ID)
if err != nil {
return err
}
project, err := client.CreateProject(cmd.Context(), created.OrganizationID, codersdk.CreateProjectRequest{
Name: "test",
VersionID: version.ID,
})
if err != nil {
return err
}
workspace, err := client.CreateWorkspace(cmd.Context(), created.UserID, codersdk.CreateWorkspaceRequest{
ProjectID: project.ID,
Name: "example",
})
if err != nil {
return err
}
logs, err = client.WorkspaceBuildLogsAfter(cmd.Context(), workspace.LatestBuild.ID, before)
if err != nil {
return err
}
for {
log, ok := <-logs
if !ok {
break
}
_, _ = fmt.Printf("terraform (%s): %s\n", log.Level, log.Output)
}
resources, err := client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID)
if err != nil {
return err
}
for _, resource := range resources {
if resource.Agent == nil {
continue
}
err = awaitAgent(cmd.Context(), client, resource)
if err != nil {
return err
}
}
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
ProjectVersionID: version.ID,
Transition: database.WorkspaceTransitionDelete,
})
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("terraform (%s): %s\n", log.Level, log.Output)
}
_ = daemonClose.Close()
srv.Close()
closeWait()
return nil
}
func awaitAgent(ctx context.Context, client *codersdk.Client, resource codersdk.WorkspaceResource) error {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
resource, err := client.WorkspaceResource(ctx, resource.ID)
if err != nil {
return err
}
if resource.Agent.FirstConnectedAt == nil {
continue
}
return nil
}
}
}
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: 500 * time.Millisecond,
Provisioners: provisionerd.Provisioners{
string(database.ProvisionerTypeTerraform): proto.NewDRPCProvisionerClient(provisionersdk.Conn(terraformClient)),
},
WorkDirectory: tempDir,
}), nil
}

View File

@ -4,6 +4,7 @@ import (
"net/http"
"net/url"
"sync"
"time"
"github.com/go-chi/chi/v5"
"google.golang.org/api/idtoken"
@ -17,10 +18,11 @@ import (
// Options are requires parameters for Coder to start.
type Options struct {
AccessURL *url.URL
Logger slog.Logger
Database database.Store
Pubsub database.Pubsub
AgentConnectionUpdateFrequency time.Duration
AccessURL *url.URL
Logger slog.Logger
Database database.Store
Pubsub database.Pubsub
GoogleTokenValidator *idtoken.Validator
}
@ -30,6 +32,9 @@ type Options struct {
// A wait function is returned to handle awaiting closure of hijacked HTTP
// requests.
func New(options *Options) (http.Handler, func()) {
if options.AgentConnectionUpdateFrequency == 0 {
options.AgentConnectionUpdateFrequency = 3 * time.Second
}
api := &api{
Options: options,
}
@ -75,9 +80,10 @@ func New(options *Options) (http.Handler, func()) {
httpmw.ExtractOrganizationParam(options.Database),
)
r.Get("/", api.project)
r.Delete("/", api.deleteProject)
r.Route("/versions", func(r chi.Router) {
r.Get("/", api.projectVersionsByProject)
r.Patch("/versions", nil)
r.Patch("/", api.patchActiveProjectVersion)
r.Get("/{projectversionname}", api.projectVersionByName)
})
})
@ -89,6 +95,7 @@ func New(options *Options) (http.Handler, func()) {
)
r.Get("/", api.projectVersion)
r.Patch("/cancel", api.patchCancelProjectVersion)
r.Get("/schema", api.projectVersionSchema)
r.Get("/parameters", api.projectVersionParameters)
r.Get("/resources", api.projectVersionResources)
@ -153,7 +160,6 @@ func New(options *Options) (http.Handler, func()) {
r.Route("/builds", func(r chi.Router) {
r.Get("/", api.workspaceBuilds)
r.Post("/", api.postWorkspaceBuilds)
r.Get("/latest", api.workspaceBuildLatest)
r.Get("/{workspacebuildname}", api.workspaceBuildByName)
})
})
@ -164,6 +170,7 @@ func New(options *Options) (http.Handler, func()) {
httpmw.ExtractWorkspaceParam(options.Database),
)
r.Get("/", api.workspaceBuild)
r.Patch("/cancel", api.patchCancelWorkspaceBuild)
r.Get("/logs", api.workspaceBuildLogs)
r.Get("/resources", api.workspaceBuildResources)
})

View File

@ -84,10 +84,11 @@ func New(t *testing.T, options *Options) *codersdk.Client {
var closeWait func()
// We set the handler after server creation for the access URL.
srv.Config.Handler, closeWait = coderd.New(&coderd.Options{
AccessURL: serverURL,
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
Database: db,
Pubsub: pubsub,
AgentConnectionUpdateFrequency: 25 * time.Millisecond,
AccessURL: serverURL,
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
Database: db,
Pubsub: pubsub,
GoogleTokenValidator: options.GoogleTokenValidator,
})
@ -118,9 +119,10 @@ func NewProvisionerDaemon(t *testing.T, client *codersdk.Client) io.Closer {
}()
closer := provisionerd.New(client.ListenProvisionerDaemon, &provisionerd.Options{
Logger: slogtest.Make(t, nil).Named("provisionerd").Leveled(slog.LevelDebug),
PollInterval: 50 * time.Millisecond,
UpdateInterval: 50 * time.Millisecond,
Logger: slogtest.Make(t, nil).Named("provisionerd").Leveled(slog.LevelDebug),
PollInterval: 50 * time.Millisecond,
UpdateInterval: 250 * time.Millisecond,
ForceCancelInterval: 250 * time.Millisecond,
Provisioners: provisionerd.Provisioners{
string(database.ProvisionerTypeEcho): proto.NewDRPCProvisionerClient(provisionersdk.Conn(echoClient)),
},
@ -134,8 +136,8 @@ func NewProvisionerDaemon(t *testing.T, client *codersdk.Client) io.Closer {
// CreateFirstUser creates a user with preset credentials and authenticates
// with the passed in codersdk client.
func CreateFirstUser(t *testing.T, client *codersdk.Client) coderd.CreateFirstUserResponse {
req := coderd.CreateFirstUserRequest{
func CreateFirstUser(t *testing.T, client *codersdk.Client) codersdk.CreateFirstUserResponse {
req := codersdk.CreateFirstUserRequest{
Email: "testuser@coder.com",
Username: "testuser",
Password: "testpass",
@ -144,7 +146,7 @@ func CreateFirstUser(t *testing.T, client *codersdk.Client) coderd.CreateFirstUs
resp, err := client.CreateFirstUser(context.Background(), req)
require.NoError(t, err)
login, err := client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
login, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{
Email: req.Email,
Password: req.Password,
})
@ -155,7 +157,7 @@ func CreateFirstUser(t *testing.T, client *codersdk.Client) coderd.CreateFirstUs
// CreateAnotherUser creates and authenticates a new user.
func CreateAnotherUser(t *testing.T, client *codersdk.Client, organization string) *codersdk.Client {
req := coderd.CreateUserRequest{
req := codersdk.CreateUserRequest{
Email: namesgenerator.GetRandomName(1) + "@coder.com",
Username: randomUsername(),
Password: "testpass",
@ -164,7 +166,7 @@ func CreateAnotherUser(t *testing.T, client *codersdk.Client, organization strin
_, err := client.CreateUser(context.Background(), req)
require.NoError(t, err)
login, err := client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
login, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{
Email: req.Email,
Password: req.Password,
})
@ -178,12 +180,12 @@ func CreateAnotherUser(t *testing.T, client *codersdk.Client, organization strin
// CreateProjectVersion creates a project import provisioner job
// with the responses provided. It uses the "echo" provisioner for compatibility
// with testing.
func CreateProjectVersion(t *testing.T, client *codersdk.Client, organization string, res *echo.Responses) coderd.ProjectVersion {
func CreateProjectVersion(t *testing.T, client *codersdk.Client, organization string, res *echo.Responses) codersdk.ProjectVersion {
data, err := echo.Tar(res)
require.NoError(t, err)
file, err := client.Upload(context.Background(), codersdk.ContentTypeTar, data)
require.NoError(t, err)
projectVersion, err := client.CreateProjectVersion(context.Background(), organization, coderd.CreateProjectVersionRequest{
projectVersion, err := client.CreateProjectVersion(context.Background(), organization, codersdk.CreateProjectVersionRequest{
StorageSource: file.Hash,
StorageMethod: database.ProvisionerStorageMethodFile,
Provisioner: database.ProvisionerTypeEcho,
@ -194,8 +196,8 @@ func CreateProjectVersion(t *testing.T, client *codersdk.Client, organization st
// CreateProject creates a project with the "echo" provisioner for
// compatibility with testing. The name assigned is randomly generated.
func CreateProject(t *testing.T, client *codersdk.Client, organization string, version uuid.UUID) coderd.Project {
project, err := client.CreateProject(context.Background(), organization, coderd.CreateProjectRequest{
func CreateProject(t *testing.T, client *codersdk.Client, organization string, version uuid.UUID) codersdk.Project {
project, err := client.CreateProject(context.Background(), organization, codersdk.CreateProjectRequest{
Name: randomUsername(),
VersionID: version,
})
@ -204,8 +206,8 @@ func CreateProject(t *testing.T, client *codersdk.Client, organization string, v
}
// AwaitProjectImportJob awaits for an import job to reach completed status.
func AwaitProjectVersionJob(t *testing.T, client *codersdk.Client, version uuid.UUID) coderd.ProjectVersion {
var projectVersion coderd.ProjectVersion
func AwaitProjectVersionJob(t *testing.T, client *codersdk.Client, version uuid.UUID) codersdk.ProjectVersion {
var projectVersion codersdk.ProjectVersion
require.Eventually(t, func() bool {
var err error
projectVersion, err = client.ProjectVersion(context.Background(), version)
@ -216,8 +218,8 @@ func AwaitProjectVersionJob(t *testing.T, client *codersdk.Client, version uuid.
}
// AwaitWorkspaceBuildJob waits for a workspace provision job to reach completed status.
func AwaitWorkspaceBuildJob(t *testing.T, client *codersdk.Client, build uuid.UUID) coderd.WorkspaceBuild {
var workspaceBuild coderd.WorkspaceBuild
func AwaitWorkspaceBuildJob(t *testing.T, client *codersdk.Client, build uuid.UUID) codersdk.WorkspaceBuild {
var workspaceBuild codersdk.WorkspaceBuild
require.Eventually(t, func() bool {
var err error
workspaceBuild, err = client.WorkspaceBuild(context.Background(), build)
@ -228,8 +230,8 @@ func AwaitWorkspaceBuildJob(t *testing.T, client *codersdk.Client, build uuid.UU
}
// AwaitWorkspaceAgents waits for all resources with agents to be connected.
func AwaitWorkspaceAgents(t *testing.T, client *codersdk.Client, build uuid.UUID) []coderd.WorkspaceResource {
var resources []coderd.WorkspaceResource
func AwaitWorkspaceAgents(t *testing.T, client *codersdk.Client, build uuid.UUID) []codersdk.WorkspaceResource {
var resources []codersdk.WorkspaceResource
require.Eventually(t, func() bool {
var err error
resources, err = client.WorkspaceResourcesByBuild(context.Background(), build)
@ -238,7 +240,7 @@ func AwaitWorkspaceAgents(t *testing.T, client *codersdk.Client, build uuid.UUID
if resource.Agent == nil {
continue
}
if resource.Agent.UpdatedAt.IsZero() {
if resource.Agent.FirstConnectedAt == nil {
return false
}
}
@ -249,8 +251,8 @@ func AwaitWorkspaceAgents(t *testing.T, client *codersdk.Client, build uuid.UUID
// CreateWorkspace creates a workspace for the user and project provided.
// A random name is generated for it.
func CreateWorkspace(t *testing.T, client *codersdk.Client, user string, projectID uuid.UUID) coderd.Workspace {
workspace, err := client.CreateWorkspace(context.Background(), user, coderd.CreateWorkspaceRequest{
func CreateWorkspace(t *testing.T, client *codersdk.Client, user string, projectID uuid.UUID) codersdk.Workspace {
workspace, err := client.CreateWorkspace(context.Background(), user, codersdk.CreateWorkspaceRequest{
ProjectID: projectID,
Name: randomUsername(),
})

View File

@ -1,16 +1,11 @@
package coderdtest_test
import (
"context"
"testing"
"go.uber.org/goleak"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/database"
)
func TestMain(m *testing.M) {
@ -26,12 +21,7 @@ func TestNew(t *testing.T) {
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID)
build, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
ProjectVersionID: project.ActiveVersionID,
Transition: database.WorkspaceTransitionStart,
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID)
coderdtest.AwaitWorkspaceAgents(t, client, build.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
closer.Close()
}

View File

@ -12,16 +12,12 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
"github.com/coder/coder/httpapi"
"github.com/coder/coder/httpmw"
)
// UploadResponse contains the hash to reference the uploaded file.
type UploadResponse struct {
Hash string `json:"hash"`
}
func (api *api) postFile(rw http.ResponseWriter, r *http.Request) {
apiKey := httpmw.APIKey(r)
contentType := r.Header.Get("Content-Type")
@ -49,7 +45,7 @@ func (api *api) postFile(rw http.ResponseWriter, r *http.Request) {
if err == nil {
// The file already exists!
render.Status(r, http.StatusOK)
render.JSON(rw, r, UploadResponse{
render.JSON(rw, r, codersdk.UploadResponse{
Hash: file.Hash,
})
return
@ -68,7 +64,7 @@ func (api *api) postFile(rw http.ResponseWriter, r *http.Request) {
return
}
render.Status(r, http.StatusCreated)
render.JSON(rw, r, UploadResponse{
render.JSON(rw, r, codersdk.UploadResponse{
Hash: file.Hash,
})
}

View File

@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
@ -13,45 +12,12 @@ import (
"github.com/moby/moby/pkg/namesgenerator"
"golang.org/x/xerrors"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
"github.com/coder/coder/httpapi"
"github.com/coder/coder/httpmw"
)
// Organization is the JSON representation of a Coder organization.
type Organization struct {
ID string `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
CreatedAt time.Time `json:"created_at" validate:"required"`
UpdatedAt time.Time `json:"updated_at" validate:"required"`
}
// CreateProjectVersionRequest enables callers to create a new Project Version.
type CreateProjectVersionRequest struct {
// ProjectID optionally associates a version with a project.
ProjectID *uuid.UUID `json:"project_id"`
StorageMethod database.ProvisionerStorageMethod `json:"storage_method" validate:"oneof=file,required"`
StorageSource string `json:"storage_source" validate:"required"`
Provisioner database.ProvisionerType `json:"provisioner" validate:"oneof=terraform echo,required"`
// ParameterValues allows for additional parameters to be provided
// during the dry-run provision stage.
ParameterValues []CreateParameterRequest `json:"parameter_values"`
}
// CreateProjectRequest provides options when creating a project.
type CreateProjectRequest struct {
Name string `json:"name" validate:"username,required"`
// VersionID is an in-progress or completed job to use as
// an initial version of the project.
//
// This is required on creation to enable a user-flow of validating a
// project works. There is no reason the data-model cannot support
// empty projects, but it doesn't make sense for users.
VersionID uuid.UUID `json:"project_version_id" validate:"required"`
}
func (*api) organization(rw http.ResponseWriter, r *http.Request) {
organization := httpmw.OrganizationParam(r)
render.Status(r, http.StatusOK)
@ -80,12 +46,12 @@ func (api *api) provisionerDaemonsByOrganization(rw http.ResponseWriter, r *http
func (api *api) postProjectVersionsByOrganization(rw http.ResponseWriter, r *http.Request) {
apiKey := httpmw.APIKey(r)
organization := httpmw.OrganizationParam(r)
var req CreateProjectVersionRequest
var req codersdk.CreateProjectVersionRequest
if !httpapi.Read(rw, r, &req) {
return
}
if req.ProjectID != nil {
_, err := api.Database.GetProjectByID(r.Context(), *req.ProjectID)
if req.ProjectID != uuid.Nil {
_, err := api.Database.GetProjectByID(r.Context(), req.ProjectID)
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(rw, http.StatusNotFound, httpapi.Response{
Message: "project does not exist",
@ -152,9 +118,9 @@ func (api *api) postProjectVersionsByOrganization(rw http.ResponseWriter, r *htt
}
var projectID uuid.NullUUID
if req.ProjectID != nil {
if req.ProjectID != uuid.Nil {
projectID = uuid.NullUUID{
UUID: *req.ProjectID,
UUID: req.ProjectID,
Valid: true,
}
}
@ -187,7 +153,7 @@ func (api *api) postProjectVersionsByOrganization(rw http.ResponseWriter, r *htt
// Create a new project in an organization.
func (api *api) postProjectsByOrganization(rw http.ResponseWriter, r *http.Request) {
var createProject CreateProjectRequest
var createProject codersdk.CreateProjectRequest
if !httpapi.Read(rw, r, &createProject) {
return
}
@ -232,7 +198,7 @@ func (api *api) postProjectsByOrganization(rw http.ResponseWriter, r *http.Reque
return
}
var project Project
var project codersdk.Project
err = api.Database.InTx(func(db database.Store) error {
dbProject, err := db.InsertProject(r.Context(), database.InsertProjectParams{
ID: uuid.New(),
@ -256,6 +222,22 @@ func (api *api) postProjectsByOrganization(rw http.ResponseWriter, r *http.Reque
if err != nil {
return xerrors.Errorf("insert project version: %s", err)
}
for _, parameterValue := range createProject.ParameterValues {
_, err = db.InsertParameterValue(r.Context(), database.InsertParameterValueParams{
ID: uuid.New(),
Name: parameterValue.Name,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
Scope: database.ParameterScopeProject,
ScopeID: dbProject.ID.String(),
SourceScheme: parameterValue.SourceScheme,
SourceValue: parameterValue.SourceValue,
DestinationScheme: parameterValue.DestinationScheme,
})
if err != nil {
return xerrors.Errorf("insert parameter value: %w", err)
}
}
project = convertProject(dbProject, 0)
return nil
})
@ -272,7 +254,9 @@ func (api *api) postProjectsByOrganization(rw http.ResponseWriter, r *http.Reque
func (api *api) projectsByOrganization(rw http.ResponseWriter, r *http.Request) {
organization := httpmw.OrganizationParam(r)
projects, err := api.Database.GetProjectsByOrganization(r.Context(), organization.ID)
projects, err := api.Database.GetProjectsByOrganization(r.Context(), database.GetProjectsByOrganizationParams{
OrganizationID: organization.ID,
})
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
@ -338,8 +322,8 @@ func (api *api) projectByOrganizationAndName(rw http.ResponseWriter, r *http.Req
}
// convertOrganization consumes the database representation and outputs an API friendly representation.
func convertOrganization(organization database.Organization) Organization {
return Organization{
func convertOrganization(organization database.Organization) codersdk.Organization {
return codersdk.Organization{
ID: organization.ID,
Name: organization.Name,
CreatedAt: organization.CreatedAt,

View File

@ -8,7 +8,6 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
@ -40,8 +39,8 @@ func TestPostProjectVersionsByOrganization(t *testing.T) {
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
projectID := uuid.New()
_, err := client.CreateProjectVersion(context.Background(), user.OrganizationID, coderd.CreateProjectVersionRequest{
ProjectID: &projectID,
_, err := client.CreateProjectVersion(context.Background(), user.OrganizationID, codersdk.CreateProjectVersionRequest{
ProjectID: projectID,
StorageMethod: database.ProvisionerStorageMethodFile,
StorageSource: "hash",
Provisioner: database.ProvisionerTypeEcho,
@ -55,7 +54,7 @@ func TestPostProjectVersionsByOrganization(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
_, err := client.CreateProjectVersion(context.Background(), user.OrganizationID, coderd.CreateProjectVersionRequest{
_, err := client.CreateProjectVersion(context.Background(), user.OrganizationID, codersdk.CreateProjectVersionRequest{
StorageMethod: database.ProvisionerStorageMethodFile,
StorageSource: "hash",
Provisioner: database.ProvisionerTypeEcho,
@ -77,11 +76,11 @@ func TestPostProjectVersionsByOrganization(t *testing.T) {
require.NoError(t, err)
file, err := client.Upload(context.Background(), codersdk.ContentTypeTar, data)
require.NoError(t, err)
_, err = client.CreateProjectVersion(context.Background(), user.OrganizationID, coderd.CreateProjectVersionRequest{
_, err = client.CreateProjectVersion(context.Background(), user.OrganizationID, codersdk.CreateProjectVersionRequest{
StorageMethod: database.ProvisionerStorageMethodFile,
StorageSource: file.Hash,
Provisioner: database.ProvisionerTypeEcho,
ParameterValues: []coderd.CreateParameterRequest{{
ParameterValues: []codersdk.CreateParameterRequest{{
Name: "example",
SourceValue: "value",
SourceScheme: database.ParameterSourceSchemeData,
@ -108,7 +107,7 @@ func TestPostProjectsByOrganization(t *testing.T) {
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
_, err := client.CreateProject(context.Background(), user.OrganizationID, coderd.CreateProjectRequest{
_, err := client.CreateProject(context.Background(), user.OrganizationID, codersdk.CreateProjectRequest{
Name: project.Name,
VersionID: version.ID,
})
@ -121,7 +120,7 @@ func TestPostProjectsByOrganization(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
_, err := client.CreateProject(context.Background(), user.OrganizationID, coderd.CreateProjectRequest{
_, err := client.CreateProject(context.Background(), user.OrganizationID, codersdk.CreateProjectRequest{
Name: "test",
VersionID: uuid.New(),
})
@ -153,6 +152,17 @@ func TestProjectsByOrganization(t *testing.T) {
require.NoError(t, err)
require.Len(t, projects, 1)
})
t.Run("ListMultiple", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
projects, err := client.ProjectsByOrganization(context.Background(), user.OrganizationID)
require.NoError(t, err)
require.Len(t, projects, 2)
})
}
func TestProjectByOrganizationAndName(t *testing.T) {

View File

@ -0,0 +1,43 @@
package parameter
import (
"sort"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"golang.org/x/xerrors"
)
// Contains parses possible values for a conditional.
func Contains(condition string) ([]string, bool, error) {
if condition == "" {
return nil, false, nil
}
expression, diags := hclsyntax.ParseExpression([]byte(condition), "", hcl.InitialPos)
if len(diags) > 0 {
return nil, false, xerrors.Errorf("parse condition: %s", diags.Error())
}
functionCallExpression, valid := expression.(*hclsyntax.FunctionCallExpr)
if !valid {
return nil, false, nil
}
if functionCallExpression.Name != "contains" {
return nil, false, nil
}
if len(functionCallExpression.Args) < 2 {
return nil, false, nil
}
value, diags := functionCallExpression.Args[0].Value(&hcl.EvalContext{})
if len(diags) > 0 {
return nil, false, xerrors.Errorf("parse value: %s", diags.Error())
}
possible := make([]string, 0)
for _, subValue := range value.AsValueSlice() {
if subValue.Type().FriendlyName() != "string" {
continue
}
possible = append(possible, subValue.AsString())
}
sort.Strings(possible)
return possible, true, nil
}

View File

@ -0,0 +1,20 @@
package parameter_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/parameter"
)
func TestValidate(t *testing.T) {
t.Parallel()
t.Run("Contains", func(t *testing.T) {
t.Parallel()
values, valid, err := parameter.Contains(`contains(["us-east1-a", "us-central1-a"], var.region)`)
require.NoError(t, err)
require.True(t, valid)
require.Len(t, values, 2)
})
}

View File

@ -5,47 +5,18 @@ import (
"errors"
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/google/uuid"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
"github.com/coder/coder/httpapi"
)
type ParameterScope string
const (
ParameterOrganization ParameterScope = "organization"
ParameterProject ParameterScope = "project"
ParameterUser ParameterScope = "user"
ParameterWorkspace ParameterScope = "workspace"
)
// Parameter represents a set value for the scope.
type Parameter struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Scope ParameterScope `db:"scope" json:"scope"`
ScopeID string `db:"scope_id" json:"scope_id"`
Name string `db:"name" json:"name"`
SourceScheme database.ParameterSourceScheme `db:"source_scheme" json:"source_scheme"`
DestinationScheme database.ParameterDestinationScheme `db:"destination_scheme" json:"destination_scheme"`
}
// CreateParameterRequest is used to create a new parameter value for a scope.
type CreateParameterRequest struct {
Name string `json:"name" validate:"required"`
SourceValue string `json:"source_value" validate:"required"`
SourceScheme database.ParameterSourceScheme `json:"source_scheme" validate:"oneof=data,required"`
DestinationScheme database.ParameterDestinationScheme `json:"destination_scheme" validate:"oneof=environment_variable provisioner_variable,required"`
}
func (api *api) postParameter(rw http.ResponseWriter, r *http.Request) {
var createRequest CreateParameterRequest
var createRequest codersdk.CreateParameterRequest
if !httpapi.Read(rw, r, &createRequest) {
return
}
@ -110,7 +81,7 @@ func (api *api) parameters(rw http.ResponseWriter, r *http.Request) {
})
return
}
apiParameterValues := make([]Parameter, 0, len(parameterValues))
apiParameterValues := make([]codersdk.Parameter, 0, len(parameterValues))
for _, parameterValue := range parameterValues {
apiParameterValues = append(apiParameterValues, convertParameterValue(parameterValue))
}
@ -154,12 +125,12 @@ func (api *api) deleteParameter(rw http.ResponseWriter, r *http.Request) {
})
}
func convertParameterValue(parameterValue database.ParameterValue) Parameter {
return Parameter{
func convertParameterValue(parameterValue database.ParameterValue) codersdk.Parameter {
return codersdk.Parameter{
ID: parameterValue.ID,
CreatedAt: parameterValue.CreatedAt,
UpdatedAt: parameterValue.UpdatedAt,
Scope: ParameterScope(parameterValue.Scope),
Scope: codersdk.ParameterScope(parameterValue.Scope),
ScopeID: parameterValue.ScopeID,
Name: parameterValue.Name,
SourceScheme: parameterValue.SourceScheme,
@ -170,13 +141,13 @@ func convertParameterValue(parameterValue database.ParameterValue) Parameter {
func readScopeAndID(rw http.ResponseWriter, r *http.Request) (database.ParameterScope, string, bool) {
var scope database.ParameterScope
switch chi.URLParam(r, "scope") {
case string(ParameterOrganization):
case string(codersdk.ParameterOrganization):
scope = database.ParameterScopeOrganization
case string(ParameterProject):
case string(codersdk.ParameterProject):
scope = database.ParameterScopeProject
case string(ParameterUser):
case string(codersdk.ParameterUser):
scope = database.ParameterScopeUser
case string(ParameterWorkspace):
case string(codersdk.ParameterWorkspace):
scope = database.ParameterScopeWorkspace
default:
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{

View File

@ -7,7 +7,6 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
@ -19,7 +18,7 @@ func TestPostParameter(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
_, err := client.CreateParameter(context.Background(), coderd.ParameterScope("something"), user.OrganizationID, coderd.CreateParameterRequest{
_, err := client.CreateParameter(context.Background(), codersdk.ParameterScope("something"), user.OrganizationID, codersdk.CreateParameterRequest{
Name: "example",
SourceValue: "tomato",
SourceScheme: database.ParameterSourceSchemeData,
@ -34,7 +33,7 @@ func TestPostParameter(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
_, err := client.CreateParameter(context.Background(), coderd.ParameterOrganization, user.OrganizationID, coderd.CreateParameterRequest{
_, err := client.CreateParameter(context.Background(), codersdk.ParameterOrganization, user.OrganizationID, codersdk.CreateParameterRequest{
Name: "example",
SourceValue: "tomato",
SourceScheme: database.ParameterSourceSchemeData,
@ -47,7 +46,7 @@ func TestPostParameter(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
_, err := client.CreateParameter(context.Background(), coderd.ParameterOrganization, user.OrganizationID, coderd.CreateParameterRequest{
_, err := client.CreateParameter(context.Background(), codersdk.ParameterOrganization, user.OrganizationID, codersdk.CreateParameterRequest{
Name: "example",
SourceValue: "tomato",
SourceScheme: database.ParameterSourceSchemeData,
@ -55,7 +54,7 @@ func TestPostParameter(t *testing.T) {
})
require.NoError(t, err)
_, err = client.CreateParameter(context.Background(), coderd.ParameterOrganization, user.OrganizationID, coderd.CreateParameterRequest{
_, err = client.CreateParameter(context.Background(), codersdk.ParameterOrganization, user.OrganizationID, codersdk.CreateParameterRequest{
Name: "example",
SourceValue: "tomato",
SourceScheme: database.ParameterSourceSchemeData,
@ -73,21 +72,21 @@ func TestParameters(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
_, err := client.Parameters(context.Background(), coderd.ParameterOrganization, user.OrganizationID)
_, err := client.Parameters(context.Background(), codersdk.ParameterOrganization, user.OrganizationID)
require.NoError(t, err)
})
t.Run("List", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
_, err := client.CreateParameter(context.Background(), coderd.ParameterOrganization, user.OrganizationID, coderd.CreateParameterRequest{
_, err := client.CreateParameter(context.Background(), codersdk.ParameterOrganization, user.OrganizationID, codersdk.CreateParameterRequest{
Name: "example",
SourceValue: "tomato",
SourceScheme: database.ParameterSourceSchemeData,
DestinationScheme: database.ParameterDestinationSchemeProvisionerVariable,
})
require.NoError(t, err)
params, err := client.Parameters(context.Background(), coderd.ParameterOrganization, user.OrganizationID)
params, err := client.Parameters(context.Background(), codersdk.ParameterOrganization, user.OrganizationID)
require.NoError(t, err)
require.Len(t, params, 1)
})
@ -99,7 +98,7 @@ func TestDeleteParameter(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
err := client.DeleteParameter(context.Background(), coderd.ParameterOrganization, user.OrganizationID, "something")
err := client.DeleteParameter(context.Background(), codersdk.ParameterOrganization, user.OrganizationID, "something")
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
@ -108,14 +107,14 @@ func TestDeleteParameter(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
param, err := client.CreateParameter(context.Background(), coderd.ParameterOrganization, user.OrganizationID, coderd.CreateParameterRequest{
param, err := client.CreateParameter(context.Background(), codersdk.ParameterOrganization, user.OrganizationID, codersdk.CreateParameterRequest{
Name: "example",
SourceValue: "tomato",
SourceScheme: database.ParameterSourceSchemeData,
DestinationScheme: database.ParameterDestinationSchemeProvisionerVariable,
})
require.NoError(t, err)
err = client.DeleteParameter(context.Background(), coderd.ParameterOrganization, user.OrganizationID, param.Name)
err = client.DeleteParameter(context.Background(), codersdk.ParameterOrganization, user.OrganizationID, param.Name)
require.NoError(t, err)
})
}

View File

@ -5,31 +5,17 @@ import (
"errors"
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/google/uuid"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
"github.com/coder/coder/httpapi"
"github.com/coder/coder/httpmw"
)
// Project is the JSON representation of a Coder project.
// This type matches the database object for now, but is
// abstracted for ease of change later on.
type Project struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
OrganizationID string `json:"organization_id"`
Name string `json:"name"`
Provisioner database.ProvisionerType `json:"provisioner"`
ActiveVersionID uuid.UUID `json:"active_version_id"`
WorkspaceOwnerCount uint32 `json:"workspace_owner_count"`
}
// Returns a single project.
func (api *api) project(rw http.ResponseWriter, r *http.Request) {
project := httpmw.ProjectParam(r)
@ -52,6 +38,42 @@ func (api *api) project(rw http.ResponseWriter, r *http.Request) {
render.JSON(rw, r, convertProject(project, count))
}
func (api *api) deleteProject(rw http.ResponseWriter, r *http.Request) {
project := httpmw.ProjectParam(r)
workspaces, err := api.Database.GetWorkspacesByProjectID(r.Context(), database.GetWorkspacesByProjectIDParams{
ProjectID: project.ID,
})
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get workspaces by project id: %s", err),
})
return
}
if len(workspaces) > 0 {
httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{
Message: "All workspaces must be deleted before a project can be removed.",
})
return
}
err = api.Database.UpdateProjectDeletedByID(r.Context(), database.UpdateProjectDeletedByIDParams{
ID: project.ID,
Deleted: true,
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("update project deleted by id: %s", err),
})
return
}
httpapi.Write(rw, http.StatusOK, httpapi.Response{
Message: "Project has been deleted!",
})
}
func (api *api) projectVersionsByProject(rw http.ResponseWriter, r *http.Request) {
project := httpmw.ProjectParam(r)
@ -81,7 +103,7 @@ func (api *api) projectVersionsByProject(rw http.ResponseWriter, r *http.Request
jobByID[job.ID.String()] = job
}
apiVersion := make([]ProjectVersion, 0)
apiVersion := make([]codersdk.ProjectVersion, 0)
for _, version := range versions {
job, exists := jobByID[version.JobID.String()]
if !exists {
@ -130,8 +152,48 @@ func (api *api) projectVersionByName(rw http.ResponseWriter, r *http.Request) {
render.JSON(rw, r, convertProjectVersion(projectVersion, convertProvisionerJob(job)))
}
func convertProjects(projects []database.Project, workspaceCounts []database.GetWorkspaceOwnerCountsByProjectIDsRow) []Project {
apiProjects := make([]Project, 0, len(projects))
func (api *api) patchActiveProjectVersion(rw http.ResponseWriter, r *http.Request) {
var req codersdk.UpdateActiveProjectVersion
if !httpapi.Read(rw, r, &req) {
return
}
project := httpmw.ProjectParam(r)
version, err := api.Database.GetProjectVersionByID(r.Context(), req.ID)
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(rw, http.StatusNotFound, httpapi.Response{
Message: "project version not found",
})
return
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get project version: %s", err),
})
return
}
if version.ProjectID.UUID.String() != project.ID.String() {
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
Message: "The provided project version doesn't belong to the specified project.",
})
return
}
err = api.Database.UpdateProjectActiveVersionByID(r.Context(), database.UpdateProjectActiveVersionByIDParams{
ID: project.ID,
ActiveVersionID: req.ID,
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("update active project version: %s", err),
})
return
}
httpapi.Write(rw, http.StatusOK, httpapi.Response{
Message: "Updated the active project version!",
})
}
func convertProjects(projects []database.Project, workspaceCounts []database.GetWorkspaceOwnerCountsByProjectIDsRow) []codersdk.Project {
apiProjects := make([]codersdk.Project, 0, len(projects))
for _, project := range projects {
found := false
for _, workspaceCount := range workspaceCounts {
@ -149,8 +211,8 @@ func convertProjects(projects []database.Project, workspaceCounts []database.Get
return apiProjects
}
func convertProject(project database.Project, workspaceOwnerCount uint32) Project {
return Project{
func convertProject(project database.Project, workspaceOwnerCount uint32) codersdk.Project {
return codersdk.Project{
ID: project.ID,
CreatedAt: project.CreatedAt,
UpdatedAt: project.UpdatedAt,

View File

@ -5,6 +5,7 @@ import (
"net/http"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/coderdtest"
@ -25,6 +26,35 @@ func TestProject(t *testing.T) {
})
}
func TestDeleteProject(t *testing.T) {
t.Parallel()
t.Run("NoWorkspaces", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
err := client.DeleteProject(context.Background(), project.ID)
require.NoError(t, err)
})
t.Run("Workspaces", 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, nil)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
coderdtest.CreateWorkspace(t, client, "me", project.ID)
err := client.DeleteProject(context.Background(), project.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusPreconditionFailed, apiErr.StatusCode())
})
}
func TestProjectVersionsByProject(t *testing.T) {
t.Parallel()
t.Run("Get", func(t *testing.T) {
@ -63,3 +93,47 @@ func TestProjectVersionByName(t *testing.T) {
require.NoError(t, err)
})
}
func TestPatchActiveProjectVersion(t *testing.T) {
t.Parallel()
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
err := client.UpdateActiveProjectVersion(context.Background(), project.ID, codersdk.UpdateActiveProjectVersion{
ID: uuid.New(),
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
})
t.Run("DoesNotBelong", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
version = coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
err := client.UpdateActiveProjectVersion(context.Background(), project.ID, codersdk.UpdateActiveProjectVersion{
ID: version.ID,
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode())
})
t.Run("Found", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
err := client.UpdateActiveProjectVersion(context.Background(), project.ID, codersdk.UpdateActiveProjectVersion{
ID: version.ID,
})
require.NoError(t, err)
})
}

View File

@ -5,33 +5,16 @@ import (
"errors"
"fmt"
"net/http"
"time"
"github.com/go-chi/render"
"github.com/google/uuid"
"github.com/coder/coder/coderd/parameter"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
"github.com/coder/coder/httpapi"
"github.com/coder/coder/httpmw"
)
// ProjectVersion represents a single version of a project.
type ProjectVersion struct {
ID uuid.UUID `json:"id"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Name string `json:"name"`
Job ProvisionerJob `json:"job"`
}
// ProjectVersionParameterSchema represents a parameter parsed from project version source.
type ProjectVersionParameterSchema database.ParameterSchema
// ProjectVersionParameter represents a computed parameter value.
type ProjectVersionParameter parameter.ComputedValue
func (api *api) projectVersion(rw http.ResponseWriter, r *http.Request) {
projectVersion := httpmw.ProjectVersionParam(r)
job, err := api.Database.GetProvisionerJobByID(r.Context(), projectVersion.JobID)
@ -45,6 +28,45 @@ func (api *api) projectVersion(rw http.ResponseWriter, r *http.Request) {
render.JSON(rw, r, convertProjectVersion(projectVersion, convertProvisionerJob(job)))
}
func (api *api) patchCancelProjectVersion(rw http.ResponseWriter, r *http.Request) {
projectVersion := httpmw.ProjectVersionParam(r)
job, err := api.Database.GetProvisionerJobByID(r.Context(), projectVersion.JobID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get provisioner job: %s", err),
})
return
}
if job.CompletedAt.Valid {
httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{
Message: "Job has already completed!",
})
return
}
if job.CanceledAt.Valid {
httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{
Message: "Job has already been marked as canceled!",
})
return
}
err = api.Database.UpdateProvisionerJobWithCancelByID(r.Context(), database.UpdateProvisionerJobWithCancelByIDParams{
ID: job.ID,
CanceledAt: sql.NullTime{
Time: database.Now(),
Valid: true,
},
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("update provisioner job: %s", err),
})
return
}
httpapi.Write(rw, http.StatusOK, httpapi.Response{
Message: "Job has been marked as canceled...",
})
}
func (api *api) projectVersionSchema(rw http.ResponseWriter, r *http.Request) {
projectVersion := httpmw.ProjectVersionParam(r)
job, err := api.Database.GetProvisionerJobByID(r.Context(), projectVersion.JobID)
@ -138,8 +160,8 @@ func (api *api) projectVersionLogs(rw http.ResponseWriter, r *http.Request) {
api.provisionerJobLogs(rw, r, job)
}
func convertProjectVersion(version database.ProjectVersion, job ProvisionerJob) ProjectVersion {
return ProjectVersion{
func convertProjectVersion(version database.ProjectVersion, job codersdk.ProvisionerJob) codersdk.ProjectVersion {
return codersdk.ProjectVersion{
ID: version.ID,
ProjectID: &version.ProjectID.UUID,
CreatedAt: version.CreatedAt,

View File

@ -27,6 +27,38 @@ func TestProjectVersion(t *testing.T) {
})
}
func TestPatchCancelProjectVersion(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_Log{
Log: &proto.Log{},
},
}},
})
require.Eventually(t, func() bool {
var err error
version, err = client.ProjectVersion(context.Background(), version.ID)
require.NoError(t, err)
t.Logf("Status: %s", version.Job.Status)
return version.Job.Status == codersdk.ProvisionerJobRunning
}, 5*time.Second, 25*time.Millisecond)
err := client.CancelProjectVersion(context.Background(), version.ID)
require.NoError(t, err)
require.Eventually(t, func() bool {
var err error
version, err = client.ProjectVersion(context.Background(), version.ID)
require.NoError(t, err)
// The echo provisioner doesn't respond to a shutdown request,
// so the job cancel will time out and fail.
return version.Job.Status == codersdk.ProvisionerJobFailed
}, 5*time.Second, 25*time.Millisecond)
}
func TestProjectVersionSchema(t *testing.T) {
t.Parallel()
t.Run("ListRunning", func(t *testing.T) {

View File

@ -27,11 +27,10 @@ import (
"github.com/coder/coder/database"
"github.com/coder/coder/httpapi"
"github.com/coder/coder/provisionerd/proto"
"github.com/coder/coder/provisionersdk"
sdkproto "github.com/coder/coder/provisionersdk/proto"
)
type ProvisionerDaemon database.ProvisionerDaemon
// Serves the provisioner daemon protobuf API over a WebSocket.
func (api *api) provisionerDaemonsListen(rw http.ResponseWriter, r *http.Request) {
api.websocketWaitGroup.Add(1)
@ -266,6 +265,12 @@ func (server *provisionerdServer) UpdateJob(ctx context.Context, request *proto.
if job.WorkerID.UUID.String() != server.ID.String() {
return nil, xerrors.New("you don't own this job")
}
if job.CanceledAt.Valid {
// Allows for graceful cancelation on the backend!
return &proto.UpdateJobResponse{
Canceled: true,
}, nil
}
err = server.Database.UpdateProvisionerJobByID(ctx, database.UpdateProvisionerJobByIDParams{
ID: parsedID,
UpdatedAt: database.Now(),
@ -358,8 +363,18 @@ func (server *provisionerdServer) UpdateJob(ctx context.Context, request *proto.
}
}
var projectID uuid.NullUUID
if job.Type == database.ProvisionerJobTypeProjectVersionImport {
projectVersion, err := server.Database.GetProjectVersionByJobID(ctx, job.ID)
if err != nil {
return nil, xerrors.Errorf("get project version by job id: %w", err)
}
projectID = projectVersion.ProjectID
}
parameters, err := parameter.Compute(ctx, server.Database, parameter.ComputeScope{
ProjectImportJobID: job.ID,
ProjectID: projectID,
OrganizationID: job.OrganizationID,
UserID: job.InitiatorID,
}, nil)
@ -454,14 +469,18 @@ func (server *provisionerdServer) CompleteJob(ctx context.Context, completed *pr
database.WorkspaceTransitionStart: jobType.ProjectImport.StartResources,
database.WorkspaceTransitionStop: jobType.ProjectImport.StopResources,
} {
for _, resource := range resources {
addresses, err := provisionersdk.ResourceAddresses(resources)
if err != nil {
return nil, xerrors.Errorf("compute resource addresses: %w", err)
}
for index, resource := range resources {
server.Logger.Info(ctx, "inserting project import job resource",
slog.F("job_id", job.ID.String()),
slog.F("resource_name", resource.Name),
slog.F("resource_type", resource.Type),
slog.F("transition", transition))
err = insertWorkspaceResource(ctx, server.Database, jobID, transition, resource)
err = insertWorkspaceResource(ctx, server.Database, jobID, transition, resource, addresses[index])
if err != nil {
return nil, xerrors.Errorf("insert resource: %w", err)
}
@ -515,13 +534,31 @@ func (server *provisionerdServer) CompleteJob(ctx context.Context, completed *pr
if err != nil {
return xerrors.Errorf("update workspace build: %w", err)
}
addresses, err := provisionersdk.ResourceAddresses(jobType.WorkspaceBuild.Resources)
if err != nil {
return xerrors.Errorf("compute resource addresses: %w", err)
}
// This could be a bulk insert to improve performance.
for _, protoResource := range jobType.WorkspaceBuild.Resources {
err = insertWorkspaceResource(ctx, db, job.ID, workspaceBuild.Transition, protoResource)
for index, protoResource := range jobType.WorkspaceBuild.Resources {
err = insertWorkspaceResource(ctx, db, job.ID, workspaceBuild.Transition, protoResource, addresses[index])
if err != nil {
return xerrors.Errorf("insert provisioner job: %w", err)
}
}
if workspaceBuild.Transition != database.WorkspaceTransitionDelete {
// This is for deleting a workspace!
return nil
}
err = db.UpdateWorkspaceDeletedByID(ctx, database.UpdateWorkspaceDeletedByIDParams{
ID: workspaceBuild.WorkspaceID,
Deleted: true,
})
if err != nil {
return xerrors.Errorf("update workspace deleted: %w", err)
}
return nil
})
if err != nil {
@ -535,12 +572,13 @@ func (server *provisionerdServer) CompleteJob(ctx context.Context, completed *pr
return &proto.Empty{}, nil
}
func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.UUID, transition database.WorkspaceTransition, protoResource *sdkproto.Resource) error {
func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.UUID, transition database.WorkspaceTransition, protoResource *sdkproto.Resource, address string) error {
resource, err := db.InsertWorkspaceResource(ctx, database.InsertWorkspaceResourceParams{
ID: uuid.New(),
CreatedAt: database.Now(),
JobID: jobID,
Transition: transition,
Address: address,
Type: protoResource.Type,
Name: protoResource.Name,
AgentID: uuid.NullUUID{
@ -581,6 +619,7 @@ func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
_, err := db.InsertWorkspaceAgent(ctx, database.InsertWorkspaceAgentParams{
ID: resource.AgentID.UUID,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
ResourceID: resource.ID,
AuthToken: authToken,
AuthInstanceID: instanceID,

View File

@ -15,39 +15,11 @@ import (
"cdr.dev/slog"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
"github.com/coder/coder/httpapi"
)
// ProvisionerJobStaus represents the at-time state of a job.
type ProvisionerJobStatus string
const (
ProvisionerJobPending ProvisionerJobStatus = "pending"
ProvisionerJobRunning ProvisionerJobStatus = "running"
ProvisionerJobSucceeded ProvisionerJobStatus = "succeeded"
ProvisionerJobCancelled ProvisionerJobStatus = "canceled"
ProvisionerJobFailed ProvisionerJobStatus = "failed"
)
type ProvisionerJob struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Error string `json:"error,omitempty"`
Status ProvisionerJobStatus `json:"status"`
WorkerID *uuid.UUID `json:"worker_id,omitempty"`
}
type ProvisionerJobLog struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
Source database.LogSource `json:"log_source"`
Level database.LogLevel `json:"log_level"`
Output string `json:"output"`
}
// Returns provisioner logs based on query parameters.
// The intended usage for a client to stream all logs (with JS API):
// const timestamp = new Date().getTime();
@ -220,7 +192,7 @@ func (api *api) provisionerJobResources(rw http.ResponseWriter, r *http.Request,
})
return
}
apiResources := make([]WorkspaceResource, 0)
apiResources := make([]codersdk.WorkspaceResource, 0)
for _, resource := range resources {
if !resource.AgentID.Valid {
apiResources = append(apiResources, convertWorkspaceResource(resource, nil))
@ -233,7 +205,7 @@ func (api *api) provisionerJobResources(rw http.ResponseWriter, r *http.Request,
})
return
}
apiAgent, err := convertWorkspaceAgent(agent)
apiAgent, err := convertWorkspaceAgent(agent, api.AgentConnectionUpdateFrequency)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("convert provisioner job agent: %s", err),
@ -246,8 +218,8 @@ func (api *api) provisionerJobResources(rw http.ResponseWriter, r *http.Request,
render.JSON(rw, r, apiResources)
}
func convertProvisionerJobLog(provisionerJobLog database.ProvisionerJobLog) ProvisionerJobLog {
return ProvisionerJobLog{
func convertProvisionerJobLog(provisionerJobLog database.ProvisionerJobLog) codersdk.ProvisionerJobLog {
return codersdk.ProvisionerJobLog{
ID: provisionerJobLog.ID,
CreatedAt: provisionerJobLog.CreatedAt,
Source: provisionerJobLog.Source,
@ -256,8 +228,8 @@ func convertProvisionerJobLog(provisionerJobLog database.ProvisionerJobLog) Prov
}
}
func convertProvisionerJob(provisionerJob database.ProvisionerJob) ProvisionerJob {
job := ProvisionerJob{
func convertProvisionerJob(provisionerJob database.ProvisionerJob) codersdk.ProvisionerJob {
job := codersdk.ProvisionerJob{
ID: provisionerJob.ID,
CreatedAt: provisionerJob.CreatedAt,
Error: provisionerJob.Error.String,
@ -274,21 +246,25 @@ func convertProvisionerJob(provisionerJob database.ProvisionerJob) ProvisionerJo
}
switch {
case provisionerJob.CancelledAt.Valid:
job.Status = ProvisionerJobCancelled
case provisionerJob.CanceledAt.Valid:
if provisionerJob.CompletedAt.Valid {
job.Status = codersdk.ProvisionerJobCanceled
} else {
job.Status = codersdk.ProvisionerJobCanceling
}
case !provisionerJob.StartedAt.Valid:
job.Status = ProvisionerJobPending
job.Status = codersdk.ProvisionerJobPending
case provisionerJob.CompletedAt.Valid:
if job.Error == "" {
job.Status = ProvisionerJobSucceeded
job.Status = codersdk.ProvisionerJobSucceeded
} else {
job.Status = ProvisionerJobFailed
job.Status = codersdk.ProvisionerJobFailed
}
case database.Now().Sub(provisionerJob.UpdatedAt) > 30*time.Second:
job.Status = ProvisionerJobFailed
job.Status = codersdk.ProvisionerJobFailed
job.Error = "Worker failed to update job in time."
default:
job.Status = ProvisionerJobRunning
job.Status = codersdk.ProvisionerJobRunning
}
return job

View File

@ -7,7 +7,6 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/database"
"github.com/coder/coder/provisioner/echo"
@ -40,16 +39,11 @@ func TestProvisionerJobLogs(t *testing.T) {
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID)
before := time.Now().UTC()
build, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
ProjectVersionID: project.ActiveVersionID,
Transition: database.WorkspaceTransitionStart,
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
ctx, cancelFunc := context.WithCancel(context.Background())
t.Cleanup(cancelFunc)
logs, err := client.WorkspaceBuildLogsAfter(ctx, build.ID, before)
logs, err := client.WorkspaceBuildLogsAfter(ctx, workspace.LatestBuild.ID, before)
require.NoError(t, err)
log, ok := <-logs
require.True(t, ok)
@ -83,14 +77,9 @@ func TestProvisionerJobLogs(t *testing.T) {
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID)
before := database.Now()
build, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
ProjectVersionID: project.ActiveVersionID,
Transition: database.WorkspaceTransitionStart,
})
require.NoError(t, err)
ctx, cancelFunc := context.WithCancel(context.Background())
t.Cleanup(cancelFunc)
logs, err := client.WorkspaceBuildLogsAfter(ctx, build.ID, before)
logs, err := client.WorkspaceBuildLogsAfter(ctx, workspace.LatestBuild.ID, before)
require.NoError(t, err)
log := <-logs
require.Equal(t, "log-output", log.Output)
@ -121,13 +110,8 @@ func TestProvisionerJobLogs(t *testing.T) {
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID)
build, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
ProjectVersionID: project.ActiveVersionID,
Transition: database.WorkspaceTransitionStart,
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID)
logs, err := client.WorkspaceBuildLogsBefore(context.Background(), build.ID, time.Now())
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
logs, err := client.WorkspaceBuildLogsBefore(context.Background(), workspace.LatestBuild.ID, time.Now())
require.NoError(t, err)
require.Len(t, logs, 1)
})

110
coderd/tunnel/tunnel.go Normal file
View File

@ -0,0 +1,110 @@
package tunnel
import (
"context"
"encoding/json"
"flag"
"net/http"
"os"
"strings"
"time"
"github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil"
"github.com/cloudflare/cloudflared/cmd/cloudflared/tunnel"
"github.com/cloudflare/cloudflared/connection"
"github.com/google/uuid"
"github.com/rs/zerolog"
"github.com/urfave/cli/v2"
"golang.org/x/xerrors"
)
// New creates a new tunnel pointing at the URL provided.
// Once created, it returns the external hostname that will resolve to it.
//
// The tunnel will exit when the context provided is canceled.
//
// Upstream connection occurs async through Cloudflare, so the error channel
// will only be executed if the tunnel has failed after numerous attempts.
func New(ctx context.Context, url string) (string, <-chan error, error) {
_ = os.Setenv("QUIC_GO_DISABLE_RECEIVE_BUFFER_WARNING", "true")
httpTimeout := time.Second * 30
client := http.Client{
Transport: &http.Transport{
TLSHandshakeTimeout: httpTimeout,
ResponseHeaderTimeout: httpTimeout,
},
Timeout: httpTimeout,
}
// Taken from:
// https://github.com/cloudflare/cloudflared/blob/22cd8ceb8cf279afc1c412ae7f98308ffcfdd298/cmd/cloudflared/tunnel/quick_tunnel.go#L38
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.trycloudflare.com/tunnel", nil)
if err != nil {
return "", nil, xerrors.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", nil, xerrors.Errorf("request quick tunnel: %w", err)
}
defer resp.Body.Close()
var data quickTunnelResponse
err = json.NewDecoder(resp.Body).Decode(&data)
if err != nil {
return "", nil, xerrors.Errorf("decode: %w", err)
}
tunnelID, err := uuid.Parse(data.Result.ID)
if err != nil {
return "", nil, xerrors.Errorf("parse tunnel id: %w", err)
}
credentials := connection.Credentials{
AccountTag: data.Result.AccountTag,
TunnelSecret: data.Result.Secret,
TunnelID: tunnelID,
}
namedTunnel := &connection.NamedTunnelProperties{
Credentials: credentials,
QuickTunnelUrl: data.Result.Hostname,
}
set := flag.NewFlagSet("", 0)
set.String("protocol", "", "")
set.String("url", "", "")
set.Int("retries", 5, "")
appCtx := cli.NewContext(&cli.App{}, set, nil)
appCtx.Context = ctx
_ = appCtx.Set("url", url)
_ = appCtx.Set("protocol", "quic")
logger := zerolog.New(os.Stdout).Level(zerolog.Disabled)
errCh := make(chan error, 1)
go func() {
err := tunnel.StartServer(appCtx, &cliutil.BuildInfo{}, namedTunnel, &logger, false)
errCh <- err
}()
if !strings.HasPrefix(data.Result.Hostname, "https://") {
data.Result.Hostname = "https://" + data.Result.Hostname
}
return data.Result.Hostname, errCh, nil
}
type quickTunnelResponse struct {
Success bool
Result quickTunnel
Errors []quickTunnelError
}
type quickTunnelError struct {
Code int
Message string
}
type quickTunnel struct {
ID string `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
AccountTag string `json:"account_tag"`
Secret []byte `json:"secret"`
}

View File

@ -0,0 +1,53 @@
package tunnel_test
import (
"context"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/tunnel"
)
// The tunnel leaks a few goroutines that aren't impactful to production scenarios.
// func TestMain(m *testing.M) {
// goleak.VerifyTestMain(m)
// }
func TestTunnel(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip()
return
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
url, _, err := tunnel.New(ctx, srv.URL)
require.NoError(t, err)
t.Log(url)
require.Eventually(t, func() bool {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
require.NoError(t, err)
res, err := http.DefaultClient.Do(req)
var dnsErr *net.DNSError
// The name might take a bit to resolve!
if xerrors.As(err, &dnsErr) {
return false
}
require.NoError(t, err)
defer res.Body.Close()
return res.StatusCode == http.StatusOK
}, 5*time.Minute, 3*time.Second)
}

View File

@ -3,6 +3,7 @@ package coderd
import (
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
@ -11,69 +12,17 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/google/uuid"
"github.com/moby/moby/pkg/namesgenerator"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/userpassword"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/cryptorand"
"github.com/coder/coder/database"
"github.com/coder/coder/httpapi"
"github.com/coder/coder/httpmw"
)
// User represents a user in Coder.
type User struct {
ID string `json:"id" validate:"required"`
Email string `json:"email" validate:"required"`
CreatedAt time.Time `json:"created_at" validate:"required"`
Username string `json:"username" validate:"required"`
}
type CreateFirstUserRequest struct {
Email string `json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,username"`
Password string `json:"password" validate:"required"`
Organization string `json:"organization" validate:"required,username"`
}
// CreateFirstUserResponse contains IDs for newly created user info.
type CreateFirstUserResponse struct {
UserID string `json:"user_id"`
OrganizationID string `json:"organization_id"`
}
type CreateUserRequest struct {
Email string `json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,username"`
Password string `json:"password" validate:"required"`
OrganizationID string `json:"organization_id" validate:"required"`
}
// LoginWithPasswordRequest enables callers to authenticate with email and password.
type LoginWithPasswordRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
}
// LoginWithPasswordResponse contains a session token for the newly authenticated user.
type LoginWithPasswordResponse struct {
SessionToken string `json:"session_token" validate:"required"`
}
// GenerateAPIKeyResponse contains an API key for a user.
type GenerateAPIKeyResponse struct {
Key string `json:"key"`
}
type CreateOrganizationRequest struct {
Name string `json:"name" validate:"required,username"`
}
// CreateWorkspaceRequest provides options for creating a new workspace.
type CreateWorkspaceRequest struct {
ProjectID uuid.UUID `json:"project_id" validate:"required"`
Name string `json:"name" validate:"username,required"`
}
// Returns whether the initial user has been created or not.
func (api *api) firstUser(rw http.ResponseWriter, r *http.Request) {
userCount, err := api.Database.GetUserCount(r.Context())
@ -96,7 +45,7 @@ func (api *api) firstUser(rw http.ResponseWriter, r *http.Request) {
// Creates the initial user for a Coder deployment.
func (api *api) postFirstUser(rw http.ResponseWriter, r *http.Request) {
var createUser CreateFirstUserRequest
var createUser codersdk.CreateFirstUserRequest
if !httpapi.Read(rw, r, &createUser) {
return
}
@ -168,7 +117,7 @@ func (api *api) postFirstUser(rw http.ResponseWriter, r *http.Request) {
}
render.Status(r, http.StatusCreated)
render.JSON(rw, r, CreateFirstUserResponse{
render.JSON(rw, r, codersdk.CreateFirstUserResponse{
UserID: user.ID,
OrganizationID: organization.ID,
})
@ -178,7 +127,7 @@ func (api *api) postFirstUser(rw http.ResponseWriter, r *http.Request) {
func (api *api) postUsers(rw http.ResponseWriter, r *http.Request) {
apiKey := httpmw.APIKey(r)
var createUser CreateUserRequest
var createUser codersdk.CreateUserRequest
if !httpapi.Read(rw, r, &createUser) {
return
}
@ -299,7 +248,7 @@ func (api *api) organizationsByUser(rw http.ResponseWriter, r *http.Request) {
return
}
publicOrganizations := make([]Organization, 0, len(organizations))
publicOrganizations := make([]codersdk.Organization, 0, len(organizations))
for _, organization := range organizations {
publicOrganizations = append(publicOrganizations, convertOrganization(organization))
}
@ -347,7 +296,7 @@ func (api *api) organizationByUserAndName(rw http.ResponseWriter, r *http.Reques
func (api *api) postOrganizationsByUser(rw http.ResponseWriter, r *http.Request) {
user := httpmw.UserParam(r)
var req CreateOrganizationRequest
var req codersdk.CreateOrganizationRequest
if !httpapi.Read(rw, r, &req) {
return
}
@ -401,7 +350,7 @@ func (api *api) postOrganizationsByUser(rw http.ResponseWriter, r *http.Request)
// Authenticates the user with an email and password.
func (api *api) postLogin(rw http.ResponseWriter, r *http.Request) {
var loginWithPassword LoginWithPasswordRequest
var loginWithPassword codersdk.LoginWithPasswordRequest
if !httpapi.Read(rw, r, &loginWithPassword) {
return
}
@ -471,7 +420,7 @@ func (api *api) postLogin(rw http.ResponseWriter, r *http.Request) {
})
render.Status(r, http.StatusCreated)
render.JSON(rw, r, LoginWithPasswordResponse{
render.JSON(rw, r, codersdk.LoginWithPasswordResponse{
SessionToken: sessionToken,
})
}
@ -517,7 +466,7 @@ func (api *api) postAPIKey(rw http.ResponseWriter, r *http.Request) {
generatedAPIKey := fmt.Sprintf("%s-%s", keyID, keySecret)
render.Status(r, http.StatusCreated)
render.JSON(rw, r, GenerateAPIKeyResponse{Key: generatedAPIKey})
render.JSON(rw, r, codersdk.GenerateAPIKeyResponse{Key: generatedAPIKey})
}
// Clear the user's session cookie
@ -536,7 +485,7 @@ func (*api) postLogout(rw http.ResponseWriter, r *http.Request) {
// Create a new workspace for the currently authenticated user.
func (api *api) postWorkspacesByUser(rw http.ResponseWriter, r *http.Request) {
var createWorkspace CreateWorkspaceRequest
var createWorkspace codersdk.CreateWorkspaceRequest
if !httpapi.Read(rw, r, &createWorkspace) {
return
}
@ -605,29 +554,126 @@ func (api *api) postWorkspacesByUser(rw http.ResponseWriter, r *http.Request) {
return
}
// Workspaces are created without any versions.
workspace, err = api.Database.InsertWorkspace(r.Context(), database.InsertWorkspaceParams{
ID: uuid.New(),
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
OwnerID: apiKey.UserID,
ProjectID: project.ID,
Name: createWorkspace.Name,
projectVersion, err := api.Database.GetProjectVersionByID(r.Context(), project.ActiveVersionID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get project version: %s", err),
})
return
}
projectVersionJob, err := api.Database.GetProvisionerJobByID(r.Context(), projectVersion.JobID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get project version job: %s", err),
})
return
}
projectVersionJobStatus := convertProvisionerJob(projectVersionJob).Status
switch projectVersionJobStatus {
case codersdk.ProvisionerJobPending, codersdk.ProvisionerJobRunning:
httpapi.Write(rw, http.StatusNotAcceptable, httpapi.Response{
Message: fmt.Sprintf("The provided project version is %s. Wait for it to complete importing!", projectVersionJobStatus),
})
return
case codersdk.ProvisionerJobFailed:
httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{
Message: fmt.Sprintf("The provided project version %q has failed to import. You cannot create workspaces using it!", projectVersion.Name),
})
return
case codersdk.ProvisionerJobCanceled:
httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{
Message: "The provided project version was canceled during import. You cannot create workspaces using it!",
})
return
}
var provisionerJob database.ProvisionerJob
var workspaceBuild database.WorkspaceBuild
err = api.Database.InTx(func(db database.Store) error {
workspaceBuildID := uuid.New()
// Workspaces are created without any versions.
workspace, err = db.InsertWorkspace(r.Context(), database.InsertWorkspaceParams{
ID: uuid.New(),
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
OwnerID: apiKey.UserID,
ProjectID: project.ID,
Name: createWorkspace.Name,
})
if err != nil {
return xerrors.Errorf("insert workspace: %w", err)
}
for _, parameterValue := range createWorkspace.ParameterValues {
_, err = db.InsertParameterValue(r.Context(), database.InsertParameterValueParams{
ID: uuid.New(),
Name: parameterValue.Name,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
Scope: database.ParameterScopeWorkspace,
ScopeID: workspace.ID.String(),
SourceScheme: parameterValue.SourceScheme,
SourceValue: parameterValue.SourceValue,
DestinationScheme: parameterValue.DestinationScheme,
})
if err != nil {
return xerrors.Errorf("insert parameter value: %w", err)
}
}
input, err := json.Marshal(workspaceProvisionJob{
WorkspaceBuildID: workspaceBuildID,
})
if err != nil {
return xerrors.Errorf("marshal provision job: %w", err)
}
provisionerJob, err = db.InsertProvisionerJob(r.Context(), database.InsertProvisionerJobParams{
ID: uuid.New(),
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
InitiatorID: apiKey.UserID,
OrganizationID: project.OrganizationID,
Provisioner: project.Provisioner,
Type: database.ProvisionerJobTypeWorkspaceBuild,
StorageMethod: projectVersionJob.StorageMethod,
StorageSource: projectVersionJob.StorageSource,
Input: input,
})
if err != nil {
return xerrors.Errorf("insert provisioner job: %w", err)
}
workspaceBuild, err = db.InsertWorkspaceBuild(r.Context(), database.InsertWorkspaceBuildParams{
ID: workspaceBuildID,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
WorkspaceID: workspace.ID,
ProjectVersionID: projectVersion.ID,
Name: namesgenerator.GetRandomName(1),
Initiator: apiKey.UserID,
Transition: database.WorkspaceTransitionStart,
JobID: provisionerJob.ID,
})
if err != nil {
return xerrors.Errorf("insert workspace build: %w", err)
}
return nil
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("insert workspace: %s", err),
Message: fmt.Sprintf("create workspace: %s", err),
})
return
}
render.Status(r, http.StatusCreated)
render.JSON(rw, r, convertWorkspace(workspace))
render.JSON(rw, r, convertWorkspace(workspace,
convertWorkspaceBuild(workspaceBuild, convertProvisionerJob(projectVersionJob)), project))
}
func (api *api) workspacesByUser(rw http.ResponseWriter, r *http.Request) {
user := httpmw.UserParam(r)
workspaces, err := api.Database.GetWorkspacesByUserID(r.Context(), user.ID)
workspaces, err := api.Database.GetWorkspacesByUserID(r.Context(), database.GetWorkspacesByUserIDParams{
OwnerID: user.ID,
})
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
@ -637,9 +683,84 @@ func (api *api) workspacesByUser(rw http.ResponseWriter, r *http.Request) {
})
return
}
apiWorkspaces := make([]Workspace, 0, len(workspaces))
workspaceIDs := make([]uuid.UUID, 0, len(workspaces))
projectIDs := make([]uuid.UUID, 0, len(workspaces))
for _, workspace := range workspaces {
apiWorkspaces = append(apiWorkspaces, convertWorkspace(workspace))
workspaceIDs = append(workspaceIDs, workspace.ID)
projectIDs = append(projectIDs, workspace.ProjectID)
}
workspaceBuilds, err := api.Database.GetWorkspaceBuildsByWorkspaceIDsWithoutAfter(r.Context(), workspaceIDs)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get workspace builds: %s", err),
})
return
}
projects, err := api.Database.GetProjectsByIDs(r.Context(), projectIDs)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get projects: %s", err),
})
return
}
jobIDs := make([]uuid.UUID, 0, len(workspaceBuilds))
for _, build := range workspaceBuilds {
jobIDs = append(jobIDs, build.JobID)
}
jobs, err := api.Database.GetProvisionerJobsByIDs(r.Context(), jobIDs)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get provisioner jobs: %s", err),
})
return
}
buildByWorkspaceID := map[string]database.WorkspaceBuild{}
for _, workspaceBuild := range workspaceBuilds {
buildByWorkspaceID[workspaceBuild.WorkspaceID.String()] = workspaceBuild
}
projectByID := map[string]database.Project{}
for _, project := range projects {
projectByID[project.ID.String()] = project
}
jobByID := map[string]database.ProvisionerJob{}
for _, job := range jobs {
jobByID[job.ID.String()] = job
}
apiWorkspaces := make([]codersdk.Workspace, 0, len(workspaces))
for _, workspace := range workspaces {
build, exists := buildByWorkspaceID[workspace.ID.String()]
if !exists {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("build not found for workspace %q", workspace.Name),
})
return
}
project, exists := projectByID[workspace.ProjectID.String()]
if !exists {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("project not found for workspace %q", workspace.Name),
})
return
}
job, exists := jobByID[build.JobID.String()]
if !exists {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("build job not found for workspace %q", workspace.Name),
})
return
}
apiWorkspaces = append(apiWorkspaces,
convertWorkspace(workspace, convertWorkspaceBuild(build, convertProvisionerJob(job)), project))
}
render.Status(r, http.StatusOK)
render.JSON(rw, r, apiWorkspaces)
@ -664,9 +785,31 @@ func (api *api) workspaceByUserAndName(rw http.ResponseWriter, r *http.Request)
})
return
}
build, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), workspace.ID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get workspace build: %s", err),
})
return
}
job, err := api.Database.GetProvisionerJobByID(r.Context(), build.JobID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get provisioner job: %s", err),
})
return
}
project, err := api.Database.GetProjectByID(r.Context(), workspace.ProjectID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get project: %s", err),
})
return
}
render.Status(r, http.StatusOK)
render.JSON(rw, r, convertWorkspace(workspace))
render.JSON(rw, r, convertWorkspace(workspace,
convertWorkspaceBuild(build, convertProvisionerJob(job)), project))
}
// Generates a new ID and secret for an API key.
@ -684,8 +827,8 @@ func generateAPIKeyIDSecret() (id string, secret string, err error) {
return id, secret, nil
}
func convertUser(user database.User) User {
return User{
func convertUser(user database.User) codersdk.User {
return codersdk.User{
ID: user.ID,
Email: user.Email,
CreatedAt: user.CreatedAt,

View File

@ -8,7 +8,6 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/httpmw"
@ -19,7 +18,7 @@ func TestFirstUser(t *testing.T) {
t.Run("BadRequest", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_, err := client.CreateFirstUser(context.Background(), coderd.CreateFirstUserRequest{})
_, err := client.CreateFirstUser(context.Background(), codersdk.CreateFirstUserRequest{})
require.Error(t, err)
})
@ -27,7 +26,7 @@ func TestFirstUser(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
_, err := client.CreateFirstUser(context.Background(), coderd.CreateFirstUserRequest{
_, err := client.CreateFirstUser(context.Background(), codersdk.CreateFirstUserRequest{
Email: "some@email.com",
Username: "exampleuser",
Password: "password",
@ -50,7 +49,7 @@ func TestPostLogin(t *testing.T) {
t.Run("InvalidUser", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_, err := client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
_, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{
Email: "my@email.org",
Password: "password",
})
@ -62,7 +61,7 @@ func TestPostLogin(t *testing.T) {
t.Run("BadPassword", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
req := coderd.CreateFirstUserRequest{
req := codersdk.CreateFirstUserRequest{
Email: "testuser@coder.com",
Username: "testuser",
Password: "testpass",
@ -70,7 +69,7 @@ func TestPostLogin(t *testing.T) {
}
_, err := client.CreateFirstUser(context.Background(), req)
require.NoError(t, err)
_, err = client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
_, err = client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{
Email: req.Email,
Password: "badpass",
})
@ -82,7 +81,7 @@ func TestPostLogin(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
req := coderd.CreateFirstUserRequest{
req := codersdk.CreateFirstUserRequest{
Email: "testuser@coder.com",
Username: "testuser",
Password: "testpass",
@ -90,7 +89,7 @@ func TestPostLogin(t *testing.T) {
}
_, err := client.CreateFirstUser(context.Background(), req)
require.NoError(t, err)
_, err = client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
_, err = client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{
Email: req.Email,
Password: req.Password,
})
@ -130,7 +129,7 @@ func TestPostUsers(t *testing.T) {
t.Run("NoAuth", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_, err := client.CreateUser(context.Background(), coderd.CreateUserRequest{})
_, err := client.CreateUser(context.Background(), codersdk.CreateUserRequest{})
require.Error(t, err)
})
@ -140,7 +139,7 @@ func TestPostUsers(t *testing.T) {
coderdtest.CreateFirstUser(t, client)
me, err := client.User(context.Background(), "")
require.NoError(t, err)
_, err = client.CreateUser(context.Background(), coderd.CreateUserRequest{
_, err = client.CreateUser(context.Background(), codersdk.CreateUserRequest{
Email: me.Email,
Username: me.Username,
Password: "password",
@ -155,7 +154,7 @@ func TestPostUsers(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
_, err := client.CreateUser(context.Background(), coderd.CreateUserRequest{
_, err := client.CreateUser(context.Background(), codersdk.CreateUserRequest{
OrganizationID: "not-exists",
Email: "another@user.org",
Username: "someone-else",
@ -171,12 +170,12 @@ func TestPostUsers(t *testing.T) {
client := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, client)
other := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
org, err := other.CreateOrganization(context.Background(), "", coderd.CreateOrganizationRequest{
org, err := other.CreateOrganization(context.Background(), "", codersdk.CreateOrganizationRequest{
Name: "another",
})
require.NoError(t, err)
_, err = client.CreateUser(context.Background(), coderd.CreateUserRequest{
_, err = client.CreateUser(context.Background(), codersdk.CreateUserRequest{
Email: "some@domain.com",
Username: "anotheruser",
Password: "testing",
@ -191,7 +190,7 @@ func TestPostUsers(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
_, err := client.CreateUser(context.Background(), coderd.CreateUserRequest{
_, err := client.CreateUser(context.Background(), codersdk.CreateUserRequest{
OrganizationID: user.OrganizationID,
Email: "another@user.org",
Username: "someone-else",
@ -236,7 +235,7 @@ func TestOrganizationByUserAndName(t *testing.T) {
client := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, client)
other := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
org, err := other.CreateOrganization(context.Background(), "", coderd.CreateOrganizationRequest{
org, err := other.CreateOrganization(context.Background(), "", codersdk.CreateOrganizationRequest{
Name: "another",
})
require.NoError(t, err)
@ -265,7 +264,7 @@ func TestPostOrganizationsByUser(t *testing.T) {
user := coderdtest.CreateFirstUser(t, client)
org, err := client.Organization(context.Background(), user.OrganizationID)
require.NoError(t, err)
_, err = client.CreateOrganization(context.Background(), "", coderd.CreateOrganizationRequest{
_, err = client.CreateOrganization(context.Background(), "", codersdk.CreateOrganizationRequest{
Name: org.Name,
})
var apiErr *codersdk.Error
@ -277,7 +276,7 @@ func TestPostOrganizationsByUser(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
_, err := client.CreateOrganization(context.Background(), "", coderd.CreateOrganizationRequest{
_, err := client.CreateOrganization(context.Background(), "", codersdk.CreateOrganizationRequest{
Name: "new",
})
require.NoError(t, err)
@ -315,7 +314,7 @@ func TestPostWorkspacesByUser(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
_, err := client.CreateWorkspace(context.Background(), "", coderd.CreateWorkspaceRequest{
_, err := client.CreateWorkspace(context.Background(), "", codersdk.CreateWorkspaceRequest{
ProjectID: uuid.New(),
Name: "workspace",
})
@ -331,14 +330,14 @@ func TestPostWorkspacesByUser(t *testing.T) {
first := coderdtest.CreateFirstUser(t, client)
other := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
org, err := other.CreateOrganization(context.Background(), "", coderd.CreateOrganizationRequest{
org, err := other.CreateOrganization(context.Background(), "", codersdk.CreateOrganizationRequest{
Name: "another",
})
require.NoError(t, err)
version := coderdtest.CreateProjectVersion(t, other, org.ID, nil)
project := coderdtest.CreateProject(t, other, org.ID, version.ID)
_, err = client.CreateWorkspace(context.Background(), "", coderd.CreateWorkspaceRequest{
_, err = client.CreateWorkspace(context.Background(), "", codersdk.CreateWorkspaceRequest{
ProjectID: project.ID,
Name: "workspace",
})
@ -351,11 +350,13 @@ func TestPostWorkspacesByUser(t *testing.T) {
t.Run("AlreadyExists", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.NewProvisionerDaemon(t, client)
user := coderdtest.CreateFirstUser(t, client)
job := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
project := coderdtest.CreateProject(t, client, user.OrganizationID, job.ID)
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "", project.ID)
_, err := client.CreateWorkspace(context.Background(), "", coderd.CreateWorkspaceRequest{
_, err := client.CreateWorkspace(context.Background(), "", codersdk.CreateWorkspaceRequest{
ProjectID: project.ID,
Name: workspace.Name,
})
@ -368,9 +369,11 @@ func TestPostWorkspacesByUser(t *testing.T) {
t.Run("Create", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.NewProvisionerDaemon(t, client)
user := coderdtest.CreateFirstUser(t, client)
job := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
project := coderdtest.CreateProject(t, client, user.OrganizationID, job.ID)
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
_ = coderdtest.CreateWorkspace(t, client, "", project.ID)
})
}
@ -387,9 +390,11 @@ func TestWorkspacesByUser(t *testing.T) {
t.Run("List", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.NewProvisionerDaemon(t, client)
user := coderdtest.CreateFirstUser(t, client)
job := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
project := coderdtest.CreateProject(t, client, user.OrganizationID, job.ID)
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
_ = coderdtest.CreateWorkspace(t, client, "", project.ID)
workspaces, err := client.WorkspacesByUser(context.Background(), "")
require.NoError(t, err)
@ -411,9 +416,11 @@ func TestWorkspaceByUserAndName(t *testing.T) {
t.Run("Get", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.NewProvisionerDaemon(t, client)
user := coderdtest.CreateFirstUser(t, client)
job := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
project := coderdtest.CreateProject(t, client, user.OrganizationID, job.ID)
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "", project.ID)
_, err := client.WorkspaceByName(context.Background(), "", workspace.Name)
require.NoError(t, err)

View File

@ -1,34 +1,18 @@
package coderd
import (
"database/sql"
"fmt"
"net/http"
"time"
"github.com/go-chi/render"
"github.com/google/uuid"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
"github.com/coder/coder/httpapi"
"github.com/coder/coder/httpmw"
)
// WorkspaceBuild is an at-point representation of a workspace state.
// Iterate on before/after to determine a chronological history.
type WorkspaceBuild struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
WorkspaceID uuid.UUID `json:"workspace_id"`
ProjectVersionID uuid.UUID `json:"project_version_id"`
BeforeID uuid.UUID `json:"before_id"`
AfterID uuid.UUID `json:"after_id"`
Name string `json:"name"`
Transition database.WorkspaceTransition `json:"transition"`
Initiator string `json:"initiator"`
Job ProvisionerJob `json:"job"`
}
func (api *api) workspaceBuild(rw http.ResponseWriter, r *http.Request) {
workspaceBuild := httpmw.WorkspaceBuildParam(r)
job, err := api.Database.GetProvisionerJobByID(r.Context(), workspaceBuild.JobID)
@ -42,6 +26,45 @@ func (api *api) workspaceBuild(rw http.ResponseWriter, r *http.Request) {
render.JSON(rw, r, convertWorkspaceBuild(workspaceBuild, convertProvisionerJob(job)))
}
func (api *api) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Request) {
workspaceBuild := httpmw.WorkspaceBuildParam(r)
job, err := api.Database.GetProvisionerJobByID(r.Context(), workspaceBuild.JobID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get provisioner job: %s", err),
})
return
}
if job.CompletedAt.Valid {
httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{
Message: "Job has already completed!",
})
return
}
if job.CanceledAt.Valid {
httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{
Message: "Job has already been marked as canceled!",
})
return
}
err = api.Database.UpdateProvisionerJobWithCancelByID(r.Context(), database.UpdateProvisionerJobWithCancelByIDParams{
ID: job.ID,
CanceledAt: sql.NullTime{
Time: database.Now(),
Valid: true,
},
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("update provisioner job: %s", err),
})
return
}
httpapi.Write(rw, http.StatusOK, httpapi.Response{
Message: "Job has been marked as canceled...",
})
}
func (api *api) workspaceBuildResources(rw http.ResponseWriter, r *http.Request) {
workspaceBuild := httpmw.WorkspaceBuildParam(r)
job, err := api.Database.GetProvisionerJobByID(r.Context(), workspaceBuild.JobID)
@ -66,9 +89,9 @@ func (api *api) workspaceBuildLogs(rw http.ResponseWriter, r *http.Request) {
api.provisionerJobLogs(rw, r, job)
}
func convertWorkspaceBuild(workspaceBuild database.WorkspaceBuild, job ProvisionerJob) WorkspaceBuild {
func convertWorkspaceBuild(workspaceBuild database.WorkspaceBuild, job codersdk.ProvisionerJob) codersdk.WorkspaceBuild {
//nolint:unconvert
return WorkspaceBuild(WorkspaceBuild{
return codersdk.WorkspaceBuild{
ID: workspaceBuild.ID,
CreatedAt: workspaceBuild.CreatedAt,
UpdatedAt: workspaceBuild.UpdatedAt,
@ -80,15 +103,16 @@ func convertWorkspaceBuild(workspaceBuild database.WorkspaceBuild, job Provision
Transition: workspaceBuild.Transition,
Initiator: workspaceBuild.Initiator,
Job: job,
})
}
}
func convertWorkspaceResource(resource database.WorkspaceResource, agent *WorkspaceAgent) WorkspaceResource {
return WorkspaceResource{
func convertWorkspaceResource(resource database.WorkspaceResource, agent *codersdk.WorkspaceAgent) codersdk.WorkspaceResource {
return codersdk.WorkspaceResource{
ID: resource.ID,
CreatedAt: resource.CreatedAt,
JobID: resource.JobID,
Transition: resource.Transition,
Address: resource.Address,
Type: resource.Type,
Name: resource.Name,
Agent: agent,

View File

@ -8,10 +8,8 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
)
@ -25,13 +23,44 @@ func TestWorkspaceBuild(t *testing.T) {
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID)
build, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
ProjectVersionID: project.ActiveVersionID,
Transition: database.WorkspaceTransitionStart,
_, err := client.WorkspaceBuild(context.Background(), workspace.LatestBuild.ID)
require.NoError(t, err)
}
func TestPatchCancelWorkspaceBuild(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_Log{
Log: &proto.Log{},
},
}},
ProvisionDryRun: echo.ProvisionComplete,
})
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "", project.ID)
var build codersdk.WorkspaceBuild
require.Eventually(t, func() bool {
var err error
build, err = client.WorkspaceBuild(context.Background(), workspace.LatestBuild.ID)
require.NoError(t, err)
return build.Job.Status == codersdk.ProvisionerJobRunning
}, 5*time.Second, 25*time.Millisecond)
err := client.CancelWorkspaceBuild(context.Background(), build.ID)
require.NoError(t, err)
_, err = client.WorkspaceBuild(context.Background(), build.ID)
require.NoError(t, err)
require.Eventually(t, func() bool {
var err error
build, err = client.WorkspaceBuild(context.Background(), build.ID)
require.NoError(t, err)
// The echo provisioner doesn't respond to a shutdown request,
// so the job cancel will time out and fail.
return build.Job.Status == codersdk.ProvisionerJobFailed
}, 5*time.Second, 25*time.Millisecond)
}
func TestWorkspaceBuildResources(t *testing.T) {
@ -46,12 +75,7 @@ func TestWorkspaceBuildResources(t *testing.T) {
closeDaemon.Close()
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "", project.ID)
build, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
ProjectVersionID: project.ActiveVersionID,
Transition: database.WorkspaceTransitionStart,
})
require.NoError(t, err)
_, err = client.WorkspaceResourcesByBuild(context.Background(), build.ID)
_, err := client.WorkspaceResourcesByBuild(context.Background(), workspace.LatestBuild.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusPreconditionFailed, apiErr.StatusCode())
@ -84,13 +108,8 @@ func TestWorkspaceBuildResources(t *testing.T) {
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "", project.ID)
build, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
ProjectVersionID: project.ActiveVersionID,
Transition: database.WorkspaceTransitionStart,
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID)
resources, err := client.WorkspaceResourcesByBuild(context.Background(), build.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
resources, err := client.WorkspaceResourcesByBuild(context.Background(), workspace.LatestBuild.ID)
require.NoError(t, err)
require.NotNil(t, resources)
require.Len(t, resources, 2)
@ -136,14 +155,9 @@ func TestWorkspaceBuildLogs(t *testing.T) {
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "", project.ID)
build, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
ProjectVersionID: project.ActiveVersionID,
Transition: database.WorkspaceTransitionStart,
})
require.NoError(t, err)
ctx, cancelFunc := context.WithCancel(context.Background())
t.Cleanup(cancelFunc)
logs, err := client.WorkspaceBuildLogsAfter(ctx, build.ID, before)
logs, err := client.WorkspaceBuildLogsAfter(ctx, workspace.LatestBuild.ID, before)
require.NoError(t, err)
log := <-logs
require.Equal(t, "example", log.Output)

View File

@ -9,27 +9,18 @@ import (
"github.com/go-chi/render"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
"github.com/coder/coder/httpapi"
"github.com/mitchellh/mapstructure"
)
type GoogleInstanceIdentityToken struct {
JSONWebToken string `json:"json_web_token" validate:"required"`
}
// WorkspaceAgentAuthenticateResponse is returned when an instance ID
// has been exchanged for a session token.
type WorkspaceAgentAuthenticateResponse struct {
SessionToken string `json:"session_token"`
}
// Google Compute Engine supports instance identity verification:
// https://cloud.google.com/compute/docs/instances/verifying-instance-identity
// Using this, we can exchange a signed instance payload for an agent token.
func (api *api) postWorkspaceAuthGoogleInstanceIdentity(rw http.ResponseWriter, r *http.Request) {
var req GoogleInstanceIdentityToken
var req codersdk.GoogleInstanceIdentityToken
if !httpapi.Read(rw, r, &req) {
return
}
@ -121,7 +112,7 @@ func (api *api) postWorkspaceAuthGoogleInstanceIdentity(rw http.ResponseWriter,
return
}
render.Status(r, http.StatusOK)
render.JSON(rw, r, WorkspaceAgentAuthenticateResponse{
render.JSON(rw, r, codersdk.WorkspaceAgentAuthenticateResponse{
SessionToken: agent.AuthToken.String(),
})
}

View File

@ -19,11 +19,9 @@ import (
"google.golang.org/api/idtoken"
"google.golang.org/api/option"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/cryptorand"
"github.com/coder/coder/database"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
)
@ -69,8 +67,22 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) {
user := coderdtest.CreateFirstUser(t, client)
coderdtest.NewProvisionerDaemon(t, client)
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionDryRun: echo.ProvisionComplete,
Parse: echo.ParseComplete,
ProvisionDryRun: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "somename",
Type: "someinstance",
Agent: &proto.Agent{
Auth: &proto.Agent_GoogleInstanceIdentity{
GoogleInstanceIdentity: &proto.GoogleInstanceIdentityAuth{},
},
},
}},
},
},
}},
Provision: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
@ -92,14 +104,9 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) {
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID)
build, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
ProjectVersionID: project.ActiveVersionID,
Transition: database.WorkspaceTransitionStart,
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
_, err = client.AuthWorkspaceGoogleInstanceIdentity(context.Background(), "", createMetadataClient(signedKey))
_, err := client.AuthWorkspaceGoogleInstanceIdentity(context.Background(), "", createMetadataClient(signedKey))
require.NoError(t, err)
})
}

View File

@ -9,11 +9,13 @@ import (
"time"
"github.com/go-chi/render"
"github.com/google/uuid"
"github.com/hashicorp/yamux"
"golang.org/x/xerrors"
"nhooyr.io/websocket"
"cdr.dev/slog"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
"github.com/coder/coder/httpapi"
"github.com/coder/coder/httpmw"
@ -22,46 +24,6 @@ import (
"github.com/coder/coder/provisionersdk"
)
type WorkspaceResource struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
JobID uuid.UUID `json:"job_id"`
Transition database.WorkspaceTransition `json:"workspace_transition"`
Type string `json:"type"`
Name string `json:"name"`
Agent *WorkspaceAgent `json:"agent,omitempty"`
}
type WorkspaceAgent struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ResourceID uuid.UUID `json:"resource_id"`
InstanceID string `json:"instance_id,omitempty"`
EnvironmentVariables map[string]string `json:"environment_variables"`
StartupScript string `json:"startup_script,omitempty"`
}
type WorkspaceAgentResourceMetadata struct {
MemoryTotal uint64 `json:"memory_total"`
DiskTotal uint64 `json:"disk_total"`
CPUCores uint64 `json:"cpu_cores"`
CPUModel string `json:"cpu_model"`
CPUMhz float64 `json:"cpu_mhz"`
}
type WorkspaceAgentInstanceMetadata struct {
JailOrchestrator string `json:"jail_orchestrator"`
OperatingSystem string `json:"operating_system"`
Platform string `json:"platform"`
PlatformFamily string `json:"platform_family"`
KernelVersion string `json:"kernel_version"`
KernelArchitecture string `json:"kernel_architecture"`
Cloud string `json:"cloud"`
Jail string `json:"jail"`
VNC bool `json:"vnc"`
}
func (api *api) workspaceResource(rw http.ResponseWriter, r *http.Request) {
workspaceBuild := httpmw.WorkspaceBuildParam(r)
workspaceResource := httpmw.WorkspaceResourceParam(r)
@ -78,7 +40,7 @@ func (api *api) workspaceResource(rw http.ResponseWriter, r *http.Request) {
})
return
}
var apiAgent *WorkspaceAgent
var apiAgent *codersdk.WorkspaceAgent
if workspaceResource.AgentID.Valid {
agent, err := api.Database.GetWorkspaceAgentByResourceID(r.Context(), workspaceResource.ID)
if err != nil {
@ -87,7 +49,7 @@ func (api *api) workspaceResource(rw http.ResponseWriter, r *http.Request) {
})
return
}
convertedAgent, err := convertWorkspaceAgent(agent)
convertedAgent, err := convertWorkspaceAgent(agent, api.AgentConnectionUpdateFrequency)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("convert provisioner job agent: %s", err),
@ -163,6 +125,16 @@ func (api *api) workspaceAgentListen(rw http.ResponseWriter, r *http.Request) {
})
return
}
resource, err := api.Database.GetWorkspaceResourceByID(r.Context(), agent.ResourceID)
if err != nil {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: fmt.Sprintf("accept websocket: %s", err),
})
return
}
api.Logger.Info(r.Context(), "accepting agent", slog.F("resource", resource), slog.F("agent", agent))
defer func() {
_ = conn.Close(websocket.StatusNormalClosure, "")
}()
@ -183,31 +155,57 @@ func (api *api) workspaceAgentListen(rw http.ResponseWriter, r *http.Request) {
return
}
defer closer.Close()
err = api.Database.UpdateWorkspaceAgentByID(r.Context(), database.UpdateWorkspaceAgentByIDParams{
ID: agent.ID,
UpdatedAt: sql.NullTime{
firstConnectedAt := agent.FirstConnectedAt
if !firstConnectedAt.Valid {
firstConnectedAt = sql.NullTime{
Time: database.Now(),
Valid: true,
},
})
}
}
lastConnectedAt := sql.NullTime{
Time: database.Now(),
Valid: true,
}
disconnectedAt := agent.DisconnectedAt
updateConnectionTimes := func() error {
err = api.Database.UpdateWorkspaceAgentConnectionByID(r.Context(), database.UpdateWorkspaceAgentConnectionByIDParams{
ID: agent.ID,
FirstConnectedAt: firstConnectedAt,
LastConnectedAt: lastConnectedAt,
DisconnectedAt: disconnectedAt,
})
if err != nil {
return err
}
return nil
}
defer func() {
disconnectedAt = sql.NullTime{
Time: database.Now(),
Valid: true,
}
_ = updateConnectionTimes()
}()
err = updateConnectionTimes()
if err != nil {
_ = conn.Close(websocket.StatusAbnormalClosure, err.Error())
return
}
ticker := time.NewTicker(5 * time.Second)
ticker := time.NewTicker(api.AgentConnectionUpdateFrequency)
defer ticker.Stop()
for {
select {
case <-session.CloseChan():
return
case <-ticker.C:
err = api.Database.UpdateWorkspaceAgentByID(r.Context(), database.UpdateWorkspaceAgentByIDParams{
ID: agent.ID,
UpdatedAt: sql.NullTime{
Time: database.Now(),
Valid: true,
},
})
lastConnectedAt = sql.NullTime{
Time: database.Now(),
Valid: true,
}
err = updateConnectionTimes()
if err != nil {
_ = conn.Close(websocket.StatusAbnormalClosure, err.Error())
return
@ -216,21 +214,49 @@ func (api *api) workspaceAgentListen(rw http.ResponseWriter, r *http.Request) {
}
}
func convertWorkspaceAgent(agent database.WorkspaceAgent) (WorkspaceAgent, error) {
func convertWorkspaceAgent(dbAgent database.WorkspaceAgent, agentUpdateFrequency time.Duration) (codersdk.WorkspaceAgent, error) {
var envs map[string]string
if agent.EnvironmentVariables.Valid {
err := json.Unmarshal(agent.EnvironmentVariables.RawMessage, &envs)
if dbAgent.EnvironmentVariables.Valid {
err := json.Unmarshal(dbAgent.EnvironmentVariables.RawMessage, &envs)
if err != nil {
return WorkspaceAgent{}, xerrors.Errorf("unmarshal: %w", err)
return codersdk.WorkspaceAgent{}, xerrors.Errorf("unmarshal: %w", err)
}
}
return WorkspaceAgent{
ID: agent.ID,
CreatedAt: agent.CreatedAt,
UpdatedAt: agent.UpdatedAt.Time,
ResourceID: agent.ResourceID,
InstanceID: agent.AuthInstanceID.String,
StartupScript: agent.StartupScript.String,
agent := codersdk.WorkspaceAgent{
ID: dbAgent.ID,
CreatedAt: dbAgent.CreatedAt,
UpdatedAt: dbAgent.UpdatedAt,
ResourceID: dbAgent.ResourceID,
InstanceID: dbAgent.AuthInstanceID.String,
StartupScript: dbAgent.StartupScript.String,
EnvironmentVariables: envs,
}, nil
}
if dbAgent.FirstConnectedAt.Valid {
agent.FirstConnectedAt = &dbAgent.FirstConnectedAt.Time
}
if dbAgent.LastConnectedAt.Valid {
agent.LastConnectedAt = &dbAgent.LastConnectedAt.Time
}
if dbAgent.DisconnectedAt.Valid {
agent.DisconnectedAt = &dbAgent.DisconnectedAt.Time
}
switch {
case !dbAgent.FirstConnectedAt.Valid:
// If the agent never connected, it's waiting for the compute
// to start up.
agent.Status = codersdk.WorkspaceAgentWaiting
case dbAgent.DisconnectedAt.Time.After(dbAgent.LastConnectedAt.Time):
// If we've disconnected after our last connection, we know the
// agent is no longer connected.
agent.Status = codersdk.WorkspaceAgentDisconnected
case agentUpdateFrequency*2 >= database.Now().Sub(dbAgent.LastConnectedAt.Time):
// The connection updated it's timestamp within the update frequency.
// We multiply by two to allow for some lag.
agent.Status = codersdk.WorkspaceAgentConnected
case database.Now().Sub(dbAgent.LastConnectedAt.Time) > agentUpdateFrequency*2:
// The connection died without updating the last connected.
agent.Status = codersdk.WorkspaceAgentDisconnected
}
return agent, nil
}

View File

@ -11,10 +11,8 @@ import (
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/agent"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
"github.com/coder/coder/peer"
"github.com/coder/coder/peerbroker"
"github.com/coder/coder/provisioner/echo"
@ -48,13 +46,8 @@ func TestWorkspaceResource(t *testing.T) {
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "", project.ID)
build, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
ProjectVersionID: project.ActiveVersionID,
Transition: database.WorkspaceTransitionStart,
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID)
resources, err := client.WorkspaceResourcesByBuild(context.Background(), build.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
resources, err := client.WorkspaceResourcesByBuild(context.Background(), workspace.LatestBuild.ID)
require.NoError(t, err)
_, err = client.WorkspaceResource(context.Background(), resources[0].ID)
require.NoError(t, err)
@ -90,12 +83,7 @@ func TestWorkspaceAgentListen(t *testing.T) {
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID)
build, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
ProjectVersionID: project.ActiveVersionID,
Transition: database.WorkspaceTransitionStart,
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
daemonCloser.Close()
agentClient := codersdk.New(client.URL)
@ -106,7 +94,7 @@ func TestWorkspaceAgentListen(t *testing.T) {
t.Cleanup(func() {
_ = agentCloser.Close()
})
resources := coderdtest.AwaitWorkspaceAgents(t, client, build.ID)
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
workspaceClient, err := client.DialWorkspaceAgent(context.Background(), resources[0].ID)
require.NoError(t, err)
t.Cleanup(func() {

View File

@ -13,32 +13,51 @@ import (
"github.com/moby/moby/pkg/namesgenerator"
"golang.org/x/xerrors"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
"github.com/coder/coder/httpapi"
"github.com/coder/coder/httpmw"
)
// Workspace is a per-user deployment of a project. It tracks
// project versions, and can be updated.
type Workspace database.Workspace
// CreateWorkspaceBuildRequest provides options to update the latest workspace build.
type CreateWorkspaceBuildRequest struct {
ProjectVersionID uuid.UUID `json:"project_version_id" validate:"required"`
Transition database.WorkspaceTransition `json:"transition" validate:"oneof=create start stop delete,required"`
}
func (*api) workspace(rw http.ResponseWriter, r *http.Request) {
func (api *api) workspace(rw http.ResponseWriter, r *http.Request) {
workspace := httpmw.WorkspaceParam(r)
render.Status(r, http.StatusOK)
render.JSON(rw, r, convertWorkspace(workspace))
build, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), workspace.ID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get workspace build: %s", err),
})
return
}
job, err := api.Database.GetProvisionerJobByID(r.Context(), build.JobID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get workspace build job: %s", err),
})
return
}
project, err := api.Database.GetProjectByID(r.Context(), workspace.ProjectID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get project: %s", err),
})
return
}
render.JSON(rw, r, convertWorkspace(workspace, convertWorkspaceBuild(build, convertProvisionerJob(job)), project))
}
func (api *api) workspaceBuilds(rw http.ResponseWriter, r *http.Request) {
workspace := httpmw.WorkspaceParam(r)
builds, err := api.Database.GetWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get workspace builds: %s", err),
})
return
}
jobIDs := make([]uuid.UUID, 0, len(builds))
@ -46,6 +65,9 @@ func (api *api) workspaceBuilds(rw http.ResponseWriter, r *http.Request) {
jobIDs = append(jobIDs, version.JobID)
}
jobs, err := api.Database.GetProvisionerJobsByIDs(r.Context(), jobIDs)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get jobs: %s", err),
@ -57,7 +79,7 @@ func (api *api) workspaceBuilds(rw http.ResponseWriter, r *http.Request) {
jobByID[job.ID.String()] = job
}
apiBuilds := make([]WorkspaceBuild, 0)
apiBuilds := make([]codersdk.WorkspaceBuild, 0)
for _, build := range builds {
job, exists := jobByID[build.JobID.String()]
if !exists {
@ -76,10 +98,20 @@ func (api *api) workspaceBuilds(rw http.ResponseWriter, r *http.Request) {
func (api *api) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
apiKey := httpmw.APIKey(r)
workspace := httpmw.WorkspaceParam(r)
var createBuild CreateWorkspaceBuildRequest
var createBuild codersdk.CreateWorkspaceBuildRequest
if !httpapi.Read(rw, r, &createBuild) {
return
}
if createBuild.ProjectVersionID == uuid.Nil {
latestBuild, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), workspace.ID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get latest workspace build: %s", err),
})
return
}
createBuild.ProjectVersionID = latestBuild.ProjectVersionID
}
projectVersion, err := api.Database.GetProjectVersionByID(r.Context(), createBuild.ProjectVersionID)
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
@ -106,19 +138,19 @@ func (api *api) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
}
projectVersionJobStatus := convertProvisionerJob(projectVersionJob).Status
switch projectVersionJobStatus {
case ProvisionerJobPending, ProvisionerJobRunning:
case codersdk.ProvisionerJobPending, codersdk.ProvisionerJobRunning:
httpapi.Write(rw, http.StatusNotAcceptable, httpapi.Response{
Message: fmt.Sprintf("The provided project version is %s. Wait for it to complete importing!", projectVersionJobStatus),
})
return
case ProvisionerJobFailed:
case codersdk.ProvisionerJobFailed:
httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{
Message: fmt.Sprintf("The provided project version %q has failed to import. You cannot create workspaces using it!", projectVersion.Name),
Message: fmt.Sprintf("The provided project version %q has failed to import: %q. You cannot build workspaces with it!", projectVersion.Name, projectVersionJob.Error.String),
})
return
case ProvisionerJobCancelled:
case codersdk.ProvisionerJobCanceled:
httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{
Message: "The provided project version was canceled during import. You cannot create workspaces using it!",
Message: "The provided project version was canceled during import. You cannot builds workspaces with it!",
})
return
}
@ -189,6 +221,7 @@ func (api *api) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
ProjectVersionID: projectVersion.ID,
BeforeID: priorHistoryID,
Name: namesgenerator.GetRandomName(1),
ProvisionerState: priorHistory.ProvisionerState,
Initiator: apiKey.UserID,
Transition: createBuild.Transition,
JobID: provisionerJob.ID,
@ -226,33 +259,6 @@ func (api *api) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
render.JSON(rw, r, convertWorkspaceBuild(workspaceBuild, convertProvisionerJob(provisionerJob)))
}
func (api *api) workspaceBuildLatest(rw http.ResponseWriter, r *http.Request) {
workspace := httpmw.WorkspaceParam(r)
workspaceBuild, err := api.Database.GetWorkspaceBuildByWorkspaceIDWithoutAfter(r.Context(), workspace.ID)
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(rw, http.StatusNotFound, httpapi.Response{
Message: "no workspace build found",
})
return
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get workspace build by name: %s", err),
})
return
}
job, err := api.Database.GetProvisionerJobByID(r.Context(), workspaceBuild.JobID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get provisioner job: %s", err),
})
return
}
render.Status(r, http.StatusOK)
render.JSON(rw, r, convertWorkspaceBuild(workspaceBuild, convertProvisionerJob(job)))
}
func (api *api) workspaceBuildByName(rw http.ResponseWriter, r *http.Request) {
workspace := httpmw.WorkspaceParam(r)
workspaceBuildName := chi.URLParam(r, "workspacebuildname")
@ -284,6 +290,16 @@ func (api *api) workspaceBuildByName(rw http.ResponseWriter, r *http.Request) {
render.JSON(rw, r, convertWorkspaceBuild(workspaceBuild, convertProvisionerJob(job)))
}
func convertWorkspace(workspace database.Workspace) Workspace {
return Workspace(workspace)
func convertWorkspace(workspace database.Workspace, workspaceBuild codersdk.WorkspaceBuild, project database.Project) codersdk.Workspace {
return codersdk.Workspace{
ID: workspace.ID,
CreatedAt: workspace.CreatedAt,
UpdatedAt: workspace.UpdatedAt,
OwnerID: workspace.OwnerID,
ProjectID: workspace.ProjectID,
LatestBuild: workspaceBuild,
ProjectName: project.Name,
Outdated: workspaceBuild.ProjectVersionID.String() != project.ActiveVersionID.String(),
Name: workspace.Name,
}
}

View File

@ -8,7 +8,6 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
@ -20,23 +19,43 @@ func TestWorkspace(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
job := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
project := coderdtest.CreateProject(t, client, user.OrganizationID, job.ID)
coderdtest.NewProvisionerDaemon(t, client)
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "", project.ID)
_, err := client.Workspace(context.Background(), workspace.ID)
require.NoError(t, err)
}
func TestWorkspaceBuilds(t *testing.T) {
t.Parallel()
t.Run("Single", 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, nil)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID)
_, err := client.WorkspaceBuilds(context.Background(), workspace.ID)
require.NoError(t, err)
})
}
func TestPostWorkspaceBuild(t *testing.T) {
t.Parallel()
t.Run("NoProjectVersion", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.NewProvisionerDaemon(t, client)
user := coderdtest.CreateFirstUser(t, client)
job := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
project := coderdtest.CreateProject(t, client, user.OrganizationID, job.ID)
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID)
_, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
_, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
ProjectVersionID: uuid.New(),
Transition: database.WorkspaceTransitionStart,
})
@ -56,12 +75,10 @@ func TestPostWorkspaceBuild(t *testing.T) {
})
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID)
_, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
ProjectVersionID: project.ActiveVersionID,
Transition: database.WorkspaceTransitionStart,
_, err := client.CreateWorkspace(context.Background(), "", codersdk.CreateWorkspaceRequest{
ProjectID: project.ID,
Name: "workspace",
})
require.Error(t, err)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusPreconditionFailed, apiErr.StatusCode())
@ -78,12 +95,7 @@ func TestPostWorkspaceBuild(t *testing.T) {
// Close here so workspace build doesn't process!
closeDaemon.Close()
workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID)
_, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
ProjectVersionID: project.ActiveVersionID,
Transition: database.WorkspaceTransitionStart,
})
require.NoError(t, err)
_, err = client.CreateWorkspaceBuild(context.Background(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
_, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
ProjectVersionID: project.ActiveVersionID,
Transition: database.WorkspaceTransitionStart,
})
@ -102,28 +114,20 @@ func TestPostWorkspaceBuild(t *testing.T) {
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID)
firstBuild, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
build, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
ProjectVersionID: project.ActiveVersionID,
Transition: database.WorkspaceTransitionStart,
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJob(t, client, firstBuild.ID)
secondBuild, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
ProjectVersionID: project.ActiveVersionID,
Transition: database.WorkspaceTransitionStart,
})
require.NoError(t, err)
require.Equal(t, firstBuild.ID.String(), secondBuild.BeforeID.String())
require.Equal(t, workspace.LatestBuild.ID.String(), build.BeforeID.String())
firstBuild, err = client.WorkspaceBuild(context.Background(), firstBuild.ID)
firstBuild, err := client.WorkspaceBuild(context.Background(), workspace.LatestBuild.ID)
require.NoError(t, err)
require.Equal(t, secondBuild.ID.String(), firstBuild.AfterID.String())
require.Equal(t, build.ID.String(), firstBuild.AfterID.String())
})
}
func TestWorkspaceBuildLatest(t *testing.T) {
t.Parallel()
t.Run("None", func(t *testing.T) {
t.Run("Delete", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
@ -132,28 +136,17 @@ func TestWorkspaceBuildLatest(t *testing.T) {
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID)
_, err := client.WorkspaceBuildLatest(context.Background(), workspace.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
})
t.Run("Found", 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, nil)
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID)
_, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
ProjectVersionID: project.ActiveVersionID,
Transition: database.WorkspaceTransitionStart,
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
build, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
Transition: database.WorkspaceTransitionDelete,
})
require.NoError(t, err)
_, err = client.WorkspaceBuildLatest(context.Background(), workspace.ID)
require.Equal(t, workspace.LatestBuild.ID.String(), build.BeforeID.String())
coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID)
workspaces, err := client.WorkspacesByUser(context.Background(), user.UserID)
require.NoError(t, err)
require.Len(t, workspaces, 0)
})
}
@ -183,10 +176,7 @@ func TestWorkspaceBuildByName(t *testing.T) {
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID)
build, err := client.CreateWorkspaceBuild(context.Background(), workspace.ID, coderd.CreateWorkspaceBuildRequest{
ProjectVersionID: project.ActiveVersionID,
Transition: database.WorkspaceTransitionStart,
})
build, err := client.WorkspaceBuild(context.Background(), workspace.LatestBuild.ID)
require.NoError(t, err)
_, err = client.WorkspaceBuildByName(context.Background(), workspace.ID, build.Name)
require.NoError(t, err)

View File

@ -6,28 +6,31 @@ import (
"fmt"
"io"
"net/http"
"github.com/coder/coder/coderd"
)
const (
ContentTypeTar = "application/x-tar"
)
// UploadResponse contains the hash to reference the uploaded file.
type UploadResponse struct {
Hash string `json:"hash"`
}
// Upload uploads an arbitrary file with the content type provided.
// This is used to upload a source-code archive.
func (c *Client) Upload(ctx context.Context, contentType string, content []byte) (coderd.UploadResponse, error) {
func (c *Client) Upload(ctx context.Context, contentType string, content []byte) (UploadResponse, error) {
res, err := c.request(ctx, http.MethodPost, "/api/v2/files", content, func(r *http.Request) {
r.Header.Set("Content-Type", contentType)
})
if err != nil {
return coderd.UploadResponse{}, err
return UploadResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated && res.StatusCode != http.StatusOK {
return coderd.UploadResponse{}, readBodyAsError(res)
return UploadResponse{}, readBodyAsError(res)
}
var resp coderd.UploadResponse
var resp UploadResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}

View File

@ -5,25 +5,63 @@ import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/coder/coder/coderd"
"github.com/google/uuid"
"github.com/coder/coder/database"
)
func (c *Client) Organization(ctx context.Context, id string) (coderd.Organization, error) {
// Organization is the JSON representation of a Coder organization.
type Organization struct {
ID string `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
CreatedAt time.Time `json:"created_at" validate:"required"`
UpdatedAt time.Time `json:"updated_at" validate:"required"`
}
// CreateProjectVersionRequest enables callers to create a new Project Version.
type CreateProjectVersionRequest struct {
// ProjectID optionally associates a version with a project.
ProjectID uuid.UUID `json:"project_id"`
StorageMethod database.ProvisionerStorageMethod `json:"storage_method" validate:"oneof=file,required"`
StorageSource string `json:"storage_source" validate:"required"`
Provisioner database.ProvisionerType `json:"provisioner" validate:"oneof=terraform echo,required"`
// ParameterValues allows for additional parameters to be provided
// during the dry-run provision stage.
ParameterValues []CreateParameterRequest `json:"parameter_values"`
}
// CreateProjectRequest provides options when creating a project.
type CreateProjectRequest struct {
Name string `json:"name" validate:"username,required"`
// VersionID is an in-progress or completed job to use as
// an initial version of the project.
//
// This is required on creation to enable a user-flow of validating a
// project works. There is no reason the data-model cannot support
// empty projects, but it doesn't make sense for users.
VersionID uuid.UUID `json:"project_version_id" validate:"required"`
ParameterValues []CreateParameterRequest `json:"parameter_values"`
}
func (c *Client) Organization(ctx context.Context, id string) (Organization, error) {
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s", id), nil)
if err != nil {
return coderd.Organization{}, err
return Organization{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return coderd.Organization{}, readBodyAsError(res)
return Organization{}, readBodyAsError(res)
}
var organization coderd.Organization
var organization Organization
return organization, json.NewDecoder(res.Body).Decode(&organization)
}
// ProvisionerDaemonsByOrganization returns provisioner daemons available for an organization.
func (c *Client) ProvisionerDaemonsByOrganization(ctx context.Context, organization string) ([]coderd.ProvisionerDaemon, error) {
func (c *Client) ProvisionerDaemonsByOrganization(ctx context.Context, organization string) ([]ProvisionerDaemon, error) {
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/provisionerdaemons", organization), nil)
if err != nil {
return nil, err
@ -32,41 +70,41 @@ func (c *Client) ProvisionerDaemonsByOrganization(ctx context.Context, organizat
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
}
var daemons []coderd.ProvisionerDaemon
var daemons []ProvisionerDaemon
return daemons, json.NewDecoder(res.Body).Decode(&daemons)
}
// CreateProjectVersion processes source-code and optionally associates the version with a project.
// Executing without a project is useful for validating source-code.
func (c *Client) CreateProjectVersion(ctx context.Context, organization string, req coderd.CreateProjectVersionRequest) (coderd.ProjectVersion, error) {
func (c *Client) CreateProjectVersion(ctx context.Context, organization string, req CreateProjectVersionRequest) (ProjectVersion, error) {
res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/organizations/%s/projectversions", organization), req)
if err != nil {
return coderd.ProjectVersion{}, err
return ProjectVersion{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return coderd.ProjectVersion{}, readBodyAsError(res)
return ProjectVersion{}, readBodyAsError(res)
}
var projectVersion coderd.ProjectVersion
var projectVersion ProjectVersion
return projectVersion, json.NewDecoder(res.Body).Decode(&projectVersion)
}
// CreateProject creates a new project inside an organization.
func (c *Client) CreateProject(ctx context.Context, organization string, request coderd.CreateProjectRequest) (coderd.Project, error) {
func (c *Client) CreateProject(ctx context.Context, organization string, request CreateProjectRequest) (Project, error) {
res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/organizations/%s/projects", organization), request)
if err != nil {
return coderd.Project{}, err
return Project{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return coderd.Project{}, readBodyAsError(res)
return Project{}, readBodyAsError(res)
}
var project coderd.Project
var project Project
return project, json.NewDecoder(res.Body).Decode(&project)
}
// ProjectsByOrganization lists all projects inside of an organization.
func (c *Client) ProjectsByOrganization(ctx context.Context, organization string) ([]coderd.Project, error) {
func (c *Client) ProjectsByOrganization(ctx context.Context, organization string) ([]Project, error) {
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/projects", organization), nil)
if err != nil {
return nil, err
@ -75,20 +113,20 @@ func (c *Client) ProjectsByOrganization(ctx context.Context, organization string
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
}
var projects []coderd.Project
var projects []Project
return projects, json.NewDecoder(res.Body).Decode(&projects)
}
// ProjectByName finds a project inside the organization provided with a case-insensitive name.
func (c *Client) ProjectByName(ctx context.Context, organization, name string) (coderd.Project, error) {
func (c *Client) ProjectByName(ctx context.Context, organization, name string) (Project, error) {
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/projects/%s", organization, name), nil)
if err != nil {
return coderd.Project{}, err
return Project{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return coderd.Project{}, readBodyAsError(res)
return Project{}, readBodyAsError(res)
}
var project coderd.Project
var project Project
return project, json.NewDecoder(res.Body).Decode(&project)
}

View File

@ -5,24 +5,56 @@ import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/coder/coder/coderd"
"github.com/google/uuid"
"github.com/coder/coder/database"
)
func (c *Client) CreateParameter(ctx context.Context, scope coderd.ParameterScope, id string, req coderd.CreateParameterRequest) (coderd.Parameter, error) {
type ParameterScope string
const (
ParameterOrganization ParameterScope = "organization"
ParameterProject ParameterScope = "project"
ParameterUser ParameterScope = "user"
ParameterWorkspace ParameterScope = "workspace"
)
// Parameter represents a set value for the scope.
type Parameter struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Scope ParameterScope `db:"scope" json:"scope"`
ScopeID string `db:"scope_id" json:"scope_id"`
Name string `db:"name" json:"name"`
SourceScheme database.ParameterSourceScheme `db:"source_scheme" json:"source_scheme"`
DestinationScheme database.ParameterDestinationScheme `db:"destination_scheme" json:"destination_scheme"`
}
// CreateParameterRequest is used to create a new parameter value for a scope.
type CreateParameterRequest struct {
Name string `json:"name" validate:"required"`
SourceValue string `json:"source_value" validate:"required"`
SourceScheme database.ParameterSourceScheme `json:"source_scheme" validate:"oneof=data,required"`
DestinationScheme database.ParameterDestinationScheme `json:"destination_scheme" validate:"oneof=environment_variable provisioner_variable,required"`
}
func (c *Client) CreateParameter(ctx context.Context, scope ParameterScope, id string, req CreateParameterRequest) (Parameter, error) {
res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/parameters/%s/%s", scope, id), req)
if err != nil {
return coderd.Parameter{}, err
return Parameter{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return coderd.Parameter{}, readBodyAsError(res)
return Parameter{}, readBodyAsError(res)
}
var param coderd.Parameter
var param Parameter
return param, json.NewDecoder(res.Body).Decode(&param)
}
func (c *Client) DeleteParameter(ctx context.Context, scope coderd.ParameterScope, id, name string) error {
func (c *Client) DeleteParameter(ctx context.Context, scope ParameterScope, id, name string) error {
res, err := c.request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/parameters/%s/%s/%s", scope, id, name), nil)
if err != nil {
return err
@ -34,7 +66,7 @@ func (c *Client) DeleteParameter(ctx context.Context, scope coderd.ParameterScop
return nil
}
func (c *Client) Parameters(ctx context.Context, scope coderd.ParameterScope, id string) ([]coderd.Parameter, error) {
func (c *Client) Parameters(ctx context.Context, scope ParameterScope, id string) ([]Parameter, error) {
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/parameters/%s/%s", scope, id), nil)
if err != nil {
return nil, err
@ -43,6 +75,6 @@ func (c *Client) Parameters(ctx context.Context, scope coderd.ParameterScope, id
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
}
var parameters []coderd.Parameter
var parameters []Parameter
return parameters, json.NewDecoder(res.Body).Decode(&parameters)
}

View File

@ -5,28 +5,73 @@ import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"github.com/coder/coder/coderd"
"github.com/coder/coder/database"
)
// Project is the JSON representation of a Coder project.
// This type matches the database object for now, but is
// abstracted for ease of change later on.
type Project struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
OrganizationID string `json:"organization_id"`
Name string `json:"name"`
Provisioner database.ProvisionerType `json:"provisioner"`
ActiveVersionID uuid.UUID `json:"active_version_id"`
WorkspaceOwnerCount uint32 `json:"workspace_owner_count"`
}
type UpdateActiveProjectVersion struct {
ID uuid.UUID `json:"id" validate:"required"`
}
// Project returns a single project.
func (c *Client) Project(ctx context.Context, project uuid.UUID) (coderd.Project, error) {
func (c *Client) Project(ctx context.Context, project uuid.UUID) (Project, error) {
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/projects/%s", project), nil)
if err != nil {
return coderd.Project{}, nil
return Project{}, nil
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return coderd.Project{}, readBodyAsError(res)
return Project{}, readBodyAsError(res)
}
var resp coderd.Project
var resp Project
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
func (c *Client) DeleteProject(ctx context.Context, project uuid.UUID) error {
res, err := c.request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/projects/%s", project), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
}
return nil
}
// UpdateActiveProjectVersion updates the active project version to the ID provided.
// The project version must be attached to the project.
func (c *Client) UpdateActiveProjectVersion(ctx context.Context, project uuid.UUID, req UpdateActiveProjectVersion) error {
res, err := c.request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/projects/%s/versions", project), req)
if err != nil {
return nil
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
}
return nil
}
// ProjectVersionsByProject lists versions associated with a project.
func (c *Client) ProjectVersionsByProject(ctx context.Context, project uuid.UUID) ([]coderd.ProjectVersion, error) {
func (c *Client) ProjectVersionsByProject(ctx context.Context, project uuid.UUID) ([]ProjectVersion, error) {
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/projects/%s/versions", project), nil)
if err != nil {
return nil, err
@ -35,21 +80,21 @@ func (c *Client) ProjectVersionsByProject(ctx context.Context, project uuid.UUID
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
}
var projectVersion []coderd.ProjectVersion
var projectVersion []ProjectVersion
return projectVersion, json.NewDecoder(res.Body).Decode(&projectVersion)
}
// ProjectVersionByName returns a project version by it's friendly name.
// This is used for path-based routing. Like: /projects/example/versions/helloworld
func (c *Client) ProjectVersionByName(ctx context.Context, project uuid.UUID, name string) (coderd.ProjectVersion, error) {
func (c *Client) ProjectVersionByName(ctx context.Context, project uuid.UUID, name string) (ProjectVersion, error) {
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/projects/%s/versions/%s", project, name), nil)
if err != nil {
return coderd.ProjectVersion{}, err
return ProjectVersion{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return coderd.ProjectVersion{}, readBodyAsError(res)
return ProjectVersion{}, readBodyAsError(res)
}
var projectVersion coderd.ProjectVersion
var projectVersion ProjectVersion
return projectVersion, json.NewDecoder(res.Body).Decode(&projectVersion)
}

View File

@ -9,25 +9,55 @@ import (
"github.com/google/uuid"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/parameter"
"github.com/coder/coder/database"
)
// ProjectVersion represents a single version of a project.
type ProjectVersion struct {
ID uuid.UUID `json:"id"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Name string `json:"name"`
Job ProvisionerJob `json:"job"`
}
// ProjectVersionParameterSchema represents a parameter parsed from project version source.
type ProjectVersionParameterSchema database.ParameterSchema
// ProjectVersionParameter represents a computed parameter value.
type ProjectVersionParameter parameter.ComputedValue
// ProjectVersion returns a project version by ID.
func (c *Client) ProjectVersion(ctx context.Context, id uuid.UUID) (coderd.ProjectVersion, error) {
func (c *Client) ProjectVersion(ctx context.Context, id uuid.UUID) (ProjectVersion, error) {
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/projectversions/%s", id), nil)
if err != nil {
return coderd.ProjectVersion{}, err
return ProjectVersion{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return coderd.ProjectVersion{}, readBodyAsError(res)
return ProjectVersion{}, readBodyAsError(res)
}
var version coderd.ProjectVersion
var version ProjectVersion
return version, json.NewDecoder(res.Body).Decode(&version)
}
// CancelProjectVersion marks a project version job as canceled.
func (c *Client) CancelProjectVersion(ctx context.Context, version uuid.UUID) error {
res, err := c.request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/projectversions/%s/cancel", version), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
}
return nil
}
// ProjectVersionSchema returns schemas for a project version by ID.
func (c *Client) ProjectVersionSchema(ctx context.Context, version uuid.UUID) ([]coderd.ProjectVersionParameterSchema, error) {
func (c *Client) ProjectVersionSchema(ctx context.Context, version uuid.UUID) ([]ProjectVersionParameterSchema, error) {
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/projectversions/%s/schema", version), nil)
if err != nil {
return nil, err
@ -36,12 +66,12 @@ func (c *Client) ProjectVersionSchema(ctx context.Context, version uuid.UUID) ([
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
}
var params []coderd.ProjectVersionParameterSchema
var params []ProjectVersionParameterSchema
return params, json.NewDecoder(res.Body).Decode(&params)
}
// ProjectVersionParameters returns computed parameters for a project version.
func (c *Client) ProjectVersionParameters(ctx context.Context, version uuid.UUID) ([]coderd.ProjectVersionParameter, error) {
func (c *Client) ProjectVersionParameters(ctx context.Context, version uuid.UUID) ([]ProjectVersionParameter, error) {
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/projectversions/%s/parameters", version), nil)
if err != nil {
return nil, err
@ -50,12 +80,12 @@ func (c *Client) ProjectVersionParameters(ctx context.Context, version uuid.UUID
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
}
var params []coderd.ProjectVersionParameter
var params []ProjectVersionParameter
return params, json.NewDecoder(res.Body).Decode(&params)
}
// ProjectVersionResources returns resources a project version declares.
func (c *Client) ProjectVersionResources(ctx context.Context, version uuid.UUID) ([]coderd.WorkspaceResource, error) {
func (c *Client) ProjectVersionResources(ctx context.Context, version uuid.UUID) ([]WorkspaceResource, error) {
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/projectversions/%s/resources", version), nil)
if err != nil {
return nil, err
@ -64,16 +94,16 @@ func (c *Client) ProjectVersionResources(ctx context.Context, version uuid.UUID)
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
}
var resources []coderd.WorkspaceResource
var resources []WorkspaceResource
return resources, json.NewDecoder(res.Body).Decode(&resources)
}
// ProjectVersionLogsBefore returns logs that occurred before a specific time.
func (c *Client) ProjectVersionLogsBefore(ctx context.Context, version uuid.UUID, before time.Time) ([]coderd.ProvisionerJobLog, error) {
func (c *Client) ProjectVersionLogsBefore(ctx context.Context, version uuid.UUID, before time.Time) ([]ProvisionerJobLog, error) {
return c.provisionerJobLogsBefore(ctx, fmt.Sprintf("/api/v2/projectversions/%s/logs", version), before)
}
// ProjectVersionLogsAfter streams logs for a project version that occurred after a specific time.
func (c *Client) ProjectVersionLogsAfter(ctx context.Context, version uuid.UUID, after time.Time) (<-chan coderd.ProvisionerJobLog, error) {
func (c *Client) ProjectVersionLogsAfter(ctx context.Context, version uuid.UUID, after time.Time) (<-chan ProvisionerJobLog, error) {
return c.provisionerJobLogsAfter(ctx, fmt.Sprintf("/api/v2/projectversions/%s/logs", version), after)
}

View File

@ -10,15 +10,48 @@ import (
"strconv"
"time"
"github.com/google/uuid"
"github.com/hashicorp/yamux"
"golang.org/x/xerrors"
"nhooyr.io/websocket"
"github.com/coder/coder/coderd"
"github.com/coder/coder/database"
"github.com/coder/coder/provisionerd/proto"
"github.com/coder/coder/provisionersdk"
)
type ProvisionerDaemon database.ProvisionerDaemon
// ProvisionerJobStaus represents the at-time state of a job.
type ProvisionerJobStatus string
const (
ProvisionerJobPending ProvisionerJobStatus = "pending"
ProvisionerJobRunning ProvisionerJobStatus = "running"
ProvisionerJobSucceeded ProvisionerJobStatus = "succeeded"
ProvisionerJobCanceling ProvisionerJobStatus = "canceling"
ProvisionerJobCanceled ProvisionerJobStatus = "canceled"
ProvisionerJobFailed ProvisionerJobStatus = "failed"
)
type ProvisionerJob struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Error string `json:"error,omitempty"`
Status ProvisionerJobStatus `json:"status"`
WorkerID *uuid.UUID `json:"worker_id,omitempty"`
}
type ProvisionerJobLog struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
Source database.LogSource `json:"log_source"`
Level database.LogLevel `json:"log_level"`
Output string `json:"output"`
}
// ListenProvisionerDaemon returns the gRPC service for a provisioner daemon implementation.
func (c *Client) ListenProvisionerDaemon(ctx context.Context) (proto.DRPCProvisionerDaemonClient, error) {
serverURL, err := c.URL.Parse("/api/v2/provisionerdaemons/me/listen")
@ -48,7 +81,7 @@ func (c *Client) ListenProvisionerDaemon(ctx context.Context) (proto.DRPCProvisi
// provisionerJobLogsBefore provides log output that occurred before a time.
// This is abstracted from a specific job type to provide consistency between
// APIs. Logs is the only shared route between jobs.
func (c *Client) provisionerJobLogsBefore(ctx context.Context, path string, before time.Time) ([]coderd.ProvisionerJobLog, error) {
func (c *Client) provisionerJobLogsBefore(ctx context.Context, path string, before time.Time) ([]ProvisionerJobLog, error) {
values := url.Values{}
if !before.IsZero() {
values["before"] = []string{strconv.FormatInt(before.UTC().UnixMilli(), 10)}
@ -62,12 +95,12 @@ func (c *Client) provisionerJobLogsBefore(ctx context.Context, path string, befo
return nil, readBodyAsError(res)
}
var logs []coderd.ProvisionerJobLog
var logs []ProvisionerJobLog
return logs, json.NewDecoder(res.Body).Decode(&logs)
}
// provisionerJobLogsAfter streams logs that occurred after a specific time.
func (c *Client) provisionerJobLogsAfter(ctx context.Context, path string, after time.Time) (<-chan coderd.ProvisionerJobLog, error) {
func (c *Client) provisionerJobLogsAfter(ctx context.Context, path string, after time.Time) (<-chan ProvisionerJobLog, error) {
afterQuery := ""
if !after.IsZero() {
afterQuery = fmt.Sprintf("&after=%d", after.UTC().UnixMilli())
@ -81,11 +114,11 @@ func (c *Client) provisionerJobLogsAfter(ctx context.Context, path string, after
return nil, readBodyAsError(res)
}
logs := make(chan coderd.ProvisionerJobLog)
logs := make(chan ProvisionerJobLog)
decoder := json.NewDecoder(res.Body)
go func() {
defer close(logs)
var log coderd.ProvisionerJobLog
var log ProvisionerJobLog
for {
err = decoder.Decode(&log)
if err != nil {

View File

@ -5,10 +5,68 @@ import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/coder/coder/coderd"
"github.com/google/uuid"
)
// User represents a user in Coder.
type User struct {
ID string `json:"id" validate:"required"`
Email string `json:"email" validate:"required"`
CreatedAt time.Time `json:"created_at" validate:"required"`
Username string `json:"username" validate:"required"`
}
type CreateFirstUserRequest struct {
Email string `json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,username"`
Password string `json:"password" validate:"required"`
Organization string `json:"organization" validate:"required,username"`
}
// CreateFirstUserResponse contains IDs for newly created user info.
type CreateFirstUserResponse struct {
UserID string `json:"user_id"`
OrganizationID string `json:"organization_id"`
}
type CreateUserRequest struct {
Email string `json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,username"`
Password string `json:"password" validate:"required"`
OrganizationID string `json:"organization_id" validate:"required"`
}
// LoginWithPasswordRequest enables callers to authenticate with email and password.
type LoginWithPasswordRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
}
// LoginWithPasswordResponse contains a session token for the newly authenticated user.
type LoginWithPasswordResponse struct {
SessionToken string `json:"session_token" validate:"required"`
}
// GenerateAPIKeyResponse contains an API key for a user.
type GenerateAPIKeyResponse struct {
Key string `json:"key"`
}
type CreateOrganizationRequest struct {
Name string `json:"name" validate:"required,username"`
}
// CreateWorkspaceRequest provides options for creating a new workspace.
type CreateWorkspaceRequest struct {
ProjectID uuid.UUID `json:"project_id" validate:"required"`
Name string `json:"name" validate:"username,required"`
// ParameterValues allows for additional parameters to be provided
// during the initial provision.
ParameterValues []CreateParameterRequest `json:"parameter_values"`
}
// HasFirstUser returns whether the first user has been created.
func (c *Client) HasFirstUser(ctx context.Context) (bool, error) {
res, err := c.request(ctx, http.MethodGet, "/api/v2/users/first", nil)
@ -27,35 +85,35 @@ func (c *Client) HasFirstUser(ctx context.Context) (bool, error) {
// CreateFirstUser attempts to create the first user on a Coder deployment.
// This initial user has superadmin privileges. If >0 users exist, this request will fail.
func (c *Client) CreateFirstUser(ctx context.Context, req coderd.CreateFirstUserRequest) (coderd.CreateFirstUserResponse, error) {
func (c *Client) CreateFirstUser(ctx context.Context, req CreateFirstUserRequest) (CreateFirstUserResponse, error) {
res, err := c.request(ctx, http.MethodPost, "/api/v2/users/first", req)
if err != nil {
return coderd.CreateFirstUserResponse{}, err
return CreateFirstUserResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return coderd.CreateFirstUserResponse{}, readBodyAsError(res)
return CreateFirstUserResponse{}, readBodyAsError(res)
}
var resp coderd.CreateFirstUserResponse
var resp CreateFirstUserResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// CreateUser creates a new user.
func (c *Client) CreateUser(ctx context.Context, req coderd.CreateUserRequest) (coderd.User, error) {
func (c *Client) CreateUser(ctx context.Context, req CreateUserRequest) (User, error) {
res, err := c.request(ctx, http.MethodPost, "/api/v2/users", req)
if err != nil {
return coderd.User{}, err
return User{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return coderd.User{}, readBodyAsError(res)
return User{}, readBodyAsError(res)
}
var user coderd.User
var user User
return user, json.NewDecoder(res.Body).Decode(&user)
}
// CreateAPIKey generates an API key for the user ID provided.
func (c *Client) CreateAPIKey(ctx context.Context, id string) (*coderd.GenerateAPIKeyResponse, error) {
func (c *Client) CreateAPIKey(ctx context.Context, id string) (*GenerateAPIKeyResponse, error) {
if id == "" {
id = "me"
}
@ -67,25 +125,25 @@ func (c *Client) CreateAPIKey(ctx context.Context, id string) (*coderd.GenerateA
if res.StatusCode > http.StatusCreated {
return nil, readBodyAsError(res)
}
apiKey := &coderd.GenerateAPIKeyResponse{}
apiKey := &GenerateAPIKeyResponse{}
return apiKey, json.NewDecoder(res.Body).Decode(apiKey)
}
// LoginWithPassword creates a session token authenticating with an email and password.
// Call `SetSessionToken()` to apply the newly acquired token to the client.
func (c *Client) LoginWithPassword(ctx context.Context, req coderd.LoginWithPasswordRequest) (coderd.LoginWithPasswordResponse, error) {
func (c *Client) LoginWithPassword(ctx context.Context, req LoginWithPasswordRequest) (LoginWithPasswordResponse, error) {
res, err := c.request(ctx, http.MethodPost, "/api/v2/users/login", req)
if err != nil {
return coderd.LoginWithPasswordResponse{}, err
return LoginWithPasswordResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return coderd.LoginWithPasswordResponse{}, readBodyAsError(res)
return LoginWithPasswordResponse{}, readBodyAsError(res)
}
var resp coderd.LoginWithPasswordResponse
var resp LoginWithPasswordResponse
err = json.NewDecoder(res.Body).Decode(&resp)
if err != nil {
return coderd.LoginWithPasswordResponse{}, err
return LoginWithPasswordResponse{}, err
}
return resp, nil
}
@ -105,24 +163,24 @@ func (c *Client) Logout(ctx context.Context) error {
// User returns a user for the ID provided.
// If the ID string is empty, the current user will be returned.
func (c *Client) User(ctx context.Context, id string) (coderd.User, error) {
func (c *Client) User(ctx context.Context, id string) (User, error) {
if id == "" {
id = "me"
}
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s", id), nil)
if err != nil {
return coderd.User{}, err
return User{}, err
}
defer res.Body.Close()
if res.StatusCode > http.StatusOK {
return coderd.User{}, readBodyAsError(res)
return User{}, readBodyAsError(res)
}
var user coderd.User
var user User
return user, json.NewDecoder(res.Body).Decode(&user)
}
// OrganizationsByUser returns all organizations the user is a member of.
func (c *Client) OrganizationsByUser(ctx context.Context, id string) ([]coderd.Organization, error) {
func (c *Client) OrganizationsByUser(ctx context.Context, id string) ([]Organization, error) {
if id == "" {
id = "me"
}
@ -134,62 +192,62 @@ func (c *Client) OrganizationsByUser(ctx context.Context, id string) ([]coderd.O
if res.StatusCode > http.StatusOK {
return nil, readBodyAsError(res)
}
var orgs []coderd.Organization
var orgs []Organization
return orgs, json.NewDecoder(res.Body).Decode(&orgs)
}
func (c *Client) OrganizationByName(ctx context.Context, user, name string) (coderd.Organization, error) {
func (c *Client) OrganizationByName(ctx context.Context, user, name string) (Organization, error) {
if user == "" {
user = "me"
}
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/organizations/%s", user, name), nil)
if err != nil {
return coderd.Organization{}, err
return Organization{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return coderd.Organization{}, readBodyAsError(res)
return Organization{}, readBodyAsError(res)
}
var org coderd.Organization
var org Organization
return org, json.NewDecoder(res.Body).Decode(&org)
}
// CreateOrganization creates an organization and adds the provided user as an admin.
func (c *Client) CreateOrganization(ctx context.Context, user string, req coderd.CreateOrganizationRequest) (coderd.Organization, error) {
func (c *Client) CreateOrganization(ctx context.Context, user string, req CreateOrganizationRequest) (Organization, error) {
if user == "" {
user = "me"
}
res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/organizations", user), req)
if err != nil {
return coderd.Organization{}, err
return Organization{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return coderd.Organization{}, readBodyAsError(res)
return Organization{}, readBodyAsError(res)
}
var org coderd.Organization
var org Organization
return org, json.NewDecoder(res.Body).Decode(&org)
}
// CreateWorkspace creates a new workspace for the project specified.
func (c *Client) CreateWorkspace(ctx context.Context, user string, request coderd.CreateWorkspaceRequest) (coderd.Workspace, error) {
func (c *Client) CreateWorkspace(ctx context.Context, user string, request CreateWorkspaceRequest) (Workspace, error) {
if user == "" {
user = "me"
}
res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/workspaces", user), request)
if err != nil {
return coderd.Workspace{}, err
return Workspace{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return coderd.Workspace{}, readBodyAsError(res)
return Workspace{}, readBodyAsError(res)
}
var workspace coderd.Workspace
var workspace Workspace
return workspace, json.NewDecoder(res.Body).Decode(&workspace)
}
// WorkspacesByUser returns all workspaces the specified user has access to.
func (c *Client) WorkspacesByUser(ctx context.Context, user string) ([]coderd.Workspace, error) {
func (c *Client) WorkspacesByUser(ctx context.Context, user string) ([]Workspace, error) {
if user == "" {
user = "me"
}
@ -201,22 +259,22 @@ func (c *Client) WorkspacesByUser(ctx context.Context, user string) ([]coderd.Wo
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
}
var workspaces []coderd.Workspace
var workspaces []Workspace
return workspaces, json.NewDecoder(res.Body).Decode(&workspaces)
}
func (c *Client) WorkspaceByName(ctx context.Context, user, name string) (coderd.Workspace, error) {
func (c *Client) WorkspaceByName(ctx context.Context, user, name string) (Workspace, error) {
if user == "" {
user = "me"
}
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/workspaces/%s", user, name), nil)
if err != nil {
return coderd.Workspace{}, err
return Workspace{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return coderd.Workspace{}, readBodyAsError(res)
return Workspace{}, readBodyAsError(res)
}
var workspace coderd.Workspace
var workspace Workspace
return workspace, json.NewDecoder(res.Body).Decode(&workspace)
}

View File

@ -9,26 +9,55 @@ import (
"github.com/google/uuid"
"github.com/coder/coder/coderd"
"github.com/coder/coder/database"
)
// WorkspaceBuild is an at-point representation of a workspace state.
// Iterate on before/after to determine a chronological history.
type WorkspaceBuild struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
WorkspaceID uuid.UUID `json:"workspace_id"`
ProjectVersionID uuid.UUID `json:"project_version_id"`
BeforeID uuid.UUID `json:"before_id"`
AfterID uuid.UUID `json:"after_id"`
Name string `json:"name"`
Transition database.WorkspaceTransition `json:"transition"`
Initiator string `json:"initiator"`
Job ProvisionerJob `json:"job"`
}
// WorkspaceBuild returns a single workspace build for a workspace.
// If history is "", the latest version is returned.
func (c *Client) WorkspaceBuild(ctx context.Context, id uuid.UUID) (coderd.WorkspaceBuild, error) {
func (c *Client) WorkspaceBuild(ctx context.Context, id uuid.UUID) (WorkspaceBuild, error) {
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspacebuilds/%s", id), nil)
if err != nil {
return coderd.WorkspaceBuild{}, err
return WorkspaceBuild{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return coderd.WorkspaceBuild{}, readBodyAsError(res)
return WorkspaceBuild{}, readBodyAsError(res)
}
var workspaceBuild coderd.WorkspaceBuild
var workspaceBuild WorkspaceBuild
return workspaceBuild, json.NewDecoder(res.Body).Decode(&workspaceBuild)
}
// CancelWorkspaceBuild marks a workspace build job as canceled.
func (c *Client) CancelWorkspaceBuild(ctx context.Context, id uuid.UUID) error {
res, err := c.request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/workspacebuilds/%s/cancel", id), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
}
return nil
}
// WorkspaceResourcesByBuild returns resources for a workspace build.
func (c *Client) WorkspaceResourcesByBuild(ctx context.Context, build uuid.UUID) ([]coderd.WorkspaceResource, error) {
func (c *Client) WorkspaceResourcesByBuild(ctx context.Context, build uuid.UUID) ([]WorkspaceResource, error) {
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspacebuilds/%s/resources", build), nil)
if err != nil {
return nil, err
@ -37,16 +66,16 @@ func (c *Client) WorkspaceResourcesByBuild(ctx context.Context, build uuid.UUID)
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
}
var resources []coderd.WorkspaceResource
var resources []WorkspaceResource
return resources, json.NewDecoder(res.Body).Decode(&resources)
}
// WorkspaceBuildLogsBefore returns logs that occurred before a specific time.
func (c *Client) WorkspaceBuildLogsBefore(ctx context.Context, version uuid.UUID, before time.Time) ([]coderd.ProvisionerJobLog, error) {
return c.provisionerJobLogsBefore(ctx, fmt.Sprintf("/api/v2/workspacebuilds/%s/logs", version), before)
func (c *Client) WorkspaceBuildLogsBefore(ctx context.Context, build uuid.UUID, before time.Time) ([]ProvisionerJobLog, error) {
return c.provisionerJobLogsBefore(ctx, fmt.Sprintf("/api/v2/workspacebuilds/%s/logs", build), before)
}
// WorkspaceBuildLogsAfter streams logs for a workspace build that occurred after a specific time.
func (c *Client) WorkspaceBuildLogsAfter(ctx context.Context, version uuid.UUID, after time.Time) (<-chan coderd.ProvisionerJobLog, error) {
return c.provisionerJobLogsAfter(ctx, fmt.Sprintf("/api/v2/workspacebuilds/%s/logs", version), after)
func (c *Client) WorkspaceBuildLogsAfter(ctx context.Context, build uuid.UUID, after time.Time) (<-chan ProvisionerJobLog, error) {
return c.provisionerJobLogsAfter(ctx, fmt.Sprintf("/api/v2/workspacebuilds/%s/logs", build), after)
}

View File

@ -8,15 +8,23 @@ import (
"cloud.google.com/go/compute/metadata"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd"
)
type GoogleInstanceIdentityToken struct {
JSONWebToken string `json:"json_web_token" validate:"required"`
}
// WorkspaceAgentAuthenticateResponse is returned when an instance ID
// has been exchanged for a session token.
type WorkspaceAgentAuthenticateResponse struct {
SessionToken string `json:"session_token"`
}
// AuthWorkspaceGoogleInstanceIdentity uses the Google Compute Engine Metadata API to
// fetch a signed JWT, and exchange it for a session token for a workspace agent.
//
// The requesting instance must be registered as a resource in the latest history for a workspace.
func (c *Client) AuthWorkspaceGoogleInstanceIdentity(ctx context.Context, serviceAccount string, gcpClient *metadata.Client) (coderd.WorkspaceAgentAuthenticateResponse, error) {
func (c *Client) AuthWorkspaceGoogleInstanceIdentity(ctx context.Context, serviceAccount string, gcpClient *metadata.Client) (WorkspaceAgentAuthenticateResponse, error) {
if serviceAccount == "" {
// This is the default name specified by Google.
serviceAccount = "default"
@ -27,18 +35,18 @@ func (c *Client) AuthWorkspaceGoogleInstanceIdentity(ctx context.Context, servic
// "format=full" is required, otherwise the responding payload will be missing "instance_id".
jwt, err := gcpClient.Get(fmt.Sprintf("instance/service-accounts/%s/identity?audience=coder&format=full", serviceAccount))
if err != nil {
return coderd.WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("get metadata identity: %w", err)
return WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("get metadata identity: %w", err)
}
res, err := c.request(ctx, http.MethodPost, "/api/v2/workspaceresources/auth/google-instance-identity", coderd.GoogleInstanceIdentityToken{
res, err := c.request(ctx, http.MethodPost, "/api/v2/workspaceresources/auth/google-instance-identity", GoogleInstanceIdentityToken{
JSONWebToken: jwt,
})
if err != nil {
return coderd.WorkspaceAgentAuthenticateResponse{}, err
return WorkspaceAgentAuthenticateResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return coderd.WorkspaceAgentAuthenticateResponse{}, readBodyAsError(res)
return WorkspaceAgentAuthenticateResponse{}, readBodyAsError(res)
}
var resp coderd.WorkspaceAgentAuthenticateResponse
var resp WorkspaceAgentAuthenticateResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}

View File

@ -7,13 +7,15 @@ import (
"io"
"net/http"
"net/http/cookiejar"
"time"
"github.com/google/uuid"
"github.com/hashicorp/yamux"
"github.com/pion/webrtc/v3"
"golang.org/x/xerrors"
"nhooyr.io/websocket"
"github.com/coder/coder/coderd"
"github.com/coder/coder/database"
"github.com/coder/coder/httpmw"
"github.com/coder/coder/peer"
"github.com/coder/coder/peerbroker"
@ -21,16 +23,69 @@ import (
"github.com/coder/coder/provisionersdk"
)
func (c *Client) WorkspaceResource(ctx context.Context, id uuid.UUID) (coderd.WorkspaceResource, error) {
type WorkspaceAgentStatus string
const (
WorkspaceAgentWaiting WorkspaceAgentStatus = "waiting"
WorkspaceAgentConnected WorkspaceAgentStatus = "connected"
WorkspaceAgentDisconnected WorkspaceAgentStatus = "disconnected"
)
type WorkspaceResource struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
JobID uuid.UUID `json:"job_id"`
Transition database.WorkspaceTransition `json:"workspace_transition"`
Address string `json:"address"`
Type string `json:"type"`
Name string `json:"name"`
Agent *WorkspaceAgent `json:"agent,omitempty"`
}
type WorkspaceAgent struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
FirstConnectedAt *time.Time `json:"first_connected_at,omitempty"`
LastConnectedAt *time.Time `json:"last_connected_at,omitempty"`
DisconnectedAt *time.Time `json:"disconnected_at,omitempty"`
Status WorkspaceAgentStatus `json:"status"`
ResourceID uuid.UUID `json:"resource_id"`
InstanceID string `json:"instance_id,omitempty"`
EnvironmentVariables map[string]string `json:"environment_variables"`
StartupScript string `json:"startup_script,omitempty"`
}
type WorkspaceAgentResourceMetadata struct {
MemoryTotal uint64 `json:"memory_total"`
DiskTotal uint64 `json:"disk_total"`
CPUCores uint64 `json:"cpu_cores"`
CPUModel string `json:"cpu_model"`
CPUMhz float64 `json:"cpu_mhz"`
}
type WorkspaceAgentInstanceMetadata struct {
JailOrchestrator string `json:"jail_orchestrator"`
OperatingSystem string `json:"operating_system"`
Platform string `json:"platform"`
PlatformFamily string `json:"platform_family"`
KernelVersion string `json:"kernel_version"`
KernelArchitecture string `json:"kernel_architecture"`
Cloud string `json:"cloud"`
Jail string `json:"jail"`
VNC bool `json:"vnc"`
}
func (c *Client) WorkspaceResource(ctx context.Context, id uuid.UUID) (WorkspaceResource, error) {
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceresources/%s", id), nil)
if err != nil {
return coderd.WorkspaceResource{}, err
return WorkspaceResource{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return coderd.WorkspaceResource{}, readBodyAsError(res)
return WorkspaceResource{}, readBodyAsError(res)
}
var resource coderd.WorkspaceResource
var resource WorkspaceResource
return resource, json.NewDecoder(res.Body).Decode(&resource)
}
@ -106,5 +161,9 @@ func (c *Client) ListenWorkspaceAgent(ctx context.Context, opts *peer.ConnOption
if err != nil {
return nil, xerrors.Errorf("multiplex client: %w", err)
}
return peerbroker.Listen(session, nil, opts)
return peerbroker.Listen(session, func(ctx context.Context) ([]webrtc.ICEServer, error) {
return []webrtc.ICEServer{{
URLs: []string{"stun:stun.l.google.com:19302"},
}}, nil
}, opts)
}

View File

@ -5,27 +5,49 @@ import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"github.com/coder/coder/coderd"
"github.com/coder/coder/database"
)
// Workspace is a per-user deployment of a project. It tracks
// project versions, and can be updated.
type Workspace struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
OwnerID string `json:"owner_id"`
ProjectID uuid.UUID `json:"project_id"`
ProjectName string `json:"project_name"`
LatestBuild WorkspaceBuild `json:"latest_build"`
Outdated bool `json:"outdated"`
Name string `json:"name"`
}
// CreateWorkspaceBuildRequest provides options to update the latest workspace build.
type CreateWorkspaceBuildRequest struct {
ProjectVersionID uuid.UUID `json:"project_version_id"`
Transition database.WorkspaceTransition `json:"transition" validate:"oneof=create start stop delete,required"`
DryRun bool `json:"dry_run"`
}
// Workspace returns a single workspace.
func (c *Client) Workspace(ctx context.Context, id uuid.UUID) (coderd.Workspace, error) {
func (c *Client) Workspace(ctx context.Context, id uuid.UUID) (Workspace, error) {
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s", id), nil)
if err != nil {
return coderd.Workspace{}, err
return Workspace{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return coderd.Workspace{}, readBodyAsError(res)
return Workspace{}, readBodyAsError(res)
}
var workspace coderd.Workspace
var workspace Workspace
return workspace, json.NewDecoder(res.Body).Decode(&workspace)
}
func (c *Client) WorkspaceBuilds(ctx context.Context, workspace uuid.UUID) ([]coderd.WorkspaceBuild, error) {
func (c *Client) WorkspaceBuilds(ctx context.Context, workspace uuid.UUID) ([]WorkspaceBuild, error) {
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/builds", workspace), nil)
if err != nil {
return nil, err
@ -34,46 +56,33 @@ func (c *Client) WorkspaceBuilds(ctx context.Context, workspace uuid.UUID) ([]co
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
}
var workspaceBuild []coderd.WorkspaceBuild
var workspaceBuild []WorkspaceBuild
return workspaceBuild, json.NewDecoder(res.Body).Decode(&workspaceBuild)
}
// CreateWorkspaceBuild queues a new build to occur for a workspace.
func (c *Client) CreateWorkspaceBuild(ctx context.Context, workspace uuid.UUID, request coderd.CreateWorkspaceBuildRequest) (coderd.WorkspaceBuild, error) {
func (c *Client) CreateWorkspaceBuild(ctx context.Context, workspace uuid.UUID, request CreateWorkspaceBuildRequest) (WorkspaceBuild, error) {
res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/workspaces/%s/builds", workspace), request)
if err != nil {
return coderd.WorkspaceBuild{}, err
return WorkspaceBuild{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return coderd.WorkspaceBuild{}, readBodyAsError(res)
return WorkspaceBuild{}, readBodyAsError(res)
}
var workspaceBuild coderd.WorkspaceBuild
var workspaceBuild WorkspaceBuild
return workspaceBuild, json.NewDecoder(res.Body).Decode(&workspaceBuild)
}
func (c *Client) WorkspaceBuildByName(ctx context.Context, workspace uuid.UUID, name string) (coderd.WorkspaceBuild, error) {
func (c *Client) WorkspaceBuildByName(ctx context.Context, workspace uuid.UUID, name string) (WorkspaceBuild, error) {
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/builds/%s", workspace, name), nil)
if err != nil {
return coderd.WorkspaceBuild{}, err
return WorkspaceBuild{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return coderd.WorkspaceBuild{}, readBodyAsError(res)
return WorkspaceBuild{}, readBodyAsError(res)
}
var workspaceBuild coderd.WorkspaceBuild
return workspaceBuild, json.NewDecoder(res.Body).Decode(&workspaceBuild)
}
func (c *Client) WorkspaceBuildLatest(ctx context.Context, workspace uuid.UUID) (coderd.WorkspaceBuild, error) {
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/builds/latest", workspace), nil)
if err != nil {
return coderd.WorkspaceBuild{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return coderd.WorkspaceBuild{}, readBodyAsError(res)
}
var workspaceBuild coderd.WorkspaceBuild
var workspaceBuild WorkspaceBuild
return workspaceBuild, json.NewDecoder(res.Body).Decode(&workspaceBuild)
}

View File

@ -36,7 +36,7 @@ func New() database.Store {
// fakeQuerier replicates database functionality to enable quick testing.
type fakeQuerier struct {
mutex sync.Mutex
mutex sync.RWMutex
// Legacy tables
apiKeys []database.APIKey
@ -108,8 +108,8 @@ func (q *fakeQuerier) DeleteParameterValueByID(_ context.Context, id uuid.UUID)
}
func (q *fakeQuerier) GetAPIKeyByID(_ context.Context, id string) (database.APIKey, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, apiKey := range q.apiKeys {
if apiKey.ID == id {
@ -120,8 +120,8 @@ func (q *fakeQuerier) GetAPIKeyByID(_ context.Context, id string) (database.APIK
}
func (q *fakeQuerier) GetFileByHash(_ context.Context, hash string) (database.File, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, file := range q.files {
if file.Hash == hash {
@ -132,8 +132,8 @@ func (q *fakeQuerier) GetFileByHash(_ context.Context, hash string) (database.Fi
}
func (q *fakeQuerier) GetUserByEmailOrUsername(_ context.Context, arg database.GetUserByEmailOrUsernameParams) (database.User, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, user := range q.users {
if user.Email == arg.Email || user.Username == arg.Username {
@ -144,8 +144,8 @@ func (q *fakeQuerier) GetUserByEmailOrUsername(_ context.Context, arg database.G
}
func (q *fakeQuerier) GetUserByID(_ context.Context, id string) (database.User, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, user := range q.users {
if user.ID == id {
@ -156,15 +156,35 @@ func (q *fakeQuerier) GetUserByID(_ context.Context, id string) (database.User,
}
func (q *fakeQuerier) GetUserCount(_ context.Context) (int64, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
return int64(len(q.users)), nil
}
func (q *fakeQuerier) GetWorkspacesByProjectID(_ context.Context, arg database.GetWorkspacesByProjectIDParams) ([]database.Workspace, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
workspaces := make([]database.Workspace, 0)
for _, workspace := range q.workspace {
if workspace.ProjectID.String() != arg.ProjectID.String() {
continue
}
if workspace.Deleted != arg.Deleted {
continue
}
workspaces = append(workspaces, workspace)
}
if len(workspaces) == 0 {
return nil, sql.ErrNoRows
}
return workspaces, nil
}
func (q *fakeQuerier) GetWorkspaceByID(_ context.Context, id uuid.UUID) (database.Workspace, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, workspace := range q.workspace {
if workspace.ID.String() == id.String() {
@ -175,8 +195,8 @@ func (q *fakeQuerier) GetWorkspaceByID(_ context.Context, id uuid.UUID) (databas
}
func (q *fakeQuerier) GetWorkspaceByUserIDAndName(_ context.Context, arg database.GetWorkspaceByUserIDAndNameParams) (database.Workspace, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, workspace := range q.workspace {
if workspace.OwnerID != arg.OwnerID {
@ -185,12 +205,18 @@ func (q *fakeQuerier) GetWorkspaceByUserIDAndName(_ context.Context, arg databas
if !strings.EqualFold(workspace.Name, arg.Name) {
continue
}
if workspace.Deleted != arg.Deleted {
continue
}
return workspace, nil
}
return database.Workspace{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetWorkspaceOwnerCountsByProjectIDs(_ context.Context, projectIDs []uuid.UUID) ([]database.GetWorkspaceOwnerCountsByProjectIDsRow, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
counts := map[string]map[string]struct{}{}
for _, projectID := range projectIDs {
found := false
@ -198,6 +224,9 @@ func (q *fakeQuerier) GetWorkspaceOwnerCountsByProjectIDs(_ context.Context, pro
if workspace.ProjectID.String() != projectID.String() {
continue
}
if workspace.Deleted {
continue
}
countByOwnerID, ok := counts[projectID.String()]
if !ok {
countByOwnerID = map[string]struct{}{}
@ -226,8 +255,8 @@ func (q *fakeQuerier) GetWorkspaceOwnerCountsByProjectIDs(_ context.Context, pro
}
func (q *fakeQuerier) GetWorkspaceBuildByID(_ context.Context, id uuid.UUID) (database.WorkspaceBuild, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, history := range q.workspaceBuild {
if history.ID.String() == id.String() {
@ -238,8 +267,8 @@ func (q *fakeQuerier) GetWorkspaceBuildByID(_ context.Context, id uuid.UUID) (da
}
func (q *fakeQuerier) GetWorkspaceBuildByJobID(_ context.Context, jobID uuid.UUID) (database.WorkspaceBuild, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, build := range q.workspaceBuild {
if build.JobID.String() == jobID.String() {
@ -250,8 +279,8 @@ func (q *fakeQuerier) GetWorkspaceBuildByJobID(_ context.Context, jobID uuid.UUI
}
func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceIDWithoutAfter(_ context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, workspaceBuild := range q.workspaceBuild {
if workspaceBuild.WorkspaceID.String() != workspaceID.String() {
@ -264,9 +293,28 @@ func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceIDWithoutAfter(_ context.Conte
return database.WorkspaceBuild{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetWorkspaceBuildsByWorkspaceIDsWithoutAfter(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceBuild, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
builds := make([]database.WorkspaceBuild, 0)
for _, workspaceBuild := range q.workspaceBuild {
for _, id := range ids {
if id.String() != workspaceBuild.WorkspaceID.String() {
continue
}
builds = append(builds, workspaceBuild)
}
}
if len(builds) == 0 {
return nil, sql.ErrNoRows
}
return builds, nil
}
func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceID(_ context.Context, workspaceID uuid.UUID) ([]database.WorkspaceBuild, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
history := make([]database.WorkspaceBuild, 0)
for _, workspaceBuild := range q.workspaceBuild {
@ -281,8 +329,8 @@ func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceID(_ context.Context, workspac
}
func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceIDAndName(_ context.Context, arg database.GetWorkspaceBuildByWorkspaceIDAndNameParams) (database.WorkspaceBuild, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, workspaceBuild := range q.workspaceBuild {
if workspaceBuild.WorkspaceID.String() != arg.WorkspaceID.String() {
@ -296,13 +344,16 @@ func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceIDAndName(_ context.Context, a
return database.WorkspaceBuild{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetWorkspacesByUserID(_ context.Context, ownerID string) ([]database.Workspace, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
func (q *fakeQuerier) GetWorkspacesByUserID(_ context.Context, req database.GetWorkspacesByUserIDParams) ([]database.Workspace, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
workspaces := make([]database.Workspace, 0)
for _, workspace := range q.workspace {
if workspace.OwnerID != ownerID {
if workspace.OwnerID != req.OwnerID {
continue
}
if workspace.Deleted != req.Deleted {
continue
}
workspaces = append(workspaces, workspace)
@ -314,8 +365,8 @@ func (q *fakeQuerier) GetWorkspacesByUserID(_ context.Context, ownerID string) (
}
func (q *fakeQuerier) GetOrganizationByID(_ context.Context, id string) (database.Organization, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, organization := range q.organizations {
if organization.ID == id {
@ -326,8 +377,8 @@ func (q *fakeQuerier) GetOrganizationByID(_ context.Context, id string) (databas
}
func (q *fakeQuerier) GetOrganizationByName(_ context.Context, name string) (database.Organization, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, organization := range q.organizations {
if organization.Name == name {
@ -338,8 +389,8 @@ func (q *fakeQuerier) GetOrganizationByName(_ context.Context, name string) (dat
}
func (q *fakeQuerier) GetOrganizationsByUserID(_ context.Context, userID string) ([]database.Organization, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
organizations := make([]database.Organization, 0)
for _, organizationMember := range q.organizationMembers {
@ -360,8 +411,8 @@ func (q *fakeQuerier) GetOrganizationsByUserID(_ context.Context, userID string)
}
func (q *fakeQuerier) GetParameterValuesByScope(_ context.Context, arg database.GetParameterValuesByScopeParams) ([]database.ParameterValue, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
parameterValues := make([]database.ParameterValue, 0)
for _, parameterValue := range q.parameterValue {
@ -380,8 +431,8 @@ func (q *fakeQuerier) GetParameterValuesByScope(_ context.Context, arg database.
}
func (q *fakeQuerier) GetProjectByID(_ context.Context, id uuid.UUID) (database.Project, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, project := range q.project {
if project.ID.String() == id.String() {
@ -392,8 +443,8 @@ func (q *fakeQuerier) GetProjectByID(_ context.Context, id uuid.UUID) (database.
}
func (q *fakeQuerier) GetProjectByOrganizationAndName(_ context.Context, arg database.GetProjectByOrganizationAndNameParams) (database.Project, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, project := range q.project {
if project.OrganizationID != arg.OrganizationID {
@ -402,14 +453,17 @@ func (q *fakeQuerier) GetProjectByOrganizationAndName(_ context.Context, arg dat
if !strings.EqualFold(project.Name, arg.Name) {
continue
}
if project.Deleted != arg.Deleted {
continue
}
return project, nil
}
return database.Project{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetProjectVersionsByProjectID(_ context.Context, projectID uuid.UUID) ([]database.ProjectVersion, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
version := make([]database.ProjectVersion, 0)
for _, projectVersion := range q.projectVersion {
@ -425,8 +479,8 @@ func (q *fakeQuerier) GetProjectVersionsByProjectID(_ context.Context, projectID
}
func (q *fakeQuerier) GetProjectVersionByProjectIDAndName(_ context.Context, arg database.GetProjectVersionByProjectIDAndNameParams) (database.ProjectVersion, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, projectVersion := range q.projectVersion {
if projectVersion.ProjectID.UUID.String() != arg.ProjectID.UUID.String() {
@ -441,8 +495,8 @@ func (q *fakeQuerier) GetProjectVersionByProjectIDAndName(_ context.Context, arg
}
func (q *fakeQuerier) GetProjectVersionByID(_ context.Context, projectVersionID uuid.UUID) (database.ProjectVersion, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, projectVersion := range q.projectVersion {
if projectVersion.ID.String() != projectVersionID.String() {
@ -453,9 +507,22 @@ func (q *fakeQuerier) GetProjectVersionByID(_ context.Context, projectVersionID
return database.ProjectVersion{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetProjectVersionByJobID(_ context.Context, jobID uuid.UUID) (database.ProjectVersion, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, projectVersion := range q.projectVersion {
if projectVersion.JobID.String() != jobID.String() {
continue
}
return projectVersion, nil
}
return database.ProjectVersion{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetParameterSchemasByJobID(_ context.Context, jobID uuid.UUID) ([]database.ParameterSchema, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
parameters := make([]database.ParameterSchema, 0)
for _, parameterSchema := range q.parameterSchema {
@ -471,8 +538,8 @@ func (q *fakeQuerier) GetParameterSchemasByJobID(_ context.Context, jobID uuid.U
}
func (q *fakeQuerier) GetParameterValueByScopeAndName(_ context.Context, arg database.GetParameterValueByScopeAndNameParams) (database.ParameterValue, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, parameterValue := range q.parameterValue {
if parameterValue.Scope != arg.Scope {
@ -489,15 +556,37 @@ func (q *fakeQuerier) GetParameterValueByScopeAndName(_ context.Context, arg dat
return database.ParameterValue{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetProjectsByOrganization(_ context.Context, organizationID string) ([]database.Project, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
func (q *fakeQuerier) GetProjectsByOrganization(_ context.Context, arg database.GetProjectsByOrganizationParams) ([]database.Project, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
projects := make([]database.Project, 0)
for _, project := range q.project {
if project.OrganizationID == organizationID {
if project.Deleted != arg.Deleted {
continue
}
if project.OrganizationID != arg.OrganizationID {
continue
}
projects = append(projects, project)
}
if len(projects) == 0 {
return nil, sql.ErrNoRows
}
return projects, nil
}
func (q *fakeQuerier) GetProjectsByIDs(_ context.Context, ids []uuid.UUID) ([]database.Project, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
projects := make([]database.Project, 0)
for _, project := range q.project {
for _, id := range ids {
if project.ID.String() != id.String() {
continue
}
projects = append(projects, project)
break
}
}
if len(projects) == 0 {
@ -507,8 +596,8 @@ func (q *fakeQuerier) GetProjectsByOrganization(_ context.Context, organizationI
}
func (q *fakeQuerier) GetOrganizationMemberByUserID(_ context.Context, arg database.GetOrganizationMemberByUserIDParams) (database.OrganizationMember, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, organizationMember := range q.organizationMembers {
if organizationMember.OrganizationID != arg.OrganizationID {
@ -523,8 +612,8 @@ func (q *fakeQuerier) GetOrganizationMemberByUserID(_ context.Context, arg datab
}
func (q *fakeQuerier) GetProvisionerDaemons(_ context.Context) ([]database.ProvisionerDaemon, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
if len(q.provisionerDaemons) == 0 {
return nil, sql.ErrNoRows
@ -533,8 +622,8 @@ func (q *fakeQuerier) GetProvisionerDaemons(_ context.Context) ([]database.Provi
}
func (q *fakeQuerier) GetWorkspaceAgentByAuthToken(_ context.Context, authToken uuid.UUID) (database.WorkspaceAgent, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, agent := range q.provisionerJobAgent {
if agent.AuthToken.String() == authToken.String() {
@ -545,8 +634,8 @@ func (q *fakeQuerier) GetWorkspaceAgentByAuthToken(_ context.Context, authToken
}
func (q *fakeQuerier) GetWorkspaceAgentByInstanceID(_ context.Context, instanceID string) (database.WorkspaceAgent, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
// The schema sorts this by created at, so we iterate the array backwards.
for i := len(q.provisionerJobAgent) - 1; i >= 0; i-- {
@ -559,8 +648,8 @@ func (q *fakeQuerier) GetWorkspaceAgentByInstanceID(_ context.Context, instanceI
}
func (q *fakeQuerier) GetWorkspaceAgentByResourceID(_ context.Context, resourceID uuid.UUID) (database.WorkspaceAgent, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, agent := range q.provisionerJobAgent {
if agent.ResourceID.String() == resourceID.String() {
@ -571,8 +660,8 @@ func (q *fakeQuerier) GetWorkspaceAgentByResourceID(_ context.Context, resourceI
}
func (q *fakeQuerier) GetProvisionerDaemonByID(_ context.Context, id uuid.UUID) (database.ProvisionerDaemon, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, provisionerDaemon := range q.provisionerDaemons {
if provisionerDaemon.ID.String() != id.String() {
@ -584,8 +673,8 @@ func (q *fakeQuerier) GetProvisionerDaemonByID(_ context.Context, id uuid.UUID)
}
func (q *fakeQuerier) GetProvisionerJobByID(_ context.Context, id uuid.UUID) (database.ProvisionerJob, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, provisionerJob := range q.provisionerJobs {
if provisionerJob.ID.String() != id.String() {
@ -597,8 +686,8 @@ func (q *fakeQuerier) GetProvisionerJobByID(_ context.Context, id uuid.UUID) (da
}
func (q *fakeQuerier) GetWorkspaceResourceByID(_ context.Context, id uuid.UUID) (database.WorkspaceResource, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, resource := range q.provisionerJobResource {
if resource.ID.String() == id.String() {
@ -609,8 +698,8 @@ func (q *fakeQuerier) GetWorkspaceResourceByID(_ context.Context, id uuid.UUID)
}
func (q *fakeQuerier) GetWorkspaceResourcesByJobID(_ context.Context, jobID uuid.UUID) ([]database.WorkspaceResource, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
resources := make([]database.WorkspaceResource, 0)
for _, resource := range q.provisionerJobResource {
@ -626,8 +715,8 @@ func (q *fakeQuerier) GetWorkspaceResourcesByJobID(_ context.Context, jobID uuid
}
func (q *fakeQuerier) GetProvisionerJobsByIDs(_ context.Context, ids []uuid.UUID) ([]database.ProvisionerJob, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
jobs := make([]database.ProvisionerJob, 0)
for _, job := range q.provisionerJobs {
@ -646,8 +735,8 @@ func (q *fakeQuerier) GetProvisionerJobsByIDs(_ context.Context, ids []uuid.UUID
}
func (q *fakeQuerier) GetProvisionerLogsByIDBetween(_ context.Context, arg database.GetProvisionerLogsByIDBetweenParams) ([]database.ProvisionerJobLog, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
q.mutex.RLock()
defer q.mutex.RUnlock()
logs := make([]database.ProvisionerJobLog, 0)
for _, jobLog := range q.provisionerJobLog {
@ -909,6 +998,7 @@ func (q *fakeQuerier) InsertWorkspaceResource(_ context.Context, arg database.In
CreatedAt: arg.CreatedAt,
JobID: arg.JobID,
Transition: arg.Transition,
Address: arg.Address,
Type: arg.Type,
Name: arg.Name,
AgentID: arg.AgentID,
@ -992,6 +1082,36 @@ func (q *fakeQuerier) UpdateAPIKeyByID(_ context.Context, arg database.UpdateAPI
return sql.ErrNoRows
}
func (q *fakeQuerier) UpdateProjectActiveVersionByID(_ context.Context, arg database.UpdateProjectActiveVersionByIDParams) error {
q.mutex.Lock()
defer q.mutex.Unlock()
for index, project := range q.project {
if project.ID.String() != arg.ID.String() {
continue
}
project.ActiveVersionID = arg.ActiveVersionID
q.project[index] = project
return nil
}
return sql.ErrNoRows
}
func (q *fakeQuerier) UpdateProjectDeletedByID(_ context.Context, arg database.UpdateProjectDeletedByIDParams) error {
q.mutex.Lock()
defer q.mutex.Unlock()
for index, project := range q.project {
if project.ID.String() != arg.ID.String() {
continue
}
project.Deleted = arg.Deleted
q.project[index] = project
return nil
}
return sql.ErrNoRows
}
func (q *fakeQuerier) UpdateProjectVersionByID(_ context.Context, arg database.UpdateProjectVersionByIDParams) error {
q.mutex.Lock()
defer q.mutex.Unlock()
@ -1024,7 +1144,7 @@ func (q *fakeQuerier) UpdateProvisionerDaemonByID(_ context.Context, arg databas
return sql.ErrNoRows
}
func (q *fakeQuerier) UpdateWorkspaceAgentByID(_ context.Context, arg database.UpdateWorkspaceAgentByIDParams) error {
func (q *fakeQuerier) UpdateWorkspaceAgentConnectionByID(_ context.Context, arg database.UpdateWorkspaceAgentConnectionByIDParams) error {
q.mutex.Lock()
defer q.mutex.Unlock()
@ -1032,7 +1152,9 @@ func (q *fakeQuerier) UpdateWorkspaceAgentByID(_ context.Context, arg database.U
if agent.ID.String() != arg.ID.String() {
continue
}
agent.UpdatedAt = arg.UpdatedAt
agent.FirstConnectedAt = arg.FirstConnectedAt
agent.LastConnectedAt = arg.LastConnectedAt
agent.DisconnectedAt = arg.DisconnectedAt
q.provisionerJobAgent[index] = agent
return nil
}
@ -1054,6 +1176,21 @@ func (q *fakeQuerier) UpdateProvisionerJobByID(_ context.Context, arg database.U
return sql.ErrNoRows
}
func (q *fakeQuerier) UpdateProvisionerJobWithCancelByID(_ context.Context, arg database.UpdateProvisionerJobWithCancelByIDParams) error {
q.mutex.Lock()
defer q.mutex.Unlock()
for index, job := range q.provisionerJobs {
if arg.ID.String() != job.ID.String() {
continue
}
job.CanceledAt = arg.CanceledAt
q.provisionerJobs[index] = job
return nil
}
return sql.ErrNoRows
}
func (q *fakeQuerier) UpdateProvisionerJobWithCompleteByID(_ context.Context, arg database.UpdateProvisionerJobWithCompleteByIDParams) error {
q.mutex.Lock()
defer q.mutex.Unlock()
@ -1064,7 +1201,7 @@ func (q *fakeQuerier) UpdateProvisionerJobWithCompleteByID(_ context.Context, ar
}
job.UpdatedAt = arg.UpdatedAt
job.CompletedAt = arg.CompletedAt
job.CancelledAt = arg.CancelledAt
job.CanceledAt = arg.CanceledAt
job.Error = arg.Error
q.provisionerJobs[index] = job
return nil
@ -1088,3 +1225,18 @@ func (q *fakeQuerier) UpdateWorkspaceBuildByID(_ context.Context, arg database.U
}
return sql.ErrNoRows
}
func (q *fakeQuerier) UpdateWorkspaceDeletedByID(_ context.Context, arg database.UpdateWorkspaceDeletedByIDParams) error {
q.mutex.Lock()
defer q.mutex.Unlock()
for index, workspace := range q.workspace {
if workspace.ID.String() != arg.ID.String() {
continue
}
workspace.Deleted = arg.Deleted
q.workspace[index] = workspace
return nil
}
return sql.ErrNoRows
}

17
database/dump.sql generated
View File

@ -169,6 +169,7 @@ CREATE TABLE projects (
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
organization_id text NOT NULL,
deleted boolean DEFAULT false NOT NULL,
name character varying(64) NOT NULL,
provisioner provisioner_type NOT NULL,
active_version_id uuid NOT NULL
@ -197,7 +198,7 @@ CREATE TABLE provisioner_jobs (
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
started_at timestamp with time zone,
cancelled_at timestamp with time zone,
canceled_at timestamp with time zone,
completed_at timestamp with time zone,
error text,
organization_id text NOT NULL,
@ -235,7 +236,10 @@ CREATE TABLE users (
CREATE TABLE workspace_agents (
id uuid NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone,
updated_at timestamp with time zone NOT NULL,
first_connected_at timestamp with time zone,
last_connected_at timestamp with time zone,
disconnected_at timestamp with time zone,
resource_id uuid NOT NULL,
auth_token uuid NOT NULL,
auth_instance_id character varying(64),
@ -265,7 +269,8 @@ CREATE TABLE workspace_resources (
created_at timestamp with time zone NOT NULL,
job_id uuid NOT NULL,
transition workspace_transition NOT NULL,
type character varying(256) NOT NULL,
address character varying(256) NOT NULL,
type character varying(192) NOT NULL,
name character varying(64) NOT NULL,
agent_id uuid
);
@ -276,6 +281,7 @@ CREATE TABLE workspaces (
updated_at timestamp with time zone NOT NULL,
owner_id text NOT NULL,
project_id uuid NOT NULL,
deleted boolean DEFAULT false NOT NULL,
name character varying(64) NOT NULL
);
@ -339,8 +345,9 @@ ALTER TABLE ONLY workspace_resources
ALTER TABLE ONLY workspaces
ADD CONSTRAINT workspaces_id_key UNIQUE (id);
ALTER TABLE ONLY workspaces
ADD CONSTRAINT workspaces_owner_id_name_key UNIQUE (owner_id, name);
CREATE UNIQUE INDEX projects_organization_id_name_idx ON projects USING btree (organization_id, name) WHERE (deleted = false);
CREATE UNIQUE INDEX workspaces_owner_id_name_idx ON workspaces USING btree (owner_id, name) WHERE (deleted = false);
ALTER TABLE ONLY parameter_schemas
ADD CONSTRAINT parameter_schemas_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE;

View File

@ -17,6 +17,7 @@ CREATE TABLE projects (
updated_at timestamptz NOT NULL,
-- Projects must be scoped to an organization.
organization_id text NOT NULL,
deleted boolean NOT NULL DEFAULT FALSE,
name varchar(64) NOT NULL,
provisioner provisioner_type NOT NULL,
-- Target's a Project Version to use for Workspaces.
@ -27,6 +28,9 @@ CREATE TABLE projects (
UNIQUE(organization_id, name)
);
-- Enforces no active projects have the same name.
CREATE UNIQUE INDEX ON projects (organization_id, name) WHERE deleted = FALSE;
-- Project Versions store historical project data. When a Project Version is imported,
-- an "import" job is queued to parse parameters. A Project Version
-- can only be used if the import job succeeds.

View File

@ -4,10 +4,13 @@ CREATE TABLE workspaces (
updated_at timestamptz NOT NULL,
owner_id text NOT NULL,
project_id uuid NOT NULL REFERENCES projects (id),
name varchar(64) NOT NULL,
UNIQUE(owner_id, name)
deleted boolean NOT NULL DEFAULT FALSE,
name varchar(64) NOT NULL
);
-- Enforces no active workspaces have the same name.
CREATE UNIQUE INDEX ON workspaces (owner_id, name) WHERE deleted = FALSE;
CREATE TYPE workspace_transition AS ENUM (
'start',
'stop',

View File

@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS provisioner_jobs (
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
started_at timestamptz,
cancelled_at timestamptz,
canceled_at timestamptz,
completed_at timestamptz,
error text,
organization_id text NOT NULL,
@ -61,7 +61,8 @@ CREATE TABLE workspace_resources (
created_at timestamptz NOT NULL,
job_id uuid NOT NULL REFERENCES provisioner_jobs (id) ON DELETE CASCADE,
transition workspace_transition NOT NULL,
type varchar(256) NOT NULL,
address varchar(256) NOT NULL,
type varchar(192) NOT NULL,
name varchar(64) NOT NULL,
agent_id uuid
);
@ -69,7 +70,10 @@ CREATE TABLE workspace_resources (
CREATE TABLE workspace_agents (
id uuid NOT NULL UNIQUE,
created_at timestamptz NOT NULL,
updated_at timestamptz,
updated_at timestamptz NOT NULL,
first_connected_at timestamptz,
last_connected_at timestamptz,
disconnected_at timestamptz,
resource_id uuid NOT NULL REFERENCES workspace_resources (id) ON DELETE CASCADE,
auth_token uuid NOT NULL UNIQUE,
auth_instance_id varchar(64),

View File

@ -338,6 +338,7 @@ type Project struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
OrganizationID string `db:"organization_id" json:"organization_id"`
Deleted bool `db:"deleted" json:"deleted"`
Name string `db:"name" json:"name"`
Provisioner ProvisionerType `db:"provisioner" json:"provisioner"`
ActiveVersionID uuid.UUID `db:"active_version_id" json:"active_version_id"`
@ -368,7 +369,7 @@ type ProvisionerJob struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
StartedAt sql.NullTime `db:"started_at" json:"started_at"`
CancelledAt sql.NullTime `db:"cancelled_at" json:"cancelled_at"`
CanceledAt sql.NullTime `db:"canceled_at" json:"canceled_at"`
CompletedAt sql.NullTime `db:"completed_at" json:"completed_at"`
Error sql.NullString `db:"error" json:"error"`
OrganizationID string `db:"organization_id" json:"organization_id"`
@ -418,13 +419,17 @@ type Workspace struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
OwnerID string `db:"owner_id" json:"owner_id"`
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
Deleted bool `db:"deleted" json:"deleted"`
Name string `db:"name" json:"name"`
}
type WorkspaceAgent struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt sql.NullTime `db:"updated_at" json:"updated_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
FirstConnectedAt sql.NullTime `db:"first_connected_at" json:"first_connected_at"`
LastConnectedAt sql.NullTime `db:"last_connected_at" json:"last_connected_at"`
DisconnectedAt sql.NullTime `db:"disconnected_at" json:"disconnected_at"`
ResourceID uuid.UUID `db:"resource_id" json:"resource_id"`
AuthToken uuid.UUID `db:"auth_token" json:"auth_token"`
AuthInstanceID sql.NullString `db:"auth_instance_id" json:"auth_instance_id"`
@ -454,6 +459,7 @@ type WorkspaceResource struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
JobID uuid.UUID `db:"job_id" json:"job_id"`
Transition WorkspaceTransition `db:"transition" json:"transition"`
Address string `db:"address" json:"address"`
Type string `db:"type" json:"type"`
Name string `db:"name" json:"name"`
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`

View File

@ -23,9 +23,11 @@ type querier interface {
GetProjectByID(ctx context.Context, id uuid.UUID) (Project, error)
GetProjectByOrganizationAndName(ctx context.Context, arg GetProjectByOrganizationAndNameParams) (Project, error)
GetProjectVersionByID(ctx context.Context, id uuid.UUID) (ProjectVersion, error)
GetProjectVersionByJobID(ctx context.Context, jobID uuid.UUID) (ProjectVersion, error)
GetProjectVersionByProjectIDAndName(ctx context.Context, arg GetProjectVersionByProjectIDAndNameParams) (ProjectVersion, error)
GetProjectVersionsByProjectID(ctx context.Context, dollar_1 uuid.UUID) ([]ProjectVersion, error)
GetProjectsByOrganization(ctx context.Context, organizationID string) ([]Project, error)
GetProjectsByIDs(ctx context.Context, ids []uuid.UUID) ([]Project, error)
GetProjectsByOrganization(ctx context.Context, arg GetProjectsByOrganizationParams) ([]Project, error)
GetProvisionerDaemonByID(ctx context.Context, id uuid.UUID) (ProvisionerDaemon, error)
GetProvisionerDaemons(ctx context.Context) ([]ProvisionerDaemon, error)
GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (ProvisionerJob, error)
@ -42,12 +44,14 @@ type querier interface {
GetWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceBuild, error)
GetWorkspaceBuildByWorkspaceIDAndName(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDAndNameParams) (WorkspaceBuild, error)
GetWorkspaceBuildByWorkspaceIDWithoutAfter(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error)
GetWorkspaceBuildsByWorkspaceIDsWithoutAfter(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error)
GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Workspace, error)
GetWorkspaceByUserIDAndName(ctx context.Context, arg GetWorkspaceByUserIDAndNameParams) (Workspace, error)
GetWorkspaceOwnerCountsByProjectIDs(ctx context.Context, ids []uuid.UUID) ([]GetWorkspaceOwnerCountsByProjectIDsRow, error)
GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error)
GetWorkspaceResourcesByJobID(ctx context.Context, jobID uuid.UUID) ([]WorkspaceResource, error)
GetWorkspacesByUserID(ctx context.Context, ownerID string) ([]Workspace, error)
GetWorkspacesByProjectID(ctx context.Context, arg GetWorkspacesByProjectIDParams) ([]Workspace, error)
GetWorkspacesByUserID(ctx context.Context, arg GetWorkspacesByUserIDParams) ([]Workspace, error)
InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error)
InsertFile(ctx context.Context, arg InsertFileParams) (File, error)
InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error)
@ -65,12 +69,16 @@ type querier interface {
InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) (WorkspaceBuild, error)
InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error)
UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error
UpdateProjectActiveVersionByID(ctx context.Context, arg UpdateProjectActiveVersionByIDParams) error
UpdateProjectDeletedByID(ctx context.Context, arg UpdateProjectDeletedByIDParams) error
UpdateProjectVersionByID(ctx context.Context, arg UpdateProjectVersionByIDParams) error
UpdateProvisionerDaemonByID(ctx context.Context, arg UpdateProvisionerDaemonByIDParams) error
UpdateProvisionerJobByID(ctx context.Context, arg UpdateProvisionerJobByIDParams) error
UpdateProvisionerJobWithCancelByID(ctx context.Context, arg UpdateProvisionerJobWithCancelByIDParams) error
UpdateProvisionerJobWithCompleteByID(ctx context.Context, arg UpdateProvisionerJobWithCompleteByIDParams) error
UpdateWorkspaceAgentByID(ctx context.Context, arg UpdateWorkspaceAgentByIDParams) error
UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error
UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) error
UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error
}
var _ querier = (*sqlQuerier)(nil)

View File

@ -5,7 +5,7 @@
;
-- Acquires the lock for a single job that isn't started, completed,
-- cancelled, and that matches an array of provisioner types.
-- canceled, and that matches an array of provisioner types.
--
-- SKIP LOCKED is used to jump over locked rows. This prevents
-- multiple provisioners from acquiring the same jobs. See:
@ -25,7 +25,7 @@ WHERE
provisioner_jobs AS nested
WHERE
nested.started_at IS NULL
AND nested.cancelled_at IS NULL
AND nested.canceled_at IS NULL
AND nested.completed_at IS NULL
AND nested.provisioner = ANY(@types :: provisioner_type [ ])
ORDER BY
@ -164,6 +164,14 @@ WHERE
LIMIT
1;
-- name: GetProjectsByIDs :many
SELECT
*
FROM
projects
WHERE
id = ANY(@ids :: uuid [ ]);
-- name: GetProjectByOrganizationAndName :one
SELECT
*
@ -171,6 +179,7 @@ FROM
projects
WHERE
organization_id = @organization_id
AND deleted = @deleted
AND LOWER(name) = LOWER(@name)
LIMIT
1;
@ -181,7 +190,8 @@ SELECT
FROM
projects
WHERE
organization_id = $1;
organization_id = $1
AND deleted = $2;
-- name: GetParameterSchemasByJobID :many
SELECT
@ -199,6 +209,14 @@ FROM
WHERE
project_id = $1 :: uuid;
-- name: GetProjectVersionByJobID :one
SELECT
*
FROM
project_versions
WHERE
job_id = $1;
-- name: GetProjectVersionByProjectIDAndName :one
SELECT
*
@ -250,7 +268,9 @@ SELECT
FROM
workspace_agents
WHERE
auth_token = $1;
auth_token = $1
ORDER BY
created_at DESC;
-- name: GetWorkspaceAgentByInstanceID :one
SELECT
@ -288,13 +308,23 @@ WHERE
LIMIT
1;
-- name: GetWorkspacesByProjectID :many
SELECT
*
FROM
workspaces
WHERE
project_id = $1
AND deleted = $2;
-- name: GetWorkspacesByUserID :many
SELECT
*
FROM
workspaces
WHERE
owner_id = $1;
owner_id = $1
AND deleted = $2;
-- name: GetWorkspaceByUserIDAndName :one
SELECT
@ -303,6 +333,7 @@ FROM
workspaces
WHERE
owner_id = @owner_id
AND deleted = @deleted
AND LOWER(name) = LOWER(@name);
-- name: GetWorkspaceOwnerCountsByProjectIDs :many
@ -365,6 +396,15 @@ WHERE
LIMIT
1;
-- name: GetWorkspaceBuildsByWorkspaceIDsWithoutAfter :many
SELECT
*
FROM
workspace_builds
WHERE
workspace_id = ANY(@ids :: uuid [ ])
AND after_id IS NULL;
-- name: GetWorkspaceResourceByID :one
SELECT
*
@ -499,12 +539,13 @@ INSERT INTO
created_at,
job_id,
transition,
address,
type,
name,
agent_id
)
VALUES
($1, $2, $3, $4, $5, $6, $7) RETURNING *;
($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *;
-- name: InsertProjectVersion :one
INSERT INTO
@ -660,6 +701,22 @@ SET
WHERE
id = $1;
-- name: UpdateProjectActiveVersionByID :exec
UPDATE
projects
SET
active_version_id = $2
WHERE
id = $1;
-- name: UpdateProjectDeletedByID :exec
UPDATE
projects
SET
deleted = $2
WHERE
id = $1;
-- name: UpdateProjectVersionByID :exec
UPDATE
project_versions
@ -686,22 +743,40 @@ SET
WHERE
id = $1;
-- name: UpdateProvisionerJobWithCancelByID :exec
UPDATE
provisioner_jobs
SET
canceled_at = $2
WHERE
id = $1;
-- name: UpdateProvisionerJobWithCompleteByID :exec
UPDATE
provisioner_jobs
SET
updated_at = $2,
completed_at = $3,
cancelled_at = $4,
canceled_at = $4,
error = $5
WHERE
id = $1;
-- name: UpdateWorkspaceAgentByID :exec
-- name: UpdateWorkspaceDeletedByID :exec
UPDATE
workspaces
SET
deleted = $2
WHERE
id = $1;
-- name: UpdateWorkspaceAgentConnectionByID :exec
UPDATE
workspace_agents
SET
updated_at = $2
first_connected_at = $2,
last_connected_at = $3,
disconnected_at = $4
WHERE
id = $1;

View File

@ -29,7 +29,7 @@ WHERE
provisioner_jobs AS nested
WHERE
nested.started_at IS NULL
AND nested.cancelled_at IS NULL
AND nested.canceled_at IS NULL
AND nested.completed_at IS NULL
AND nested.provisioner = ANY($3 :: provisioner_type [ ])
ORDER BY
@ -38,7 +38,7 @@ WHERE
SKIP LOCKED
LIMIT
1
) RETURNING id, created_at, updated_at, started_at, cancelled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, storage_source, type, input, worker_id
) RETURNING id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, storage_source, type, input, worker_id
`
type AcquireProvisionerJobParams struct {
@ -48,7 +48,7 @@ type AcquireProvisionerJobParams struct {
}
// Acquires the lock for a single job that isn't started, completed,
// cancelled, and that matches an array of provisioner types.
// canceled, and that matches an array of provisioner types.
//
// SKIP LOCKED is used to jump over locked rows. This prevents
// multiple provisioners from acquiring the same jobs. See:
@ -61,7 +61,7 @@ func (q *sqlQuerier) AcquireProvisionerJob(ctx context.Context, arg AcquireProvi
&i.CreatedAt,
&i.UpdatedAt,
&i.StartedAt,
&i.CancelledAt,
&i.CanceledAt,
&i.CompletedAt,
&i.Error,
&i.OrganizationID,
@ -417,7 +417,7 @@ func (q *sqlQuerier) GetParameterValuesByScope(ctx context.Context, arg GetParam
const getProjectByID = `-- name: GetProjectByID :one
SELECT
id, created_at, updated_at, organization_id, name, provisioner, active_version_id
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id
FROM
projects
WHERE
@ -434,6 +434,7 @@ func (q *sqlQuerier) GetProjectByID(ctx context.Context, id uuid.UUID) (Project,
&i.CreatedAt,
&i.UpdatedAt,
&i.OrganizationID,
&i.Deleted,
&i.Name,
&i.Provisioner,
&i.ActiveVersionID,
@ -443,29 +444,32 @@ func (q *sqlQuerier) GetProjectByID(ctx context.Context, id uuid.UUID) (Project,
const getProjectByOrganizationAndName = `-- name: GetProjectByOrganizationAndName :one
SELECT
id, created_at, updated_at, organization_id, name, provisioner, active_version_id
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id
FROM
projects
WHERE
organization_id = $1
AND LOWER(name) = LOWER($2)
AND deleted = $2
AND LOWER(name) = LOWER($3)
LIMIT
1
`
type GetProjectByOrganizationAndNameParams struct {
OrganizationID string `db:"organization_id" json:"organization_id"`
Deleted bool `db:"deleted" json:"deleted"`
Name string `db:"name" json:"name"`
}
func (q *sqlQuerier) GetProjectByOrganizationAndName(ctx context.Context, arg GetProjectByOrganizationAndNameParams) (Project, error) {
row := q.db.QueryRowContext(ctx, getProjectByOrganizationAndName, arg.OrganizationID, arg.Name)
row := q.db.QueryRowContext(ctx, getProjectByOrganizationAndName, arg.OrganizationID, arg.Deleted, arg.Name)
var i Project
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.OrganizationID,
&i.Deleted,
&i.Name,
&i.Provisioner,
&i.ActiveVersionID,
@ -498,6 +502,31 @@ func (q *sqlQuerier) GetProjectVersionByID(ctx context.Context, id uuid.UUID) (P
return i, err
}
const getProjectVersionByJobID = `-- name: GetProjectVersionByJobID :one
SELECT
id, project_id, organization_id, created_at, updated_at, name, description, job_id
FROM
project_versions
WHERE
job_id = $1
`
func (q *sqlQuerier) GetProjectVersionByJobID(ctx context.Context, jobID uuid.UUID) (ProjectVersion, error) {
row := q.db.QueryRowContext(ctx, getProjectVersionByJobID, jobID)
var i ProjectVersion
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.OrganizationID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Name,
&i.Description,
&i.JobID,
)
return i, err
}
const getProjectVersionByProjectIDAndName = `-- name: GetProjectVersionByProjectIDAndName :one
SELECT
id, project_id, organization_id, created_at, updated_at, name, description, job_id
@ -570,17 +599,17 @@ func (q *sqlQuerier) GetProjectVersionsByProjectID(ctx context.Context, dollar_1
return items, nil
}
const getProjectsByOrganization = `-- name: GetProjectsByOrganization :many
const getProjectsByIDs = `-- name: GetProjectsByIDs :many
SELECT
id, created_at, updated_at, organization_id, name, provisioner, active_version_id
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id
FROM
projects
WHERE
organization_id = $1
id = ANY($1 :: uuid [ ])
`
func (q *sqlQuerier) GetProjectsByOrganization(ctx context.Context, organizationID string) ([]Project, error) {
rows, err := q.db.QueryContext(ctx, getProjectsByOrganization, organizationID)
func (q *sqlQuerier) GetProjectsByIDs(ctx context.Context, ids []uuid.UUID) ([]Project, error) {
rows, err := q.db.QueryContext(ctx, getProjectsByIDs, pq.Array(ids))
if err != nil {
return nil, err
}
@ -593,6 +622,54 @@ func (q *sqlQuerier) GetProjectsByOrganization(ctx context.Context, organization
&i.CreatedAt,
&i.UpdatedAt,
&i.OrganizationID,
&i.Deleted,
&i.Name,
&i.Provisioner,
&i.ActiveVersionID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getProjectsByOrganization = `-- name: GetProjectsByOrganization :many
SELECT
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id
FROM
projects
WHERE
organization_id = $1
AND deleted = $2
`
type GetProjectsByOrganizationParams struct {
OrganizationID string `db:"organization_id" json:"organization_id"`
Deleted bool `db:"deleted" json:"deleted"`
}
func (q *sqlQuerier) GetProjectsByOrganization(ctx context.Context, arg GetProjectsByOrganizationParams) ([]Project, error) {
rows, err := q.db.QueryContext(ctx, getProjectsByOrganization, arg.OrganizationID, arg.Deleted)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Project
for rows.Next() {
var i Project
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.OrganizationID,
&i.Deleted,
&i.Name,
&i.Provisioner,
&i.ActiveVersionID,
@ -672,7 +749,7 @@ func (q *sqlQuerier) GetProvisionerDaemons(ctx context.Context) ([]ProvisionerDa
const getProvisionerJobByID = `-- name: GetProvisionerJobByID :one
SELECT
id, created_at, updated_at, started_at, cancelled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, storage_source, type, input, worker_id
id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, storage_source, type, input, worker_id
FROM
provisioner_jobs
WHERE
@ -687,7 +764,7 @@ func (q *sqlQuerier) GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (P
&i.CreatedAt,
&i.UpdatedAt,
&i.StartedAt,
&i.CancelledAt,
&i.CanceledAt,
&i.CompletedAt,
&i.Error,
&i.OrganizationID,
@ -704,7 +781,7 @@ func (q *sqlQuerier) GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (P
const getProvisionerJobsByIDs = `-- name: GetProvisionerJobsByIDs :many
SELECT
id, created_at, updated_at, started_at, cancelled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, storage_source, type, input, worker_id
id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, storage_source, type, input, worker_id
FROM
provisioner_jobs
WHERE
@ -725,7 +802,7 @@ func (q *sqlQuerier) GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUI
&i.CreatedAt,
&i.UpdatedAt,
&i.StartedAt,
&i.CancelledAt,
&i.CanceledAt,
&i.CompletedAt,
&i.Error,
&i.OrganizationID,
@ -899,11 +976,13 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context) (int64, error) {
const getWorkspaceAgentByAuthToken = `-- name: GetWorkspaceAgentByAuthToken :one
SELECT
id, created_at, updated_at, resource_id, auth_token, auth_instance_id, environment_variables, startup_script, instance_metadata, resource_metadata
id, created_at, updated_at, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, environment_variables, startup_script, instance_metadata, resource_metadata
FROM
workspace_agents
WHERE
auth_token = $1
ORDER BY
created_at DESC
`
func (q *sqlQuerier) GetWorkspaceAgentByAuthToken(ctx context.Context, authToken uuid.UUID) (WorkspaceAgent, error) {
@ -913,6 +992,9 @@ func (q *sqlQuerier) GetWorkspaceAgentByAuthToken(ctx context.Context, authToken
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.FirstConnectedAt,
&i.LastConnectedAt,
&i.DisconnectedAt,
&i.ResourceID,
&i.AuthToken,
&i.AuthInstanceID,
@ -926,7 +1008,7 @@ func (q *sqlQuerier) GetWorkspaceAgentByAuthToken(ctx context.Context, authToken
const getWorkspaceAgentByInstanceID = `-- name: GetWorkspaceAgentByInstanceID :one
SELECT
id, created_at, updated_at, resource_id, auth_token, auth_instance_id, environment_variables, startup_script, instance_metadata, resource_metadata
id, created_at, updated_at, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, environment_variables, startup_script, instance_metadata, resource_metadata
FROM
workspace_agents
WHERE
@ -942,6 +1024,9 @@ func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInst
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.FirstConnectedAt,
&i.LastConnectedAt,
&i.DisconnectedAt,
&i.ResourceID,
&i.AuthToken,
&i.AuthInstanceID,
@ -955,7 +1040,7 @@ func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInst
const getWorkspaceAgentByResourceID = `-- name: GetWorkspaceAgentByResourceID :one
SELECT
id, created_at, updated_at, resource_id, auth_token, auth_instance_id, environment_variables, startup_script, instance_metadata, resource_metadata
id, created_at, updated_at, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, environment_variables, startup_script, instance_metadata, resource_metadata
FROM
workspace_agents
WHERE
@ -969,6 +1054,9 @@ func (q *sqlQuerier) GetWorkspaceAgentByResourceID(ctx context.Context, resource
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.FirstConnectedAt,
&i.LastConnectedAt,
&i.DisconnectedAt,
&i.ResourceID,
&i.AuthToken,
&i.AuthInstanceID,
@ -1154,9 +1242,55 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDWithoutAfter(ctx context.Cont
return i, err
}
const getWorkspaceBuildsByWorkspaceIDsWithoutAfter = `-- name: GetWorkspaceBuildsByWorkspaceIDsWithoutAfter :many
SELECT
id, created_at, updated_at, workspace_id, project_version_id, name, before_id, after_id, transition, initiator, provisioner_state, job_id
FROM
workspace_builds
WHERE
workspace_id = ANY($1 :: uuid [ ])
AND after_id IS NULL
`
func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceIDsWithoutAfter(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) {
rows, err := q.db.QueryContext(ctx, getWorkspaceBuildsByWorkspaceIDsWithoutAfter, pq.Array(ids))
if err != nil {
return nil, err
}
defer rows.Close()
var items []WorkspaceBuild
for rows.Next() {
var i WorkspaceBuild
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.WorkspaceID,
&i.ProjectVersionID,
&i.Name,
&i.BeforeID,
&i.AfterID,
&i.Transition,
&i.Initiator,
&i.ProvisionerState,
&i.JobID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getWorkspaceByID = `-- name: GetWorkspaceByID :one
SELECT
id, created_at, updated_at, owner_id, project_id, name
id, created_at, updated_at, owner_id, project_id, deleted, name
FROM
workspaces
WHERE
@ -1174,6 +1308,7 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp
&i.UpdatedAt,
&i.OwnerID,
&i.ProjectID,
&i.Deleted,
&i.Name,
)
return i, err
@ -1181,21 +1316,23 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp
const getWorkspaceByUserIDAndName = `-- name: GetWorkspaceByUserIDAndName :one
SELECT
id, created_at, updated_at, owner_id, project_id, name
id, created_at, updated_at, owner_id, project_id, deleted, name
FROM
workspaces
WHERE
owner_id = $1
AND LOWER(name) = LOWER($2)
AND deleted = $2
AND LOWER(name) = LOWER($3)
`
type GetWorkspaceByUserIDAndNameParams struct {
OwnerID string `db:"owner_id" json:"owner_id"`
Deleted bool `db:"deleted" json:"deleted"`
Name string `db:"name" json:"name"`
}
func (q *sqlQuerier) GetWorkspaceByUserIDAndName(ctx context.Context, arg GetWorkspaceByUserIDAndNameParams) (Workspace, error) {
row := q.db.QueryRowContext(ctx, getWorkspaceByUserIDAndName, arg.OwnerID, arg.Name)
row := q.db.QueryRowContext(ctx, getWorkspaceByUserIDAndName, arg.OwnerID, arg.Deleted, arg.Name)
var i Workspace
err := row.Scan(
&i.ID,
@ -1203,6 +1340,7 @@ func (q *sqlQuerier) GetWorkspaceByUserIDAndName(ctx context.Context, arg GetWor
&i.UpdatedAt,
&i.OwnerID,
&i.ProjectID,
&i.Deleted,
&i.Name,
)
return i, err
@ -1251,7 +1389,7 @@ func (q *sqlQuerier) GetWorkspaceOwnerCountsByProjectIDs(ctx context.Context, id
const getWorkspaceResourceByID = `-- name: GetWorkspaceResourceByID :one
SELECT
id, created_at, job_id, transition, type, name, agent_id
id, created_at, job_id, transition, address, type, name, agent_id
FROM
workspace_resources
WHERE
@ -1266,6 +1404,7 @@ func (q *sqlQuerier) GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID)
&i.CreatedAt,
&i.JobID,
&i.Transition,
&i.Address,
&i.Type,
&i.Name,
&i.AgentID,
@ -1275,7 +1414,7 @@ func (q *sqlQuerier) GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID)
const getWorkspaceResourcesByJobID = `-- name: GetWorkspaceResourcesByJobID :many
SELECT
id, created_at, job_id, transition, type, name, agent_id
id, created_at, job_id, transition, address, type, name, agent_id
FROM
workspace_resources
WHERE
@ -1296,6 +1435,7 @@ func (q *sqlQuerier) GetWorkspaceResourcesByJobID(ctx context.Context, jobID uui
&i.CreatedAt,
&i.JobID,
&i.Transition,
&i.Address,
&i.Type,
&i.Name,
&i.AgentID,
@ -1313,17 +1453,23 @@ func (q *sqlQuerier) GetWorkspaceResourcesByJobID(ctx context.Context, jobID uui
return items, nil
}
const getWorkspacesByUserID = `-- name: GetWorkspacesByUserID :many
const getWorkspacesByProjectID = `-- name: GetWorkspacesByProjectID :many
SELECT
id, created_at, updated_at, owner_id, project_id, name
id, created_at, updated_at, owner_id, project_id, deleted, name
FROM
workspaces
WHERE
owner_id = $1
project_id = $1
AND deleted = $2
`
func (q *sqlQuerier) GetWorkspacesByUserID(ctx context.Context, ownerID string) ([]Workspace, error) {
rows, err := q.db.QueryContext(ctx, getWorkspacesByUserID, ownerID)
type GetWorkspacesByProjectIDParams struct {
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
Deleted bool `db:"deleted" json:"deleted"`
}
func (q *sqlQuerier) GetWorkspacesByProjectID(ctx context.Context, arg GetWorkspacesByProjectIDParams) ([]Workspace, error) {
rows, err := q.db.QueryContext(ctx, getWorkspacesByProjectID, arg.ProjectID, arg.Deleted)
if err != nil {
return nil, err
}
@ -1337,6 +1483,53 @@ func (q *sqlQuerier) GetWorkspacesByUserID(ctx context.Context, ownerID string)
&i.UpdatedAt,
&i.OwnerID,
&i.ProjectID,
&i.Deleted,
&i.Name,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getWorkspacesByUserID = `-- name: GetWorkspacesByUserID :many
SELECT
id, created_at, updated_at, owner_id, project_id, deleted, name
FROM
workspaces
WHERE
owner_id = $1
AND deleted = $2
`
type GetWorkspacesByUserIDParams struct {
OwnerID string `db:"owner_id" json:"owner_id"`
Deleted bool `db:"deleted" json:"deleted"`
}
func (q *sqlQuerier) GetWorkspacesByUserID(ctx context.Context, arg GetWorkspacesByUserIDParams) ([]Workspace, error) {
rows, err := q.db.QueryContext(ctx, getWorkspacesByUserID, arg.OwnerID, arg.Deleted)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Workspace
for rows.Next() {
var i Workspace
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.OwnerID,
&i.ProjectID,
&i.Deleted,
&i.Name,
); err != nil {
return nil, err
@ -1730,7 +1923,7 @@ INSERT INTO
active_version_id
)
VALUES
($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, updated_at, organization_id, name, provisioner, active_version_id
($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id
`
type InsertProjectParams struct {
@ -1759,6 +1952,7 @@ func (q *sqlQuerier) InsertProject(ctx context.Context, arg InsertProjectParams)
&i.CreatedAt,
&i.UpdatedAt,
&i.OrganizationID,
&i.Deleted,
&i.Name,
&i.Provisioner,
&i.ActiveVersionID,
@ -1868,7 +2062,7 @@ INSERT INTO
input
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at, started_at, cancelled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, storage_source, type, input, worker_id
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, storage_source, type, input, worker_id
`
type InsertProvisionerJobParams struct {
@ -1903,7 +2097,7 @@ func (q *sqlQuerier) InsertProvisionerJob(ctx context.Context, arg InsertProvisi
&i.CreatedAt,
&i.UpdatedAt,
&i.StartedAt,
&i.CancelledAt,
&i.CanceledAt,
&i.CompletedAt,
&i.Error,
&i.OrganizationID,
@ -2051,7 +2245,7 @@ INSERT INTO
name
)
VALUES
($1, $2, $3, $4, $5, $6) RETURNING id, created_at, updated_at, owner_id, project_id, name
($1, $2, $3, $4, $5, $6) RETURNING id, created_at, updated_at, owner_id, project_id, deleted, name
`
type InsertWorkspaceParams struct {
@ -2079,6 +2273,7 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar
&i.UpdatedAt,
&i.OwnerID,
&i.ProjectID,
&i.Deleted,
&i.Name,
)
return i, err
@ -2099,13 +2294,13 @@ INSERT INTO
resource_metadata
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at, resource_id, auth_token, auth_instance_id, environment_variables, startup_script, instance_metadata, resource_metadata
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, environment_variables, startup_script, instance_metadata, resource_metadata
`
type InsertWorkspaceAgentParams struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt sql.NullTime `db:"updated_at" json:"updated_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ResourceID uuid.UUID `db:"resource_id" json:"resource_id"`
AuthToken uuid.UUID `db:"auth_token" json:"auth_token"`
AuthInstanceID sql.NullString `db:"auth_instance_id" json:"auth_instance_id"`
@ -2133,6 +2328,9 @@ func (q *sqlQuerier) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspa
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.FirstConnectedAt,
&i.LastConnectedAt,
&i.DisconnectedAt,
&i.ResourceID,
&i.AuthToken,
&i.AuthInstanceID,
@ -2216,12 +2414,13 @@ INSERT INTO
created_at,
job_id,
transition,
address,
type,
name,
agent_id
)
VALUES
($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, job_id, transition, type, name, agent_id
($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, created_at, job_id, transition, address, type, name, agent_id
`
type InsertWorkspaceResourceParams struct {
@ -2229,6 +2428,7 @@ type InsertWorkspaceResourceParams struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
JobID uuid.UUID `db:"job_id" json:"job_id"`
Transition WorkspaceTransition `db:"transition" json:"transition"`
Address string `db:"address" json:"address"`
Type string `db:"type" json:"type"`
Name string `db:"name" json:"name"`
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
@ -2240,6 +2440,7 @@ func (q *sqlQuerier) InsertWorkspaceResource(ctx context.Context, arg InsertWork
arg.CreatedAt,
arg.JobID,
arg.Transition,
arg.Address,
arg.Type,
arg.Name,
arg.AgentID,
@ -2250,6 +2451,7 @@ func (q *sqlQuerier) InsertWorkspaceResource(ctx context.Context, arg InsertWork
&i.CreatedAt,
&i.JobID,
&i.Transition,
&i.Address,
&i.Type,
&i.Name,
&i.AgentID,
@ -2291,6 +2493,44 @@ func (q *sqlQuerier) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDP
return err
}
const updateProjectActiveVersionByID = `-- name: UpdateProjectActiveVersionByID :exec
UPDATE
projects
SET
active_version_id = $2
WHERE
id = $1
`
type UpdateProjectActiveVersionByIDParams struct {
ID uuid.UUID `db:"id" json:"id"`
ActiveVersionID uuid.UUID `db:"active_version_id" json:"active_version_id"`
}
func (q *sqlQuerier) UpdateProjectActiveVersionByID(ctx context.Context, arg UpdateProjectActiveVersionByIDParams) error {
_, err := q.db.ExecContext(ctx, updateProjectActiveVersionByID, arg.ID, arg.ActiveVersionID)
return err
}
const updateProjectDeletedByID = `-- name: UpdateProjectDeletedByID :exec
UPDATE
projects
SET
deleted = $2
WHERE
id = $1
`
type UpdateProjectDeletedByIDParams struct {
ID uuid.UUID `db:"id" json:"id"`
Deleted bool `db:"deleted" json:"deleted"`
}
func (q *sqlQuerier) UpdateProjectDeletedByID(ctx context.Context, arg UpdateProjectDeletedByIDParams) error {
_, err := q.db.ExecContext(ctx, updateProjectDeletedByID, arg.ID, arg.Deleted)
return err
}
const updateProjectVersionByID = `-- name: UpdateProjectVersionByID :exec
UPDATE
project_versions
@ -2352,13 +2592,32 @@ func (q *sqlQuerier) UpdateProvisionerJobByID(ctx context.Context, arg UpdatePro
return err
}
const updateProvisionerJobWithCancelByID = `-- name: UpdateProvisionerJobWithCancelByID :exec
UPDATE
provisioner_jobs
SET
canceled_at = $2
WHERE
id = $1
`
type UpdateProvisionerJobWithCancelByIDParams struct {
ID uuid.UUID `db:"id" json:"id"`
CanceledAt sql.NullTime `db:"canceled_at" json:"canceled_at"`
}
func (q *sqlQuerier) UpdateProvisionerJobWithCancelByID(ctx context.Context, arg UpdateProvisionerJobWithCancelByIDParams) error {
_, err := q.db.ExecContext(ctx, updateProvisionerJobWithCancelByID, arg.ID, arg.CanceledAt)
return err
}
const updateProvisionerJobWithCompleteByID = `-- name: UpdateProvisionerJobWithCompleteByID :exec
UPDATE
provisioner_jobs
SET
updated_at = $2,
completed_at = $3,
cancelled_at = $4,
canceled_at = $4,
error = $5
WHERE
id = $1
@ -2368,7 +2627,7 @@ type UpdateProvisionerJobWithCompleteByIDParams struct {
ID uuid.UUID `db:"id" json:"id"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
CompletedAt sql.NullTime `db:"completed_at" json:"completed_at"`
CancelledAt sql.NullTime `db:"cancelled_at" json:"cancelled_at"`
CanceledAt sql.NullTime `db:"canceled_at" json:"canceled_at"`
Error sql.NullString `db:"error" json:"error"`
}
@ -2377,28 +2636,37 @@ func (q *sqlQuerier) UpdateProvisionerJobWithCompleteByID(ctx context.Context, a
arg.ID,
arg.UpdatedAt,
arg.CompletedAt,
arg.CancelledAt,
arg.CanceledAt,
arg.Error,
)
return err
}
const updateWorkspaceAgentByID = `-- name: UpdateWorkspaceAgentByID :exec
const updateWorkspaceAgentConnectionByID = `-- name: UpdateWorkspaceAgentConnectionByID :exec
UPDATE
workspace_agents
SET
updated_at = $2
first_connected_at = $2,
last_connected_at = $3,
disconnected_at = $4
WHERE
id = $1
`
type UpdateWorkspaceAgentByIDParams struct {
ID uuid.UUID `db:"id" json:"id"`
UpdatedAt sql.NullTime `db:"updated_at" json:"updated_at"`
type UpdateWorkspaceAgentConnectionByIDParams struct {
ID uuid.UUID `db:"id" json:"id"`
FirstConnectedAt sql.NullTime `db:"first_connected_at" json:"first_connected_at"`
LastConnectedAt sql.NullTime `db:"last_connected_at" json:"last_connected_at"`
DisconnectedAt sql.NullTime `db:"disconnected_at" json:"disconnected_at"`
}
func (q *sqlQuerier) UpdateWorkspaceAgentByID(ctx context.Context, arg UpdateWorkspaceAgentByIDParams) error {
_, err := q.db.ExecContext(ctx, updateWorkspaceAgentByID, arg.ID, arg.UpdatedAt)
func (q *sqlQuerier) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error {
_, err := q.db.ExecContext(ctx, updateWorkspaceAgentConnectionByID,
arg.ID,
arg.FirstConnectedAt,
arg.LastConnectedAt,
arg.DisconnectedAt,
)
return err
}
@ -2429,3 +2697,22 @@ func (q *sqlQuerier) UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWor
)
return err
}
const updateWorkspaceDeletedByID = `-- name: UpdateWorkspaceDeletedByID :exec
UPDATE
workspaces
SET
deleted = $2
WHERE
id = $1
`
type UpdateWorkspaceDeletedByIDParams struct {
ID uuid.UUID `db:"id" json:"id"`
Deleted bool `db:"deleted" json:"deleted"`
}
func (q *sqlQuerier) UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error {
_, err := q.db.ExecContext(ctx, updateWorkspaceDeletedByID, arg.ID, arg.Deleted)
return err
}

View File

@ -31,5 +31,5 @@ function create_initial_user() {
trap 'kill 0' SIGINT
create_initial_user &
CODERV2_HOST=http://127.0.0.1:3000 yarn --cwd=./site dev &
go run cmd/coder/main.go daemon
go run cmd/coder/main.go start
)

157
examples/examples.go Normal file
View File

@ -0,0 +1,157 @@
package examples
import (
"archive/tar"
"bytes"
"embed"
"path"
"sync"
"github.com/gohugoio/hugo/parser/pageparser"
"golang.org/x/sync/singleflight"
"golang.org/x/xerrors"
)
var (
//go:embed */*.md
//go:embed */*.tf
files embed.FS
examples = make([]Example, 0)
parseExamples sync.Once
archives = singleflight.Group{}
)
type Example struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Markdown string `json:"markdown"`
}
// List returns all embedded examples.
func List() ([]Example, error) {
var returnError error
parseExamples.Do(func() {
dirs, err := files.ReadDir(".")
if err != nil {
returnError = xerrors.Errorf("read dir: %w", err)
return
}
for _, dir := range dirs {
exampleID := dir.Name()
// Each one of these is a example!
readme, err := files.ReadFile(path.Join(dir.Name(), "README.md"))
if err != nil {
returnError = xerrors.Errorf("example %q does not contain README.md", exampleID)
return
}
frontMatter, err := pageparser.ParseFrontMatterAndContent(bytes.NewReader(readme))
if err != nil {
returnError = xerrors.Errorf("parse example %q front matter: %w", exampleID, err)
return
}
nameRaw, exists := frontMatter.FrontMatter["name"]
if !exists {
returnError = xerrors.Errorf("example %q front matter does not contain name", exampleID)
return
}
name, valid := nameRaw.(string)
if !valid {
returnError = xerrors.Errorf("example %q name isn't a string", exampleID)
return
}
descriptionRaw, exists := frontMatter.FrontMatter["description"]
if !exists {
returnError = xerrors.Errorf("example %q front matter does not contain name", exampleID)
return
}
description, valid := descriptionRaw.(string)
if !valid {
returnError = xerrors.Errorf("example %q description isn't a string", exampleID)
return
}
examples = append(examples, Example{
ID: exampleID,
Name: name,
Description: description,
Markdown: string(frontMatter.Content),
})
}
})
return examples, returnError
}
// Archive returns a tar by example ID.
func Archive(exampleID string) ([]byte, error) {
rawData, err, _ := archives.Do(exampleID, func() (interface{}, error) {
examples, err := List()
if err != nil {
return nil, err
}
var selected Example
for _, example := range examples {
if example.ID != exampleID {
continue
}
selected = example
break
}
if selected.ID == "" {
return nil, xerrors.Errorf("example with id %q not found", exampleID)
}
entries, err := files.ReadDir(exampleID)
if err != nil {
return nil, xerrors.Errorf("read dir: %w", err)
}
var buffer bytes.Buffer
tarWriter := tar.NewWriter(&buffer)
for _, entry := range entries {
file, err := files.Open(path.Join(exampleID, entry.Name()))
if err != nil {
return nil, xerrors.Errorf("open file: %w", err)
}
info, err := file.Stat()
if err != nil {
return nil, xerrors.Errorf("stat file: %w", err)
}
if info.IsDir() {
continue
}
data := make([]byte, info.Size())
_, err = file.Read(data)
if err != nil {
return nil, xerrors.Errorf("read data: %w", err)
}
header, err := tar.FileInfoHeader(info, entry.Name())
if err != nil {
return nil, xerrors.Errorf("get file header: %w", err)
}
header.Mode = 0644
err = tarWriter.WriteHeader(header)
if err != nil {
return nil, xerrors.Errorf("write file: %w", err)
}
_, err = tarWriter.Write(data)
if err != nil {
return nil, xerrors.Errorf("write: %w", err)
}
}
err = tarWriter.Flush()
if err != nil {
return nil, xerrors.Errorf("flush archive: %w", err)
}
return buffer.Bytes(), nil
})
if err != nil {
return nil, err
}
data, valid := rawData.([]byte)
if !valid {
panic("dev error: data must be a byte slice")
}
return data, nil
}

Some files were not shown because too many files have changed in this diff Show More