Skip to content
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

Cli setup command #3384

Merged
merged 17 commits into from
Mar 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cli/common/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ import (
)

var GlobalFlags = append([]cli.Flag{
&cli.StringFlag{
EnvVars: []string{"WOODPECKER_CONFIG"},
Name: "config",
Aliases: []string{"c"},
Usage: "path to config file",
},
&cli.StringFlag{
EnvVars: []string{"WOODPECKER_TOKEN"},
Name: "token",
Expand Down
5 changes: 3 additions & 2 deletions cli/common/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v2"

"go.woodpecker-ci.org/woodpecker/v2/cli/internal/config"
"go.woodpecker-ci.org/woodpecker/v2/cli/update"
)

Expand All @@ -17,7 +18,7 @@ var (
)

func Before(c *cli.Context) error {
if err := SetupGlobalLogger(c); err != nil {
if err := setupGlobalLogger(c); err != nil {
return err
}

Expand Down Expand Up @@ -49,7 +50,7 @@ func Before(c *cli.Context) error {
}
}()

return nil
return config.Load(c)
}

func After(_ *cli.Context) error {
Expand Down
2 changes: 1 addition & 1 deletion cli/common/zerologger.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ import (
"go.woodpecker-ci.org/woodpecker/v2/shared/logger"
)

func SetupGlobalLogger(c *cli.Context) error {
func setupGlobalLogger(c *cli.Context) error {
return logger.SetupGlobalLogger(c, false)
}
131 changes: 131 additions & 0 deletions cli/internal/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package config

import (
"encoding/json"
"errors"
"os"

"github.com/adrg/xdg"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v2"
"github.com/zalando/go-keyring"
)

type Config struct {
ServerURL string `json:"server_url"`
Token string `json:"-"`
LogLevel string `json:"log_level"`
}

func Load(c *cli.Context) error {
// If the command is setup, we don't need to load the config
if firstArg := c.Args().First(); firstArg == "setup" {
return nil
}

config, err := Get(c, c.String("config"))
if err != nil {
return err
}

if config == nil && !c.IsSet("server-url") && !c.IsSet("token") {
log.Info().Msg("The woodpecker-cli is not yet set up. Please run `woodpecker-cli setup`")
return errors.New("woodpecker-cli is not setup")
}

if !c.IsSet("server") {
err = c.Set("server", config.ServerURL)
if err != nil {
return err
}
}

if !c.IsSet("token") {
err = c.Set("token", config.Token)
if err != nil {
return err
}
}

if !c.IsSet("log-level") {
err = c.Set("log-level", config.LogLevel)
if err != nil {
return err
}
}

return nil
}

func getConfigPath(configPath string) (string, error) {
if configPath != "" {
return configPath, nil
}

configPath, err := xdg.ConfigFile("woodpecker/config.json")
qwerty287 marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return "", err
}

return configPath, nil
}

func Get(ctx *cli.Context, _configPath string) (*Config, error) {
configPath, err := getConfigPath(_configPath)
if err != nil {
return nil, err
}

content, err := os.ReadFile(configPath)
if err != nil && !os.IsNotExist(err) {
log.Debug().Err(err).Msg("Failed to read the config file")
return nil, err
} else if err != nil && os.IsNotExist(err) {
log.Debug().Msg("The config file does not exist")
return nil, nil
}

c := &Config{}
err = json.Unmarshal(content, c)
if err != nil {
return nil, err
}

// load token from keyring
service := ctx.App.Name
secret, err := keyring.Get(service, c.ServerURL)
if err != nil && !errors.Is(err, keyring.ErrNotFound) {
return nil, err
}
if err == nil {
c.Token = secret
}

return c, nil
}

func Save(ctx *cli.Context, _configPath string, c *Config) error {
config, err := json.Marshal(c)
if err != nil {
return err
}

configPath, err := getConfigPath(_configPath)
if err != nil {
return err
}

// save token to keyring
service := ctx.App.Name
err = keyring.Set(service, c.ServerURL, c.Token)
if err != nil {
return err
}

err = os.WriteFile(configPath, config, 0o600)
if err != nil {
return err
}

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

import (
"errors"
"strings"

"github.com/rs/zerolog/log"
"github.com/urfave/cli/v2"

"go.woodpecker-ci.org/woodpecker/v2/cli/internal/config"
"go.woodpecker-ci.org/woodpecker/v2/cli/setup/ui"
)

// Command exports the setup command.
var Command = &cli.Command{
Name: "setup",
Usage: "setup the woodpecker-cli for the first time",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "server-url",
Usage: "The URL of the woodpecker server",
},
&cli.StringFlag{
Name: "token",
Usage: "The token to authenticate with the woodpecker server",
},
},
Action: setup,
}

func setup(c *cli.Context) error {
_config, err := config.Get(c, c.String("config"))
if err != nil {
return err
} else if _config != nil {
setupAgain, err := ui.Confirm("The woodpecker-cli was already configured. Do you want to configure it again?")
if err != nil {
return err
}

if !setupAgain {
log.Info().Msg("Configuration skipped")
return nil
}
}

serverURL := c.String("server-url")

if serverURL == "" {
serverURL, err = ui.Ask("Enter the URL of the woodpecker server", "https://ci.woodpecker-ci.org", true)
if err != nil {
return err
}

if serverURL == "" {
return errors.New("server URL cannot be empty")
}
}

if !strings.Contains(serverURL, "://") {
serverURL = "https://" + serverURL
}

token := c.String("token")
if token == "" {
token, err = receiveTokenFromUI(c.Context, serverURL)
if err != nil {
return err
}

if token == "" {
return errors.New("no token received from the UI")
}
}

err = config.Save(c, c.String("config"), &config.Config{
ServerURL: serverURL,
Token: token,
LogLevel: "info",
})
if err != nil {
return err
}

log.Info().Msg("The woodpecker-cli has been successfully setup")

return nil
}
117 changes: 117 additions & 0 deletions cli/setup/token_fetcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package setup

import (
"context"
"errors"
"fmt"
"math/rand"
"net/http"
"os/exec"
"runtime"
"time"

"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
)

func receiveTokenFromUI(c context.Context, serverURL string) (string, error) {
port := randomPort()

tokenReceived := make(chan string)

srv := &http.Server{Addr: fmt.Sprintf("127.0.0.1:%d", port)}
srv.Handler = setupRouter(tokenReceived)

go func() {
log.Debug().Msgf("Listening for token response on :%d", port)
_ = srv.ListenAndServe()
}()

defer func() {
log.Debug().Msg("Shutting down server")
_ = srv.Shutdown(c)
}()

err := openBrowser(fmt.Sprintf("%s/cli/auth?port=%d", serverURL, port))
if err != nil {
return "", err
}

// wait for token to be received or timeout
select {
case token := <-tokenReceived:
return token, nil
case <-c.Done():
return "", c.Err()
case <-time.After(5 * time.Minute):
return "", errors.New("timed out waiting for token")
}
}

func setupRouter(tokenReceived chan string) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
e := gin.New()
e.UseRawPath = true
e.Use(gin.Recovery())

e.Use(func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")

if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}

c.Next()
})

e.POST("/token", func(c *gin.Context) {
data := struct {
Token string `json:"token"`
}{}

err := c.BindJSON(&data)
if err != nil {
log.Debug().Err(err).Msg("Failed to bind JSON")
c.JSON(400, gin.H{
"error": "invalid request",
})
return
}

tokenReceived <- data.Token

c.JSON(200, gin.H{
"ok": "true",
})
})

return e
}

func openBrowser(url string) error {
var err error

log.Debug().Msgf("Opening browser with URL: %s", url)

switch runtime.GOOS {
case "linux":
err = exec.Command("xdg-open", url).Start()
case "windows":
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
err = exec.Command("open", url).Start()
default:
err = fmt.Errorf("unsupported platform")
}
return err
}

func randomPort() int {
s1 := rand.NewSource(time.Now().UnixNano())
r1 := rand.New(s1)
return r1.Intn(10000) + 20000
}
Loading