Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat: Allow running standalone provisioner daemons #3563

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 10 commits into from
47 changes: 47 additions & 0 deletions cli/provisionercreate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package cli

import (
"fmt"

"github.com/spf13/cobra"
"golang.org/x/xerrors"

"github.com/coder/coder/codersdk"
)

func provisionerCreate() *cobra.Command {
cmd := &cobra.Command{
Use: "create [name]",
Short: "Create a provisioner daemon instance",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := CreateClient(cmd)
if err != nil {
return err
}

provisionerName := args[0]

provisionerDaemon, err := client.CreateProvisionerDaemon(cmd.Context(), codersdk.CreateProvisionerDaemonRequest{
Name: provisionerName,
})
if err != nil {
return err
}

if provisionerDaemon.AuthToken == nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kind of related to my other question about nullability of the auth_token field, but why would this ever be allowed to happen during create?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it shouldn't be possible. A null auth_token would indicate that the provisioner was incorrectly registered as "in-process" rather than "out-of-process". But if that does somehow happen, this is just a sanity check so that we generate a meaningful error message rather than panicking.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make it so the API errors instead (and doesn't return a nullable UUID)? (I think that would be nicer for consumers in general.)

return xerrors.New("provisioner daemon was created without an auth token")
}
tokenArg := provisionerDaemon.AuthToken.String()

_, _ = fmt.Fprintln(cmd.OutOrStderr(), `A new provisioner daemon has been registered.

Start the provisioner daemon with the following command:

coder provisioners run --token `+tokenArg)

return nil
},
}
return cmd
}
64 changes: 64 additions & 0 deletions cli/provisionercreate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package cli_test

import (
"bufio"
"bytes"
"context"
"strings"
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/require"

"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
)

func TestProvisionerCreate(t *testing.T) {
t.Parallel()

t.Run("OK", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
cmd, root := clitest.New(t, "provisioners", "create", "foobar")
clitest.SetupConfig(t, client, root)
buf := new(bytes.Buffer)
cmd.SetOut(buf)
err := cmd.Execute()
require.NoError(t, err)

var token *uuid.UUID
const tokenPrefix = "coder provisioners run --token "
s := bufio.NewScanner(buf)
for s.Scan() {
line := s.Text()
if strings.HasPrefix(line, tokenPrefix) {
tokenString := strings.TrimPrefix(line, tokenPrefix)
parsedToken, err := uuid.Parse(tokenString)
require.NoError(t, err, "provisioner token has invalid format")
token = &parsedToken
}
}
require.NotNil(t, token, "provisioner token not generated in output")

provisioners, err := client.ProvisionerDaemons(context.Background())
require.NoError(t, err)
tokensByName := make(map[string]*uuid.UUID)
for _, p := range provisioners {
tokensByName[p.Name] = p.AuthToken
}
require.Equal(t, token, tokensByName["foobar"])
})

t.Run("Unprivileged", func(t *testing.T) {
t.Parallel()
adminClient := coderdtest.New(t, nil)
admin := coderdtest.CreateFirstUser(t, adminClient)
otherClient := coderdtest.CreateAnotherUser(t, adminClient, admin.OrganizationID)
cmd, root := clitest.New(t, "provisioners", "create", "foobar")
clitest.SetupConfig(t, otherClient, root)
err := cmd.Execute()
require.Error(t, err, "unprivileged user was allowed to create provisioner")
})
}
78 changes: 78 additions & 0 deletions cli/provisionerrun.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package cli

import (
"fmt"
"os"
"os/signal"
"path/filepath"

"github.com/spf13/cobra"
"golang.org/x/xerrors"

"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/cli/cliflag"
"github.com/coder/coder/cli/cliui"
)

func provisionerRun() *cobra.Command {
var (
cacheDir string
verbose bool
useEchoProvisioner bool
)
cmd := &cobra.Command{
Use: "run",
Short: "Run a standalone Coder provisioner",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
notifyCtx, notifyStop := signal.NotifyContext(ctx, interruptSignals...)
defer notifyStop()

logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()))
if verbose {
logger = logger.Leveled(slog.LevelDebug)
}

client, err := CreateClient(cmd)
if err != nil {
return err
}

errCh := make(chan error, 1)
provisionerDaemon, err := newProvisionerDaemon(ctx, client.ListenProvisionerDaemon, logger, cacheDir, errCh, useEchoProvisioner)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not saying it has to happen here, but since the provisioner has a name, consider using filepath.Join(cacheDir, "provisionerd", name). Perhaps in newProvisionerDaemon.

