Add cli contexts (#5929)

This commit is contained in:
Anbraten
2026-01-11 15:32:00 +01:00
committed by GitHub
parent cda7c8e474
commit 503bd7bcfb
6 changed files with 612 additions and 12 deletions

174
cli/context/context.go Normal file
View File

@@ -0,0 +1,174 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package context
import (
"context"
"fmt"
"os"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v3"
"go.woodpecker-ci.org/woodpecker/v3/cli/common"
"go.woodpecker-ci.org/woodpecker/v3/cli/internal/config"
"go.woodpecker-ci.org/woodpecker/v3/cli/output"
)
// Command exports the context command set.
var Command = &cli.Command{
Name: "context",
Aliases: []string{"ctx"},
Usage: "manage contexts",
Description: "Contexts can be used to manage users on one or multiple servers.\nTo create a new context run the setup command",
Commands: []*cli.Command{
listCommand,
useCommand,
deleteCommand,
renameCommand,
},
}
var listCommand = &cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "list all contexts",
Flags: append(common.OutputFlags("table"), []cli.Flag{
&cli.BoolFlag{
Name: "output-no-headers",
Usage: "do not print headers in output",
},
}...),
Action: listContexts,
}
var useCommand = &cli.Command{
Name: "use",
Usage: "set the current context",
ArgsUsage: "<context-name>",
Action: useContext,
}
var deleteCommand = &cli.Command{
Name: "delete",
Aliases: []string{"rm"},
Usage: "delete a context",
ArgsUsage: "<context-name>",
Action: deleteContext,
}
var renameCommand = &cli.Command{
Name: "rename",
Usage: "rename a context",
ArgsUsage: "<old-name> <new-name>",
Action: renameContext,
}
func listContexts(_ context.Context, c *cli.Command) error {
contexts, err := config.LoadContexts()
if err != nil {
return err
}
if len(contexts.Contexts) == 0 {
fmt.Println("No contexts found. Run 'woodpecker-cli setup' to create one.")
return nil
}
_, outOpt := output.ParseOutputOptions(c.String("output"))
out := os.Stdout
noHeader := c.Bool("output-no-headers")
table := output.NewTable(out)
// Add custom field mapping
table.AddFieldFn("Name", func(obj any) string {
c, ok := obj.(config.Context)
if !ok {
return "???"
}
if contexts.CurrentContext == c.Name {
return c.Name + " *"
}
return c.Name
})
table.AddFieldAlias("ServerURL", "Server URL")
table.AddFieldAlias("LogLevel", "Log Level")
table.AddFieldAlias("Name", "Name (selected)")
cols := []string{"Name (selected)", "Server URL"}
if len(outOpt) > 0 {
cols = outOpt
}
if !noHeader {
table.WriteHeader(cols)
}
for _, c := range contexts.Contexts {
if err := table.Write(cols, c); err != nil {
return err
}
}
return table.Flush()
}
func useContext(_ context.Context, c *cli.Command) error {
contextName := c.Args().First()
if contextName == "" {
return fmt.Errorf("context name is required")
}
err := config.SetCurrentContext(contextName)
if err != nil {
return err
}
log.Info().Msgf("Switched to context '%s'", contextName)
return nil
}
func deleteContext(_ context.Context, c *cli.Command) error {
contextName := c.Args().First()
if contextName == "" {
return fmt.Errorf("context name is required")
}
err := config.DeleteContext(c, contextName)
if err != nil {
return err
}
log.Info().Msgf("Context '%s' deleted", contextName)
return nil
}
func renameContext(_ context.Context, c *cli.Command) error {
if c.Args().Len() < 2 { //nolint:mnd // min args
return fmt.Errorf("both old name and new name are required")
}
oldName := c.Args().Get(0)
newName := c.Args().Get(1)
err := config.RenameContext(oldName, newName)
if err != nil {
return err
}
log.Info().Msgf("Context renamed from '%s' to '%s'", oldName, newName)
return nil
}

View File

