Skip to content
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
2 changes: 2 additions & 0 deletions cmd/mcp/tools_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type providerSummary struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
Default bool `json:"default,omitempty"`
Status string `json:"status,omitempty"`
}

type providerListOutput struct {
Expand Down Expand Up @@ -108,6 +109,7 @@ func handleProviderList(_ context.Context, g *flags.GlobalFlags) (providerListOu
Name: entry.Config.Name,
Version: entry.Config.Version,
Default: entry.Config.Name == defaultProvider,
Status: string(entry.Status),
})
}
sort.Slice(summaries, func(i, j int) bool {
Expand Down
57 changes: 47 additions & 10 deletions cmd/provider/configure_shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,35 @@ func initProvider(
provider *provider2.ProviderConfig,
io2 initIO,
) error {
err := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{
lock, err := provider2.GetProviderInitLock(devsyConfig.DefaultContext, provider.Name)
if err != nil {
return fmt.Errorf("get init lock: %w", err)
}
locked, err := lock.TryLock()
if err != nil {
return fmt.Errorf("lock provider init: %w", err)
}
if !locked {
return fmt.Errorf("provider %q is already being initialized", provider.Name)
}
defer func() { _ = lock.Unlock() }()

if devsyConfig.Current().Providers == nil {
devsyConfig.Current().Providers = map[string]*config.ProviderConfig{}
}
if devsyConfig.Current().Providers[provider.Name] == nil {
devsyConfig.Current().Providers[provider.Name] = &config.ProviderConfig{}
}
entry := devsyConfig.Current().Providers[provider.Name]

entry.InitAttempted = true
entry.InitError = ""
entry.Initialized = false
if err := config.SaveConfig(devsyConfig); err != nil {
return fmt.Errorf("save init state: %w", err)
}

runErr := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{
Ctx: ctx,
Command: provider.Exec.Init,
Context: devsyConfig.DefaultContext,
Expand All @@ -191,15 +219,24 @@ func initProvider(
Stdout: io2.stdout,
Stderr: io2.stderr,
})
if err != nil {
return fmt.Errorf("init: %w", err)
}
if devsyConfig.Current().Providers == nil {
devsyConfig.Current().Providers = map[string]*config.ProviderConfig{}
}
if devsyConfig.Current().Providers[provider.Name] == nil {
devsyConfig.Current().Providers[provider.Name] = &config.ProviderConfig{}
if runErr != nil {
entry.InitError = truncateInitError(runErr.Error())
if saveErr := config.SaveConfig(devsyConfig); saveErr != nil {
log.Warnf("save init failure state for provider %s: %v", provider.Name, saveErr)
}
return fmt.Errorf("init: %w", runErr)
}
devsyConfig.Current().Providers[provider.Name].Initialized = true

entry.Initialized = true
return nil
}

const maxInitErrorLen = 500

func truncateInitError(msg string) string {
runes := []rune(msg)
if len(runes) <= maxInitErrorLen {
return msg
}
return string(runes[:maxInitErrorLen]) + "..."
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
4 changes: 2 additions & 2 deletions cmd/provider/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ func (cmd *ListCmd) renderInstalledPlain(
entry.Config.Name,
entry.Config.Version,
strconv.FormatBool(devsyConfig.Current().DefaultProvider == entry.Config.Name),
strconv.FormatBool(entry.State != nil && entry.State.Initialized),
string(entry.Status),
entry.Config.Description,
})
}
Expand All @@ -127,7 +127,7 @@ func (cmd *ListCmd) renderInstalledPlain(
"Name",
"Version",
"Default",
"Initialized",
"Status",
"Description",
}, tableEntries)

Expand Down
2 changes: 2 additions & 0 deletions desktop/src/main/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface ProviderEntry {
optionGroups?: unknown[]
}
state?: { initialized?: boolean; singleMachine?: boolean }
status?: string
default?: boolean
}

Expand All @@ -47,6 +48,7 @@ export function parseProviderEntries(raw: Record<string, ProviderEntry>) {
options: entry.config.options ?? {},
optionGroups: entry.config.optionGroups ?? [],
isDefault: entry.default ?? false,
status: entry.status ?? "not_initialized",
state: {
initialized: entry.state?.initialized ?? false,
singleMachine: entry.state?.singleMachine ?? false,
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/renderer/src/lib/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export interface ProviderOption {
group?: string
}

export type ProviderStatus = "not_initialized" | "initializing" | "initialized" | "failed"

export interface Provider {
name: string
version?: string
Expand All @@ -75,6 +77,7 @@ export interface Provider {
options?: Record<string, ProviderOption>
optionGroups?: ProviderOptionGroup[]
isDefault?: boolean
status?: ProviderStatus
state?: {
initialized?: boolean
singleMachine?: boolean
Expand Down
11 changes: 11 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@ type ProviderConfig struct {
// Initialized holds if the provider was initialized correctly.
Initialized bool `json:"initialized,omitempty"`

// InitAttempted holds if an initialization attempt has been started, used
// with the provider's init lock (see pkg/provider) to tell "never
// initialized" apart from "attempted but didn't finish" when Initialized
// is false.
InitAttempted bool `json:"initAttempted,omitempty"`

// InitError holds the truncated error from the last failed initialization
// attempt. Empty on success, if never run, or if abandoned by a crashed
// process.
InitError string `json:"initError,omitempty"`

// SingleMachine signals Devsy if a single machine should be used for this provider.
SingleMachine bool `json:"singleMachine,omitempty"`

Expand Down
66 changes: 66 additions & 0 deletions pkg/provider/initlock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package provider

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

"github.com/devsy-org/devsy/pkg/config"
"github.com/gofrs/flock"
)

// InitState is the lifecycle state of a provider's initialization.
type InitState string

const (
InitStateNotInitialized InitState = "not_initialized"
InitStateInitializing InitState = "initializing"
InitStateInitialized InitState = "initialized"
InitStateFailed InitState = "failed"
)

// GetProviderInitLock returns the advisory lock held while a provider's init
// command runs, so ResolveInitState can tell a live "initializing" run apart
// from one abandoned by a crashed process.
func GetProviderInitLock(contextName, name string) (*flock.Flock, error) {
locksDir, err := GetLocksDir(contextName)
if err != nil {
return nil, fmt.Errorf("get locks dir: %w", err)
}
// #nosec G301 -- mirrors existing lock dir permissions elsewhere in this package
if err := os.MkdirAll(locksDir, 0o755); err != nil {
return nil, fmt.Errorf("create locks dir: %w", err)
}

return flock.New(filepath.Join(locksDir, name+".provider-init.lock")), nil
}

// ResolveInitState derives the current init lifecycle state for a provider
// from its persisted config and the live state of its init lock.
func ResolveInitState(contextName, name string, state *config.ProviderConfig) (InitState, error) {
if state != nil && state.Initialized {
return InitStateInitialized, nil
}

lock, err := GetProviderInitLock(contextName, name)
if err != nil {
return "", err
}

locked, err := lock.TryRLock()
if err != nil {
return "", fmt.Errorf("check init lock: %w", err)
}
if !locked {
return InitStateInitializing, nil
}
defer func() { _ = lock.Unlock() }()

if state != nil && state.InitAttempted {
// The lock is free but a prior attempt never reached Initialized:
// it either failed cleanly or was abandoned by a crashed process.
return InitStateFailed, nil
}

return InitStateNotInitialized, nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
105 changes: 105 additions & 0 deletions pkg/provider/initlock_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package provider

import (
"testing"

"github.com/devsy-org/devsy/pkg/config"
)

func useTempDevsyHome(t *testing.T) {
t.Helper()
t.Setenv(config.EnvHome, t.TempDir())
config.SetPathManager(config.NewPathManager())
}

func TestResolveInitState_NeverAttempted(t *testing.T) {
useTempDevsyHome(t)

got, err := ResolveInitState("default", "docker", &config.ProviderConfig{})
if err != nil {
t.Fatalf("ResolveInitState: %v", err)
}
if got != InitStateNotInitialized {
t.Fatalf("got %q, want %q", got, InitStateNotInitialized)
}
}

func TestResolveInitState_NilState(t *testing.T) {
useTempDevsyHome(t)

got, err := ResolveInitState("default", "docker", nil)
if err != nil {
t.Fatalf("ResolveInitState: %v", err)
}
if got != InitStateNotInitialized {
t.Fatalf("got %q, want %q", got, InitStateNotInitialized)
}
}

func TestResolveInitState_Initialized(t *testing.T) {
useTempDevsyHome(t)

got, err := ResolveInitState("default", "docker", &config.ProviderConfig{Initialized: true})
if err != nil {
t.Fatalf("ResolveInitState: %v", err)
}
if got != InitStateInitialized {
t.Fatalf("got %q, want %q", got, InitStateInitialized)
}
}

func TestResolveInitState_LiveLockHeldMeansInitializing(t *testing.T) {
useTempDevsyHome(t)

lock, err := GetProviderInitLock("default", "docker")
if err != nil {
t.Fatalf("GetProviderInitLock: %v", err)
}
locked, err := lock.TryLock()
if err != nil || !locked {
t.Fatalf("TryLock: locked=%v err=%v", locked, err)
}
defer func() { _ = lock.Unlock() }()

got, err := ResolveInitState("default", "docker", &config.ProviderConfig{InitAttempted: true})
if err != nil {
t.Fatalf("ResolveInitState: %v", err)
}
if got != InitStateInitializing {
t.Fatalf("got %q, want %q", got, InitStateInitializing)
}
}

func TestResolveInitState_InitializedTakesPrecedenceOverInitError(t *testing.T) {
useTempDevsyHome(t)

// initProvider must reset Initialized to false before a retry runs, so
// this combination shouldn't occur on disk in practice. Locks in the
// intended precedence if it ever does: a stale Initialized=true still
// reads as initialized, since InitError alone is not a failure signal.
got, err := ResolveInitState("default", "docker", &config.ProviderConfig{
Initialized: true,
InitError: "boom",
})
if err != nil {
t.Fatalf("ResolveInitState: %v", err)
}
if got != InitStateInitialized {
t.Fatalf("got %q, want %q", got, InitStateInitialized)
}
}

func TestResolveInitState_FreeLockWithAttemptMeansFailed(t *testing.T) {
useTempDevsyHome(t)

// Simulate a crash mid-init: InitAttempted was persisted before the
// process died, but nothing holds the lock anymore since the OS released
// it when the process exited.
got, err := ResolveInitState("default", "docker", &config.ProviderConfig{InitAttempted: true})
if err != nil {
t.Fatalf("ResolveInitState: %v", err)
}
if got != InitStateFailed {
t.Fatalf("got %q, want %q", got, InitStateFailed)
}
}
16 changes: 16 additions & 0 deletions pkg/workspace/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ var ErrNoWorkspaceFound = errors.New("no workspace found")
type ProviderWithOptions struct {
Config *provider.ProviderConfig `json:"config,omitempty"`
State *config.ProviderConfig `json:"state,omitempty"`
Status provider.InitState `json:"status,omitempty"`
}

type ProviderParams struct {
Expand Down Expand Up @@ -64,6 +65,21 @@ func LoadAllProviders(
return nil, err
}

for name, entry := range retProviders {
status, err := provider.ResolveInitState(devsyConfig.DefaultContext, name, entry.State)
if err != nil {
log.Warnf("error resolving init state for provider %s: %v", name, err)
// Never leave Status empty: an unset value renders as a blank
// column and is dropped from JSON by omitempty.
if entry.State != nil && entry.State.Initialized {
status = provider.InitStateInitialized
} else {
status = provider.InitStateNotInitialized
}
}
entry.Status = status
}

return retProviders, nil
}

Expand Down
Loading