This will allow multiple provisioners to run on the same machine without potentially breaking terraform init.

if err != nil {
return xerrors.Errorf("create provisioner daemon: %w", err)
}

var exitErr error
select {
case <-notifyCtx.Done():
exitErr = notifyCtx.Err()
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Bold.Render(
"Interrupt caught, gracefully exiting. Use ctrl+\\ to force quit",
))
case exitErr = <-errCh:
}

err = provisionerDaemon.Close()
if err != nil {
cmd.PrintErrf("Close provisioner daemon: %s\n", err)
return err
}

return exitErr
},
}
defaultCacheDir := filepath.Join(os.TempDir(), "coder-cache")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (optional): We could consider fixing #2534 (partially) here too? Something like adrg/xdg and using xdg.CacheHome could work (I only took a quick look, and it seems pretty fully-featured).

Then again, perhaps we should do a more thorough fix all at once, (i.e. respect XDG elsewhere too, like config).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion, but I think this changeset is already kind of big and sprawling as it is (it fixes four separate issues) so I would lean toward doing that in a separate PR.

if dir := os.Getenv("CACHE_DIRECTORY"); dir != "" {
// For compatibility with systemd.
defaultCacheDir = dir
}
cliflag.StringVarP(cmd.Flags(), &cacheDir, "cache-dir", "", "CODER_CACHE_DIRECTORY", defaultCacheDir, "Specifies a directory to cache binaries for provision operations. If unspecified and $CACHE_DIRECTORY is set, it will be used for compatibility with systemd.")
cliflag.BoolVarP(cmd.Flags(), &verbose, "verbose", "v", "CODER_VERBOSE", false, "Enables verbose logging.")
// flags for testing only
cmd.Flags().BoolVarP(&useEchoProvisioner, "test.use-echo-provisioner", "", false, "Enable the echo provisioner")
_ = cmd.Flags().MarkHidden("test.use-echo-provisioner")
return cmd
}
103 changes: 103 additions & 0 deletions cli/provisionerrun_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package cli_test

import (
"context"
"testing"

"github.com/stretchr/testify/require"

"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/pty/ptytest"
)

func TestProvisionerRun(t *testing.T) {
t.Parallel()
t.Run("Provisioner", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
provisionerResponse, err := client.CreateProvisionerDaemon(context.Background(),
codersdk.CreateProvisionerDaemonRequest{
Name: "foobar",
},
)
require.NoError(t, err)
token := provisionerResponse.AuthToken
require.NotNil(t, token)

doneCh := make(chan error)
defer func() {
err := <-doneCh
require.ErrorIs(t, err, context.Canceled, "provisioner command terminated with error")
}()

ctx, cancelFunc := context.WithCancel(context.Background())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider allowing tests to timeout individually, e.g.:

Suggested change
ctx, cancelFunc := context.WithCancel(context.Background())
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)

defer cancelFunc()

cmd, root := clitest.New(t, "provisioners", "run",
"--token", token.String(),
"--verbose", // to test debug-level logs
"--test.use-echo-provisioner",
)
pty := ptytest.New(t)
defer pty.Close()
cmd.SetErr(pty.Output())
// command should only have access to provisioner auth token, not user credentials
err = root.URL().Write(client.URL.String())
require.NoError(t, err)

go func() {
defer close(doneCh)
doneCh <- cmd.ExecuteContext(ctx)
}()

pty.ExpectMatch("\tprovisioner client connected")

source := clitest.CreateTemplateVersionSource(t, &echo.Responses{
Parse: echo.ParseComplete,
Provision: provisionCompleteWithAgent,
})
args := []string{
"templates",
"create",
"my-template",
"--directory", source,
"--test.provisioner", string(database.ProvisionerTypeEcho),
"--max-ttl", "24h",
"--min-autostart-interval", "2h",
}
createCmd, root := clitest.New(t, args...)
clitest.SetupConfig(t, client, root)
pty = ptytest.New(t)
defer pty.Close()
createCmd.SetIn(pty.Input())
createCmd.SetOut(pty.Output())

execDone := make(chan error)
go func() {
execDone <- createCmd.Execute()
}()

matches := []struct {
match string
write string
}{
{match: "Create and upload", write: "yes"},
{match: "compute.main"},
{match: "smith (linux, i386)"},
{match: "Confirm create?", write: "yes"},
}
for _, m := range matches {
pty.ExpectMatch(m.match)
if len(m.write) > 0 {
pty.WriteLine(m.write)
}
}

require.NoError(t, <-execDone)
})
}
19 changes: 19 additions & 0 deletions cli/provisioners.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package cli