@@ -31,20 +31,46 @@ func (c *Config) MergeIfNotSet(c2 *Config) {
}
}
var skipSetupForCommands = []string{"setup", "help", "h", "version", "update", "lint", "exec", "completion", ""}
var skipSetupForCommands = []string{"setup", "help", "h", "version", "update", "lint", "exec", "completion", "", "context", "ctx"}
func Load(ctx context.Context, c *cli.Command) error {
if firstArg := c.Args().First(); slices.Contains(skipSetupForCommands, firstArg) {
return nil
}
contextConfig, contextErr := GetCurrentContext(ctx, c)
if contextErr == nil {
if !c.IsSet("server") {
err := c.Set("server", contextConfig.ServerURL)
if err != nil {
return err
}
}
if !c.IsSet("token") {
err := c.Set("token", contextConfig.Token)
if err != nil {
return err
}
}
if !c.IsSet("log-level") && contextConfig.LogLevel != "" {
err := c.Set("log-level", contextConfig.LogLevel)
if err != nil {
return err
}
}
log.Debug().Any("config", contextConfig).Msg("loaded config from context")
return nil
}
// TODO: remove with next major release
// Fallback: try legacy config file (for backward compatibility)
config, err := Get(ctx, c, c.String("config"))
if err != nil {
return err
}
if config.ServerURL == "" || config.Token == "" {
log.Info().Msg("woodpecker-cli is not set up, run `woodpecker-cli setup` or provide required environment variables/flags")
log.Info().Msg("woodpecker-cli is not set up, run `woodpecker-cli setup` to create a context")
return errors.New("woodpecker-cli is not configured")
}
@@ -63,7 +89,7 @@ func Load(ctx context.Context, c *cli.Command) error {
return err
}
log.Debug().Any("config", config).Msg("loaded config")
log.Debug().Any("config", config).Msg("loaded config from legacy file")
return nil
}

View File

