mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: Add systemd service and production deployment (#545)
* feat: Add systemd service and production deployment This modifies CI to use a dpkg produced from release to update and run Coder on a tiny VM in GCP. It's intentionally kept simple, because customers should be able to get this same easy install experience. * Update globalSetup.ts * Update globalSetup.ts * Update globalSetup.ts * Update coder.yaml * Use pinned version of Go
This commit is contained in:
+10
-3
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
type JobOptions struct {
|
||||
Title string
|
||||
Output bool
|
||||
Fetch func() (codersdk.ProvisionerJob, error)
|
||||
Cancel func() error
|
||||
Logs func() (<-chan codersdk.ProvisionerJobLog, error)
|
||||
@@ -40,7 +41,7 @@ func Job(cmd *cobra.Command, opts JobOptions) (codersdk.ProvisionerJob, error) {
|
||||
var err error
|
||||
job, err = opts.Fetch()
|
||||
if err != nil {
|
||||
// If a single fetch fails, it could be a one-off.
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), defaultStyles.Error.Render(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -94,12 +95,15 @@ func Job(cmd *cobra.Command, opts JobOptions) (codersdk.ProvisionerJob, error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
signal.Stop(stopChan)
|
||||
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)
|
||||
spin.Stop()
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), defaultStyles.Error.Render(err.Error()))
|
||||
return
|
||||
}
|
||||
refresh()
|
||||
}()
|
||||
@@ -123,12 +127,15 @@ func Job(cmd *cobra.Command, opts JobOptions) (codersdk.ProvisionerJob, error) {
|
||||
case log, ok := <-logs:
|
||||
if !ok {
|
||||
refresh()
|
||||
continue
|
||||
return job, nil
|
||||
}
|
||||
if !firstLog {
|
||||
refresh()
|
||||
firstLog = true
|
||||
}
|
||||
if !opts.Output {
|
||||
continue
|
||||
}
|
||||
spin.Stop()
|
||||
var style lipgloss.Style
|
||||
switch log.Level {
|
||||
|
||||
+211
-53
@@ -2,15 +2,18 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/briandowns/spinner"
|
||||
"github.com/coreos/go-systemd/daemon"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
"google.golang.org/api/idtoken"
|
||||
@@ -19,6 +22,7 @@ import (
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/cli/config"
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/coderd/tunnel"
|
||||
"github.com/coder/coder/codersdk"
|
||||
@@ -32,14 +36,28 @@ import (
|
||||
|
||||
func start() *cobra.Command {
|
||||
var (
|
||||
address string
|
||||
dev bool
|
||||
useTunnel bool
|
||||
address string
|
||||
postgresURL string
|
||||
provisionerDaemonCount uint8
|
||||
dev bool
|
||||
useTunnel bool
|
||||
)
|
||||
root := &cobra.Command{
|
||||
Use: "start",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
logger := slog.Make(sloghuman.Sink(os.Stderr))
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), ` ▄█▀ ▀█▄
|
||||
▄▄ ▀▀▀ █▌ ██▀▀█▄ ▐█
|
||||
▄▄██▀▀█▄▄▄ ██ ██ █▀▀█ ▐█▀▀██ ▄█▀▀█ █▀▀
|
||||
█▌ ▄▌ ▐█ █▌ ▀█▄▄▄█▌ █ █ ▐█ ██ ██▀▀ █
|
||||
██████▀▄█ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀ ▀
|
||||
|
||||
`)
|
||||
|
||||
if postgresURL == "" {
|
||||
// Default to the environment variable!
|
||||
postgresURL = os.Getenv("CODER_PG_CONNECTION_URL")
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("listen %q: %w", address, err)
|
||||
@@ -49,6 +67,7 @@ func start() *cobra.Command {
|
||||
if !valid {
|
||||
return xerrors.New("must be listening on tcp")
|
||||
}
|
||||
// If just a port is specified, assume localhost.
|
||||
if tcpAddr.IP.IsUnspecified() {
|
||||
tcpAddr.IP = net.IPv4(127, 0, 0, 1)
|
||||
}
|
||||
@@ -59,8 +78,16 @@ func start() *cobra.Command {
|
||||
}
|
||||
accessURL := localURL
|
||||
var tunnelErr <-chan error
|
||||
if dev {
|
||||
if useTunnel {
|
||||
// If we're attempting to tunnel in dev-mode, the access URL
|
||||
// needs to be changed to use the tunnel.
|
||||
if dev && useTunnel {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Coder requires a network endpoint that can be accessed by provisioned workspaces. In dev mode, a free tunnel can be created for you. This will expose your Coder deployment to the internet.")+"\n")
|
||||
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Would you like Coder to start a tunnel for simple setup?",
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err == nil {
|
||||
var accessURLRaw string
|
||||
accessURLRaw, tunnelErr, err = tunnel.New(cmd.Context(), localURL.String())
|
||||
if err != nil {
|
||||
@@ -70,40 +97,60 @@ func start() *cobra.Command {
|
||||
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()+`Tunnel started. Your deployment is accessible at:`))+"\n "+cliui.Styles.Field.Render(accessURL.String()))
|
||||
}
|
||||
|
||||
_, _ = 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{
|
||||
|
||||
logger := slog.Make(sloghuman.Sink(os.Stderr))
|
||||
options := &coderd.Options{
|
||||
AccessURL: accessURL,
|
||||
Logger: logger,
|
||||
Logger: logger.Named("coderd"),
|
||||
Database: databasefake.New(),
|
||||
Pubsub: database.NewPubsubInMemory(),
|
||||
GoogleTokenValidator: validator,
|
||||
})
|
||||
}
|
||||
|
||||
if !dev {
|
||||
sqlDB, err := sql.Open("postgres", postgresURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("dial postgres: %w", err)
|
||||
}
|
||||
err = sqlDB.Ping()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("ping postgres: %w", err)
|
||||
}
|
||||
err = database.MigrateUp(sqlDB)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("migrate up: %w", err)
|
||||
}
|
||||
options.Database = database.New(sqlDB)
|
||||
options.Pubsub, err = database.NewPubsub(cmd.Context(), sqlDB, postgresURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create pubsub: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
handler, closeCoderd := coderd.New(options)
|
||||
client := codersdk.New(localURL)
|
||||
|
||||
daemonClose, err := newProvisionerDaemon(cmd.Context(), client, logger)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create provisioner daemon: %w", err)
|
||||
provisionerDaemons := make([]*provisionerd.Server, 0)
|
||||
for i := uint8(0); i < provisionerDaemonCount; i++ {
|
||||
daemonClose, err := newProvisionerDaemon(cmd.Context(), client, logger)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create provisioner daemon: %w", err)
|
||||
}
|
||||
provisionerDaemons = append(provisionerDaemons, daemonClose)
|
||||
}
|
||||
defer daemonClose.Close()
|
||||
defer func() {
|
||||
for _, provisionerDaemon := range provisionerDaemons {
|
||||
_ = provisionerDaemon.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
errCh := make(chan error)
|
||||
go func() {
|
||||
@@ -111,57 +158,168 @@ func start() *cobra.Command {
|
||||
errCh <- http.Serve(listener, handler)
|
||||
}()
|
||||
|
||||
config := createConfig(cmd)
|
||||
|
||||
if dev {
|
||||
config := createConfig(cmd)
|
||||
_, err = client.CreateFirstUser(cmd.Context(), codersdk.CreateFirstUserRequest{
|
||||
Email: "dev@coder.com",
|
||||
Username: "developer",
|
||||
Password: "password",
|
||||
Organization: "coder",
|
||||
})
|
||||
err = createFirstUser(cmd, client, config)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create first user: %w\n", err)
|
||||
return xerrors.Errorf("create first user: %w", err)
|
||||
}
|
||||
token, err := client.LoginWithPassword(cmd.Context(), codersdk.LoginWithPasswordRequest{
|
||||
Email: "dev@coder.com",
|
||||
Password: "password",
|
||||
})
|
||||
|
||||
_, _ = 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! Do not use in production. Press `+cliui.Styles.Field.Render("ctrl+c")+` to clean up provisioned infrastructure.`))+
|
||||
`
|
||||
`+
|
||||
cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Run `+cliui.Styles.Code.Render("coder projects init")+" in a new terminal to get started.\n"))+`
|
||||
`)
|
||||
} else {
|
||||
// This is helpful for tests, but can be silently ignored.
|
||||
// Coder may be ran as users that don't have permission to write in the homedir,
|
||||
// such as via the systemd service.
|
||||
_ = config.URL().Write(client.URL.String())
|
||||
|
||||
hasFirstUser, err := client.HasFirstUser(cmd.Context())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("login with first user: %w", err)
|
||||
return xerrors.Errorf("check for 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)
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Started in `+
|
||||
cliui.Styles.Field.Render("production")+` mode. All data is stored in the PostgreSQL provided! Press `+cliui.Styles.Field.Render("ctrl+c")+` to gracefully shutdown.`))+"\n")
|
||||
|
||||
if !hasFirstUser {
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.FocusedPrompt.String()+`Run `+cliui.Styles.Code.Render("coder login "+client.URL.String())+" in a new terminal to get started.\n")))
|
||||
}
|
||||
}
|
||||
|
||||
closeCoderd()
|
||||
// Updates the systemd status from activating to activated.
|
||||
_, err = daemon.SdNotify(false, daemon.SdNotifyReady)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("notify systemd: %w", err)
|
||||
}
|
||||
|
||||
stopChan := make(chan os.Signal, 1)
|
||||
defer signal.Stop(stopChan)
|
||||
signal.Notify(stopChan, os.Interrupt)
|
||||
select {
|
||||
case <-cmd.Context().Done():
|
||||
closeCoderd()
|
||||
return cmd.Context().Err()
|
||||
case err := <-tunnelErr:
|
||||
return err
|
||||
case err := <-errCh:
|
||||
closeCoderd()
|
||||
return err
|
||||
case <-stopChan:
|
||||
}
|
||||
signal.Stop(stopChan)
|
||||
_, err = daemon.SdNotify(false, daemon.SdNotifyStopping)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("notify systemd: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "\n\n"+cliui.Styles.Bold.Render("Interrupt caught. Gracefully exiting..."))
|
||||
|
||||
if dev {
|
||||
workspaces, err := client.WorkspacesByUser(cmd.Context(), "")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspaces: %w", err)
|
||||
}
|
||||
for _, workspace := range workspaces {
|
||||
before := time.Now()
|
||||
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: database.WorkspaceTransitionDelete,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("delete workspace: %w", err)
|
||||
}
|
||||
|
||||
_, err = cliui.Job(cmd, cliui.JobOptions{
|
||||
Title: fmt.Sprintf("Deleting workspace %s...", cliui.Styles.Keyword.Render(workspace.Name)),
|
||||
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 xerrors.Errorf("delete workspace %s: %w", workspace.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, provisionerDaemon := range provisionerDaemons {
|
||||
spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond)
|
||||
spin.Writer = cmd.OutOrStdout()
|
||||
spin.Suffix = cliui.Styles.Keyword.Render(" Shutting down provisioner daemon...")
|
||||
spin.Start()
|
||||
err = provisionerDaemon.Shutdown(cmd.Context())
|
||||
if err != nil {
|
||||
spin.FinalMSG = cliui.Styles.Prompt.String() + "Failed to shutdown provisioner daemon: " + err.Error()
|
||||
spin.Stop()
|
||||
}
|
||||
err = provisionerDaemon.Close()
|
||||
if err != nil {
|
||||
spin.Stop()
|
||||
return xerrors.Errorf("close provisioner daemon: %w", err)
|
||||
}
|
||||
spin.FinalMSG = cliui.Styles.Prompt.String() + "Gracefully shut down provisioner daemon!\n"
|
||||
spin.Stop()
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Prompt.String()+"Waiting for WebSocket connections to close...\n")
|
||||
closeCoderd()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
defaultAddress, ok := os.LookupEnv("ADDRESS")
|
||||
if !ok {
|
||||
defaultAddress := os.Getenv("CODER_ADDRESS")
|
||||
if defaultAddress == "" {
|
||||
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.`)
|
||||
root.Flags().StringVarP(&postgresURL, "postgres-url", "", "", "URL of a PostgreSQL database to connect to (defaults to $CODER_PG_CONNECTION_URL).")
|
||||
root.Flags().Uint8VarP(&provisionerDaemonCount, "provisioner-daemons", "", 1, "The amount of provisioner daemons to create on start.")
|
||||
root.Flags().BoolVarP(&useTunnel, "tunnel", "", true, "Serve dev mode through a Cloudflare Tunnel for easy setup.")
|
||||
_ = root.Flags().MarkHidden("tunnel")
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func newProvisionerDaemon(ctx context.Context, client *codersdk.Client, logger slog.Logger) (io.Closer, error) {
|
||||
func createFirstUser(cmd *cobra.Command, client *codersdk.Client, cfg config.Root) error {
|
||||
_, err := client.CreateFirstUser(cmd.Context(), codersdk.CreateFirstUserRequest{
|
||||
Email: "admin@coder.com",
|
||||
Username: "developer",
|
||||
Password: "password",
|
||||
Organization: "acme-corp",
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create first user: %w", err)
|
||||
}
|
||||
token, err := client.LoginWithPassword(cmd.Context(), codersdk.LoginWithPasswordRequest{
|
||||
Email: "admin@coder.com",
|
||||
Password: "password",
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("login with first user: %w", err)
|
||||
}
|
||||
client.SessionToken = token.SessionToken
|
||||
|
||||
err = cfg.URL().Write(client.URL.String())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write local url: %w", err)
|
||||
}
|
||||
err = cfg.Session().Write(token.SessionToken)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write session token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newProvisionerDaemon(ctx context.Context, client *codersdk.Client, logger slog.Logger) (*provisionerd.Server, error) {
|
||||
terraformClient, terraformServer := provisionersdk.TransportPipe()
|
||||
go func() {
|
||||
err := terraform.Serve(ctx, &terraform.ServeOptions{
|
||||
|
||||
+36
-4
@@ -3,6 +3,7 @@ package cli_test
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -10,17 +11,48 @@ import (
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/database/postgres"
|
||||
)
|
||||
|
||||
func TestStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Production", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS != "linux" || testing.Short() {
|
||||
// Skip on non-Linux because it spawns a PostgreSQL instance.
|
||||
t.SkipNow()
|
||||
}
|
||||
connectionURL, closeFunc, err := postgres.Open()
|
||||
require.NoError(t, err)
|
||||
defer closeFunc()
|
||||
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)
|
||||
done := make(chan struct{})
|
||||
root, cfg := clitest.New(t, "start", "--address", ":0", "--postgres-url", connectionURL)
|
||||
go func() {
|
||||
defer close(done)
|
||||
err = root.ExecuteContext(ctx)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}()
|
||||
var client *codersdk.Client
|
||||
require.Eventually(t, func() bool {
|
||||
rawURL, err := cfg.URL().Read()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
accessURL, err := url.Parse(rawURL)
|
||||
require.NoError(t, err)
|
||||
client = codersdk.New(accessURL)
|
||||
return true
|
||||
}, 15*time.Second, 25*time.Millisecond)
|
||||
_, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
|
||||
Email: "some@one.com",
|
||||
Username: "example",
|
||||
Password: "password",
|
||||
Organization: "example",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
cancelFunc()
|
||||
<-done
|
||||
})
|
||||
t.Run("Development", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
Reference in New Issue
Block a user