import (
"github.com/spf13/cobra"
)

func provisioners() *cobra.Command {
cmd := &cobra.Command{
Use: "provisioners",
Short: "Create, manage and run standalone provisioner daemons",
Aliases: []string{"provisioner"},
}
cmd.AddCommand(
provisionerRun(),
provisionerCreate(),
)

return cmd
}
1 change: 1 addition & 0 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func Core() []*cobra.Command {
logout(),
parameters(),
portForward(),
provisioners(),
publickey(),
resetPassword(),
schedules(),
Expand Down
6 changes: 3 additions & 3 deletions cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ func Server(newAPI func(*coderd.Options) *coderd.API) *cobra.Command {
}
}()
for i := 0; uint8(i) < provisionerDaemonCount; i++ {
daemon, err := newProvisionerDaemon(ctx, coderAPI, logger, cacheDir, errCh, false)
daemon, err := newProvisionerDaemon(ctx, coderAPI.ListenProvisionerDaemon, logger, cacheDir, errCh, false)
if err != nil {
return xerrors.Errorf("create provisioner daemon: %w", err)
}
Expand Down Expand Up @@ -835,7 +835,7 @@ func shutdownWithTimeout(s interface{ Shutdown(context.Context) error }, timeout
}

// nolint:revive
func newProvisionerDaemon(ctx context.Context, coderAPI *coderd.API,
func newProvisionerDaemon(ctx context.Context, dialer provisionerd.Dialer,
logger slog.Logger, cacheDir string, errCh chan error, dev bool,
) (srv *provisionerd.Server, err error) {
ctx, cancel := context.WithCancel(ctx)
Expand Down Expand Up @@ -903,7 +903,7 @@ func newProvisionerDaemon(ctx context.Context, coderAPI *coderd.API,
}()
provisioners[string(database.ProvisionerTypeEcho)] = proto.NewDRPCProvisionerClient(provisionersdk.Conn(echoClient))
}
return provisionerd.New(coderAPI.ListenProvisionerDaemon, &provisionerd.Options{
return provisionerd.New(dialer, &provisionerd.Options{
Logger: logger,
PollInterval: 500 * time.Millisecond,
UpdateInterval: 500 * time.Millisecond,
Expand Down
13 changes: 9 additions & 4 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,10 +188,15 @@ func New(options *Options) *API {
r.Post("/", api.postFile)
})
r.Route("/provisionerdaemons", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
)
r.Get("/", api.provisionerDaemons)
r.Group(func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Get("/", api.provisionerDaemons)
r.Post("/", api.postProvisionerDaemon)
})
r.Route("/me", func(r chi.Router) {
r.Use(httpmw.ExtractProvisionerDaemon(options.Database))
r.Get("/listen", api.provisionerDaemonsListen)
})
})
r.Route("/organizations", func(r chi.Router) {
r.Use(
Expand Down
8 changes: 8 additions & 0 deletions coderd/coderdtest/authtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,9 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
"GET:/api/v2/workspaceagents/{workspaceagent}/iceservers": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/{workspaceagent}/derp": {NoAuthorize: true},

// Provisioner daemon endpoint does not use RBAC
"GET:/api/v2/provisionerdaemons/me/listen": {NoAuthorize: true},

// These endpoints have more assertions. This is good, add more endpoints to assert if you can!
"GET:/api/v2/organizations/{organization}": {AssertObject: rbac.ResourceOrganization.InOrg(a.Admin.OrganizationID)},
"GET:/api/v2/users/{user}/organizations": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceOrganization},
Expand Down Expand Up @@ -356,6 +359,11 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
},
"GET:/api/v2/provisionerdaemons": {
StatusCode: http.StatusOK,
AssertAction: rbac.ActionRead,
AssertObject: rbac.ResourceProvisionerDaemon,
},
"POST:/api/v2/provisionerdaemons": {
AssertAction: rbac.ActionCreate,
AssertObject: rbac.ResourceProvisionerDaemon,
},

Expand Down
Loading