@@ -0,0 +1,245 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package config
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/adrg/xdg"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v3"
"github.com/zalando/go-keyring"
)
// Context represents a single CLI context with its connection details.
type Context struct {
Name string `json:"name"`
ServerURL string `json:"server_url"`
LogLevel string `json:"log_level,omitempty"`
}
// Contexts holds all contexts and tracks the current active one.
type Contexts struct {
CurrentContext string `json:"current_context"`
Contexts map[string]Context `json:"contexts"`
}
func getContextsPath() (string, error) {
configPath, err := xdg.ConfigFile("woodpecker/contexts.json")
if err != nil {
return "", err
}
return configPath, nil
}
// LoadContexts loads all contexts from the contexts file.
func LoadContexts() (*Contexts, error) {
contextsPath, err := getContextsPath()
if err != nil {
return nil, err
}
content, err := os.ReadFile(contextsPath)
if err != nil {
if os.IsNotExist(err) {
return &Contexts{
Contexts: make(map[string]Context),
}, nil
}
return nil, err
}
var contexts Contexts
err = json.Unmarshal(content, &contexts)
if err != nil {
return nil, err
}
if contexts.Contexts == nil {
contexts.Contexts = make(map[string]Context)
}
return &contexts, nil
}
// SaveContexts saves all contexts to the contexts file.
func SaveContexts(contexts *Contexts) error {
data, err := json.MarshalIndent(contexts, "", " ")
if err != nil {
return err
}
contextsPath, err := getContextsPath()
if err != nil {
return err
}
// Ensure the directory exists.
dir := filepath.Dir(contextsPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return os.WriteFile(contextsPath, data, 0o600)
}
// GetCurrentContext returns the current active context.
func GetCurrentContext(ctx context.Context, c *cli.Command) (*Config, error) {
contexts, err := LoadContexts()
if err != nil {
return nil, err
}
if contexts.CurrentContext == "" {
return nil, errors.New("no context is currently set")
}
context, exists := contexts.Contexts[contexts.CurrentContext]
if !exists {
return nil, fmt.Errorf("current context '%s' not found", contexts.CurrentContext)
}
return GetContextConfig(c, &context)
}
// GetContextConfig loads the config for a specific context including the token from keyring.
func GetContextConfig(c *cli.Command, ctx *Context) (*Config, error) {
conf := &Config{
ServerURL: ctx.ServerURL,
LogLevel: ctx.LogLevel,
}
// Load token from keyring
service := c.Root().Name
secret, err := keyring.Get(service, ctx.ServerURL)
if errors.Is(err, keyring.ErrUnsupportedPlatform) {
log.Warn().Msg("keyring is not supported on this platform")
return conf, nil
}
if errors.Is(err, keyring.ErrNotFound) {
return nil, fmt.Errorf("token not found in keyring for context '%s'", ctx.Name)
}
if err != nil {
return nil, err
}
conf.Token = secret
return conf, nil
}
// AddOrUpdateContext adds or updates a context and optionally sets it as current.
func AddOrUpdateContext(c *cli.Command, name, serverURL, token, logLevel string, setCurrent bool) error {
contexts, err := LoadContexts()
if err != nil {
return err
}
contexts.Contexts[name] = Context{
Name: name,
ServerURL: serverURL,
LogLevel: logLevel,
}
if setCurrent || contexts.CurrentContext == "" {
contexts.CurrentContext = name
}
// Save token to keyring
service := c.Root().Name
err = keyring.Set(service, serverURL, token)
if err != nil {
return err
}
return SaveContexts(contexts)
}
// DeleteContext removes a context.
func DeleteContext(c *cli.Command, name string) error {
contexts, err := LoadContexts()
if err != nil {
return err
}
context, exists := contexts.Contexts[name]
if !exists {
return fmt.Errorf("context '%s' not found", name)
}
// Try to delete token from keyring
service := c.Root().Name
err = keyring.Delete(service, context.ServerURL)
if err != nil && !errors.Is(err, keyring.ErrNotFound) {
log.Warn().Err(err).Msg("failed to delete token from keyring")
}
delete(contexts.Contexts, name)
// If we deleted the current context, unset it
if contexts.CurrentContext == name {
contexts.CurrentContext = ""
}
return SaveContexts(contexts)
}
// SetCurrentContext sets the current active context.
func SetCurrentContext(name string) error {
contexts, err := LoadContexts()
if err != nil {
return err
}
if _, exists := contexts.Contexts[name]; !exists {
return fmt.Errorf("context '%s' not found", name)
}
contexts.CurrentContext = name
return SaveContexts(contexts)
}
// RenameContext renames an existing context.
func RenameContext(oldName, newName string) error {
contexts, err := LoadContexts()
if err != nil {
return err
}
context, exists := contexts.Contexts[oldName]
if !exists {
return fmt.Errorf("context '%s' not found", oldName)
}
if _, exists := contexts.Contexts[newName]; exists {
return fmt.Errorf("context '%s' already exists", newName)
}
// Update the name in the context
context.Name = newName
contexts.Contexts[newName] = context
delete(contexts.Contexts, oldName)
// Update current context if necessary
if contexts.CurrentContext == oldName {
contexts.CurrentContext = newName
}
return SaveContexts(contexts)
}

View File

@@ -0,0 +1,142 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package config
import (
"os"
"testing"
"github.com/adrg/xdg"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestContextManagement(t *testing.T) {
// Create a temporary directory for test contexts
tmpDir := t.TempDir()
// Override xdg directories for testing
t.Setenv("HOME", tmpDir)
xdg.Reload()
contextsFile, err := xdg.ConfigFile("woodpecker/contexts.json")
require.NoError(t, err)
t.Run("LoadContexts returns empty when file doesn't exist", func(t *testing.T) {
contexts, err := LoadContexts()
require.NoError(t, err)
assert.NotNil(t, contexts)
assert.Empty(t, contexts.Contexts)
assert.Empty(t, contexts.CurrentContext)
})
t.Run("SaveContexts creates valid JSON", func(t *testing.T) {
contexts := &Contexts{
CurrentContext: "test",
Contexts: map[string]Context{
"test": {
Name: "test",
ServerURL: "https://test.example.com",
LogLevel: "info",
},
},
}
err := SaveContexts(contexts)
require.NoError(t, err)
// Verify file exists and contains valid JSON
data, err := os.ReadFile(contextsFile)
require.NoError(t, err)
assert.Contains(t, string(data), "test.example.com")
})
t.Run("LoadContexts reads saved contexts", func(t *testing.T) {
contexts, err := LoadContexts()
require.NoError(t, err)
assert.Equal(t, "test", contexts.CurrentContext)
assert.Len(t, contexts.Contexts, 1)
assert.Equal(t, "https://test.example.com", contexts.Contexts["test"].ServerURL)
})
t.Run("SetCurrentContext updates current context", func(t *testing.T) {
contexts := &Contexts{
CurrentContext: "test",
Contexts: map[string]Context{
"test": {
Name: "test",
ServerURL: "https://test.example.com",
},
"prod": {
Name: "prod",
ServerURL: "https://prod.example.com",
},
},
}
err := SaveContexts(contexts)
require.NoError(t, err)
err = SetCurrentContext("prod")
require.NoError(t, err)
contexts, err = LoadContexts()
require.NoError(t, err)
assert.Equal(t, "prod", contexts.CurrentContext)
})
t.Run("SetCurrentContext fails for non-existent context", func(t *testing.T) {
err := SetCurrentContext("nonexistent")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
})
t.Run("RenameContext updates context name", func(t *testing.T) {
contexts := &Contexts{
CurrentContext: "old",
Contexts: map[string]Context{
"old": {
Name: "old",
ServerURL: "https://test.example.com",
},
},
}
err := SaveContexts(contexts)
require.NoError(t, err)
err = RenameContext("old", "new")
require.NoError(t, err)
contexts, err = LoadContexts()
require.NoError(t, err)
assert.Equal(t, "new", contexts.CurrentContext)
assert.Contains(t, contexts.Contexts, "new")
assert.NotContains(t, contexts.Contexts, "old")
assert.Equal(t, "new", contexts.Contexts["new"].Name)
})
t.Run("RenameContext fails if target exists", func(t *testing.T) {
contexts := &Contexts{
Contexts: map[string]Context{
"ctx1": {Name: "ctx1", ServerURL: "https://test1.example.com"},
"ctx2": {Name: "ctx2", ServerURL: "https://test2.example.com"},
},
}
err := SaveContexts(contexts)
require.NoError(t, err)
err = RenameContext("ctx1", "ctx2")
assert.Error(t, err)
assert.Contains(t, err.Error(), "already exists")
})
}

View File

@@ -3,6 +3,7 @@ package setup
import (
"context"
"errors"
"fmt"
"strings"
"github.com/rs/zerolog/log"
@@ -26,16 +27,29 @@ var Command = &cli.Command{
Name: "token",
Usage: "token to authenticate with the woodpecker server",
},
&cli.StringFlag{
Name: "context",
Aliases: []string{"ctx"},
Usage: "name for the context (defaults to 'default')",
},
},
Action: setup,
}
func setup(ctx context.Context, c *cli.Command) error {
_config, err := config.Get(ctx, c, c.String("config"))
contextName := c.String("context")
if contextName == "" {
contextName = "default"
}
// Check if context already exists
contexts, err := config.LoadContexts()
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 existingCtx, exists := contexts.Contexts[contextName]; exists {
setupAgain, err := ui.Confirm(fmt.Sprintf("Context '%s' already exists (server: %s). Do you want to reconfigure it?", contextName, existingCtx.ServerURL))
if err != nil {
return err
}
@@ -78,16 +92,13 @@ func setup(ctx context.Context, c *cli.Command) error {
}
}
err = config.Save(ctx, c, c.String("config"), &config.Config{
ServerURL: serverURL,
Token: token,
LogLevel: "info",
})
// Save as context
err = config.AddOrUpdateContext(c, contextName, serverURL, token, "info", true)
if err != nil {
return err
}
log.Info().Msg("woodpecker-cli has been successfully setup")
log.Info().Msgf("Context '%s' has been successfully created and set as current", contextName)
return nil
}

View File

@@ -19,6 +19,7 @@ import (
"go.woodpecker-ci.org/woodpecker/v3/cli/admin"
"go.woodpecker-ci.org/woodpecker/v3/cli/common"
"go.woodpecker-ci.org/woodpecker/v3/cli/context"
"go.woodpecker-ci.org/woodpecker/v3/cli/exec"
"go.woodpecker-ci.org/woodpecker/v3/cli/info"
"go.woodpecker-ci.org/woodpecker/v3/cli/lint"
@@ -47,6 +48,7 @@ func newApp() *cli.Command {
}
app.Commands = []*cli.Command{
admin.Command,
context.Command,
exec.Command,
info.Command,
lint.Command,