diff --git a/pkgs/defang/cli.nix b/pkgs/defang/cli.nix index 18bef01e6..d32122eb2 100644 --- a/pkgs/defang/cli.nix +++ b/pkgs/defang/cli.nix @@ -7,7 +7,7 @@ buildGo124Module { pname = "defang-cli"; version = "git"; src = lib.cleanSource ../../src; - vendorHash = "sha256-le/O0WhOXO8ItgiG1ZRqDzMNV/2mOwWATn8T/xfn6dM="; # TODO: use fetchFromGitHub + vendorHash = "sha256-/1a2G+8JQUYhMv2zHlhDoDsFLXhxA//AwVfIWw5fqeo="; # TODO: use fetchFromGitHub subPackages = [ "cmd/cli" ]; diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index a1a0cbc7c..ddbcc77e2 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -15,12 +15,14 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/DefangLabs/defang/src/pkg" + "github.com/DefangLabs/defang/src/pkg/agent" "github.com/DefangLabs/defang/src/pkg/cli" cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/cli/client/byoc" "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/gcp" "github.com/DefangLabs/defang/src/pkg/cli/compose" "github.com/DefangLabs/defang/src/pkg/clouds/aws" + "github.com/DefangLabs/defang/src/pkg/debug" "github.com/DefangLabs/defang/src/pkg/dryrun" "github.com/DefangLabs/defang/src/pkg/login" "github.com/DefangLabs/defang/src/pkg/logs" @@ -466,6 +468,24 @@ var RootCmd = &cobra.Command{ return err }, + RunE: func(cmd *cobra.Command, args []string) error { + if global.NonInteractive { + return cmd.Help() + } + + ctx := cmd.Context() + err := login.InteractiveRequireLoginAndToS(ctx, global.Client, getCluster()) + if err != nil { + return err + } + + prompt := "Welcome to Defang. I can help you deploy your project to the cloud" + ag, err := agent.New(ctx, getCluster(), &global.ProviderID, &global.Stack) + if err != nil { + return err + } + return ag.StartWithUserPrompt(ctx, prompt) + }, } var loginCmd = &cobra.Command{ @@ -912,6 +932,7 @@ var debugCmd = &cobra.Command{ Hidden: true, Short: "Debug a build, deployment, or service failure", RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() etag, _ := cmd.Flags().GetString("etag") deployment, _ := cmd.Flags().GetString("deployment") since, _ := cmd.Flags().GetString("since") @@ -922,12 +943,17 @@ var debugCmd = &cobra.Command{ } loader := configureLoader(cmd) - provider, err := newProviderChecked(cmd.Context(), loader) + _, err := newProviderChecked(ctx, loader) if err != nil { return err } - project, err := loader.LoadProject(cmd.Context()) + project, err := loader.LoadProject(ctx) + if err != nil { + return err + } + + debugger, err := debug.NewDebugger(ctx, getCluster(), &global.ProviderID, &global.Stack) if err != nil { return err } @@ -942,16 +968,15 @@ var debugCmd = &cobra.Command{ return fmt.Errorf("invalid 'until' time: %w", err) } - debugConfig := cli.DebugConfig{ + debugConfig := debug.DebugConfig{ Deployment: deployment, FailedServices: args, - ModelId: global.ModelID, Project: project, - Provider: provider, + ProviderID: &global.ProviderID, Since: sinceTs.UTC(), Until: untilTs.UTC(), } - return cli.DebugDeployment(cmd.Context(), global.Client, debugConfig) + return debugger.DebugDeployment(ctx, debugConfig) }, } diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index 5842d237f..1d707a313 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -15,6 +15,7 @@ import ( cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/cli/client/byoc" "github.com/DefangLabs/defang/src/pkg/cli/compose" + "github.com/DefangLabs/defang/src/pkg/debug" "github.com/DefangLabs/defang/src/pkg/dryrun" "github.com/DefangLabs/defang/src/pkg/logs" "github.com/DefangLabs/defang/src/pkg/modes" @@ -67,8 +68,13 @@ func makeComposeUpCmd() *cobra.Command { return err } - track.Evt("Debug Prompted", P("loadErr", loadErr)) - return cli.InteractiveDebugForClientError(ctx, global.Client, project, loadErr) + debugger, err := debug.NewDebugger(ctx, getCluster(), &global.ProviderID, &global.Stack) + if err != nil { + return err + } + return debugger.DebugComposeLoadError(ctx, debug.DebugConfig{ + Project: project, + }, loadErr) } provider, err := newProviderChecked(ctx, loader) @@ -137,7 +143,12 @@ func makeComposeUpCmd() *cobra.Command { Mode: global.Mode, }) if err != nil { - return handleComposeUpErr(ctx, err, project, provider) + composeErr := err + debugger, err := debug.NewDebugger(ctx, getCluster(), &global.ProviderID, &global.Stack) + if err != nil { + return err + } + return handleComposeUpErr(ctx, debugger, project, provider, composeErr) } if len(deploy.Services) == 0 { @@ -161,14 +172,21 @@ func makeComposeUpCmd() *cobra.Command { tailOptions := newTailOptionsForDeploy(deploy.Etag, since, global.Verbose) serviceStates, err := cli.TailAndMonitor(ctx, project, provider, time.Duration(waitTimeout)*time.Second, tailOptions) if err != nil { - handleTailAndMonitorErr(ctx, err, global.Client, cli.DebugConfig{ + deploymentErr := err + debugger, err := debug.NewDebugger(ctx, getCluster(), &global.ProviderID, &global.Stack) + if err != nil { + term.Warn("Failed to initialize debugger:", err) + return deploymentErr + } + handleTailAndMonitorErr(ctx, deploymentErr, debugger, debug.DebugConfig{ Deployment: deploy.Etag, - ModelId: global.ModelID, Project: project, - Provider: provider, + ProviderID: &global.ProviderID, + Stack: &global.Stack, Since: since, + Until: time.Now(), }) - return err + return deploymentErr } for _, service := range deploy.Services { @@ -200,7 +218,7 @@ func makeComposeUpCmd() *cobra.Command { return composeUpCmd } -func handleComposeUpErr(ctx context.Context, err error, project *compose.Project, provider cliClient.Provider) error { +func handleComposeUpErr(ctx context.Context, debugger *debug.Debugger, project *compose.Project, provider cliClient.Provider, err error) error { if errors.Is(err, types.ErrComposeFileNotFound) { // TODO: generate a compose file based on the current project printDefangHint("To start a new project, do:", "new") @@ -226,11 +244,12 @@ func handleComposeUpErr(ctx context.Context, err error, project *compose.Project } term.Error("Error:", cliClient.PrettyError(err)) - track.Evt("Debug Prompted", P("composeErr", err)) - return cli.InteractiveDebugForClientError(ctx, global.Client, project, err) + return debugger.DebugDeploymentError(ctx, debug.DebugConfig{ + Project: project, + }, err) } -func handleTailAndMonitorErr(ctx context.Context, err error, client *cliClient.GrpcClient, debugConfig cli.DebugConfig) { +func handleTailAndMonitorErr(ctx context.Context, err error, debugger *debug.Debugger, debugConfig debug.DebugConfig) { var errDeploymentFailed cliClient.ErrDeploymentFailed if errors.As(err, &errDeploymentFailed) { // Tail got canceled because of deployment failure: prompt to show the debugger @@ -238,17 +257,17 @@ func handleTailAndMonitorErr(ctx context.Context, err error, client *cliClient.G if errDeploymentFailed.Service != "" { debugConfig.FailedServices = []string{errDeploymentFailed.Service} } + if global.NonInteractive { printDefangHint("To debug the deployment, do:", debugConfig.String()) - } else { - track.Evt("Debug Prompted", P("failedServices", debugConfig.FailedServices), P("etag", debugConfig.Deployment), P("reason", errDeploymentFailed)) + return + } - // Call the AI debug endpoint using the original command context (not the tail ctx which is canceled) - if nil != cli.InteractiveDebugDeployment(ctx, client, debugConfig) { - // don't show this defang hint if debugging was successful - tailOptions := newTailOptionsForDeploy(debugConfig.Deployment, debugConfig.Since, true) - printDefangHint("To see the logs of the failed service, run:", "logs "+tailOptions.String()) - } + // Call the AI debug endpoint using the original command context (not the tail ctx which is canceled) + if nil != debugger.DebugDeploymentError(ctx, debugConfig, errDeploymentFailed) { + // don't show this defang hint if debugging was successful + tailOptions := newTailOptionsForDeploy(debugConfig.Deployment, debugConfig.Since, true) + printDefangHint("To see the logs of the failed service, run:", "logs "+tailOptions.String()) } } } @@ -446,11 +465,19 @@ func makeComposeConfigCmd() *cobra.Command { term.Error("Cannot load project:", loadErr) project, err := loader.CreateProjectForDebug() if err != nil { - return err + term.Warn("Failed to create project for debug:", err) + return loadErr } track.Evt("Debug Prompted", P("loadErr", loadErr)) - return cli.InteractiveDebugForClientError(ctx, global.Client, project, loadErr) + debugger, err := debug.NewDebugger(ctx, getCluster(), &global.ProviderID, &global.Stack) + if err != nil { + term.Warn("Failed to initialize debugger:", err) + return loadErr + } + return debugger.DebugComposeLoadError(ctx, debug.DebugConfig{ + Project: project, + }, loadErr) } provider, err := newProvider(ctx, loader) diff --git a/src/cmd/cli/command/globals.go b/src/cmd/cli/command/globals.go index e186d77e0..c412c83b8 100644 --- a/src/cmd/cli/command/globals.go +++ b/src/cmd/cli/command/globals.go @@ -1,17 +1,15 @@ package command import ( - "fmt" "os" - "path/filepath" "strconv" cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/cluster" "github.com/DefangLabs/defang/src/pkg/migrate" "github.com/DefangLabs/defang/src/pkg/modes" + "github.com/DefangLabs/defang/src/pkg/stacks" "github.com/DefangLabs/defang/src/pkg/term" - "github.com/joho/godotenv" "github.com/spf13/pflag" ) @@ -254,28 +252,10 @@ godotenv.Load respects existing environment variables. Stack-specific RC files are considered required when specified, while the general RC file is optional. */ func (r *GlobalConfig) loadDotDefang(stackName string) error { - dotfile := ".defang" if stackName != "" { // If a stack name is provided, load the stack-specific RC file but return error if it fails or does not exist - dotfile = filepath.Join(dotfile, stackName) - if abs, err := filepath.Abs(dotfile); err == nil { - dotfile = abs - } - if err := godotenv.Load(dotfile); err != nil { - return fmt.Errorf("could not load stack %q: %w", stackName, err) - } - } else { - // If no stack name is provided, trying load the general .defang file - if abs, err := filepath.Abs(dotfile); err == nil { - dotfile = abs - } - // An error here is non-fatal since the file is optional - if err := godotenv.Load(dotfile); err != nil { - term.Debugf("could not load stack %q; continuing without env file: %v", stackName, err) - return nil // continue if no general env file - } + return stacks.Load(stackName) // ensure stack exists } - term.Debugf("loaded globals from %s", dotfile) return nil } diff --git a/src/cmd/cli/command/globals_test.go b/src/cmd/cli/command/globals_test.go index 5b92dbd76..dcba90367 100644 --- a/src/cmd/cli/command/globals_test.go +++ b/src/cmd/cli/command/globals_test.go @@ -28,30 +28,6 @@ func Test_readGlobals(t *testing.T) { os.Unsetenv("VALUE") }) - t.Run(".defang/test beats .defang", func(t *testing.T) { - t.Chdir("testdata/with-stack") - err := testConfig.loadDotDefang("test") - if err != nil { - t.Fatalf("%v", err) - } - if v := os.Getenv("VALUE"); v != "from .defang/test" { - t.Errorf("expected VALUE to be 'from .defang/test', got '%s'", v) - } - os.Unsetenv("VALUE") - }) - - t.Run(".defang used if no stack", func(t *testing.T) { - t.Chdir("testdata/no-stack") - err := testConfig.loadDotDefang("") - if err != nil { - t.Fatalf("%v", err) - } - if v := os.Getenv("VALUE"); v != "from .defang" { - t.Errorf("expected VALUE to be 'from .defang', got '%s'", v) - } - os.Unsetenv("VALUE") - }) - t.Run("incorrect stackname used if no stack", func(t *testing.T) { err := testConfig.loadDotDefang("non-existent-stack") if err == nil { @@ -245,41 +221,6 @@ func Test_configurationPrecedence(t *testing.T) { }, expected: defaultConfig, }, - { - name: "default .defang name, when no env vars or flags", - createRCFile: true, - rcStack: stack{ - stackname: "", - entries: map[string]string{ - "DEFANG_MODE": "AFFORDABLE", - "DEFANG_VERBOSE": "true", - "DEFANG_DEBUG": "false", - "DEFANG_STACK": "from-env", - "DEFANG_FABRIC": "from-env-cluster", - "DEFANG_PROVIDER": "defang", - "DEFANG_ORG": "from-env-org", - "DEFANG_SOURCE_PLATFORM": "heroku", - "DEFANG_COLOR": "always", - "DEFANG_TTY": "false", - "DEFANG_NON_INTERACTIVE": "true", - "DEFANG_HIDE_UPDATE": "true", - }, - }, - expected: GlobalConfig{ - Mode: modes.ModeAffordable, // env file values - Verbose: true, - Debug: false, - Stack: "from-env", - Cluster: "from-env-cluster", - ProviderID: cliClient.ProviderDefang, - Org: "from-env-org", - SourcePlatform: migrate.SourcePlatformHeroku, - ColorMode: ColorAlways, - HasTty: false, // from env - NonInteractive: true, // from env - HideUpdate: true, // from env - }, - }, { name: "default .defang name and no values, when no env vars or flags", createRCFile: true, diff --git a/src/cmd/cli/command/mcp.go b/src/cmd/cli/command/mcp.go index ded0946b7..7ecd03c9e 100644 --- a/src/cmd/cli/command/mcp.go +++ b/src/cmd/cli/command/mcp.go @@ -48,7 +48,11 @@ var mcpServerCmd = &cobra.Command{ // Create a new MCP server term.Debug("Creating MCP server") - s, err := mcp.NewDefangMCPServer(RootCmd.Version, getCluster(), &global.ProviderID, mcpClient, tools.DefaultToolCLI{}) + s, err := mcp.NewDefangMCPServer(RootCmd.Version, mcpClient, tools.DefaultToolCLI{}, mcp.StackConfig{ + Cluster: getCluster(), + ProviderID: &global.ProviderID, + Stack: &global.Stack, + }) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) } diff --git a/src/go.mod b/src/go.mod index 0049f62a3..625ad7651 100644 --- a/src/go.mod +++ b/src/go.mod @@ -1,19 +1,19 @@ module github.com/DefangLabs/defang/src -go 1.24 +go 1.24.1 toolchain go1.24.5 replace github.com/spf13/cobra v1.8.0 => github.com/DefangLabs/cobra v1.8.0-defang require ( - cloud.google.com/go/artifactregistry v1.16.1 + cloud.google.com/go/artifactregistry v1.17.1 cloud.google.com/go/cloudbuild v1.22.2 - cloud.google.com/go/iam v1.5.0 + cloud.google.com/go/iam v1.5.2 cloud.google.com/go/logging v1.13.0 - cloud.google.com/go/resourcemanager v1.10.3 - cloud.google.com/go/run v1.9.0 - cloud.google.com/go/secretmanager v1.14.5 + cloud.google.com/go/resourcemanager v1.10.6 + cloud.google.com/go/run v1.9.3 + cloud.google.com/go/secretmanager v1.14.7 cloud.google.com/go/storage v1.50.0 github.com/AlecAivazis/survey/v2 v2.3.7 github.com/DefangLabs/secret-detector v0.0.0-20250811234530-d4b4214cd679 @@ -35,17 +35,19 @@ require ( github.com/compose-spec/compose-go/v2 v2.7.2-0.20250715094302-8da9902241f9 github.com/digitalocean/godo v1.131.1 github.com/docker/docker v27.3.1+incompatible + github.com/firebase/genkit/go v1.2.0 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 - github.com/googleapis/gax-go/v2 v2.14.1 - github.com/gorilla/websocket v1.5.0 + github.com/googleapis/gax-go/v2 v2.14.2 + github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-retryablehttp v0.7.7 github.com/joho/godotenv v1.5.1 - github.com/mark3labs/mcp-go v0.38.0 + github.com/mark3labs/mcp-go v0.41.0 github.com/miekg/dns v1.1.59 github.com/moby/buildkit v0.17.3 github.com/moby/patternmatcher v0.6.0 github.com/muesli/termenv v0.15.2 + github.com/openai/openai-go v1.12.0 github.com/opencontainers/image-spec v1.1.0 github.com/pelletier/go-toml/v2 v2.2.2 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c @@ -56,42 +58,45 @@ require ( github.com/spf13/pflag v1.0.6 github.com/stretchr/testify v1.10.0 go.yaml.in/yaml/v3 v3.0.4 - golang.org/x/mod v0.21.0 - golang.org/x/oauth2 v0.29.0 - golang.org/x/sys v0.32.0 - golang.org/x/term v0.31.0 - google.golang.org/api v0.229.0 - google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb - google.golang.org/grpc v1.72.0 + golang.org/x/mod v0.25.0 + golang.org/x/oauth2 v0.30.0 + golang.org/x/sys v0.34.0 + golang.org/x/term v0.33.0 + google.golang.org/api v0.236.0 + google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 + google.golang.org/grpc v1.73.0 google.golang.org/protobuf v1.36.6 gopkg.in/yaml.v3 v3.0.1 ) require ( - cel.dev/expr v0.20.0 // indirect + cel.dev/expr v0.23.0 // indirect cloud.google.com/go v0.120.0 // indirect - cloud.google.com/go/auth v0.16.0 // indirect + cloud.google.com/go/auth v0.16.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.6.0 // indirect - cloud.google.com/go/longrunning v0.6.6 // indirect - cloud.google.com/go/monitoring v1.24.0 // indirect + cloud.google.com/go/compute/metadata v0.7.0 // indirect + cloud.google.com/go/longrunning v0.6.7 // indirect + cloud.google.com/go/monitoring v1.24.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.26.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.52.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.52.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 // indirect + github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f // indirect github.com/containerd/typeurl/v2 v2.2.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/creack/pty v1.1.21 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect - github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-jose/go-jose/v4 v4.1.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/goccy/go-yaml v1.17.1 // indirect + github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect @@ -101,8 +106,9 @@ require ( github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/rivo/uniseg v0.4.2 // indirect @@ -112,18 +118,26 @@ require ( github.com/spf13/cast v1.7.1 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.34.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect - golang.org/x/crypto v0.37.0 // indirect - golang.org/x/net v0.39.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250422160041-2d3770c4ea7f // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/net v0.41.0 // indirect + google.golang.org/genai v1.30.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect gopkg.in/ini.v1 v1.66.2 // indirect ) @@ -146,7 +160,7 @@ require ( github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -158,14 +172,14 @@ require ( github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pkg/errors v0.9.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect - go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.36.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0 // indirect - go.opentelemetry.io/otel/metric v1.35.0 // indirect - go.opentelemetry.io/otel/sdk v1.35.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/text v0.24.0 // indirect - golang.org/x/time v0.11.0 // indirect - golang.org/x/tools v0.22.0 // indirect + go.opentelemetry.io/otel/metric v1.36.0 // indirect + go.opentelemetry.io/otel/sdk v1.36.0 // indirect + go.opentelemetry.io/otel/trace v1.36.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/text v0.27.0 // indirect + golang.org/x/time v0.12.0 // indirect + golang.org/x/tools v0.34.0 // indirect ) diff --git a/src/go.sum b/src/go.sum index 8a23fcffd..1d35500aa 100644 --- a/src/go.sum +++ b/src/go.sum @@ -1,35 +1,35 @@ -cel.dev/expr v0.20.0 h1:OunBvVCfvpWlt4dN7zg3FM6TDkzOePe1+foGJ9AXeeI= -cel.dev/expr v0.20.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cel.dev/expr v0.23.0 h1:wUb94w6OYQS4uXraxo9U+wUAs9jT47Xvl4iPgAwM2ss= +cel.dev/expr v0.23.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA= cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q= -cloud.google.com/go/artifactregistry v1.16.1 h1:ZNXGB6+T7VmWdf6//VqxLdZ/sk0no8W0ujanHeJwDRw= -cloud.google.com/go/artifactregistry v1.16.1/go.mod h1:sPvFPZhfMavpiongKwfg93EOwJ18Tnj9DIwTU9xWUgs= -cloud.google.com/go/auth v0.16.0 h1:Pd8P1s9WkcrBE2n/PhAwKsdrR35V3Sg2II9B+ndM3CU= -cloud.google.com/go/auth v0.16.0/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= +cloud.google.com/go/artifactregistry v1.17.1 h1:A20kj2S2HO9vlyBVyVFHPxArjxkXvLP5LjcdE7NhaPc= +cloud.google.com/go/artifactregistry v1.17.1/go.mod h1:06gLv5QwQPWtaudI2fWO37gfwwRUHwxm3gA8Fe568Hc= +cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4= +cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/cloudbuild v1.22.2 h1:4LlrIFa3IFLgD1mGEXmUE4cm9fYoU71OLwTvjM7Dg3c= cloud.google.com/go/cloudbuild v1.22.2/go.mod h1:rPyXfINSgMqMZvuTk1DbZcbKYtvbYF/i9IXQ7eeEMIM= -cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= -cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= -cloud.google.com/go/iam v1.5.0 h1:QlLcVMhbLGOjRcGe6VTGGTyQib8dRLK2B/kYNV0+2xs= -cloud.google.com/go/iam v1.5.0/go.mod h1:U+DOtKQltF/LxPEtcDLoobcsZMilSRwR7mgNL7knOpo= +cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= +cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= +cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc= cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA= -cloud.google.com/go/longrunning v0.6.6 h1:XJNDo5MUfMM05xK3ewpbSdmt7R2Zw+aQEMbdQR65Rbw= -cloud.google.com/go/longrunning v0.6.6/go.mod h1:hyeGJUrPHcx0u2Uu1UFSoYZLn4lkMrccJig0t4FI7yw= -cloud.google.com/go/monitoring v1.24.0 h1:csSKiCJ+WVRgNkRzzz3BPoGjFhjPY23ZTcaenToJxMM= -cloud.google.com/go/monitoring v1.24.0/go.mod h1:Bd1PRK5bmQBQNnuGwHBfUamAV1ys9049oEPHnn4pcsc= -cloud.google.com/go/resourcemanager v1.10.3 h1:SHOMw0kX0xWratC5Vb5VULBeWiGlPYAs82kiZqNtWpM= -cloud.google.com/go/resourcemanager v1.10.3/go.mod h1:JSQDy1JA3K7wtaFH23FBGld4dMtzqCoOpwY55XYR8gs= -cloud.google.com/go/run v1.9.0 h1:9WeTqeEcriXqRViXMNwczjFJjixOSBlSlk/fW3lfKPg= -cloud.google.com/go/run v1.9.0/go.mod h1:Dh0+mizUbtBOpPEzeXMM22t8qYQpyWpfmUiWQ0+94DU= -cloud.google.com/go/secretmanager v1.14.5 h1:W++V0EL9iL6T2+ec24Dm++bIti0tI6Gx6sCosDBters= -cloud.google.com/go/secretmanager v1.14.5/go.mod h1:GXznZF3qqPZDGZQqETZwZqHw4R6KCaYVvcGiRBA+aqY= +cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= +cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= +cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM= +cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= +cloud.google.com/go/resourcemanager v1.10.6 h1:LIa8kKE8HF71zm976oHMqpWFiaDHVw/H1YMO71lrGmo= +cloud.google.com/go/resourcemanager v1.10.6/go.mod h1:VqMoDQ03W4yZmxzLPrB+RuAoVkHDS5tFUUQUhOtnRTg= +cloud.google.com/go/run v1.9.3 h1:BrB0Y/BlsyWKdHebDp3CpbV9knwcWqqQI4RWYElf1zQ= +cloud.google.com/go/run v1.9.3/go.mod h1:Si9yDIkUGr5vsXE2QVSWFmAjJkv/O8s3tJ1eTxw3p1o= +cloud.google.com/go/secretmanager v1.14.7 h1:VkscIRzj7GcmZyO4z9y1EH7Xf81PcoiAo7MtlD+0O80= +cloud.google.com/go/secretmanager v1.14.7/go.mod h1:uRuB4F6NTFbg0vLQ6HsT7PSsfbY7FqHbtJP1J94qxGc= cloud.google.com/go/storage v1.50.0 h1:3TbVkzTooBvnZsk7WaAQfOsNrdoM8QHusXA1cpk6QJs= cloud.google.com/go/storage v1.50.0/go.mod h1:l7XeiD//vx5lfqE3RavfmU9yvk5Pp0Zhcv482poyafY= -cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE= -cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8= +cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4= +cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= @@ -38,14 +38,14 @@ github.com/DefangLabs/cobra v1.8.0-defang h1:rTzAg1XbEk3yXUmQPumcwkLgi8iNCby5Cjy github.com/DefangLabs/cobra v1.8.0-defang/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/DefangLabs/secret-detector v0.0.0-20250811234530-d4b4214cd679 h1:qNT7R4qrN+5u5ajSbqSW1opHP4LA8lzA+ASyw5MQZjs= github.com/DefangLabs/secret-detector v0.0.0-20250811234530-d4b4214cd679/go.mod h1:blbwPQh4DTlCZEfk1BLU4oMIhLda2U+A840Uag9DsZw= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.26.0 h1:f2Qw/Ehhimh5uO1fayV0QIW7DShEQqhtUfhYc+cBPlw= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.26.0/go.mod h1:2bIszWvQRlJVmJLiuLhukLImRjKPcYdzzsx6darK02A= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0 h1:5IT7xOdq17MtcdtL/vtl6mGfzhaq4m4vpollPRmlsBQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0/go.mod h1:ZV4VOm0/eHR06JLrXWe09068dHpr3TRpY9Uo7T+anuA= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.50.0 h1:nNMpRpnkWDAaqcpxMJvxa/Ud98gjbYwayJY4/9bdjiU= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.50.0/go.mod h1:SZiPHWGOOk3bl8tkevxkoiwPgsIl6CwrWcbwjfHZpdM= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0 h1:ig/FpDD2JofP/NExKQUbn7uOSZzJAQqogfqluZK4ed4= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.52.0 h1:QFgWzcdmJlgEAwJz/zePYVJQxfoJGRtgIqZfIUFg5oQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.52.0/go.mod h1:ayYHuYU7iNcNtEs1K9k6D/Bju7u1VEHMQm5qQ1n3GtM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.52.0 h1:0l8ynskVvq1dvIn5vJbFMf/a/3TqFpRmCMrruFbzlvk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.52.0/go.mod h1:f/ad5NuHnYz8AOZGuR0cY+l36oSCstdxD73YlIchr6I= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.52.0 h1:wbMd4eG/fOhsCa6+IP8uEDvWF5vl7rNoUWmP5f72Tbs= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.52.0/go.mod h1:gdIm9TxRk5soClCwuB0FtdXsbqtw0aqPwBEurK9tPkw= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= @@ -116,8 +116,8 @@ github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqy github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 h1:Om6kYQYDUk5wWbT0t0q6pvyM49i9XZAv9dDrkDA7gjk= -github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f h1:C5bqEmzEPLsHm9Mv73lSE9e9bKV23aB1vxOsmZrkl3k= +github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/compose-spec/compose-go/v2 v2.7.2-0.20250715094302-8da9902241f9 h1:kqvhWCmg3fVAPbfE8aJdV+qX1VqK4oK/DRI5yxeVd4E= github.com/compose-spec/compose-go/v2 v2.7.2-0.20250715094302-8da9902241f9/go.mod h1:veko/VB7URrg/tKz3vmIAQDaz+CGiXH8vZsW79NmAww= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= @@ -157,25 +157,31 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/firebase/genkit/go v1.2.0 h1:C31p32vdMZhhSSQQvXouH/kkcleTH4jlgFmpqlJtBS4= +github.com/firebase/genkit/go v1.2.0/go.mod h1:ru1cIuxG1s3HeUjhnadVveDJ1yhinj+j+uUh0f0pyxE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= -github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY= +github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY= +github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254 h1:okN800+zMJOGHLJCgry+OGzhhtH6YrjQh1rluHmOacE= +github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254/go.mod h1:k8cjJAQWc//ac/bMnzItyOFbfT01tgRTZGgxELCuxEQ= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -191,10 +197,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= -github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= +github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -222,7 +228,6 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGw github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -236,10 +241,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mark3labs/mcp-go v0.38.0 h1:E5tmJiIXkhwlV0pLAwAT0O5ZjUZSISE/2Jxg+6vpq4I= -github.com/mark3labs/mcp-go v0.38.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.41.0 h1:IFfJaovCet65F3av00bE1HzSnmHpMRWM1kz96R98I70= +github.com/mark3labs/mcp-go v0.41.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -251,6 +256,8 @@ github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWV github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a h1:v2cBA3xWKv2cIOVhnzX/gNgkNXqiHfUgJtA3r61Hf7A= +github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a/go.mod h1:Y6ghKH+ZijXn5d9E7qGGZBmjitx7iitZdQiIW97EpTU= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= @@ -272,6 +279,8 @@ github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8 github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= github.com/onsi/gomega v1.33.0 h1:snPCflnZrpMsy94p4lXVEkHo12lmPnc3vY5XBbreexE= github.com/onsi/gomega v1.33.0/go.mod h1:+925n5YtiFsLzzafLUHzVMBpvvRAzrydIBiSIxjX3wY= +github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0= +github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= @@ -313,6 +322,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -322,8 +332,25 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= @@ -335,59 +362,61 @@ github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/detectors/gcp v1.34.0 h1:JRxssobiPg23otYU5SbWtQC//snGVIM3Tx6QRzlQBao= -go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/contrib/detectors/gcp v1.35.0 h1:bGvFt68+KTiAKFlacHW6AhA56GF2rS0bdD3aJYEnmzA= +go.opentelemetry.io/contrib/detectors/gcp v1.35.0/go.mod h1:qGWP8/+ILwMRIUf9uIVLloR1uo5ZYAslM4O6OqUi1DA= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= +go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.22.0 h1:9M3+rhx7kZCIQQhQRYaZCdNu1V73tm4TvXs2ntl98C4= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.22.0/go.mod h1:noq80iT8rrHP1SfybmPiRGc9dc5M8RPmGvtwo7Oo7tc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0 h1:FyjCyI9jVEfqhUh2MoSkmolPjfh5fp2hnV0b0irxH4Q= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0/go.mod h1:hYwym2nDEeZfG/motx0p7L7J1N1vyzIThemQsb4g2qY= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0 h1:WDdP9acbMYjbKIyJUhTvtzj601sVJOqgWdUxSdR/Ysc= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0/go.mod h1:BLbf7zbNIONBLPwvFnwNHGj4zge8uTCM/UPIVW1Mq2I= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= -go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= -go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= +go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= +go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= +go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= +go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= +go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= -golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= -golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -401,41 +430,43 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= +golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= -golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.229.0 h1:p98ymMtqeJ5i3lIBMj5MpR9kzIIgzpHHh8vQ+vgAzx8= -google.golang.org/api v0.229.0/go.mod h1:wyDfmq5g1wYJWn29O22FDWN48P7Xcz0xz+LBpptYvB0= -google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= -google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE= -google.golang.org/genproto/googleapis/api v0.0.0-20250422160041-2d3770c4ea7f h1:tjZsroqekhC63+WMqzmWyW5Twj/ZfR5HAlpd5YQ1Vs0= -google.golang.org/genproto/googleapis/api v0.0.0-20250422160041-2d3770c4ea7f/go.mod h1:Cd8IzgPo5Akum2c9R6FsXNaZbH3Jpa2gpHlW89FqlyQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:ztQaXfzEXTmCBvbtWYRhJxW+0iJcz2qXfd38/e9l7bA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM= -google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/api v0.236.0 h1:CAiEiDVtO4D/Qja2IA9VzlFrgPnK3XVMmRoJZlSWbc0= +google.golang.org/api v0.236.0/go.mod h1:X1WF9CU2oTc+Jml1tiIxGmWFK/UZezdqEu09gcxZAj4= +google.golang.org/genai v1.30.0 h1:7021aneIvl24nEBLbtQFEWleHsMbjzpcQvkT4WcJ1dc= +google.golang.org/genai v1.30.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg= +google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78= +google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk= +google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ= +google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/src/pkg/agent/agent.go b/src/pkg/agent/agent.go index 488315541..0ca462345 100644 --- a/src/pkg/agent/agent.go +++ b/src/pkg/agent/agent.go @@ -1 +1,186 @@ package agent + +import ( + "context" + "errors" + "fmt" + "os" + "regexp" + "time" + + "github.com/AlecAivazis/survey/v2" + "github.com/AlecAivazis/survey/v2/terminal" + "github.com/DefangLabs/defang/src/pkg" + "github.com/DefangLabs/defang/src/pkg/agent/plugins/fabric" + "github.com/DefangLabs/defang/src/pkg/agent/tools" + "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/cluster" + "github.com/DefangLabs/defang/src/pkg/elicitations" + "github.com/DefangLabs/defang/src/pkg/term" + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/core/api" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/googlegenai" + "github.com/openai/openai-go/option" +) + +var whitespacePattern = regexp.MustCompile(`^\s*$`) + +type Agent struct { + generator *Generator + printer Printer + system string +} + +func New(ctx context.Context, clusterAddr string, providerId *client.ProviderID, stack *string) (*Agent, error) { + accessToken := cluster.GetExistingToken(clusterAddr) + aiProvider := "fabric" + var providerPlugin api.Plugin + _, addr := cluster.SplitTenantHost(clusterAddr) + // Generate a random session ID prepended with timestamp for easier sorting + sessionID := fmt.Sprintf("%s-%s", time.Now().Format("20060102T150405Z"), pkg.RandomID()) + providerPlugin = &fabric.OpenAI{ + APIKey: accessToken, + Opts: []option.RequestOption{ + option.WithBaseURL(fmt.Sprintf("https://%s/api/v1", addr)), + option.WithHeader("X-Defang-Agent-Session-Id", sessionID), + }, + } + defaultModel := "google/gemini-2.5-flash" + + if os.Getenv("GOOGLE_API_KEY") != "" { + aiProvider = "googleai" + providerPlugin = &googlegenai.GoogleAI{} + defaultModel = "gemini-2.5-flash" + } + + model := pkg.Getenv("DEFANG_MODEL_ID", defaultModel) + + gk := genkit.Init(ctx, + genkit.WithDefaultModel(fmt.Sprintf("%s/%s", aiProvider, model)), + genkit.WithPlugins(providerPlugin), + ) + + elicitationsClient := elicitations.NewSurveyClient(os.Stdin, os.Stdout, os.Stderr) + ec := elicitations.NewController(elicitationsClient) + + printer := printer{outStream: os.Stdout} + toolManager := NewToolManager(gk, printer) + defangTools := tools.CollectDefangTools(ec, tools.StackConfig{ + Cluster: clusterAddr, + ProviderID: providerId, + Stack: stack, + }) + toolManager.RegisterTools(defangTools...) + fsTools := CollectFsTools() + toolManager.RegisterTools(fsTools...) + + generator := NewGenerator( + gk, + printer, + toolManager, + ) + + preparedSystemPrompt, err := prepareSystemPrompt() + if err != nil { + return nil, err + } + + a := &Agent{ + printer: printer, + generator: generator, + system: preparedSystemPrompt, + } + + return a, nil +} + +func (a *Agent) StartWithUserPrompt(ctx context.Context, userPrompt string) error { + a.printer.Printf("\n%s\n", userPrompt) + a.printer.Printf("Type '/exit' to quit.\n") + return a.startSession(ctx) +} + +func (a *Agent) StartWithMessage(ctx context.Context, msg string) error { + a.printer.Printf("Type '/exit' to quit.\n") + + if err := a.handleUserMessage(ctx, msg); err != nil { + return fmt.Errorf("error handling initial message: %w", err) + } + + return a.startSession(ctx) +} + +func (a *Agent) startSession(ctx context.Context) error { + for { + var input string + err := survey.AskOne( + &survey.Input{ + Message: "", + }, + &input, + survey.WithStdio(term.DefaultTerm.Stdio()), + survey.WithIcons(func(icons *survey.IconSet) { + icons.Question.Text = ">" + }), + ) + if err != nil { + if errors.Is(err, terminal.InterruptErr) { + return nil + } + return fmt.Errorf("error reading input: %w", err) + } + + // if input is empty or all whitespace, continue + if whitespacePattern.MatchString(input) { + continue + } + + if input == "/exit" { + return nil + } + + if err := a.handleUserMessage(ctx, input); err != nil { + a.printer.Println("Error handling message: %v", err) + } + } +} + +func (a *Agent) handleUserMessage(ctx context.Context, msg string) error { + maxTurns := 8 + for { + err := a.generator.HandleMessage(ctx, a.system, maxTurns, ai.NewUserMessage(ai.NewTextPart(msg))) + if err == nil { + return nil + } + if _, ok := err.(*maxTurnsReachedError); !ok { + return err + } + + var continueSession bool + err = survey.AskOne(&survey.Confirm{ + Message: "Defang is still working on this, would you like to continue?", + Default: true, + }, &continueSession, survey.WithStdio(term.DefaultTerm.Stdio())) + if err != nil { + return fmt.Errorf("error prompting to continue session: %w", err) + } + if continueSession { + continue + } + return nil + } +} + +func prepareSystemPrompt() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("error getting current working directory: %w", err) + } + currentDate := time.Now().Format(time.RFC3339) + return fmt.Sprintf( + "The current working directory is %q\nThe current date is %s", + cwd, + currentDate, + ), nil +} diff --git a/src/pkg/agent/agent_test.go b/src/pkg/agent/agent_test.go new file mode 100644 index 000000000..c37489c86 --- /dev/null +++ b/src/pkg/agent/agent_test.go @@ -0,0 +1,34 @@ +package agent + +import ( + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +// Mock implementations for testing (simplified) +type mockPrinter struct { + output []string +} + +func (m *mockPrinter) Printf(format string, args ...interface{}) { + m.output = append(m.output, fmt.Sprintf(format, args...)) +} + +func (m *mockPrinter) Println(args ...interface{}) { + m.output = append(m.output, fmt.Sprintln(args...)) +} + +func TestPrepareSystemPrompt(t *testing.T) { + result, err := prepareSystemPrompt() + + assert.NoError(t, err) + assert.Contains(t, result, "The current working directory is") + assert.Contains(t, result, "The current date is") + + // Check that it includes current working directory + cwd, _ := os.Getwd() + assert.Contains(t, result, cwd) +} diff --git a/src/pkg/agent/common/common.go b/src/pkg/agent/common/common.go index d7fb8ffb0..71ff060ac 100644 --- a/src/pkg/agent/common/common.go +++ b/src/pkg/agent/common/common.go @@ -1,25 +1,19 @@ package common import ( - "context" "errors" "fmt" "os" - "strings" - "github.com/DefangLabs/defang/src/pkg/cli" - "github.com/DefangLabs/defang/src/pkg/cli/client" - cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/cli/compose" "github.com/DefangLabs/defang/src/pkg/term" - "github.com/mark3labs/mcp-go/mcp" ) var MCPDevelopmentClient = "" // set by NewDefangMCPServer const PostPrompt = "Please deploy my application with Defang now." -var ErrNoProviderSet = errors.New("no cloud provider is configured. Use `/` to open prompts and use the 3 Defang setup prompts, or use tools: set_aws_provider, set_gcp_provider, or set_playground_provider.") +var ErrNoProviderSet = errors.New("no cloud provider is configured.") func GetStringArg(args map[string]string, key, defaultValue string) string { if val, exists := args[key]; exists { @@ -28,32 +22,35 @@ func GetStringArg(args map[string]string, key, defaultValue string) string { return defaultValue } -func ConfigureLoader(request mcp.CallToolRequest) (*compose.Loader, error) { - wd, err := request.RequireString("working_directory") - if err != nil || wd == "" { - return nil, fmt.Errorf("invalid working directory: %w", err) +type LoaderParams struct { + WorkingDirectory string `json:"working_directory" jsonschema:"description=The working directory containing the compose files. Usually the current directory."` + ProjectName string `json:"project_name,omitempty" jsonschema:"description=Optional: The name of the project. Useful when working with projects that are not in the current directory."` + ComposeFilePaths []string `json:"compose_file_paths,omitempty" jsonschema:"description=Optional: Paths to the compose files to use for the project. If not provided, defaults to the compose file in the working directory."` +} + +func ConfigureAgentLoader(params LoaderParams) (*compose.Loader, error) { + if params.WorkingDirectory == "" { + params.WorkingDirectory = "." } - err = os.Chdir(wd) - if err != nil { - return nil, fmt.Errorf("failed to change working directory: %w", err) + if params.WorkingDirectory != "." { + err := os.Chdir(params.WorkingDirectory) + if err != nil { + return nil, fmt.Errorf("failed to change working directory: %w", err) + } } - projectName, err := request.RequireString("project_name") - if err == nil { + projectName := params.ProjectName + if projectName != "" { term.Debugf("Project name provided: %s", projectName) term.Debug("Function invoked: compose.NewLoader") return compose.NewLoader(compose.WithProjectName(projectName)), nil } - arguments := request.GetArguments() - composeFilePathsArgs, ok := arguments["compose_file_paths"] - if ok { - composeFilePaths, ok := composeFilePathsArgs.([]string) - if ok { - term.Debugf("Compose file paths provided: %s", composeFilePaths) - term.Debug("Function invoked: compose.NewLoader") - return compose.NewLoader(compose.WithPath(composeFilePaths...)), nil - } + composeFilePaths := params.ComposeFilePaths + if len(composeFilePaths) > 0 { + term.Debugf("Compose file paths provided: %s", composeFilePaths) + term.Debug("Function invoked: compose.NewLoader") + return compose.NewLoader(compose.WithPath(composeFilePaths...)), nil } //TODO: Talk about using both project name and compose file paths @@ -65,28 +62,3 @@ func ConfigureLoader(request mcp.CallToolRequest) (*compose.Loader, error) { term.Debug("Function invoked: compose.NewLoader") return compose.NewLoader(), nil } - -func FixupConfigError(err error) error { - if strings.Contains(err.Error(), "missing configs") { - return fmt.Errorf("The operation failed due to missing configs not being set, use the Defang tool called set_config to set the variable: %w", err) - } - return err -} - -func ProviderNotConfiguredError(providerId client.ProviderID) error { - if providerId == client.ProviderAuto { - return ErrNoProviderSet - } - return nil -} - -func CheckProviderConfigured(ctx context.Context, client cliClient.FabricClient, providerId cliClient.ProviderID, projectName, stack string, serviceCount int) (cliClient.Provider, error) { - provider := cli.NewProvider(ctx, providerId, client, stack) - - err := cliClient.CanIUseProvider(ctx, client, provider, projectName, stack, serviceCount) - if err != nil { - return nil, fmt.Errorf("failed to use provider: %w", err) - } - - return provider, nil -} diff --git a/src/pkg/agent/common/common_test.go b/src/pkg/agent/common/common_test.go deleted file mode 100644 index 2642c9b45..000000000 --- a/src/pkg/agent/common/common_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package common - -import ( - "errors" - "testing" - - "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/mark3labs/mcp-go/mcp" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestConfigureLoaderBranches(t *testing.T) { - makeReq := func(args map[string]any) mcp.CallToolRequest { - return mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: args}} - } - - // project_name path - loader1, err := ConfigureLoader(makeReq(map[string]any{"working_directory": "/tmp", "project_name": "myproj"})) - require.NoError(t, err) - assert.NotNil(t, loader1) - - // compose_file_paths path - loader2, err := ConfigureLoader(makeReq(map[string]any{"working_directory": "/tmp", "compose_file_paths": []string{"a.yml", "b.yml"}})) - require.NoError(t, err) - assert.NotNil(t, loader2) - - // default path (no working_directory) - loader3, err := ConfigureLoader(makeReq(map[string]any{})) - require.Error(t, err) - assert.Nil(t, loader3) -} - -func TestFixupConfigError(t *testing.T) { - cfgErr := errors.New("missing configs: DB_PASSWORD") - newErr := FixupConfigError(cfgErr) - assert.EqualError(t, newErr, "The operation failed due to missing configs not being set, use the Defang tool called set_config to set the variable: missing configs: DB_PASSWORD") - - otherErr := errors.New("another error") - res2 := FixupConfigError(otherErr) - assert.EqualError(t, res2, otherErr.Error()) -} - -func TestProviderNotConfiguredError(t *testing.T) { - // provider auto should error - err := ProviderNotConfiguredError(client.ProviderAuto) - assert.Error(t, err) - - // a real provider (simulate AWS value 'aws') should not error - var pid client.ProviderID - _ = pid.Set("aws") - err2 := ProviderNotConfiguredError(pid) - require.NoError(t, err2) -} diff --git a/src/pkg/agent/fs.go b/src/pkg/agent/fs.go new file mode 100644 index 000000000..7d5b32aad --- /dev/null +++ b/src/pkg/agent/fs.go @@ -0,0 +1,112 @@ +package agent + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + + "github.com/firebase/genkit/go/ai" +) + +type ReadFileParams struct { + Path string `json:"path"` +} + +type FindFilesParams struct { + Path string `json:"path"` + Pattern string `json:"pattern"` +} + +type ListFilesParams struct { + Path string `json:"path"` +} + +func isSafePath(path string) bool { + cleaned := filepath.Clean(path) + + // Reject absolute paths + if filepath.IsAbs(cleaned) { + return false + } + + // Reject paths that traverse outside the current directory + if cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return false + } + + return true +} + +func CollectFsTools() []ai.Tool { + return []ai.Tool{ + ai.NewTool[ReadFileParams, string]( + "read_file", + "Read the contents of a file from the local filesystem", + func(ctx *ai.ToolContext, params ReadFileParams) (string, error) { + if !isSafePath(params.Path) { + return "", errors.New("Accessing files outside the current working directory is not permitted") + } + bytes, err := os.ReadFile(params.Path) + if err != nil { + return "", err + } + return string(bytes), nil + }, + ), + ai.NewTool[FindFilesParams, string]( + "find_files", + "Find files in a directory on the local filesystem matching a given pattern", + func(ctx *ai.ToolContext, params FindFilesParams) (string, error) { + if !isSafePath(params.Path) { + return "", errors.New("Accessing files outside the current working directory is not permitted") + } + var matches []string + err := filepath.Walk(params.Path, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + matched, err := filepath.Match(params.Pattern, info.Name()) + if err != nil { + return err + } + if matched { + matches = append(matches, path) + } + return nil + }) + if err != nil { + return "", err + } + b, err := json.MarshalIndent(matches, "", " ") + if err != nil { + return "", err + } + return string(b), nil + }, + ), + ai.NewTool[ListFilesParams, string]( + "list_files", + "List files in a directory on the local filesystem", + func(ctx *ai.ToolContext, params ListFilesParams) (string, error) { + if !isSafePath(params.Path) { + return "", errors.New("Accessing files outside the current working directory is not permitted") + } + entries, err := os.ReadDir(params.Path) + if err != nil { + return "", err + } + var files []string + for _, entry := range entries { + files = append(files, entry.Name()) + } + b, err := json.MarshalIndent(files, "", " ") + if err != nil { + return "", err + } + return string(b), nil + }, + ), + } +} diff --git a/src/pkg/agent/generator.go b/src/pkg/agent/generator.go new file mode 100644 index 000000000..0d92bfe34 --- /dev/null +++ b/src/pkg/agent/generator.go @@ -0,0 +1,123 @@ +package agent + +import ( + "context" + "encoding/json" + + "github.com/DefangLabs/defang/src/pkg/term" + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" +) + +type GenkitGenerator interface { + Generate(ctx context.Context, prompt string, tools []ai.ToolRef, messages []*ai.Message, streamingCallback func(context.Context, *ai.ModelResponseChunk) error) (*ai.ModelResponse, error) +} + +type genkitGenerator struct { + genkit *genkit.Genkit +} + +func (g *genkitGenerator) Generate(ctx context.Context, prompt string, tools []ai.ToolRef, messages []*ai.Message, streamingCallback func(context.Context, *ai.ModelResponseChunk) error) (*ai.ModelResponse, error) { + return genkit.Generate(ctx, g.genkit, + ai.WithSystem(prompt), + ai.WithTools(tools...), + ai.WithMessages(messages...), + ai.WithStreaming(streamingCallback), + ai.WithReturnToolRequests(true), + ) +} + +type Generator struct { + messages []*ai.Message + genkitGenerator GenkitGenerator + printer Printer + toolManager *ToolManager +} + +func NewGenerator(genkit *genkit.Genkit, printer Printer, toolManager *ToolManager) *Generator { + return &Generator{ + genkitGenerator: &genkitGenerator{genkit: genkit}, + printer: printer, + toolManager: toolManager, + } +} + +func (g *Generator) streamingCallback(_ context.Context, chunk *ai.ModelResponseChunk) error { + for _, part := range chunk.Content { + if part.Kind == ai.PartText { + g.printer.Printf("%s", part.Text) + } + if part.Kind == ai.PartReasoning { + g.printer.Printf("_%s_", part.Text) + } + } + return nil +} + +type maxTurnsReachedError struct{} + +func (e *maxTurnsReachedError) Error() string { + return "maximum number of turns reached" +} + +func (g *Generator) HandleMessage(ctx context.Context, prompt string, maxTurns int, message *ai.Message) error { + if message != nil { + g.messages = append(g.messages, message) + } + for range maxTurns { + resp, err := g.generate(ctx, prompt, g.messages) + if err != nil { + term.Debugf("error: %v", err) + continue + } + + g.messages = append(g.messages, resp.Message) + + toolRequests := resp.ToolRequests() + if len(toolRequests) == 0 { + return nil + } + + toolResp := g.toolManager.HandleToolCalls(ctx, toolRequests) + g.messages = append(g.messages, toolResp) + } + + return &maxTurnsReachedError{} +} + +func (g *Generator) generate(ctx context.Context, prompt string, messages []*ai.Message) (*ai.ModelResponse, error) { + g.printer.Println("* Thinking...") + resp, err := g.genkitGenerator.Generate( + ctx, + prompt, + g.toolManager.tools, + messages, + g.streamingCallback, + ) + if err != nil { + return nil, err + } + if len(resp.Message.Content) == 0 { + return nil, &EmptyResponseError{} + } + g.printer.Println("") + for _, part := range resp.Message.Content { + if part.Kind == ai.PartToolRequest { + req := part.ToolRequest + inputs, err := json.Marshal(req.Input) + if err != nil { + g.printer.Printf("! error marshaling tool request input: %v\n", err) + } else { + g.printer.Printf("* %s(%s)\n", req.Name, inputs) + } + } + } + + return resp, nil +} + +type EmptyResponseError struct{} + +func (e *EmptyResponseError) Error() string { + return "empty response from model" +} diff --git a/src/pkg/agent/generator_test.go b/src/pkg/agent/generator_test.go new file mode 100644 index 000000000..7f2f0573b --- /dev/null +++ b/src/pkg/agent/generator_test.go @@ -0,0 +1,193 @@ +package agent + +import ( + "context" + "testing" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" + "github.com/stretchr/testify/assert" +) + +// create a mock GenkitGenerator for testing +type mockGenkitGenerator struct { + responses []*ai.ModelResponse + err error + callCount int +} + +func (m *mockGenkitGenerator) Generate(ctx context.Context, prompt string, tools []ai.ToolRef, messages []*ai.Message, streamingCallback func(context.Context, *ai.ModelResponseChunk) error) (*ai.ModelResponse, error) { + if m.callCount < len(m.responses) { + resp := m.responses[m.callCount] + m.callCount++ + return resp, m.err + } + return nil, m.err +} + +func TestHandleMessage(t *testing.T) { + prompt := "Test prompt" + tests := []struct { + name string + maxTurns int + generatorResponses []*ai.ModelResponse + expectedResponseMessages []*ai.Message + expectedError error + }{ + { + name: "HandleMessage no tool calls", + maxTurns: 2, + generatorResponses: []*ai.ModelResponse{ + { + Message: ai.NewModelTextMessage("Response 1"), + }, + }, + expectedResponseMessages: []*ai.Message{ + ai.NewUserTextMessage("User message"), + ai.NewModelTextMessage("Response 1"), + }, + }, + { + name: "HandleMessage with tool calls", + maxTurns: 2, + generatorResponses: []*ai.ModelResponse{ + { + Message: ai.NewModelMessage( + ai.NewToolRequestPart(&ai.ToolRequest{ + Name: "read_file", + Input: map[string]any{ + "path": "value1", + }, + }), + ), + }, + { + Message: ai.NewModelTextMessage("All done"), + }, + }, + expectedResponseMessages: []*ai.Message{ + ai.NewUserTextMessage("User message"), + ai.NewModelMessage( + ai.NewToolRequestPart(&ai.ToolRequest{ + Name: "read_file", + Input: map[string]any{ + "path": "value1", + }, + }), + ), + ai.NewMessage(ai.RoleTool, nil, + ai.NewToolResponsePart(&ai.ToolResponse{ + Name: "read_file", + Ref: "", + Output: "error calling tool read_file: open value1: no such file or directory", + }), + ), + ai.NewModelMessage( + ai.NewTextPart("All done"), + ), + }, + }, + { + name: "HandleMessage with tool calls in both responses", + maxTurns: 2, + generatorResponses: []*ai.ModelResponse{ + { + Message: ai.NewModelMessage( + ai.NewToolRequestPart(&ai.ToolRequest{ + Name: "read_file", + Input: map[string]any{ + "path": "value1", + }, + }), + ), + }, + { + Message: ai.NewModelMessage( + ai.NewToolRequestPart(&ai.ToolRequest{ + Name: "read_file", + Input: map[string]any{ + "path": "value2", + }, + }), + ), + }, + }, + expectedResponseMessages: []*ai.Message{ + ai.NewUserTextMessage("User message"), + ai.NewModelMessage( + ai.NewToolRequestPart(&ai.ToolRequest{ + Name: "read_file", + Input: map[string]any{ + "path": "value1", + }, + }), + ), + ai.NewMessage(ai.RoleTool, nil, + ai.NewToolResponsePart(&ai.ToolResponse{ + Name: "read_file", + Ref: "", + Output: "error calling tool read_file: open value1: no such file or directory", + }), + ), + ai.NewModelMessage( + ai.NewToolRequestPart(&ai.ToolRequest{ + Name: "read_file", + Input: map[string]any{ + "path": "value2", + }, + }), + ), + ai.NewMessage(ai.RoleTool, nil, + ai.NewToolResponsePart(&ai.ToolResponse{ + Name: "read_file", + Ref: "", + Output: "error calling tool read_file: open value2: no such file or directory", + }), + ), + }, + expectedError: &maxTurnsReachedError{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Here you would set up the necessary context, prompt, and messages + // For demonstration purposes, we'll use placeholders + ctx := t.Context() + printer := &mockPrinter{} + + gk := genkit.Init(ctx) + toolManager := NewToolManager(gk, printer) + fsTools := CollectFsTools() + toolManager.RegisterTools(fsTools...) + generator := &Generator{ + genkitGenerator: &mockGenkitGenerator{ + responses: tt.generatorResponses, + }, + printer: printer, + toolManager: toolManager, + } + + message := ai.NewUserTextMessage("User message") + err := generator.HandleMessage(ctx, prompt, tt.maxTurns, message) + if tt.expectedError != nil { + assert.ErrorIs(t, err, tt.expectedError, "HandleMessage should return the expected error") + } else { + assert.NoError(t, err, "HandleMessage should not return an error") + } + for i, resp := range generator.messages { + expectedContent := tt.expectedResponseMessages[i].Content[0] + actualContent := resp.Content[0] + assert.Equal(t, expectedContent.Kind, actualContent.Kind, "Response message part kind should match") + assert.Equal(t, expectedContent.Text, actualContent.Text, "Response message should match expected") + if expectedContent.ToolRequest != nil { + assert.Equal(t, expectedContent.ToolRequest.Name, actualContent.ToolRequest.Name, "Tool request name should match") + } + if expectedContent.ToolResponse != nil { + assert.Equal(t, expectedContent.ToolResponse.Name, actualContent.ToolResponse.Name, "Tool response name should match") + assert.Equal(t, expectedContent.ToolResponse.Output, actualContent.ToolResponse.Output, "Tool response output should match") + } + } + }) + } +} diff --git a/src/pkg/agent/plugins/compat_oai/compat_oai.go b/src/pkg/agent/plugins/compat_oai/compat_oai.go new file mode 100644 index 000000000..fac68a1cf --- /dev/null +++ b/src/pkg/agent/plugins/compat_oai/compat_oai.go @@ -0,0 +1,261 @@ +package compat_oai + +// Copyright 2025 Google LLC +// +// 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. + +import ( + "context" + "fmt" + "sync" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/core/api" + "github.com/firebase/genkit/go/genkit" + "github.com/openai/openai-go" + "github.com/openai/openai-go/option" +) + +var ( + // BasicText describes model capabilities for text-only GPT models. + BasicText = ai.ModelSupports{ + Multiturn: true, + Tools: true, + SystemRole: true, + Media: false, + } + + // Multimodal describes model capabilities for multimodal GPT models. + Multimodal = ai.ModelSupports{ + Multiturn: true, + Tools: true, + SystemRole: true, + Media: true, + ToolChoice: true, + } +) + +// OpenAICompatible is a plugin that provides compatibility with OpenAI's Compatible APIs. +// It allows defining models and embedders that can be used with Genkit. +type OpenAICompatible struct { + // mu protects concurrent access to the client and initialization state + mu sync.Mutex + + // initted tracks whether the plugin has been initialized + initted bool + + // client is the OpenAI client used for making API requests + // see https://github.com/openai/openai-go + client *openai.Client + + // Opts contains request options for the OpenAI client. + // Required: Must include at least WithAPIKey for authentication. + // Optional: Can include other options like WithOrganization, WithBaseURL, etc. + Opts []option.RequestOption + + // Provider is a unique identifier for the plugin. + // This will be used as a prefix for model names (e.g., "myprovider/model-name"). + // Should be lowercase and match the plugin's Name() method. + Provider string + + // API key to use with the desired plugin. + APIKey string + + // Base URL to use for custom endpoints. + // This should be used if you are running through a proxy or + // using a non-official endpoint + BaseURL string +} + +// Init implements genkit.Plugin. +func (o *OpenAICompatible) Init(ctx context.Context) []api.Action { + o.mu.Lock() + defer o.mu.Unlock() + if o.initted { + panic("compat_oai.Init already called") + } + + if o.APIKey != "" { + o.Opts = append([]option.RequestOption{option.WithAPIKey(o.APIKey)}, o.Opts...) + } + + if o.BaseURL != "" { + o.Opts = append([]option.RequestOption{option.WithBaseURL(o.BaseURL)}, o.Opts...) + } + + // create client + client := openai.NewClient(o.Opts...) + o.client = &client + o.initted = true + + return []api.Action{} +} + +// Name implements genkit.Plugin. +func (o *OpenAICompatible) Name() string { + return o.Provider +} + +// DefineModel defines a model in the registry +func (o *OpenAICompatible) DefineModel(provider, id string, opts ai.ModelOptions) ai.Model { + o.mu.Lock() + defer o.mu.Unlock() + if !o.initted { + panic("OpenAICompatible.Init not called") + } + + return ai.NewModel(api.NewName(provider, id), &opts, func( + ctx context.Context, + input *ai.ModelRequest, + cb func(context.Context, *ai.ModelResponseChunk) error, + ) (*ai.ModelResponse, error) { + // Configure the response generator with input + generator := NewModelGenerator(o.client, id).WithMessages(input.Messages).WithConfig(input.Config).WithTools(input.Tools) + + // Generate response + resp, err := generator.Generate(ctx, input, cb) + if err != nil { + return nil, err + } + + return resp, nil + }) +} + +// DefineEmbedder defines an embedder with a given name. +func (o *OpenAICompatible) DefineEmbedder(provider, name string, embedOpts *ai.EmbedderOptions) ai.Embedder { + o.mu.Lock() + defer o.mu.Unlock() + if !o.initted { + panic("OpenAICompatible.Init not called") + } + + return ai.NewEmbedder(api.NewName(provider, name), embedOpts, func(ctx context.Context, req *ai.EmbedRequest) (*ai.EmbedResponse, error) { + var data openai.EmbeddingNewParamsInputUnion + for _, doc := range req.Input { + for _, p := range doc.Content { + data.OfArrayOfStrings = append(data.OfArrayOfStrings, p.Text) + } + } + + params := openai.EmbeddingNewParams{ + // nolint:unconvert + Input: openai.EmbeddingNewParamsInputUnion(data), + Model: name, + EncodingFormat: openai.EmbeddingNewParamsEncodingFormatFloat, + } + + embeddingResp, err := o.client.Embeddings.New(ctx, params) + if err != nil { + return nil, err + } + + resp := &ai.EmbedResponse{} + for _, emb := range embeddingResp.Data { + embedding := make([]float32, len(emb.Embedding)) + for i, val := range emb.Embedding { + embedding[i] = float32(val) + } + resp.Embeddings = append(resp.Embeddings, &ai.Embedding{Embedding: embedding}) + } + return resp, nil + }) +} + +// IsDefinedEmbedder reports whether the named [Embedder] is defined by this plugin. +func (o *OpenAICompatible) IsDefinedEmbedder(g *genkit.Genkit, name string) bool { + return genkit.LookupEmbedder(g, name) != nil +} + +// Embedder returns the [ai.Embedder] with the given name. +// It returns nil if the embedder was not defined. +func (o *OpenAICompatible) Embedder(g *genkit.Genkit, name string) ai.Embedder { + return genkit.LookupEmbedder(g, name) +} + +// Model returns the [ai.Model] with the given name. +// It returns nil if the model was not defined. +func (o *OpenAICompatible) Model(g *genkit.Genkit, name string) ai.Model { + return genkit.LookupModel(g, name) +} + +// IsDefinedModel reports whether the named [Model] is defined by this plugin. +func (o *OpenAICompatible) IsDefinedModel(g *genkit.Genkit, name string) bool { + return genkit.LookupModel(g, name) != nil +} + +func (o *OpenAICompatible) ListActions(ctx context.Context) []api.ActionDesc { + actions := []api.ActionDesc{} + + models, err := listOpenAIModels(ctx, o.client) + if err != nil { + return nil + } + for _, name := range models { + metadata := map[string]any{ + "model": map[string]any{ + "supports": map[string]any{ + "media": true, + "multiturn": true, + "systemRole": true, + "tools": true, + "toolChoice": true, + "constrained": "all", + }, + }, + "versions": []string{}, + "stage": string(ai.ModelStageStable), + } + metadata["label"] = fmt.Sprintf("%s - %s", o.Provider, name) + + actions = append(actions, api.ActionDesc{ + Type: api.ActionTypeModel, + Name: fmt.Sprintf("%s/%s", o.Provider, name), + Key: fmt.Sprintf("/%s/%s/%s", api.ActionTypeModel, o.Provider, name), + Metadata: metadata, + }) + } + + return actions +} + +func (o *OpenAICompatible) ResolveAction(atype api.ActionType, name string) api.Action { + switch atype { + case api.ActionTypeModel: + if model := o.DefineModel(o.Provider, name, ai.ModelOptions{ + Label: fmt.Sprintf("%s - %s", o.Provider, name), + Stage: ai.ModelStageStable, + Versions: []string{}, + Supports: &Multimodal, + }); model != nil { + //nolint:forcetypeassert + return model.(api.Action) + } + } + + return nil +} + +func listOpenAIModels(ctx context.Context, client *openai.Client) ([]string, error) { + models := []string{} + iter := client.Models.ListAutoPaging(ctx) + for iter.Next() { + m := iter.Current() + models = append(models, m.ID) + } + if err := iter.Err(); err != nil { + return nil, err + } + + return models, nil +} diff --git a/src/pkg/agent/plugins/compat_oai/generate.go b/src/pkg/agent/plugins/compat_oai/generate.go new file mode 100644 index 000000000..2cd1e3001 --- /dev/null +++ b/src/pkg/agent/plugins/compat_oai/generate.go @@ -0,0 +1,499 @@ +package compat_oai + +// Copyright 2025 Google LLC +// +// 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. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/DefangLabs/defang/src/pkg/term" + "github.com/firebase/genkit/go/ai" + "github.com/openai/openai-go" + "github.com/openai/openai-go/packages/param" + "github.com/openai/openai-go/shared" +) + +// mapToStruct unmarshals a map[string]any to the expected config api. +func mapToStruct(m map[string]any, v any) error { + jsonData, err := json.Marshal(m) + if err != nil { + return err + } + return json.Unmarshal(jsonData, v) +} + +// ModelGenerator handles OpenAI generation requests +type ModelGenerator struct { + client *openai.Client + modelName string + request *openai.ChatCompletionNewParams + messages []openai.ChatCompletionMessageParamUnion + tools []openai.ChatCompletionToolParam + // nolint:unused + toolChoice openai.ChatCompletionToolChoiceOptionUnionParam + // Store any errors that occur during building + err error +} + +func (g *ModelGenerator) GetRequest() *openai.ChatCompletionNewParams { + return g.request +} + +// NewModelGenerator creates a new ModelGenerator instance +func NewModelGenerator(client *openai.Client, modelName string) *ModelGenerator { + return &ModelGenerator{ + client: client, + modelName: modelName, + request: &openai.ChatCompletionNewParams{ + Model: modelName, + MaxCompletionTokens: param.NewOpt[int64](1024), + Temperature: param.NewOpt[float64](0.4), + }, + } +} + +// WithMessages adds messages to the request +func (g *ModelGenerator) WithMessages(messages []*ai.Message) *ModelGenerator { + // Return early if we already have an error + if g.err != nil { + return g + } + + if messages == nil { + return g + } + + oaiMessages := make([]openai.ChatCompletionMessageParamUnion, 0, len(messages)) + for _, msg := range messages { + content := g.concatenateContent(msg.Content) + switch msg.Role { + case ai.RoleSystem: + oaiMessages = append(oaiMessages, openai.SystemMessage(content)) + case ai.RoleModel: + + am := openai.ChatCompletionAssistantMessageParam{} + am.Content.OfString = param.NewOpt(content) + toolCalls, err := convertToolCalls(msg.Content) + if err != nil { + g.err = err + return g + } + if len(toolCalls) > 0 { + am.ToolCalls = (toolCalls) + } + oaiMessages = append(oaiMessages, openai.ChatCompletionMessageParamUnion{ + OfAssistant: &am, + }) + case ai.RoleTool: + for _, p := range msg.Content { + if !p.IsToolResponse() { + continue + } + // Use the captured tool call ID (Ref) if available, otherwise fall back to tool name + toolCallID := p.ToolResponse.Ref + if toolCallID == "" { + toolCallID = p.ToolResponse.Name + } + + toolOutput, err := anyToJSONString(p.ToolResponse.Output) + if err != nil { + g.err = err + return g + } + tm := openai.ToolMessage(toolOutput, toolCallID) + oaiMessages = append(oaiMessages, tm) + } + case ai.RoleUser: + parts := []openai.ChatCompletionContentPartUnionParam{} + if content != "" { + parts = append(parts, openai.TextContentPart(content)) + } + + for _, p := range msg.Content { + if p.IsMedia() { + part := openai.ImageContentPart( + openai.ChatCompletionContentPartImageImageURLParam{ + URL: p.Text, + }) + parts = append(parts, part) + continue + } + } + oaiMessages = append(oaiMessages, openai.ChatCompletionMessageParamUnion{ + OfUser: &openai.ChatCompletionUserMessageParam{ + Content: openai.ChatCompletionUserMessageParamContentUnion{OfArrayOfContentParts: parts}, + }, + }) + default: + // ignore parts from not supported roles + continue + } + } + g.messages = oaiMessages + return g +} + +// WithConfig adds configuration parameters from the model request +// see https://platform.openai.com/docs/api-reference/responses/create +// for more details on openai's request fields +func (g *ModelGenerator) WithConfig(config any) *ModelGenerator { + // Return early if we already have an error + if g.err != nil { + return g + } + + if config == nil { + return g + } + + var openaiConfig openai.ChatCompletionNewParams + switch cfg := config.(type) { + case openai.ChatCompletionNewParams: + openaiConfig = cfg + case *openai.ChatCompletionNewParams: + openaiConfig = *cfg + case map[string]any: + if err := mapToStruct(cfg, &openaiConfig); err != nil { + g.err = fmt.Errorf("failed to convert config to openai.ChatCompletionNewParams: %w", err) + return g + } + default: + g.err = fmt.Errorf("unexpected config type: %T", config) + return g + } + + // keep the original model in the updated config structure + openaiConfig.Model = g.request.Model + g.request = &openaiConfig + return g +} + +// WithTools adds tools to the request +func (g *ModelGenerator) WithTools(tools []*ai.ToolDefinition) *ModelGenerator { + if g.err != nil { + return g + } + + if tools == nil { + return g + } + + toolParams := make([]openai.ChatCompletionToolParam, 0, len(tools)) + for _, tool := range tools { + if tool == nil || tool.Name == "" { + continue + } + + toolParams = append(toolParams, openai.ChatCompletionToolParam{ + Function: (shared.FunctionDefinitionParam{ + Name: tool.Name, + Description: openai.String(tool.Description), + Parameters: openai.FunctionParameters(tool.InputSchema), + Strict: openai.Bool(false), // TODO: implement strict mode + }), + }) + } + + // Set the tools in the request + // If no tools are provided, set it to nil + // This is important to avoid sending an empty array in the request + // which is not supported by some vendor APIs + if len(toolParams) > 0 { + g.tools = toolParams + } + + return g +} + +// Generate executes the generation request +func (g *ModelGenerator) Generate(ctx context.Context, req *ai.ModelRequest, handleChunk func(context.Context, *ai.ModelResponseChunk) error) (*ai.ModelResponse, error) { + // Check for any errors that occurred during building + if g.err != nil { + return nil, g.err + } + + if len(g.messages) == 0 { + // nolint:perfsprint + return nil, fmt.Errorf("no messages provided") + } + g.request.Messages = (g.messages) + + if len(g.tools) > 0 { + g.request.Tools = (g.tools) + } + + if handleChunk != nil { + return g.generateStream(ctx, handleChunk) + } + return g.generateComplete(ctx, req) +} + +// concatenateContent concatenates text content into a single string +func (g *ModelGenerator) concatenateContent(parts []*ai.Part) string { + content := "" + for _, part := range parts { + content += part.Text + } + return content +} + +// generateStream generates a streaming model response +func (g *ModelGenerator) generateStream(ctx context.Context, handleChunk func(context.Context, *ai.ModelResponseChunk) error) (*ai.ModelResponse, error) { + reqParams, err := json.Marshal(g.request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request params for debug: %w", err) + } + _, _ = term.Debugf("Chat.Completions.NewStreaming: %s", string(reqParams)) + stream := g.client.Chat.Completions.NewStreaming(ctx, *g.request) + defer stream.Close() + + var fullResponse ai.ModelResponse + fullResponse.Message = &ai.Message{ + Role: ai.RoleModel, + Content: make([]*ai.Part, 0), + } + + // Initialize request and usage + fullResponse.Request = &ai.ModelRequest{} + fullResponse.Usage = &ai.GenerationUsage{ + InputTokens: 0, + OutputTokens: 0, + TotalTokens: 0, + } + + var currentToolCall *ai.ToolRequest + var currentArguments string + var toolCallCollects []struct { + toolCall *ai.ToolRequest + args string + } + + for stream.Next() { + chunk := stream.Current() + if len(chunk.Choices) > 0 { + choice := chunk.Choices[0] + modelChunk := &ai.ModelResponseChunk{} + + switch choice.FinishReason { + case "tool_calls", "stop": + fullResponse.FinishReason = ai.FinishReasonStop + case "length": + fullResponse.FinishReason = ai.FinishReasonLength + case "content_filter": + fullResponse.FinishReason = ai.FinishReasonBlocked + case "function_call": + fullResponse.FinishReason = ai.FinishReasonOther + default: + fullResponse.FinishReason = ai.FinishReasonUnknown + } + + // handle tool calls + for _, toolCall := range choice.Delta.ToolCalls { + // first tool call (= current tool call is nil) contains the tool call name + if currentToolCall != nil && toolCall.ID != "" && currentToolCall.Ref != toolCall.ID { + toolCallCollects = append(toolCallCollects, struct { + toolCall *ai.ToolRequest + args string + }{ + toolCall: currentToolCall, + args: currentArguments, + }) + currentToolCall = nil + currentArguments = "" + } + + if currentToolCall == nil { + currentToolCall = &ai.ToolRequest{ + Name: toolCall.Function.Name, + Ref: toolCall.ID, + } + } + + if toolCall.Function.Arguments != "" { + currentArguments += toolCall.Function.Arguments + } + + modelChunk.Content = append(modelChunk.Content, ai.NewToolRequestPart(&ai.ToolRequest{ + Name: currentToolCall.Name, + Input: toolCall.Function.Arguments, + Ref: currentToolCall.Ref, + })) + } + + // when tool call is complete + if choice.FinishReason == "tool_calls" && currentToolCall != nil { + // parse accumulated arguments string + for _, toolcall := range toolCallCollects { + args, err := jsonStringToMap(toolcall.args) + if err != nil { + return nil, fmt.Errorf("could not parse tool args: %w", err) + } + toolcall.toolCall.Input = args + fullResponse.Message.Content = append(fullResponse.Message.Content, ai.NewToolRequestPart(toolcall.toolCall)) + } + if currentArguments != "" { + args, err := jsonStringToMap(currentArguments) + if err != nil { + return nil, fmt.Errorf("could not parse tool args: %w", err) + } + currentToolCall.Input = args + } + fullResponse.Message.Content = append(fullResponse.Message.Content, ai.NewToolRequestPart(currentToolCall)) + } + + content := chunk.Choices[0].Delta.Content + // when starting a tool call, the content is empty + if content != "" { + modelChunk.Content = append(modelChunk.Content, ai.NewTextPart(content)) + fullResponse.Message.Content = append(fullResponse.Message.Content, modelChunk.Content...) + } + + if err := handleChunk(ctx, modelChunk); err != nil { + return nil, fmt.Errorf("callback error: %w", err) + } + + fullResponse.Usage.InputTokens += int(chunk.Usage.PromptTokens) + fullResponse.Usage.OutputTokens += int(chunk.Usage.CompletionTokens) + fullResponse.Usage.TotalTokens += int(chunk.Usage.TotalTokens) + } + } + + if err := stream.Err(); err != nil { + return nil, fmt.Errorf("stream error: %w", err) + } + + return &fullResponse, nil +} + +// generateComplete generates a complete model response +func (g *ModelGenerator) generateComplete(ctx context.Context, req *ai.ModelRequest) (*ai.ModelResponse, error) { + completion, err := g.client.Chat.Completions.New(ctx, *g.request) + if err != nil { + return nil, fmt.Errorf("failed to create completion: %w", err) + } + + resp := &ai.ModelResponse{ + Request: req, + Usage: &ai.GenerationUsage{ + InputTokens: int(completion.Usage.PromptTokens), + OutputTokens: int(completion.Usage.CompletionTokens), + TotalTokens: int(completion.Usage.TotalTokens), + }, + Message: &ai.Message{ + Role: ai.RoleModel, + }, + } + + if len(completion.Choices) == 0 { + return nil, errors.New("no choices returned from completion") + } + choice := completion.Choices[0] + + switch choice.FinishReason { + case "stop", "tool_calls": + resp.FinishReason = ai.FinishReasonStop + case "length": + resp.FinishReason = ai.FinishReasonLength + case "content_filter": + resp.FinishReason = ai.FinishReasonBlocked + case "function_call": + resp.FinishReason = ai.FinishReasonOther + default: + resp.FinishReason = ai.FinishReasonUnknown + } + + // handle tool calls + var toolRequestParts []*ai.Part + for _, toolCall := range choice.Message.ToolCalls { + args, err := jsonStringToMap(toolCall.Function.Arguments) + if err != nil { + return nil, err + } + toolRequestParts = append(toolRequestParts, ai.NewToolRequestPart(&ai.ToolRequest{ + Ref: toolCall.ID, + Name: toolCall.Function.Name, + Input: args, + })) + } + + // content and tool call may exist simultaneously + if completion.Choices[0].Message.Content != "" { + resp.Message.Content = append(resp.Message.Content, ai.NewTextPart(completion.Choices[0].Message.Content)) + } + + if len(toolRequestParts) > 0 { + resp.Message.Content = append(resp.Message.Content, toolRequestParts...) + return resp, nil + } + + return resp, nil +} + +func convertToolCalls(content []*ai.Part) ([]openai.ChatCompletionMessageToolCallParam, error) { + var toolCalls []openai.ChatCompletionMessageToolCallParam + for _, p := range content { + if !p.IsToolRequest() { + continue + } + toolCall, err := convertToolCall(p) + if err != nil { + return nil, err + } + toolCalls = append(toolCalls, *toolCall) + } + return toolCalls, nil +} + +func convertToolCall(part *ai.Part) (*openai.ChatCompletionMessageToolCallParam, error) { + toolCallID := part.ToolRequest.Ref + if toolCallID == "" { + toolCallID = part.ToolRequest.Name + } + + param := &openai.ChatCompletionMessageToolCallParam{ + ID: (toolCallID), + Function: (openai.ChatCompletionMessageToolCallFunctionParam{ + Name: (part.ToolRequest.Name), + }), + } + + args, err := anyToJSONString(part.ToolRequest.Input) + if err != nil { + return nil, err + } + if part.ToolRequest.Input != nil { + param.Function.Arguments = args + } + + return param, nil +} + +func jsonStringToMap(jsonString string) (map[string]any, error) { + var result map[string]any + if err := json.Unmarshal([]byte(jsonString), &result); err != nil { + return nil, fmt.Errorf("unmarshal failed to parse json string %s: %w", jsonString, err) + } + return result, nil +} + +func anyToJSONString(data any) (string, error) { + jsonBytes, err := json.Marshal(data) + if err != nil { + return "", fmt.Errorf("failed to marshal any to JSON string: data, %#v %w", data, err) + } + return string(jsonBytes), nil +} diff --git a/src/pkg/agent/plugins/fabric/fabric.go b/src/pkg/agent/plugins/fabric/fabric.go new file mode 100644 index 000000000..d023e8784 --- /dev/null +++ b/src/pkg/agent/plugins/fabric/fabric.go @@ -0,0 +1,164 @@ +package fabric + +// Copyright 2025 Google LLC +// +// 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. + +import ( + "context" + "os" + + "github.com/DefangLabs/defang/src/pkg/agent/plugins/compat_oai" + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/core" + "github.com/firebase/genkit/go/core/api" + "github.com/firebase/genkit/go/genkit" + openaiGo "github.com/openai/openai-go" + "github.com/openai/openai-go/option" +) + +const provider = "fabric" + +type TextEmbeddingConfig struct { + Dimensions int `json:"dimensions,omitempty"` + EncodingFormat openaiGo.EmbeddingNewParamsEncodingFormat `json:"encodingFormat,omitempty"` +} + +// EmbedderRef represents the main structure for an embedding model's definition. +type EmbedderRef struct { + Name string + ConfigSchema TextEmbeddingConfig // Represents the schema, can be used for default config + Label string + Supports *ai.EmbedderSupports + Dimensions int +} + +var ( + supportedModels = map[string]ai.ModelOptions{ + "google/gemini-2.5-flash": { + Label: "Gemini 2.5 Flash", + Versions: []string{}, + Supports: &ai.ModelSupports{ + Multiturn: true, + Tools: true, + ToolChoice: true, + SystemRole: true, + // Media: true, + Constrained: ai.ConstrainedSupportNoTools, + }, + Stage: ai.ModelStageStable, + }, + } + + supportedEmbeddingModels = map[string]EmbedderRef{} +) + +type OpenAI struct { + // APIKey is the API key for the OpenAI API. If empty, the values of the environment variable "OPENAI_API_KEY" will be consulted. + // Request a key at https://platform.openai.com/api-keys + APIKey string + // Optional: Opts are additional options for the OpenAI client. + // Can include other options like WithOrganization, WithBaseURL, etc. + Opts []option.RequestOption + + openAICompatible *compat_oai.OpenAICompatible +} + +// Name implements genkit.Plugin. +func (o *OpenAI) Name() string { + return provider +} + +// Init implements genkit.Plugin. +func (o *OpenAI) Init(ctx context.Context) []api.Action { + apiKey := o.APIKey + + // if api key is not set, get it from environment variable + if apiKey == "" { + apiKey = os.Getenv("OPENAI_API_KEY") + } + + if apiKey == "" { + panic("openai plugin initialization failed: apiKey is required") + } + + if o.openAICompatible == nil { + o.openAICompatible = &compat_oai.OpenAICompatible{} + } + + // set the options + o.openAICompatible.Opts = []option.RequestOption{ + option.WithAPIKey(apiKey), + } + if len(o.Opts) > 0 { + o.openAICompatible.Opts = append(o.openAICompatible.Opts, o.Opts...) + } + + o.openAICompatible.Provider = provider + compatActions := o.openAICompatible.Init(ctx) + + var actions []api.Action + actions = append(actions, compatActions...) + + // define default models + for model, opts := range supportedModels { + aiModel := o.DefineModel(model, opts) + action, ok := aiModel.(api.Action) + if !ok { + panic("model is not an action") + } + actions = append(actions, action) + } + + // define default embedders + for _, embedder := range supportedEmbeddingModels { + opts := &ai.EmbedderOptions{ + ConfigSchema: core.InferSchemaMap(embedder.ConfigSchema), + Label: embedder.Label, + Supports: embedder.Supports, + Dimensions: embedder.Dimensions, + } + aiEmbedder := o.DefineEmbedder(embedder.Name, opts) + action, ok := aiEmbedder.(api.Action) + if !ok { + panic("embedder is not an action") + } + actions = append(actions, action) + } + + return actions +} + +func (o *OpenAI) Model(g *genkit.Genkit, name string) ai.Model { + return o.openAICompatible.Model(g, api.NewName(provider, name)) +} + +func (o *OpenAI) DefineModel(id string, opts ai.ModelOptions) ai.Model { + return o.openAICompatible.DefineModel(provider, id, opts) +} + +func (o *OpenAI) DefineEmbedder(id string, opts *ai.EmbedderOptions) ai.Embedder { + return o.openAICompatible.DefineEmbedder(provider, id, opts) +} + +func (o *OpenAI) Embedder(g *genkit.Genkit, name string) ai.Embedder { + return o.openAICompatible.Embedder(g, api.NewName(provider, name)) +} + +func (o *OpenAI) ListActions(ctx context.Context) []api.ActionDesc { + return o.openAICompatible.ListActions(ctx) +} + +func (o *OpenAI) ResolveAction(atype api.ActionType, name string) api.Action { + return o.openAICompatible.ResolveAction(atype, name) +} diff --git a/src/pkg/agent/printer.go b/src/pkg/agent/printer.go new file mode 100644 index 000000000..5b82d11b4 --- /dev/null +++ b/src/pkg/agent/printer.go @@ -0,0 +1,23 @@ +package agent + +import ( + "fmt" + "io" +) + +type printer struct { + outStream io.Writer +} + +type Printer interface { + Printf(format string, args ...interface{}) + Println(args ...interface{}) +} + +func (p printer) Printf(format string, args ...interface{}) { + fmt.Fprintf(p.outStream, format, args...) +} + +func (p printer) Println(args ...interface{}) { + fmt.Fprintln(p.outStream, args...) +} diff --git a/src/pkg/agent/tee.go b/src/pkg/agent/tee.go new file mode 100644 index 000000000..55780d307 --- /dev/null +++ b/src/pkg/agent/tee.go @@ -0,0 +1,49 @@ +package agent + +import ( + "bytes" + "fmt" + "io" + "os" + + "github.com/DefangLabs/defang/src/pkg/term" +) + +func CaptureTerm(f func() (any, error)) (string, error) { + return captureTerm(false, f) +} + +func TeeTerm(f func() (any, error)) (string, error) { + return captureTerm(true, f) +} + +// TODO: consider using generics or string instead of any +func captureTerm(tee bool, f func() (any, error)) (string, error) { + // replace the default term with a new term that writes to a buffer + originalTerm := term.DefaultTerm + outBuffer := bytes.NewBuffer(nil) + errBuffer := bytes.NewBuffer(nil) + var outWriter io.Writer + var errWriter io.Writer + if tee { + outWriter = io.MultiWriter(outBuffer, os.Stdout) + errWriter = io.MultiWriter(errBuffer, os.Stderr) + // Replace newTerm creation below to use outWriter and errWriter + } else { + outWriter = outBuffer + errWriter = errBuffer + } + newTerm := term.NewTerm( + os.Stdin, + // whenever something is written to outBuffer or errBuffer, also write it to the original term's outStream + outWriter, + errWriter, + ) + term.DefaultTerm = newTerm + defer func() { + term.DefaultTerm = originalTerm + }() + result, err := f() + output := outBuffer.String() + errBuffer.String() + return output + fmt.Sprint(result), err +} diff --git a/src/pkg/agent/toolmanager.go b/src/pkg/agent/toolmanager.go new file mode 100644 index 000000000..e3277730e --- /dev/null +++ b/src/pkg/agent/toolmanager.go @@ -0,0 +1,142 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/DefangLabs/defang/src/pkg/agent/common" + "github.com/DefangLabs/defang/src/pkg/term" + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" +) + +type GenkitToolManager interface { + RegisterTools(tools ...ai.Tool) + LookupTool(name string) ai.Tool +} + +type genkitToolManager struct { + genkit *genkit.Genkit +} + +func (g *genkitToolManager) RegisterTools(tools ...ai.Tool) { + for _, tool := range tools { + genkit.RegisterAction(g.genkit, tool) + } +} + +func (g *genkitToolManager) LookupTool(name string) ai.Tool { + return genkit.LookupTool(g.genkit, name) +} + +func NewGenkitToolManager(genkit *genkit.Genkit) GenkitToolManager { + return &genkitToolManager{genkit: genkit} +} + +type ToolManager struct { + gktm GenkitToolManager + printer Printer + prevTurnToolRequestsJSON map[string]bool + tools []ai.ToolRef +} + +func NewToolManager(genkit *genkit.Genkit, printer Printer) *ToolManager { + return &ToolManager{ + gktm: NewGenkitToolManager(genkit), + printer: printer, + prevTurnToolRequestsJSON: make(map[string]bool), + tools: make([]ai.ToolRef, 0), + } +} + +func (t *ToolManager) RegisterTools(tools ...ai.Tool) { + for _, tool := range tools { + t.gktm.RegisterTools(tool) + t.tools = append(t.tools, ai.ToolRef(tool)) + } +} + +func (t *ToolManager) HandleToolCalls(ctx context.Context, requests []*ai.ToolRequest) *ai.Message { + if t.EqualPrevious(requests) { + return ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{ + Name: "error", + Ref: "error", + Output: "The same tool request was made in the previous turn. To prevent infinite loops, no action was taken.", + })) + } + + parts := []*ai.Part{} + for _, req := range requests { + var part *ai.Part + toolResp, err := t.handleToolRequest(ctx, req) + if err != nil { + t.printer.Printf("! %v", err) + part = ai.NewToolResponsePart(&ai.ToolResponse{ + Name: req.Name, + Ref: req.Ref, + Output: err.Error(), + }) + } else { + t.printer.Println("~ ", toolResp.Output) + part = ai.NewToolResponsePart(toolResp) + } + parts = append(parts, part) + } + + return ai.NewMessage(ai.RoleTool, nil, parts...) +} + +func (t *ToolManager) handleToolRequest(ctx context.Context, req *ai.ToolRequest) (*ai.ToolResponse, error) { + tool := t.gktm.LookupTool(req.Name) + if tool == nil { + return nil, fmt.Errorf("tool %q not found", req.Name) + } + + output, err := TeeTerm(func() (any, error) { + return tool.RunRaw(ctx, req.Input) + }) + if err != nil { + if errors.Is(err, common.ErrNoProviderSet) { + return &ai.ToolResponse{ + Name: req.Name, + Ref: req.Ref, + Output: "Please set up a provider using one of the setup tools.", + }, nil + } + return nil, err + } + + return &ai.ToolResponse{ + Name: req.Name, + Ref: req.Ref, + Output: output, + }, nil +} + +func (t *ToolManager) EqualPrevious(toolRequests []*ai.ToolRequest) bool { + newToolsRequestsJSON := make(map[string]bool) + for _, req := range toolRequests { + inputs, err := json.Marshal(req.Input) + if err != nil { + term.Debugf("error marshaling tool request input: %v", err) + continue + } + currJSON := fmt.Sprintf("%s:%s", req.Name, inputs) + newToolsRequestsJSON[currJSON] = true + } + + isEqual := len(newToolsRequestsJSON) == len(t.prevTurnToolRequestsJSON) + if isEqual { + for key := range newToolsRequestsJSON { + if !t.prevTurnToolRequestsJSON[key] { + isEqual = false + break + } + } + } + + t.prevTurnToolRequestsJSON = newToolsRequestsJSON + return isEqual +} diff --git a/src/pkg/agent/toolmanager_test.go b/src/pkg/agent/toolmanager_test.go new file mode 100644 index 000000000..d7dd17559 --- /dev/null +++ b/src/pkg/agent/toolmanager_test.go @@ -0,0 +1,59 @@ +package agent + +import ( + "testing" + + "github.com/firebase/genkit/go/ai" +) + +func TestToolManager_EqualPrevious(t *testing.T) { + tm := &ToolManager{ + prevTurnToolRequestsJSON: make(map[string]bool), + } + + // Helper to create ToolRequest + newReq := func(name string, input any) *ai.ToolRequest { + return &ai.ToolRequest{Name: name, Input: input} + } + + // First call, should return false (no previous) + reqs1 := []*ai.ToolRequest{ + newReq("toolA", map[string]any{"foo": "bar"}), + newReq("toolB", map[string]any{"baz": 42}), + } + if tm.EqualPrevious(reqs1) { + t.Errorf("expected false on first call, got true") + } + + // Second call, same requests, should return true (loop detected) + if !tm.EqualPrevious(reqs1) { + t.Errorf("expected true for identical requests, got false") + } + + // Third call, different input, should return false + reqs2 := []*ai.ToolRequest{ + newReq("toolA", map[string]any{"foo": "bar"}), + newReq("toolB", map[string]any{"baz": 43}), // changed value + } + if tm.EqualPrevious(reqs2) { + t.Errorf("expected false for different requests, got true") + } + + // Fourth call, same as third, should return true + if !tm.EqualPrevious(reqs2) { + t.Errorf("expected true for identical requests, got false") + } + + // Fifth call, different length, should return false + reqs3 := []*ai.ToolRequest{ + newReq("toolA", map[string]any{"foo": "bar"}), + } + if tm.EqualPrevious(reqs3) { + t.Errorf("expected false for different length, got true") + } + + // Sixth call, same as fifth, should return true + if !tm.EqualPrevious(reqs3) { + t.Errorf("expected true for identical requests, got false") + } +} diff --git a/src/pkg/agent/tools/default_tool_cli.go b/src/pkg/agent/tools/default_tool_cli.go index 800278710..f7802af27 100644 --- a/src/pkg/agent/tools/default_tool_cli.go +++ b/src/pkg/agent/tools/default_tool_cli.go @@ -5,8 +5,8 @@ import ( "context" "os" "strconv" + "time" - "github.com/DefangLabs/defang/src/pkg/agent/common" "github.com/DefangLabs/defang/src/pkg/cli" cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/cli/compose" @@ -15,9 +15,14 @@ import ( "github.com/DefangLabs/defang/src/pkg/modes" "github.com/DefangLabs/defang/src/pkg/term" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" - "github.com/pkg/browser" ) +type StackConfig struct { + Cluster string + ProviderID *cliClient.ProviderID + Stack *string +} + // DefaultToolCLI implements all tool interfaces as passthroughs to the real CLI logic // This consolidates all DefaultCLI structs into one // Implements: CLIInterface @@ -49,8 +54,8 @@ func (DefaultToolCLI) ComposeUp(ctx context.Context, client *cliClient.GrpcClien return cli.ComposeUp(ctx, client, provider, params) } -func (DefaultToolCLI) Tail(ctx context.Context, provider cliClient.Provider, project *compose.Project, options cli.TailOptions) error { - return cli.Tail(ctx, provider, project.Name, options) +func (DefaultToolCLI) Tail(ctx context.Context, provider cliClient.Provider, projectName string, options cli.TailOptions) error { + return cli.Tail(ctx, provider, projectName, options) } func (DefaultToolCLI) ComposeDown(ctx context.Context, projectName string, client *cliClient.GrpcClient, provider cliClient.Provider) (string, error) { @@ -69,10 +74,6 @@ func (DefaultToolCLI) GetServices(ctx context.Context, projectName string, provi return deployment_info.GetServices(ctx, projectName, provider) } -func (DefaultToolCLI) CheckProviderConfigured(ctx context.Context, client *cliClient.GrpcClient, providerId cliClient.ProviderID, projectName, stack string, serviceCount int) (cliClient.Provider, error) { - return common.CheckProviderConfigured(ctx, client, providerId, projectName, stack, serviceCount) -} - func (DefaultToolCLI) PrintEstimate(mode modes.Mode, estimate *defangv1.EstimateResponse) string { stdout := new(bytes.Buffer) captureTerm := term.NewTerm( @@ -98,10 +99,6 @@ func (DefaultToolCLI) NewProvider(ctx context.Context, providerId cliClient.Prov return cli.NewProvider(ctx, providerId, client, stack) } -func (DefaultToolCLI) OpenBrowser(url string) error { - return browser.OpenURL(url) -} - func (DefaultToolCLI) GenerateAuthURL(authPort int) string { // Use the same logic as the old DefaultLoginCLI return "Please open this URL in your browser: http://127.0.0.1:" + strconv.Itoa(authPort) + " to login" @@ -110,3 +107,7 @@ func (DefaultToolCLI) GenerateAuthURL(authPort int) string { func (DefaultToolCLI) InteractiveLoginMCP(ctx context.Context, client *cliClient.GrpcClient, cluster string, mcpClient string) error { return login.InteractiveLoginMCP(ctx, client, cluster, mcpClient) } + +func (DefaultToolCLI) TailAndMonitor(ctx context.Context, project *compose.Project, provider cliClient.Provider, waitTimeout time.Duration, options cli.TailOptions) (cli.ServiceStates, error) { + return cli.TailAndMonitor(ctx, project, provider, waitTimeout, options) +} diff --git a/src/pkg/agent/tools/deploy.go b/src/pkg/agent/tools/deploy.go index 6188ce0f4..c1c9a1b97 100644 --- a/src/pkg/agent/tools/deploy.go +++ b/src/pkg/agent/tools/deploy.go @@ -7,19 +7,22 @@ import ( "strings" "github.com/DefangLabs/defang/src/pkg/agent/common" + "github.com/DefangLabs/defang/src/pkg/auth" cliTypes "github.com/DefangLabs/defang/src/pkg/cli" + "github.com/DefangLabs/defang/src/pkg/cli/client" cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/cli/compose" + "github.com/DefangLabs/defang/src/pkg/elicitations" + "github.com/DefangLabs/defang/src/pkg/logs" "github.com/DefangLabs/defang/src/pkg/modes" "github.com/DefangLabs/defang/src/pkg/term" ) -func HandleDeployTool(ctx context.Context, loader cliClient.ProjectLoader, providerId *cliClient.ProviderID, cluster string, cli CLIInterface) (string, error) { - err := common.ProviderNotConfiguredError(*providerId) - if err != nil { - return "", err - } +type DeployParams struct { + common.LoaderParams +} +func HandleDeployTool(ctx context.Context, loader cliClient.ProjectLoader, cli CLIInterface, ec elicitations.Controller, config StackConfig) (string, error) { term.Debug("Function invoked: loader.LoadProject") project, err := cli.LoadProject(ctx, loader) if err != nil { @@ -29,16 +32,27 @@ func HandleDeployTool(ctx context.Context, loader cliClient.ProjectLoader, provi } term.Debug("Function invoked: cli.Connect") - client, err := cli.Connect(ctx, cluster) + client, err := cli.Connect(ctx, config.Cluster) if err != nil { - return "", fmt.Errorf("could not connect: %w", err) + err = cli.InteractiveLoginMCP(ctx, client, config.Cluster, common.MCPDevelopmentClient) + if err != nil { + var noBrowserErr auth.ErrNoBrowser + if errors.As(err, &noBrowserErr) { + return noBrowserErr.Error(), nil + } + return "", err + } } - term.Debug("Function invoked: cli.NewProvider") + pp := NewProviderPreparer(cli, ec, client) + providerID, provider, err := pp.SetupProvider(ctx, config.Stack) + if err != nil { + return "", fmt.Errorf("failed to setup provider: %w", err) + } - provider, err := cli.CheckProviderConfigured(ctx, client, *providerId, project.Name, "", len(project.Services)) + err = cli.CanIUseProvider(ctx, client, *providerID, project.Name, provider, len(project.Services)) if err != nil { - return "", fmt.Errorf("provider not configured correctly: %w", err) + return "", fmt.Errorf("failed to use provider: %w", err) } // Deploy the services @@ -54,7 +68,17 @@ func HandleDeployTool(ctx context.Context, loader cliClient.ProjectLoader, provi if err != nil { err = fmt.Errorf("failed to compose up services: %w", err) - err = common.FixupConfigError(err) + var missing compose.ErrMissingConfig + if errors.As(err, &missing) { + err := requestMissingConfig(ctx, ec, cli, provider, project.Name, missing) + if err != nil { + return "", fmt.Errorf("failed to request missing config: %w", err) + } + + // try again + return HandleDeployTool(ctx, loader, cli, ec, config) + } + return "", err } @@ -62,41 +86,17 @@ func HandleDeployTool(ctx context.Context, loader cliClient.ProjectLoader, provi return "", errors.New("no services deployed") } - // Success case - term.Debugf("Successfully started deployed services with etag: %s", deployResp.Etag) - - // Log deployment success - term.Debug("Deployment Started!") term.Debugf("Deployment ID: %s", deployResp.Etag) - var portal string - if *providerId == cliClient.ProviderDefang { - // Get the portal URL for browser preview - portalURL := "https://portal.defang.io/" - - // Open the portal URL in the browser - term.Debugf("Opening portal URL in browser: %s", portalURL) - go func() { - err := cli.OpenBrowser(portalURL) - if err != nil { - term.Error("Failed to open URL in browser", "error", err, "url", portalURL) - } - }() - - // Log browser preview information - term.Debugf("🌐 %s available", portalURL) - portal = "Please use the web portal url: %s" + portalURL - } else { - // portalURL := fmt.Sprintf("https://%s.signin.aws.amazon.com/console") - portal = fmt.Sprintf("Please use the %s console", providerId) - } - - // Log service details - term.Debug("Services:") - for _, serviceInfo := range deployResp.Services { - term.Debugf("- %s", serviceInfo.Service.Name) - term.Debugf(" Public URL: %s", serviceInfo.PublicFqdn) - term.Debugf(" Status: %s", serviceInfo.Status) + _, err = cli.TailAndMonitor(ctx, project, provider, 0, cliTypes.TailOptions{ + Follow: true, + Deployment: deployResp.Etag, + Verbose: true, + LogType: logs.LogTypeAll, + Raw: true, + }) + if err != nil { + return "", fmt.Errorf("error during deployment %q: %w", deployResp.Etag, err) } urls := strings.Builder{} @@ -106,6 +106,21 @@ func HandleDeployTool(ctx context.Context, loader cliClient.ProjectLoader, provi } } - // Return the etag data as text - return fmt.Sprintf("%s to follow the deployment of %s, with the deployment ID of %s:\n%s", portal, project.Name, deployResp.Etag, urls.String()), nil + return fmt.Sprintf("Deployment %q completed successfully\n%s", deployResp.Etag, urls.String()), nil +} + +func requestMissingConfig(ctx context.Context, ec elicitations.Controller, cli CLIInterface, provider client.Provider, projectName string, names []string) error { + for _, name := range names { + value, err := ec.RequestString(ctx, "This config value needs to be set", name) + if err != nil { + return fmt.Errorf("failed to request config %q: %w", name, err) + } + + err = cli.ConfigSet(ctx, projectName, provider, name, value) + if err != nil { + return fmt.Errorf("failed to set config %q: %w", name, err) + } + } + + return nil } diff --git a/src/pkg/agent/tools/deploy_test.go b/src/pkg/agent/tools/deploy_test.go index 82ae8fd35..bb7fa68fb 100644 --- a/src/pkg/agent/tools/deploy_test.go +++ b/src/pkg/agent/tools/deploy_test.go @@ -4,12 +4,14 @@ import ( "context" "errors" "fmt" + "os" "testing" + "time" - "github.com/DefangLabs/defang/src/pkg/agent/common" "github.com/DefangLabs/defang/src/pkg/cli" "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/cli/compose" + "github.com/DefangLabs/defang/src/pkg/elicitations" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -33,6 +35,7 @@ type MockDeployCLI struct { CheckProviderConfiguredError error LoadProjectError error OpenBrowserError error + InteractiveLoginMCPError error ComposeUpResponse *defangv1.DeployResponse Project *compose.Project CallLog []string @@ -41,7 +44,7 @@ type MockDeployCLI struct { func (m *MockDeployCLI) Connect(ctx context.Context, cluster string) (*client.GrpcClient, error) { m.CallLog = append(m.CallLog, fmt.Sprintf("Connect(%s)", cluster)) if m.ConnectError != nil { - return nil, m.ConnectError + return &client.GrpcClient{}, m.ConnectError } // Return a base GrpcClient - we need to handle Track method differently return &client.GrpcClient{}, nil @@ -52,6 +55,11 @@ func (m *MockDeployCLI) NewProvider(ctx context.Context, providerId client.Provi return nil } +func (m *MockDeployCLI) InteractiveLoginMCP(ctx context.Context, client *client.GrpcClient, cluster string, mcpClient string) error { + m.CallLog = append(m.CallLog, "InteractiveLoginMCP") + return m.InteractiveLoginMCPError +} + func (m *MockDeployCLI) ComposeUp(ctx context.Context, fabric *client.GrpcClient, provider client.Provider, params cli.ComposeUpParams) (*defangv1.DeployResponse, *compose.Project, error) { m.CallLog = append(m.CallLog, "ComposeUp") if m.ComposeUpError != nil { @@ -60,11 +68,6 @@ func (m *MockDeployCLI) ComposeUp(ctx context.Context, fabric *client.GrpcClient return m.ComposeUpResponse, m.Project, nil } -func (m *MockDeployCLI) CheckProviderConfigured(ctx context.Context, grpcClient *client.GrpcClient, providerId client.ProviderID, projectName, stack string, serviceCount int) (client.Provider, error) { - m.CallLog = append(m.CallLog, fmt.Sprintf("CheckProviderConfigured(%s, %s, %d)", providerId, projectName, serviceCount)) - return nil, m.CheckProviderConfiguredError -} - func (m *MockDeployCLI) LoadProject(ctx context.Context, loader client.Loader) (*compose.Project, error) { m.CallLog = append(m.CallLog, "LoadProject") if m.LoadProjectError != nil { @@ -73,48 +76,41 @@ func (m *MockDeployCLI) LoadProject(ctx context.Context, loader client.Loader) ( return m.Project, nil } -func (m *MockDeployCLI) OpenBrowser(url string) error { - m.CallLog = append(m.CallLog, fmt.Sprintf("OpenBrowser(%s)", url)) - return m.OpenBrowserError +func (m *MockDeployCLI) TailAndMonitor(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, options cli.TailOptions) (cli.ServiceStates, error) { + m.CallLog = append(m.CallLog, "TailAndMonitor") + return nil, nil +} + +func (m *MockDeployCLI) CanIUseProvider(ctx context.Context, client *client.GrpcClient, providerId client.ProviderID, projectName string, provider client.Provider, serviceCount int) error { + m.CallLog = append(m.CallLog, "CanIUseProvider") + return nil } func TestHandleDeployTool(t *testing.T) { tests := []struct { name string - providerID client.ProviderID setupMock func(*MockDeployCLI) expectedTextContains string expectedError string }{ { - name: "load_project_error", - providerID: client.ProviderAWS, + name: "load_project_error", setupMock: func(m *MockDeployCLI) { m.LoadProjectError = errors.New("failed to parse compose file") }, expectedError: "local deployment failed: failed to parse compose file: failed to parse compose file. Please provide a valid compose file path.", }, { - name: "connect_error", - providerID: client.ProviderAWS, + name: "connect_error", setupMock: func(m *MockDeployCLI) { m.Project = &compose.Project{Name: "test-project"} m.ConnectError = errors.New("connection failed") + m.InteractiveLoginMCPError = errors.New("connection failed") }, - expectedError: "could not connect: connection failed", + expectedError: "connection failed", }, { - name: "check_provider_configured_error", - providerID: client.ProviderAWS, - setupMock: func(m *MockDeployCLI) { - m.Project = &compose.Project{Name: "test-project"} - m.CheckProviderConfiguredError = errors.New("provider not configured") - }, - expectedError: "provider not configured correctly: provider not configured", - }, - { - name: "compose_up_error", - providerID: client.ProviderAWS, + name: "compose_up_error", setupMock: func(m *MockDeployCLI) { m.Project = &compose.Project{Name: "test-project"} m.ComposeUpError = errors.New("compose up failed") @@ -122,8 +118,7 @@ func TestHandleDeployTool(t *testing.T) { expectedError: "failed to compose up services: compose up failed", }, { - name: "no_services_deployed", - providerID: client.ProviderAWS, + name: "no_services_deployed", setupMock: func(m *MockDeployCLI) { m.Project = &compose.Project{Name: "test-project"} m.ComposeUpResponse = &defangv1.DeployResponse{ @@ -134,8 +129,7 @@ func TestHandleDeployTool(t *testing.T) { expectedError: "no services deployed", }, { - name: "successful_deploy_defang_provider", - providerID: client.ProviderDefang, + name: "successful_deploy_defang_provider", setupMock: func(m *MockDeployCLI) { m.Project = &compose.Project{Name: "test-project"} m.ComposeUpResponse = &defangv1.DeployResponse{ @@ -145,11 +139,10 @@ func TestHandleDeployTool(t *testing.T) { }, } }, - expectedTextContains: "Please use the web portal url:", + expectedTextContains: "Deployment \"test-etag\" completed successfully", }, { - name: "successful_deploy_aws_provider", - providerID: client.ProviderAWS, + name: "successful_deploy_aws_provider", setupMock: func(m *MockDeployCLI) { m.Project = &compose.Project{Name: "test-project"} m.ComposeUpResponse = &defangv1.DeployResponse{ @@ -159,27 +152,38 @@ func TestHandleDeployTool(t *testing.T) { }, } }, - expectedTextContains: "Please use the aws console", - }, - { - name: "provider_auto_not_configured", - providerID: client.ProviderAuto, - setupMock: func(m *MockDeployCLI) {}, - expectedError: common.ErrNoProviderSet.Error(), + expectedTextContains: "Deployment \"test-etag\" completed successfully", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Chdir("testdata") + os.Unsetenv("DEFANG_PROVIDER") + os.Unsetenv("AWS_PROFILE") + os.Unsetenv("AWS_REGION") // Create mock and configure it mockCLI := &MockDeployCLI{ CallLog: []string{}, } tt.setupMock(mockCLI) + providerID := client.ProviderAWS + // Call the function loader := &client.MockLoader{} - result, err := HandleDeployTool(t.Context(), loader, &tt.providerID, "test-cluster", mockCLI) + ec := elicitations.NewController(&mockElicitationsClient{ + responses: map[string]string{ + "strategy": "profile", + "profile_name": "default", + }, + }) + stackName := "test-stack" + result, err := HandleDeployTool(t.Context(), loader, mockCLI, ec, StackConfig{ + Cluster: "test-cluster", + ProviderID: &providerID, + Stack: &stackName, + }) // Verify error expectations if tt.expectedError != "" { @@ -196,9 +200,10 @@ func TestHandleDeployTool(t *testing.T) { expectedCalls := []string{ "LoadProject", "Connect(test-cluster)", - "CheckProviderConfigured(defang, test-project, 0)", + "NewProvider(aws)", + "CanIUseProvider", "ComposeUp", - // Note: OpenBrowser is called in a goroutine, so it may not be tracked in time + "TailAndMonitor", } assert.Equal(t, expectedCalls, mockCLI.CallLog) } diff --git a/src/pkg/agent/tools/destroy.go b/src/pkg/agent/tools/destroy.go index efd7fd4f5..9adcd4509 100644 --- a/src/pkg/agent/tools/destroy.go +++ b/src/pkg/agent/tools/destroy.go @@ -7,32 +7,38 @@ import ( "github.com/DefangLabs/defang/src/pkg/agent/common" cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/elicitations" "github.com/DefangLabs/defang/src/pkg/term" "github.com/bufbuild/connect-go" ) -func HandleDestroyTool(ctx context.Context, loader cliClient.ProjectLoader, providerId *cliClient.ProviderID, cluster string, cli CLIInterface) (string, error) { - err := common.ProviderNotConfiguredError(*providerId) - if err != nil { - return "", err - } +type DestroyParams struct { + common.LoaderParams +} +func HandleDestroyTool(ctx context.Context, loader cliClient.ProjectLoader, cli CLIInterface, ec elicitations.Controller, config StackConfig) (string, error) { term.Debug("Function invoked: cli.Connect") - client, err := cli.Connect(ctx, cluster) + client, err := cli.Connect(ctx, config.Cluster) if err != nil { return "", fmt.Errorf("could not connect: %w", err) } - term.Debug("Function invoked: cli.NewProvider") - provider := cli.NewProvider(ctx, *providerId, client, "") - + pp := NewProviderPreparer(cli, ec, client) + _, provider, err := pp.SetupProvider(ctx, config.Stack) + if err != nil { + return "", fmt.Errorf("failed to setup provider: %w", err) + } term.Debug("Function invoked: cliClient.LoadProjectNameWithFallback") projectName, err := cli.LoadProjectNameWithFallback(ctx, loader, provider) if err != nil { return "", fmt.Errorf("failed to load project name: %w", err) } - err = cli.CanIUseProvider(ctx, client, *providerId, projectName, provider, 0) + if config.ProviderID == nil { + return "", errors.New("provider ID is required to destroy a project") + } + + err = cli.CanIUseProvider(ctx, client, *config.ProviderID, projectName, provider, 0) if err != nil { return "", fmt.Errorf("failed to use provider: %w", err) } diff --git a/src/pkg/agent/tools/destroy_test.go b/src/pkg/agent/tools/destroy_test.go index 8c975b676..cb89436d2 100644 --- a/src/pkg/agent/tools/destroy_test.go +++ b/src/pkg/agent/tools/destroy_test.go @@ -4,10 +4,11 @@ import ( "context" "errors" "fmt" + "os" "testing" - "github.com/DefangLabs/defang/src/pkg/agent/common" "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/elicitations" "github.com/bufbuild/connect-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -55,8 +56,11 @@ func (m *MockDestroyCLI) LoadProjectNameWithFallback(ctx context.Context, loader } func (m *MockDestroyCLI) CanIUseProvider(ctx context.Context, grpcClient *client.GrpcClient, providerId client.ProviderID, projectName string, provider client.Provider, serviceCount int) error { - m.CallLog = append(m.CallLog, fmt.Sprintf("CanIUseProvider(%s, %s, %d)", providerId, projectName, serviceCount)) - return m.CanIUseProviderError + m.CallLog = append(m.CallLog, fmt.Sprintf("CanIUseProvider(%s, %s)", providerId, projectName)) + if m.CanIUseProviderError != nil { + return m.CanIUseProviderError + } + return nil } func TestHandleDestroyTool(t *testing.T) { @@ -119,16 +123,14 @@ func TestHandleDestroyTool(t *testing.T) { }, expectedTextContains: "The project is in the process of being destroyed: test-project", }, - { - name: "provider_auto_not_configured", - providerID: client.ProviderAuto, - setupMock: func(m *MockDestroyCLI) {}, - expectedError: common.ErrNoProviderSet.Error(), - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Chdir("testdata") + os.Unsetenv("DEFANG_PROVIDER") + os.Unsetenv("AWS_PROFILE") + os.Unsetenv("AWS_REGION") // Create mock and configure it mockCLI := &MockDestroyCLI{ CallLog: []string{}, @@ -137,7 +139,18 @@ func TestHandleDestroyTool(t *testing.T) { // Call the function loader := &client.MockLoader{} - result, err := HandleDestroyTool(t.Context(), loader, &tt.providerID, "test-cluster", mockCLI) + ec := elicitations.NewController(&mockElicitationsClient{ + responses: map[string]string{ + "strategy": "profile", + "profile_name": "default", + }, + }) + stackName := "test-stack" + result, err := HandleDestroyTool(t.Context(), loader, mockCLI, ec, StackConfig{ + Cluster: "test-cluster", + ProviderID: &tt.providerID, + Stack: &stackName, + }) // Verify error expectations if tt.expectedError != "" { @@ -155,7 +168,7 @@ func TestHandleDestroyTool(t *testing.T) { "Connect(test-cluster)", "NewProvider(aws)", "LoadProjectNameWithFallback", - "CanIUseProvider(aws, test-project, 0)", + "CanIUseProvider(aws, test-project)", "ComposeDown(test-project)", } assert.Equal(t, expectedCalls, mockCLI.CallLog) diff --git a/src/pkg/agent/tools/estimate.go b/src/pkg/agent/tools/estimate.go index efbf7eedc..86fed2631 100644 --- a/src/pkg/agent/tools/estimate.go +++ b/src/pkg/agent/tools/estimate.go @@ -3,54 +3,21 @@ package tools import ( "context" "fmt" - "strings" + "github.com/DefangLabs/defang/src/pkg/agent/common" cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/modes" "github.com/DefangLabs/defang/src/pkg/term" - "github.com/mark3labs/mcp-go/mcp" ) type EstimateParams struct { - DeploymentMode modes.Mode `json:"deployment_mode"` - Provider cliClient.ProviderID `json:"provider"` - Region string `json:"region"` + common.LoaderParams + DeploymentMode string `json:"deployment_mode,omit_empty" jsonschema:"default=affordable,enum=affordable,enum=balanced,enum=high_availability,description=The deployment mode for which to estimate costs (e.g., AFFORDABLE, BALANCED, HIGH_AVAILABILITY)."` + Provider string `json:"provider" jsonschema:"required,enum=aws,enum=gcp description=The cloud provider for which to estimate costs."` + Region string `json:"region,omit_empty" jsonschema:"description=The region in which to estimate costs."` } -func ParseEstimateParams(request mcp.CallToolRequest, providerId *cliClient.ProviderID) (EstimateParams, error) { - modeString, err := request.RequireString("deployment_mode") - if err != nil { - modeString = "AFFORDABLE" // Default to AFFORDABLE if not provided - } - - mode, err := modes.Parse(modeString) // Validate the mode string - if err != nil { - term.Warnf("Unknown deployment mode provided - %q", modeString) - return EstimateParams{}, fmt.Errorf("unknown deployment mode %q, please use one of %s", modeString, strings.Join(modes.AllDeploymentModes(), ", ")) - } - - providerString, err := request.RequireString("provider") - if err != nil { - providerString = "auto" // Default to auto if not provided - } - err = providerId.Set(providerString) - if err != nil { - return EstimateParams{}, fmt.Errorf("invalid provider specified: %w", err) - } - - var region string - if region == "" { - region = cliClient.GetRegion(*providerId) // This sets the default region based on the provider - } - - return EstimateParams{ - DeploymentMode: mode, - Provider: *providerId, - Region: region, - }, nil -} - -func HandleEstimateTool(ctx context.Context, loader cliClient.ProjectLoader, params EstimateParams, cluster string, cli CLIInterface) (string, error) { +func HandleEstimateTool(ctx context.Context, loader cliClient.ProjectLoader, params EstimateParams, cli CLIInterface, sc StackConfig) (string, error) { term.Debug("Function invoked: loader.LoadProject") project, err := cli.LoadProject(ctx, loader) if err != nil { @@ -59,22 +26,33 @@ func HandleEstimateTool(ctx context.Context, loader cliClient.ProjectLoader, par } term.Debug("Function invoked: cli.Connect") - client, err := cli.Connect(ctx, cluster) + client, err := cli.Connect(ctx, sc.Cluster) if err != nil { return "", fmt.Errorf("could not connect: %w", err) } defangProvider := cli.CreatePlaygroundProvider(client) - term.Debug("Function invoked: cli.RunEstimate") + var providerID cliClient.ProviderID + err = providerID.Set(params.Provider) + if err != nil { + return "", err + } - estimate, err := cli.RunEstimate(ctx, project, client, defangProvider, params.Provider, params.Region, params.DeploymentMode) + var deploymentMode modes.Mode + err = deploymentMode.Set(params.DeploymentMode) + if err != nil { + return "", err + } + + term.Debug("Function invoked: cli.RunEstimate") + estimate, err := cli.RunEstimate(ctx, project, client, defangProvider, providerID, params.Region, deploymentMode) if err != nil { return "", fmt.Errorf("failed to run estimate: %w", err) } term.Debugf("Estimate: %+v", estimate) - estimateText := cli.PrintEstimate(params.DeploymentMode, estimate) + estimateText := cli.PrintEstimate(deploymentMode, estimate) - return "Successfully estimated the cost of the project to " + params.Provider.Name() + ":\n" + estimateText, nil + return "Successfully estimated the cost of the project to " + providerID.Name() + ":\n" + estimateText, nil } diff --git a/src/pkg/agent/tools/estimate_test.go b/src/pkg/agent/tools/estimate_test.go index 7d5ce76c3..eba3ac2ed 100644 --- a/src/pkg/agent/tools/estimate_test.go +++ b/src/pkg/agent/tools/estimate_test.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "strings" "testing" "github.com/DefangLabs/defang/src/pkg/cli/client" @@ -12,7 +11,6 @@ import ( "github.com/DefangLabs/defang/src/pkg/modes" _type "github.com/DefangLabs/defang/src/protos/google/type" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" - "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -58,11 +56,6 @@ func (m *MockEstimateCLI) RunEstimate(ctx context.Context, project *compose.Proj return m.EstimateResponse, nil } -func (m *MockEstimateCLI) ConfigureLoader(request mcp.CallToolRequest) client.Loader { - m.CallLog = append(m.CallLog, "ConfigureLoader") - return nil -} - func (m *MockEstimateCLI) CreatePlaygroundProvider(grpcClient *client.GrpcClient) client.Provider { m.CallLog = append(m.CallLog, "CreatePlaygroundProvider") return nil @@ -84,6 +77,7 @@ func TestHandleEstimateTool(t *testing.T) { { name: "unknown_deployment_mode_fails", arguments: map[string]interface{}{ + "provider": "aws", "deployment_mode": "unknown-mode", "region": "us-west-2", }, @@ -99,7 +93,7 @@ func TestHandleEstimateTool(t *testing.T) { } m.CapturedOutput = "Estimated cost: $15.00/month" }, - expectedError: "unknown deployment mode \"unknown-mode\", please use one of " + strings.Join(modes.AllDeploymentModes(), ", "), + expectedError: "invalid mode: unknown-mode, not one of [AFFORDABLE BALANCED HIGH_AVAILABILITY]", }, { name: "load_project_error", @@ -127,13 +121,14 @@ func TestHandleEstimateTool(t *testing.T) { setupMock: func(m *MockEstimateCLI) { m.Project = &compose.Project{Name: "test-project"} }, - expectedError: "invalid provider specified: provider not one of [auto defang aws digitalocean gcp]", + expectedError: "provider not one of [auto defang aws digitalocean gcp]", }, { name: "run_estimate_error", arguments: map[string]interface{}{ - "provider": "aws", - "region": "us-west-2", + "provider": "aws", + "region": "us-west-2", + "deployment_mode": "AFFORDABLE", }, setupMock: func(m *MockEstimateCLI) { m.Project = &compose.Project{Name: "test-project"} @@ -144,8 +139,9 @@ func TestHandleEstimateTool(t *testing.T) { { name: "successful_estimate_default_mode", arguments: map[string]interface{}{ - "provider": "aws", - "region": "us-west-2", + "provider": "aws", + "region": "us-west-2", + "deployment_mode": "AFFORDABLE", }, setupMock: func(m *MockEstimateCLI) { m.Project = &compose.Project{Name: "test-project"} @@ -192,28 +188,34 @@ func TestHandleEstimateTool(t *testing.T) { } tt.setupMock(mockCLI) - request := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "estimate", - Arguments: tt.arguments, - }, + providerID := client.ProviderAuto // Default provider ID + + // Extract arguments with defaults for missing values + provider := "" + if p, ok := tt.arguments["provider"].(string); ok { + provider = p + } + region := "" + if r, ok := tt.arguments["region"].(string); ok { + region = r + } + deploymentMode := "" + if d, ok := tt.arguments["deployment_mode"].(string); ok { + deploymentMode = d } - providerID := client.ProviderAuto // Default provider ID + params := EstimateParams{ + Provider: provider, + Region: region, + DeploymentMode: deploymentMode, + } // Call the function loader := &client.MockLoader{} - params, err := ParseEstimateParams(request, &providerID) - if err != nil { - // If parsing params fails, check if this was the expected error - if tt.expectedError != "" { - assert.EqualError(t, err, tt.expectedError) - return - } else { - require.NoError(t, err) - } - } - result, err := HandleEstimateTool(t.Context(), loader, params, "test-cluster", mockCLI) + result, err := HandleEstimateTool(t.Context(), loader, params, mockCLI, StackConfig{ + Cluster: "test-cluster", + ProviderID: &providerID, + }) // Verify error expectations if tt.expectedError != "" { diff --git a/src/pkg/agent/tools/interfaces.go b/src/pkg/agent/tools/interfaces.go index ee86a8709..23860fe34 100644 --- a/src/pkg/agent/tools/interfaces.go +++ b/src/pkg/agent/tools/interfaces.go @@ -3,6 +3,7 @@ package tools import ( "context" + "time" "github.com/DefangLabs/defang/src/pkg/cli" cliTypes "github.com/DefangLabs/defang/src/pkg/cli" @@ -15,7 +16,6 @@ import ( type CLIInterface interface { CanIUseProvider(ctx context.Context, client *cliClient.GrpcClient, providerId cliClient.ProviderID, projectName string, provider cliClient.Provider, serviceCount int) error - CheckProviderConfigured(ctx context.Context, client *cliClient.GrpcClient, providerId cliClient.ProviderID, projectName, stack string, serviceCount int) (cliClient.Provider, error) ComposeDown(ctx context.Context, projectName string, client *cliClient.GrpcClient, provider cliClient.Provider) (string, error) ComposeUp(ctx context.Context, client *cliClient.GrpcClient, provider cliClient.Provider, params cli.ComposeUpParams) (*defangv1.DeployResponse, *compose.Project, error) ConfigDelete(ctx context.Context, projectName string, provider cliClient.Provider, name string) error @@ -29,8 +29,8 @@ type CLIInterface interface { LoadProject(ctx context.Context, loader cliClient.Loader) (*compose.Project, error) LoadProjectNameWithFallback(ctx context.Context, loader cliClient.Loader, provider cliClient.Provider) (string, error) NewProvider(ctx context.Context, providerId cliClient.ProviderID, client cliClient.FabricClient, stack string) cliClient.Provider - OpenBrowser(url string) error PrintEstimate(mode modes.Mode, estimate *defangv1.EstimateResponse) string RunEstimate(ctx context.Context, project *compose.Project, client *cliClient.GrpcClient, provider cliClient.Provider, providerId cliClient.ProviderID, region string, mode modes.Mode) (*defangv1.EstimateResponse, error) - Tail(ctx context.Context, provider cliClient.Provider, project *compose.Project, options cliTypes.TailOptions) error + Tail(ctx context.Context, provider cliClient.Provider, projectName string, options cliTypes.TailOptions) error + TailAndMonitor(ctx context.Context, project *compose.Project, provider cliClient.Provider, waitTimeout time.Duration, options cliTypes.TailOptions) (cli.ServiceStates, error) } diff --git a/src/pkg/agent/tools/listConfig.go b/src/pkg/agent/tools/listConfig.go index 375146388..e62bcf1c8 100644 --- a/src/pkg/agent/tools/listConfig.go +++ b/src/pkg/agent/tools/listConfig.go @@ -7,36 +7,39 @@ import ( "github.com/DefangLabs/defang/src/pkg/agent/common" cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/elicitations" "github.com/DefangLabs/defang/src/pkg/term" ) -// HandleListConfigTool handles the list config tool logic -func HandleListConfigTool(ctx context.Context, loader cliClient.ProjectLoader, providerId *cliClient.ProviderID, cluster string, cli CLIInterface) (string, error) { - err := common.ProviderNotConfiguredError(*providerId) - if err != nil { - return "", err - } +type ListConfigsParams struct { + common.LoaderParams +} +// HandleListConfigTool handles the list config tool logic +func HandleListConfigTool(ctx context.Context, loader cliClient.ProjectLoader, cli CLIInterface, ec elicitations.Controller, sc StackConfig) (string, error) { term.Debug("Function invoked: cli.Connect") - client, err := cli.Connect(ctx, cluster) + client, err := cli.Connect(ctx, sc.Cluster) if err != nil { return "", fmt.Errorf("Could not connect: %w", err) } - term.Debug("Function invoked: cli.NewProvider") - provider := cli.NewProvider(ctx, *providerId, client, "") + pp := NewProviderPreparer(cli, ec, client) + _, provider, err := pp.SetupProvider(ctx, sc.Stack) + if err != nil { + return "", fmt.Errorf("failed to setup provider: %w", err) + } term.Debug("Function invoked: cliClient.LoadProjectNameWithFallback") projectName, err := cli.LoadProjectNameWithFallback(ctx, loader, provider) if err != nil { - return "", fmt.Errorf("Failed to load project name: %w", err) + return "", fmt.Errorf("failed to load project name: %w", err) } term.Debug("Project name loaded:", projectName) term.Debug("Function invoked: cli.ConfigList") config, err := cli.ListConfig(ctx, provider, projectName) if err != nil { - return "", fmt.Errorf("Failed to list config variables: %w", err) + return "", fmt.Errorf("failed to list config variables: %w", err) } numConfigs := len(config.Names) diff --git a/src/pkg/agent/tools/listConfig_test.go b/src/pkg/agent/tools/listConfig_test.go index 204812302..cb77ba141 100644 --- a/src/pkg/agent/tools/listConfig_test.go +++ b/src/pkg/agent/tools/listConfig_test.go @@ -4,10 +4,11 @@ import ( "context" "errors" "fmt" + "os" "testing" - "github.com/DefangLabs/defang/src/pkg/agent/common" "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/elicitations" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -61,12 +62,6 @@ func TestHandleListConfigTool(t *testing.T) { expectedTextContains string expectedError string }{ - { - name: "provider_auto_not_configured", - providerID: client.ProviderAuto, - setupMock: func(m *MockListConfigCLI) {}, - expectedError: common.ErrNoProviderSet.Error(), - }, { name: "connect_error", providerID: client.ProviderAWS, @@ -81,7 +76,7 @@ func TestHandleListConfigTool(t *testing.T) { setupMock: func(m *MockListConfigCLI) { m.LoadProjectNameError = errors.New("failed to load project name") }, - expectedError: "Failed to load project name: failed to load project name", + expectedError: "failed to load project name: failed to load project name", }, { name: "list_config_error", @@ -90,7 +85,7 @@ func TestHandleListConfigTool(t *testing.T) { m.ProjectName = "test-project" m.ListConfigError = errors.New("failed to list configs") }, - expectedError: "Failed to list config variables: failed to list configs", + expectedError: "failed to list config variables: failed to list configs", }, { name: "no_config_variables_found", @@ -118,6 +113,11 @@ func TestHandleListConfigTool(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Chdir("testdata") + os.Unsetenv("DEFANG_PROVIDER") + os.Unsetenv("AWS_PROFILE") + os.Unsetenv("AWS_REGION") + // Create mock and configure it mockCLI := &MockListConfigCLI{ CallLog: []string{}, @@ -126,7 +126,19 @@ func TestHandleListConfigTool(t *testing.T) { // Call the function loader := &client.MockLoader{} - result, err := HandleListConfigTool(t.Context(), loader, &tt.providerID, "test-cluster", mockCLI) + ec := elicitations.NewController(&mockElicitationsClient{ + responses: map[string]string{ + "strategy": "profile", + "profile_name": "default", + }, + }) + + stackName := "test-stack" + result, err := HandleListConfigTool(t.Context(), loader, mockCLI, ec, StackConfig{ + Cluster: "test-cluster", + ProviderID: &tt.providerID, + Stack: &stackName, + }) // Verify error expectations if tt.expectedError != "" { diff --git a/src/pkg/agent/tools/login.go b/src/pkg/agent/tools/login.go deleted file mode 100644 index bdfd9e28d..000000000 --- a/src/pkg/agent/tools/login.go +++ /dev/null @@ -1,32 +0,0 @@ -package tools - -import ( - "context" - "errors" - - "github.com/DefangLabs/defang/src/pkg/agent/common" - "github.com/DefangLabs/defang/src/pkg/auth" - "github.com/DefangLabs/defang/src/pkg/term" -) - -// HandleLoginTool handles the login tool logic -func HandleLoginTool(ctx context.Context, cluster string, cli CLIInterface) (string, error) { - term.Debug("Function invoked: cli.Connect") - client, err := cli.Connect(ctx, cluster) - if err != nil { - term.Debug("Function invoked: cli.InteractiveLoginPrompt") - err = cli.InteractiveLoginMCP(ctx, client, cluster, common.MCPDevelopmentClient) - if err != nil { - var noBrowserErr auth.ErrNoBrowser - if errors.As(err, &noBrowserErr) { - return noBrowserErr.Error(), nil - } - return "", err - } - } - - output := "Successfully logged in to Defang" - - term.Debug(output) - return output, nil -} diff --git a/src/pkg/agent/tools/login_test.go b/src/pkg/agent/tools/login_test.go deleted file mode 100644 index aebbc2dd6..000000000 --- a/src/pkg/agent/tools/login_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package tools - -import ( - "context" - "errors" - "fmt" - "testing" - - "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// MockLoginCLI implements CLIInterface for testing -type MockLoginCLI struct { - CLIInterface - ConnectError error - InteractiveLoginError error - AuthURL string - CallLog []string -} - -func (m *MockLoginCLI) Connect(ctx context.Context, cluster string) (*client.GrpcClient, error) { - m.CallLog = append(m.CallLog, fmt.Sprintf("Connect(%s)", cluster)) - if m.ConnectError != nil { - return nil, m.ConnectError - } - return &client.GrpcClient{}, nil -} - -func (m *MockLoginCLI) InteractiveLoginMCP(ctx context.Context, grpcClient *client.GrpcClient, cluster string, mcpClient string) error { - m.CallLog = append(m.CallLog, fmt.Sprintf("InteractiveLoginMCP(%s)", cluster)) - return m.InteractiveLoginError -} - -func (m *MockLoginCLI) GenerateAuthURL(authPort int) string { - m.CallLog = append(m.CallLog, fmt.Sprintf("GenerateAuthURL(%d)", authPort)) - if m.AuthURL != "" { - return m.AuthURL - } - return fmt.Sprintf("Please open this URL in your browser: http://127.0.0.1:%d to login", authPort) -} - -func TestHandleLoginTool(t *testing.T) { - tests := []struct { - name string - cluster string - authPort int - setupMock func(*MockLoginCLI) - expectedTextContains string - expectedError string - }{ - { - name: "successful_login_already_connected", - cluster: "test-cluster", - authPort: 0, - setupMock: func(m *MockLoginCLI) { - // No connect error means already logged in - }, - expectedTextContains: "Successfully logged in to Defang", - }, - { - name: "connect_error_interactive_login_success", - cluster: "test-cluster", - authPort: 0, - setupMock: func(m *MockLoginCLI) { - m.ConnectError = errors.New("connection failed - not authenticated") - // InteractiveLoginError is nil, so login succeeds - }, - expectedTextContains: "Successfully logged in to Defang", - }, - { - name: "connect_error_interactive_login_failure", - cluster: "test-cluster", - authPort: 0, - setupMock: func(m *MockLoginCLI) { - m.ConnectError = errors.New("connection failed - not authenticated") - m.InteractiveLoginError = errors.New("login failed") - }, - expectedError: "login failed", - }, - { - // Note: Removed cluster-specific duplicate scenarios (playground/aws) to keep suite concise - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create mock and configure it - mockCLI := &MockLoginCLI{CallLog: []string{}} - if tt.setupMock != nil { - tt.setupMock(mockCLI) - } - - // Call the function - var err error - result, err := HandleLoginTool(context.Background(), tt.cluster, mockCLI) - if tt.expectedError != "" { - assert.EqualError(t, err, tt.expectedError) - } else { - require.NoError(t, err) - if tt.expectedTextContains != "" && len(result) > 0 { - assert.Contains(t, result, tt.expectedTextContains) - } - } - - // For specific cases, verify CLI methods were called in order - if tt.name == "successful_login_already_connected" { - expectedCalls := []string{ - "Connect(test-cluster)", - } - assert.Equal(t, expectedCalls, mockCLI.CallLog) - } - - if tt.name == "connect_error_interactive_login_success" { - expectedCalls := []string{ - "Connect(test-cluster)", - "InteractiveLoginMCP(test-cluster)", - } - assert.Equal(t, expectedCalls, mockCLI.CallLog) - } - }) - } -} diff --git a/src/pkg/agent/tools/logs.go b/src/pkg/agent/tools/logs.go index e6ebab1fe..8793bb0c6 100644 --- a/src/pkg/agent/tools/logs.go +++ b/src/pkg/agent/tools/logs.go @@ -2,72 +2,79 @@ package tools import ( "context" + "errors" "fmt" "time" + "github.com/DefangLabs/defang/src/pkg/agent/common" cliTypes "github.com/DefangLabs/defang/src/pkg/cli" cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/elicitations" + "github.com/DefangLabs/defang/src/pkg/logs" "github.com/DefangLabs/defang/src/pkg/term" "github.com/DefangLabs/defang/src/pkg/timeutils" - "github.com/mark3labs/mcp-go/mcp" ) type LogsParams struct { - DeploymentID string - Since string - Until string + common.LoaderParams + DeploymentID string `json:"deployment_id,omitempty" jsonschema:"description=Optional: Retrieve logs from a specific deployment."` + Since string `json:"since,omitempty" jsonschema:"description=Optional: Retrieve logs written after this time. Format as RFC3339 or duration (e.g., '2023-10-01T15:04:05Z' or '1h')."` + Until string `json:"until,omitempty" jsonschema:"description=Optional: Retrieve logs written before this time. Format as RFC3339 or duration (e.g., '2023-10-01T15:04:05Z' or '1h')."` } -func ParseLogsParams(request mcp.CallToolRequest) LogsParams { - deploymentId := request.GetString("deployment_id", "") - since := request.GetString("since", "") - until := request.GetString("until", "") - return LogsParams{ - DeploymentID: deploymentId, - Since: since, - Until: until, +func HandleLogsTool(ctx context.Context, loader cliClient.ProjectLoader, params LogsParams, cli CLIInterface, ec elicitations.Controller, config StackConfig) (string, error) { + var sinceTime, untilTime time.Time + var err error + now := time.Now() + if params.Since != "" { + sinceTime, err = timeutils.ParseTimeOrDuration(params.Since, now) + if err != nil { + return "", fmt.Errorf("invalid parameter 'since', must be in RFC3339 format: %w", err) + } } -} - -func HandleLogsTool(ctx context.Context, loader cliClient.ProjectLoader, params LogsParams, cluster string, providerId *cliClient.ProviderID, cli CLIInterface) (string, error) { - term.Debug("Function invoked: loader.LoadProject") - project, err := cli.LoadProject(ctx, loader) - if err != nil { - err = fmt.Errorf("failed to parse compose file: %w", err) - term.Error("Failed to deploy services", "error", err) - - return "", fmt.Errorf("local deployment failed: %v. Please provide a valid compose file path.", err) + if params.Until != "" { + untilTime, err = timeutils.ParseTimeOrDuration(params.Until, now) + if err != nil { + return "", fmt.Errorf("invalid parameter 'until', must be in RFC3339 format: %w", err) + } } term.Debug("Function invoked: cli.Connect") - client, err := cli.Connect(ctx, cluster) + client, err := cli.Connect(ctx, config.Cluster) if err != nil { return "", fmt.Errorf("could not connect: %w", err) } - term.Debug("Function invoked: cli.NewProvider") - - provider, err := cli.CheckProviderConfigured(ctx, client, *providerId, project.Name, "", len(project.Services)) + pp := NewProviderPreparer(cli, ec, client) + _, provider, err := pp.SetupProvider(ctx, config.Stack) if err != nil { - return "", fmt.Errorf("provider not configured correctly: %w", err) + return "", fmt.Errorf("failed to setup provider: %w", err) } - sinceTime, err := timeutils.ParseTimeOrDuration(params.Since, time.Now()) + term.Debug("Function invoked: cli.LoadProjectNameWithFallback") + projectName, err := cli.LoadProjectNameWithFallback(ctx, loader, provider) if err != nil { - return "", fmt.Errorf("failed to parse 'since' parameter: %w", err) + return "", fmt.Errorf("failed to load project name: %w", err) + } + term.Debug("Project name loaded:", projectName) + + if config.ProviderID == nil { + return "", errors.New("provider ID is required to fetch logs") } - untilTime, err := timeutils.ParseTimeOrDuration(params.Until, time.Now()) + err = cli.CanIUseProvider(ctx, client, *config.ProviderID, projectName, provider, 0) if err != nil { - return "", fmt.Errorf("failed to parse 'until' parameter: %w", err) + return "", fmt.Errorf("failed to use provider: %w", err) } - err = cli.Tail(ctx, provider, project, cliTypes.TailOptions{ + err = cli.Tail(ctx, provider, projectName, cliTypes.TailOptions{ Deployment: params.DeploymentID, Since: sinceTime, Until: untilTime, Limit: 100, + LogType: logs.LogTypeAll, PrintBookends: true, + Verbose: true, }) if err != nil { @@ -76,5 +83,5 @@ func HandleLogsTool(ctx context.Context, loader cliClient.ProjectLoader, params return "", fmt.Errorf("failed to fetch logs: %w", err) } - return "EOF", nil + return "", nil } diff --git a/src/pkg/agent/tools/provider.go b/src/pkg/agent/tools/provider.go new file mode 100644 index 000000000..9c16700a7 --- /dev/null +++ b/src/pkg/agent/tools/provider.go @@ -0,0 +1,317 @@ +package tools + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "sort" + "strings" + + cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/elicitations" + "github.com/DefangLabs/defang/src/pkg/stacks" + "github.com/DefangLabs/defang/src/pkg/term" +) + +const CreateNewStack = "Create new stack" + +type ProviderCreator interface { + NewProvider(ctx context.Context, providerId cliClient.ProviderID, client cliClient.FabricClient, stack string) cliClient.Provider +} + +type providerPreparer struct { + pc ProviderCreator + ec elicitations.Controller + fc cliClient.FabricClient +} + +func NewProviderPreparer(pc ProviderCreator, ec elicitations.Controller, fc cliClient.FabricClient) *providerPreparer { + return &providerPreparer{ + pc: pc, + ec: ec, + fc: fc, + } +} + +func (pp *providerPreparer) SetupProvider(ctx context.Context, stackName *string) (*cliClient.ProviderID, cliClient.Provider, error) { + var providerID cliClient.ProviderID + var err error + var stack *stacks.StackParameters + if stackName == nil { + return nil, nil, errors.New("stackName cannot be nil") + } + if *stackName != "" { + stack, err = stacks.Read(*stackName) + if err != nil { + return nil, nil, fmt.Errorf("failed to read stack: %w", err) + } + err = stacks.Load(*stackName) + if err != nil { + return nil, nil, fmt.Errorf("failed to load stack: %w", err) + } + } else { + stack, err = pp.setupStack(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to setup stack: %w", err) + } + *stackName = stack.Name + } + + err = providerID.Set(stack.Provider.Name()) + if err != nil { + return nil, nil, fmt.Errorf("failed to set provider ID: %w", err) + } + + err = pp.setupProviderAuthentication(ctx, providerID) + if err != nil { + return nil, nil, fmt.Errorf("failed to setup provider authentication: %w", err) + } + + term.Debug("Function invoked: cli.NewProvider") + provider := pp.pc.NewProvider(ctx, providerID, pp.fc, *stackName) + return &providerID, provider, nil +} + +func selectStack(ctx context.Context, ec elicitations.Controller) (string, error) { + stackList, err := stacks.List() + if err != nil { + return "", fmt.Errorf("failed to list stacks: %w", err) + } + + if len(stackList) == 0 { + return CreateNewStack, nil + } + + stackNames := make([]string, 0, len(stackList)+1) + for _, s := range stackList { + stackNames = append(stackNames, s.Name) + } + stackNames = append(stackNames, CreateNewStack) + + selectedStackName, err := ec.RequestEnum(ctx, "Select a stack", "stack", stackNames) + if err != nil { + return "", fmt.Errorf("failed to elicit stack choice: %w", err) + } + + return selectedStackName, nil +} + +func (pp *providerPreparer) setupStack(ctx context.Context) (*stacks.StackParameters, error) { + selectedStackName, err := selectStack(ctx, pp.ec) + if err != nil { + return nil, fmt.Errorf("failed to select stack: %w", err) + } + + if selectedStackName == CreateNewStack { + newStack, err := pp.createNewStack(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create new stack: %w", err) + } + selectedStackName = newStack.Name + } + + err = stacks.Load(selectedStackName) + if err != nil { + return nil, fmt.Errorf("failed to load stack: %w", err) + } + + return stacks.Read(selectedStackName) +} + +func (pp *providerPreparer) createNewStack(ctx context.Context) (*stacks.StackListItem, error) { + providerName, err := pp.ec.RequestEnum( + ctx, + "Where do you want to deploy?", + "provider", + []string{"aws", "gcp", "digitalocean", "playground"}, + ) + if err != nil { + return nil, fmt.Errorf("failed to elicit provider choice: %w", err) + } + + var providerID cliClient.ProviderID + err = providerID.Set(providerName) + if err != nil { + return nil, err + } + defaultRegion := cliClient.GetRegion(providerID) + region, err := pp.ec.RequestStringWithDefault(ctx, "Which region do you want to deploy to?", "region", defaultRegion) + if err != nil { + return nil, fmt.Errorf("failed to elicit region choice: %w", err) + } + + // TODO: use the helper function (stacks.MakeDefaultName or something) + defaultName := "production" + name, err := pp.ec.RequestStringWithDefault(ctx, "Enter a name for your stack:", "stack_name", defaultName) + if err != nil { + return nil, fmt.Errorf("failed to elicit stack name: %w", err) + } + params := stacks.StackParameters{ + Provider: providerID, + Region: region, + Name: name, + } + _, err = stacks.Create(params) + if err != nil { + return nil, fmt.Errorf("failed to create stack: %w", err) + } + + return &stacks.StackListItem{ + Name: name, + Provider: providerID.Name(), + Region: region, + }, nil +} + +func (pp *providerPreparer) setupProviderAuthentication(ctx context.Context, providerId cliClient.ProviderID) error { + switch providerId { + case cliClient.ProviderAWS: + return pp.SetupAWSAuthentication(ctx) + case cliClient.ProviderGCP: + return pp.SetupGCPAuthentication(ctx) + case cliClient.ProviderDO: + return pp.SetupDOAuthentication(ctx) + } + return nil +} + +func (pp *providerPreparer) SetupAWSAuthentication(ctx context.Context) error { + if os.Getenv("AWS_PROFILE") != "" || (os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "") { + return nil + } + + // TODO: check the fs for AWS credentials file or config for profile names + // TODO: add support for aws sso strategy + strategy, err := pp.ec.RequestEnum(ctx, "How do you authenticate to AWS?", "strategy", []string{ + "profile", + "access_key", + }) + if err != nil { + return fmt.Errorf("failed to elicit AWS Access Key ID: %w", err) + } + if strategy == "profile" { + if os.Getenv("AWS_PROFILE") == "" { + knownProfiles, err := listAWSProfiles() + if err != nil { + return fmt.Errorf("failed to list AWS profiles: %w", err) + } + profile, err := pp.ec.RequestEnum(ctx, "Select your profile", "profile_name", knownProfiles) + if err != nil { + return fmt.Errorf("failed to elicit AWS Profile Name: %w", err) + } + if err := os.Setenv("AWS_PROFILE", profile); err != nil { + return fmt.Errorf("failed to set AWS_PROFILE environment variable: %w", err) + } + } + } else { + if os.Getenv("AWS_ACCESS_KEY_ID") == "" { + accessKeyID, err := pp.ec.RequestString(ctx, "Enter your AWS Access Key ID:", "access_key_id") + if err != nil { + return fmt.Errorf("failed to elicit AWS Access Key ID: %w", err) + } + if err := os.Setenv("AWS_ACCESS_KEY_ID", accessKeyID); err != nil { + return fmt.Errorf("failed to set AWS_ACCESS_KEY_ID environment variable: %w", err) + } + } + if os.Getenv("AWS_SECRET_ACCESS_KEY") == "" { + accessKeySecret, err := pp.ec.RequestString(ctx, "Enter your AWS Secret Access Key:", "access_key_secret") + if err != nil { + return fmt.Errorf("failed to elicit AWS Secret Access Key: %w", err) + } + if err := os.Setenv("AWS_SECRET_ACCESS_KEY", accessKeySecret); err != nil { + return fmt.Errorf("failed to set AWS_SECRET_ACCESS_KEY environment variable: %w", err) + } + } + } + return nil +} + +func (pp *providerPreparer) SetupGCPAuthentication(ctx context.Context) error { + if os.Getenv("GCP_PROJECT_ID") == "" { + gcpProjectID, err := pp.ec.RequestString(ctx, "Enter your GCP Project ID:", "gcp_project_id") + if err != nil { + return fmt.Errorf("failed to elicit GCP Project ID: %w", err) + } + if err := os.Setenv("GCP_PROJECT_ID", gcpProjectID); err != nil { + return fmt.Errorf("failed to set GCP_PROJECT_ID environment variable: %w", err) + } + } + return nil +} + +func (pp *providerPreparer) SetupDOAuthentication(ctx context.Context) error { + if os.Getenv("DIGITALOCEAN_TOKEN") == "" { + pat, err := pp.ec.RequestString(ctx, "Enter your DigitalOcean Personal Access Token:", "personal_access_token") + if err != nil { + return fmt.Errorf("failed to elicit DigitalOcean Personal Access Token: %w", err) + } + if err := os.Setenv("DIGITALOCEAN_TOKEN", pat); err != nil { + return fmt.Errorf("failed to set DIGITALOCEAN_TOKEN environment variable: %w", err) + } + } + + if os.Getenv("SPACES_ACCESS_KEY_ID") == "" { + spaces_access_key, err := pp.ec.RequestString(ctx, "Enter your DigitalOcean Spaces Access Key:", "spaces_access_key") + if err != nil { + return fmt.Errorf("failed to elicit DigitalOcean Spaces Access Key: %w", err) + } + if err := os.Setenv("SPACES_ACCESS_KEY_ID", spaces_access_key); err != nil { + return fmt.Errorf("failed to set SPACES_ACCESS_KEY_ID environment variable: %w", err) + } + } + + if os.Getenv("SPACES_SECRET_ACCESS_KEY") == "" { + spaces_secret_key, err := pp.ec.RequestString(ctx, "Enter your DigitalOcean Spaces Secret Access Key:", "spaces_secret_access_key") + if err != nil { + return fmt.Errorf("failed to elicit DigitalOcean Spaces Secret Key: %w", err) + } + if err := os.Setenv("SPACES_SECRET_ACCESS_KEY", spaces_secret_key); err != nil { + return fmt.Errorf("failed to set SPACES_SECRET_ACCESS_KEY environment variable: %w", err) + } + } + return nil +} + +func listAWSProfiles() ([]string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get home directory: %w", err) + } + + files := []string{ + homeDir + "/.aws/credentials", + homeDir + "/.aws/config", + } + + profiles := make(map[string]struct{}) + + for _, file := range files { + f, err := os.Open(file) + if err != nil { + continue // skip missing files + } + + var section string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.Trim(line, "[]") + // In config, profiles are named "profile NAME" + section = strings.TrimPrefix(section, "profile ") + profiles[section] = struct{}{} + } + } + f.Close() + } + + result := make([]string, 0, len(profiles)) + for p := range profiles { + result = append(result, p) + } + sort.Strings(result) + return result, nil +} diff --git a/src/pkg/agent/tools/removeConfig.go b/src/pkg/agent/tools/removeConfig.go index ba82f9c60..5a96cc0a0 100644 --- a/src/pkg/agent/tools/removeConfig.go +++ b/src/pkg/agent/tools/removeConfig.go @@ -6,56 +6,41 @@ import ( "github.com/DefangLabs/defang/src/pkg/agent/common" cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/elicitations" "github.com/DefangLabs/defang/src/pkg/term" "github.com/bufbuild/connect-go" - "github.com/mark3labs/mcp-go/mcp" ) type RemoveConfigParams struct { - Name string -} - -func ParseRemoveConfigParams(request mcp.CallToolRequest) (RemoveConfigParams, error) { - name, err := request.RequireString("name") - if err != nil || name == "" { - return RemoveConfigParams{}, fmt.Errorf("missing config `name`: %w", err) - } - return RemoveConfigParams{ - Name: name, - }, nil + common.LoaderParams + Name string `json:"name" jsonschema:"required"` } // HandleRemoveConfigTool handles the remove config tool logic -func HandleRemoveConfigTool(ctx context.Context, loader cliClient.ProjectLoader, params RemoveConfigParams, providerId *cliClient.ProviderID, cluster string, cli CLIInterface) (string, error) { - err := common.ProviderNotConfiguredError(*providerId) - if err != nil { - return "", err - } - +func HandleRemoveConfigTool(ctx context.Context, loader cliClient.ProjectLoader, params RemoveConfigParams, cli CLIInterface, ec elicitations.Controller, sc StackConfig) (string, error) { term.Debug("Function invoked: cli.Connect") - client, err := cli.Connect(ctx, cluster) + client, err := cli.Connect(ctx, sc.Cluster) if err != nil { return "", fmt.Errorf("Could not connect: %w", err) } - term.Debug("Function invoked: cli.NewProvider") - provider := cli.NewProvider(ctx, *providerId, client, "") - + pp := NewProviderPreparer(cli, ec, client) + _, provider, err := pp.SetupProvider(ctx, sc.Stack) + if err != nil { + return "", fmt.Errorf("failed to setup provider: %w", err) + } term.Debug("Function invoked: cliClient.LoadProjectNameWithFallback") projectName, err := cli.LoadProjectNameWithFallback(ctx, loader, provider) if err != nil { - return "", fmt.Errorf("Failed to load project name: %w", err) + return "", fmt.Errorf("failed to load project name: %w", err) } - term.Debug("Project name loaded:", projectName) - - term.Debug("Function invoked: cli.ConfigDelete") if err := cli.ConfigDelete(ctx, projectName, provider, params.Name); err != nil { // Show a warning (not an error) if the config was not found if connect.CodeOf(err) == connect.CodeNotFound { return fmt.Sprintf("Config variable %q not found in project %q", params.Name, projectName), nil } - return "", fmt.Errorf("Failed to remove config variable %q from project %q: %w", params.Name, projectName, err) + return "", fmt.Errorf("failed to remove config variable %q from project %q: %w", params.Name, projectName, err) } - return fmt.Sprintf("Successfully remove the config variable %q from project %q", params.Name, projectName), nil + return fmt.Sprintf("Successfully removed the config variable %q from project %q", params.Name, projectName), nil } diff --git a/src/pkg/agent/tools/removeConfig_test.go b/src/pkg/agent/tools/removeConfig_test.go index 1ee1d71cc..73ad5e770 100644 --- a/src/pkg/agent/tools/removeConfig_test.go +++ b/src/pkg/agent/tools/removeConfig_test.go @@ -4,12 +4,13 @@ import ( "context" "errors" "fmt" + "os" "testing" - "github.com/DefangLabs/defang/src/pkg/agent/common" "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/cli/compose" + "github.com/DefangLabs/defang/src/pkg/elicitations" "github.com/bufbuild/connect-go" - "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -58,33 +59,16 @@ func TestHandleRemoveConfigTool(t *testing.T) { tests := []struct { name string configName string - providerID client.ProviderID setupMock func(*MockRemoveConfigCLI) expectError bool expectedTextContains string expectedError string }{ - { - name: "provider_auto_not_configured", - configName: "DATABASE_URL", - providerID: client.ProviderAuto, - setupMock: func(m *MockRemoveConfigCLI) {}, - expectError: true, - expectedError: common.ErrNoProviderSet.Error(), - }, - { - name: "missing_config_name", - configName: "", - providerID: client.ProviderAWS, - setupMock: func(m *MockRemoveConfigCLI) {}, - expectError: true, - expectedError: "missing config `name`: required argument \"name\" not found", - }, { name: "connect_error", configName: "DATABASE_URL", - providerID: client.ProviderAWS, setupMock: func(m *MockRemoveConfigCLI) { + m.ProjectName = "test-project" m.ConnectError = errors.New("connection failed") }, expectError: true, @@ -93,17 +77,16 @@ func TestHandleRemoveConfigTool(t *testing.T) { { name: "load_project_name_error", configName: "DATABASE_URL", - providerID: client.ProviderAWS, setupMock: func(m *MockRemoveConfigCLI) { + m.ProjectName = "test-project" m.LoadProjectNameError = errors.New("failed to load project name") }, expectError: true, - expectedError: "Failed to load project name: failed to load project name", + expectedError: "failed to load project name: failed to load project name", }, { name: "config_not_found", configName: "NONEXISTENT_CONFIG", - providerID: client.ProviderAWS, setupMock: func(m *MockRemoveConfigCLI) { m.ProjectName = "test-project" m.ConfigDeleteNotFoundError = true @@ -114,29 +97,32 @@ func TestHandleRemoveConfigTool(t *testing.T) { { name: "config_delete_error", configName: "DATABASE_URL", - providerID: client.ProviderAWS, setupMock: func(m *MockRemoveConfigCLI) { m.ProjectName = "test-project" m.ConfigDeleteError = errors.New("failed to delete config") }, expectError: true, - expectedError: "Failed to remove config variable \"DATABASE_URL\" from project \"test-project\": failed to delete config", + expectedError: "failed to remove config variable \"DATABASE_URL\" from project \"test-project\": failed to delete config", }, { name: "successful_config_removal", configName: "DATABASE_URL", - providerID: client.ProviderAWS, setupMock: func(m *MockRemoveConfigCLI) { m.ProjectName = "test-project" // No errors, successful removal }, expectError: false, - expectedTextContains: "Successfully remove the config variable \"DATABASE_URL\" from project \"test-project\"", + expectedTextContains: "Successfully removed the config variable \"DATABASE_URL\" from project \"test-project\"", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Chdir("testdata") + os.Unsetenv("DEFANG_PROVIDER") + os.Unsetenv("AWS_PROFILE") + os.Unsetenv("AWS_REGION") + // Create mock and configure it mockCLI := &MockRemoveConfigCLI{ CallLog: []string{}, @@ -149,26 +135,27 @@ func TestHandleRemoveConfigTool(t *testing.T) { args["name"] = tt.configName } - request := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "remove_config", - Arguments: args, - }, - } - - params, err := ParseRemoveConfigParams(request) - if err != nil { - if tt.expectError { - assert.EqualError(t, err, tt.expectedError) - return - } else { - require.NoError(t, err) - } + params := RemoveConfigParams{ + Name: tt.configName, } // Call the function - loader := &client.MockLoader{} - result, err := HandleRemoveConfigTool(t.Context(), loader, params, &tt.providerID, "test-cluster", mockCLI) + loader := &client.MockLoader{ + Project: compose.Project{Name: "test-project"}, + } + ec := elicitations.NewController(&mockElicitationsClient{ + responses: map[string]string{ + "strategy": "profile", + "profile_name": "default", + }, + }) + provider := client.ProviderAWS + stackName := "test-stack" + result, err := HandleRemoveConfigTool(t.Context(), loader, params, mockCLI, ec, StackConfig{ + Cluster: "test-cluster", + ProviderID: &provider, + Stack: &stackName, + }) // Verify error expectations if tt.expectError { diff --git a/src/pkg/agent/tools/services.go b/src/pkg/agent/tools/services.go index 6c5db8f04..d12222229 100644 --- a/src/pkg/agent/tools/services.go +++ b/src/pkg/agent/tools/services.go @@ -10,32 +10,33 @@ import ( "github.com/DefangLabs/defang/src/pkg/agent/common" defangcli "github.com/DefangLabs/defang/src/pkg/cli" cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/elicitations" "github.com/DefangLabs/defang/src/pkg/term" "github.com/bufbuild/connect-go" ) -func HandleServicesTool(ctx context.Context, loader cliClient.ProjectLoader, providerId *cliClient.ProviderID, cluster string, cli CLIInterface) (string, error) { - err := common.ProviderNotConfiguredError(*providerId) - if err != nil { - return "", err - } +type ServicesParams struct { + common.LoaderParams +} +func HandleServicesTool(ctx context.Context, loader cliClient.ProjectLoader, cli CLIInterface, ec elicitations.Controller, config StackConfig) (string, error) { term.Debug("Function invoked: cli.Connect") - client, err := cli.Connect(ctx, cluster) + client, err := cli.Connect(ctx, config.Cluster) if err != nil { return "", fmt.Errorf("could not connect: %w", err) } - // Create a Defang client - term.Debug("Function invoked: cli.NewProvider") - provider := cli.NewProvider(ctx, *providerId, client, "") - + pp := NewProviderPreparer(cli, ec, client) + _, provider, err := pp.SetupProvider(ctx, config.Stack) + if err != nil { + return "", fmt.Errorf("failed to setup provider: %w", err) + } term.Debug("Function invoked: cli.LoadProjectNameWithFallback") projectName, err := cli.LoadProjectNameWithFallback(ctx, loader, provider) term.Debugf("Project name loaded: %s", projectName) if err != nil { if strings.Contains(err.Error(), "no projects found") { - return "", fmt.Errorf("no projects found on Playground: %w", err) + return "no projects found on Playground", nil } return "", fmt.Errorf("failed to load project name: %w", err) } @@ -44,10 +45,10 @@ func HandleServicesTool(ctx context.Context, loader cliClient.ProjectLoader, pro if err != nil { var noServicesErr defangcli.ErrNoServices if errors.As(err, &noServicesErr) { - return "", fmt.Errorf("no services found for the specified project %s: %w", projectName, err) + return fmt.Sprintf("no services found for the specified project %q", projectName), nil } if connect.CodeOf(err) == connect.CodeNotFound && strings.Contains(err.Error(), "is not deployed in Playground") { - return "", fmt.Errorf("project %s is not deployed in Playground: %w", projectName, err) + return fmt.Sprintf("project %s is not deployed in Playground", projectName), nil } return "", fmt.Errorf("failed to get services: %w", err) diff --git a/src/pkg/agent/tools/services_test.go b/src/pkg/agent/tools/services_test.go index 1385c11f9..ff9d3b050 100644 --- a/src/pkg/agent/tools/services_test.go +++ b/src/pkg/agent/tools/services_test.go @@ -3,12 +3,17 @@ package tools import ( "context" "errors" + "os" "testing" + "time" - "github.com/DefangLabs/defang/src/pkg/agent/common" defangcli "github.com/DefangLabs/defang/src/pkg/cli" "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/cli/compose" + "github.com/DefangLabs/defang/src/pkg/elicitations" "github.com/DefangLabs/defang/src/pkg/mcp/deployment_info" + "github.com/DefangLabs/defang/src/pkg/modes" + defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" "github.com/bufbuild/connect-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -61,19 +66,90 @@ func (m *MockCLI) GetServices(ctx context.Context, projectName string, provider return m.MockServices, nil } +func (m *MockCLI) ComposeDown(ctx context.Context, projectName string, client *client.GrpcClient, provider client.Provider) (string, error) { + return "", nil +} + +func (m *MockCLI) ComposeUp(ctx context.Context, client *client.GrpcClient, provider client.Provider, params defangcli.ComposeUpParams) (*defangv1.DeployResponse, *compose.Project, error) { + return nil, nil, nil +} + +func (m *MockCLI) ConfigDelete(ctx context.Context, projectName string, provider client.Provider, name string) error { + return nil +} + +func (m *MockCLI) ConfigSet(ctx context.Context, projectName string, provider client.Provider, name, value string) error { + return nil +} + +func (m *MockCLI) CreatePlaygroundProvider(client *client.GrpcClient) client.Provider { + return m.MockProvider +} + +func (m *MockCLI) GenerateAuthURL(authPort int) string { + return "" +} + +func (m *MockCLI) InteractiveLoginMCP(ctx context.Context, client *client.GrpcClient, cluster string, mcpClient string) error { + return nil +} + +func (m *MockCLI) ListConfig(ctx context.Context, provider client.Provider, projectName string) (*defangv1.Secrets, error) { + return nil, nil +} + +func (m *MockCLI) LoadProject(ctx context.Context, loader client.Loader) (*compose.Project, error) { + return nil, nil +} + +func (m *MockCLI) PrintEstimate(mode modes.Mode, estimate *defangv1.EstimateResponse) string { + return "" +} + +func (m *MockCLI) RunEstimate(ctx context.Context, project *compose.Project, client *client.GrpcClient, provider client.Provider, providerId client.ProviderID, region string, mode modes.Mode) (*defangv1.EstimateResponse, error) { + return nil, nil +} + +func (m *MockCLI) Tail(ctx context.Context, provider client.Provider, projectName string, options defangcli.TailOptions) error { + return nil +} + +func (m *MockCLI) TailAndMonitor(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, options defangcli.TailOptions) (defangcli.ServiceStates, error) { + return nil, nil +} + // createConnectError creates a connect error with the specified code and message func createConnectError(code connect.Code, message string) error { return connect.NewError(code, errors.New(message)) } -func TestHandleServicesToolWithMockCLI(t *testing.T) { - ctx := t.Context() +type mockElicitationsClient struct { + responses map[string]string +} - // Common test data - const ( - testCluster = "test-cluster" - ) +func (m *mockElicitationsClient) Request(ctx context.Context, req elicitations.Request) (elicitations.Response, error) { + properties, ok := req.Schema["properties"].(map[string]any) + if !ok || len(properties) == 0 { + panic("invalid schema properties") + } + fields := make([]string, 0) + for field := range properties { + fields = append(fields, field) + } + + if len(fields) > 1 { + panic("mockElicitationsClient only supports single-field requests") + } + return elicitations.Response{ + Action: "accept", + Content: map[string]any{ + fields[0]: m.responses[fields[0]], + }, + }, nil +} + +func TestHandleServicesToolWithMockCLI(t *testing.T) { tests := []struct { name string providerId client.ProviderID @@ -97,17 +173,6 @@ func TestHandleServicesToolWithMockCLI(t *testing.T) { errorMessage: "connection failed", expectedGetServices: false, }, - { - name: "auto_provider_not_configured", - providerId: client.ProviderAuto, - mockCLI: &MockCLI{ - MockClient: &client.GrpcClient{}, - }, - - expectedError: true, - errorMessage: common.ErrNoProviderSet.Error(), - expectedGetServices: false, - }, { name: "load_project_name_error", providerId: client.ProviderDefang, @@ -132,8 +197,8 @@ func TestHandleServicesToolWithMockCLI(t *testing.T) { MockProjectName: "test-project", GetServicesError: defangcli.ErrNoServices{ProjectName: "test-project"}, }, - expectedError: true, // Go error is returned - errorMessage: "no services found in project", + expectedError: false, // Returns successful result with message + resultTextContains: "no services found for the specified project", expectedGetServices: true, expectedProjectName: "test-project", }, @@ -146,8 +211,8 @@ func TestHandleServicesToolWithMockCLI(t *testing.T) { MockProjectName: "test-project", GetServicesError: createConnectError(connect.CodeNotFound, "project test-project is not deployed in Playground"), }, - expectedError: true, - errorMessage: "is not deployed in Playground", + expectedError: false, // Returns successful result with message + resultTextContains: "is not deployed in Playground", expectedGetServices: true, expectedProjectName: "test-project", }, @@ -191,8 +256,23 @@ func TestHandleServicesToolWithMockCLI(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Chdir("testdata") + os.Unsetenv("DEFANG_PROVIDER") + os.Unsetenv("AWS_PROFILE") + os.Unsetenv("AWS_REGION") loader := &client.MockLoader{} - result, err := HandleServicesTool(ctx, loader, &tt.providerId, testCluster, tt.mockCLI) + ec := elicitations.NewController(&mockElicitationsClient{ + responses: map[string]string{ + "strategy": "profile", + "profile_name": "default", + }, + }) + stackName := "test-stack" + result, err := HandleServicesTool(t.Context(), loader, tt.mockCLI, ec, StackConfig{ + Cluster: "test-cluster", + ProviderID: &tt.providerId, + Stack: &stackName, + }) // Check Go error expectation if tt.expectedError { diff --git a/src/pkg/agent/tools/setAWSProvider.go b/src/pkg/agent/tools/setAWSProvider.go deleted file mode 100644 index a60251775..000000000 --- a/src/pkg/agent/tools/setAWSProvider.go +++ /dev/null @@ -1,31 +0,0 @@ -package tools - -import ( - "context" - "fmt" - - cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/mcp/actions" - "github.com/mark3labs/mcp-go/mcp" -) - -// HandleSetAWSProvider handles the set AWS provider MCP tool request -func HandleSetAWSProvider(ctx context.Context, request mcp.CallToolRequest, providerId *cliClient.ProviderID, cluster string) (string, error) { - awsId, err := request.RequireString("accessKeyId") - if err != nil { - return "", fmt.Errorf("Invalid AWS access key Id: %w", err) - } - awsSecretAccessKey, err := request.RequireString("secretAccessKey") - if err != nil { - return "", fmt.Errorf("Invalid AWS secret access key: %w", err) - } - awsRegion, err := request.RequireString("region") - if err != nil { - return "", fmt.Errorf("Invalid AWS region: %w", err) - } - if err := actions.SetAWSByocProvider(ctx, providerId, cluster, awsId, awsSecretAccessKey, awsRegion); err != nil { - return "", fmt.Errorf("Failed to set AWS provider: %w", err) - } - - return fmt.Sprintf("Successfully set the provider %q", *providerId), nil -} diff --git a/src/pkg/agent/tools/setConfig.go b/src/pkg/agent/tools/setConfig.go index 16608aba9..0cf54bd2f 100644 --- a/src/pkg/agent/tools/setConfig.go +++ b/src/pkg/agent/tools/setConfig.go @@ -7,61 +7,46 @@ import ( "github.com/DefangLabs/defang/src/pkg" "github.com/DefangLabs/defang/src/pkg/agent/common" cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/elicitations" "github.com/DefangLabs/defang/src/pkg/term" - "github.com/mark3labs/mcp-go/mcp" ) type SetConfigParams struct { - Name string - Value string + common.LoaderParams + Name string `json:"name" jsonschema:"required"` + Value string `json:"value" jsonschema:"required"` } -func ParseSetConfigParams(request mcp.CallToolRequest) (SetConfigParams, error) { - name, err := request.RequireString("name") - if err != nil || name == "" { - return SetConfigParams{}, fmt.Errorf("missing 'name' parameter: %w", err) - } - value, err := request.RequireString("value") - if err != nil || value == "" { - return SetConfigParams{}, fmt.Errorf("missing 'value' parameter: %w", err) - } - return SetConfigParams{ - Name: name, - Value: value, - }, nil -} - -// HandleSetConfig handles the set config MCP tool request -func HandleSetConfig(ctx context.Context, loader cliClient.ProjectLoader, params SetConfigParams, providerId *cliClient.ProviderID, cluster string, cli CLIInterface) (string, error) { - err := common.ProviderNotConfiguredError(*providerId) - if err != nil { - return "", err - } - +func HandleSetConfig(ctx context.Context, loader cliClient.ProjectLoader, params SetConfigParams, cli CLIInterface, ec elicitations.Controller, sc StackConfig) (string, error) { term.Debug("Function invoked: cli.Connect") - client, err := cli.Connect(ctx, cluster) + client, err := cli.Connect(ctx, sc.Cluster) if err != nil { return "", fmt.Errorf("Could not connect: %w", err) } - term.Debug("Function invoked: cli.NewProvider") - provider := cli.NewProvider(ctx, *providerId, client, "") - - term.Debug("Function invoked: cli.LoadProjectNameWithFallback") - projectName, err := cli.LoadProjectNameWithFallback(ctx, loader, provider) + pp := NewProviderPreparer(cli, ec, client) + _, provider, err := pp.SetupProvider(ctx, sc.Stack) if err != nil { - return "", fmt.Errorf("Failed to load project name: %w", err) + return "", fmt.Errorf("failed to setup provider: %w", err) + } + + if params.ProjectName == "" { + term.Debug("Function invoked: cliClient.LoadProjectNameWithFallback") + projectName, err := cli.LoadProjectNameWithFallback(ctx, loader, provider) + if err != nil { + return "", fmt.Errorf("failed to load project name: %w", err) + } + params.ProjectName = projectName } - term.Debug("Project name loaded:", projectName) if !pkg.IsValidSecretName(params.Name) { return "", fmt.Errorf("Invalid config name: secret name %q is not valid", params.Name) } term.Debug("Function invoked: cli.ConfigSet") - if err := cli.ConfigSet(ctx, projectName, provider, params.Name, params.Value); err != nil { - return "", fmt.Errorf("Failed to set config: %w", err) + if err := cli.ConfigSet(ctx, params.ProjectName, provider, params.Name, params.Value); err != nil { + return "", fmt.Errorf("failed to set config: %w", err) } - return fmt.Sprintf("Successfully set the config variable %q for project %q", params.Name, projectName), nil + return fmt.Sprintf("Successfully set the config variable %q for project %q", params.Name, params.ProjectName), nil } diff --git a/src/pkg/agent/tools/setConfig_test.go b/src/pkg/agent/tools/setConfig_test.go index f3c633cb9..04a15edb6 100644 --- a/src/pkg/agent/tools/setConfig_test.go +++ b/src/pkg/agent/tools/setConfig_test.go @@ -3,10 +3,11 @@ package tools import ( "context" "errors" + "os" "testing" "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/mark3labs/mcp-go/mcp" + "github.com/DefangLabs/defang/src/pkg/elicitations" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -81,15 +82,6 @@ func (m *MockSetConfigCLI) ConfigSet(ctx context.Context, projectName string, pr return m.ConfigSetError } -func createCallToolRequest(args map[string]interface{}) mcp.CallToolRequest { - return mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "setConfig", - Arguments: args, - }, - } -} - func TestHandleSetConfig(t *testing.T) { // Common test data const ( @@ -97,8 +89,6 @@ func TestHandleSetConfig(t *testing.T) { testConfigName = "test-config" testValue = "test-value" ) - testContext := t.Context() - tests := []struct { name string cluster string @@ -115,40 +105,52 @@ func TestHandleSetConfig(t *testing.T) { }{ // Input validation tests { - name: "missing config name", - cluster: testCluster, - providerId: client.ProviderID(""), - requestArgs: map[string]interface{}{"value": testValue}, - mockCLI: &MockSetConfigCLI{}, - expectedError: true, - errorMessage: "missing 'name' parameter: required argument \"name\" not found", + name: "missing config name", + cluster: testCluster, + providerId: client.ProviderID(""), + requestArgs: map[string]interface{}{"value": testValue}, + mockCLI: &MockSetConfigCLI{}, + expectedError: true, + errorMessage: "Invalid config name: secret name \"\" is not valid", + expectedConnectCalls: true, + expectedProviderCalls: true, + expectedProjectNameCalls: true, }, { - name: "empty config name", - cluster: testCluster, - providerId: client.ProviderID(""), - requestArgs: map[string]interface{}{"name": "", "value": testValue}, - mockCLI: &MockSetConfigCLI{}, - expectedError: true, - errorMessage: "missing 'name' parameter: %!w()", + name: "empty config name", + cluster: testCluster, + providerId: client.ProviderID(""), + requestArgs: map[string]interface{}{"name": "", "value": testValue}, + mockCLI: &MockSetConfigCLI{}, + expectedError: true, + errorMessage: "Invalid config name: secret name \"\" is not valid", + expectedConnectCalls: true, + expectedProviderCalls: true, + expectedProjectNameCalls: true, }, { - name: "missing config value", - cluster: testCluster, - providerId: client.ProviderID(""), - requestArgs: map[string]interface{}{"name": testConfigName}, - mockCLI: &MockSetConfigCLI{}, - expectedError: true, - errorMessage: "missing 'value' parameter: required argument \"value\" not found", + name: "missing config value", + cluster: testCluster, + providerId: client.ProviderID(""), + requestArgs: map[string]interface{}{"name": testConfigName}, + mockCLI: &MockSetConfigCLI{}, + expectedError: true, + errorMessage: "Invalid config name: secret name \"test-config\" is not valid", + expectedConnectCalls: true, + expectedProviderCalls: true, + expectedProjectNameCalls: true, }, { - name: "empty config value", - cluster: testCluster, - providerId: client.ProviderID(""), - requestArgs: map[string]interface{}{"name": testConfigName, "value": ""}, - mockCLI: &MockSetConfigCLI{}, - expectedError: true, - errorMessage: "missing 'value' parameter: %!w()", + name: "empty config value", + cluster: testCluster, + providerId: client.ProviderID(""), + requestArgs: map[string]interface{}{"name": testConfigName, "value": ""}, + mockCLI: &MockSetConfigCLI{}, + expectedError: true, + errorMessage: "Invalid config name: secret name \"test-config\" is not valid", + expectedConnectCalls: true, + expectedProviderCalls: true, + expectedProjectNameCalls: true, }, // CLI operation error tests @@ -169,7 +171,7 @@ func TestHandleSetConfig(t *testing.T) { requestArgs: map[string]interface{}{"name": testConfigName, "value": testValue}, mockCLI: &MockSetConfigCLI{LoadProjectNameError: errors.New("project loading failed")}, expectedError: true, - errorMessage: "Failed to load project name: project loading failed", + errorMessage: "failed to load project name: project loading failed", expectedConnectCalls: true, expectedProviderCalls: true, expectedProjectNameCalls: true, @@ -181,7 +183,7 @@ func TestHandleSetConfig(t *testing.T) { requestArgs: map[string]interface{}{"name": "valid_config_name", "value": testValue}, mockCLI: &MockSetConfigCLI{ConfigSetError: errors.New("config set failed")}, expectedError: true, - errorMessage: "Failed to set config: config set failed", + errorMessage: "failed to set config: config set failed", expectedConnectCalls: true, expectedProviderCalls: true, expectedProjectNameCalls: true, @@ -217,18 +219,39 @@ func TestHandleSetConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - request := createCallToolRequest(tt.requestArgs) + t.Chdir("testdata") + os.Unsetenv("DEFANG_PROVIDER") + os.Unsetenv("AWS_PROFILE") + os.Unsetenv("AWS_REGION") + loader := &client.MockLoader{} - params, err := ParseSetConfigParams(request) - if err != nil { - if tt.expectedError { - assert.EqualError(t, err, tt.errorMessage) - return - } else { - require.NoError(t, err) - } + + // Extract arguments with defaults for missing values + name := "" + if n, ok := tt.requestArgs["name"].(string); ok { + name = n + } + value := "" + if v, ok := tt.requestArgs["value"].(string); ok { + value = v + } + + params := SetConfigParams{ + Name: name, + Value: value, } - result, err := HandleSetConfig(testContext, loader, params, &tt.providerId, tt.cluster, tt.mockCLI) + ec := elicitations.NewController(&mockElicitationsClient{ + responses: map[string]string{ + "strategy": "profile", + "profile_name": "default", + }, + }) + stackName := "test-stack" + result, err := HandleSetConfig(t.Context(), loader, params, tt.mockCLI, ec, StackConfig{ + Cluster: tt.cluster, + ProviderID: &tt.providerId, + Stack: &stackName, + }) if tt.expectedError { assert.Error(t, err) diff --git a/src/pkg/agent/tools/setGCPProvider.go b/src/pkg/agent/tools/setGCPProvider.go deleted file mode 100644 index a2e74932e..000000000 --- a/src/pkg/agent/tools/setGCPProvider.go +++ /dev/null @@ -1,24 +0,0 @@ -package tools - -import ( - "context" - "fmt" - - cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/mcp/actions" - "github.com/mark3labs/mcp-go/mcp" -) - -// HandleSetGCPProvider handles the set GCP provider MCP tool request -func HandleSetGCPProvider(ctx context.Context, request mcp.CallToolRequest, providerId *cliClient.ProviderID, cluster string) (string, error) { - gcpProjectID, err := request.RequireString("gcpProjectId") - if err != nil { - return "", fmt.Errorf("Invalid GCP project ID: %w", err) - } - - if err := actions.SetGCPByocProvider(ctx, providerId, cluster, gcpProjectID); err != nil { - return "", fmt.Errorf("Failed to set GCP provider: %w", err) - } - - return fmt.Sprintf("Successfully set the provider %q", *providerId), nil -} diff --git a/src/pkg/agent/tools/setPlaygroundProvider.go b/src/pkg/agent/tools/setPlaygroundProvider.go deleted file mode 100644 index 8472cea98..000000000 --- a/src/pkg/agent/tools/setPlaygroundProvider.go +++ /dev/null @@ -1,17 +0,0 @@ -package tools - -import ( - "fmt" - - cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/mcp/actions" -) - -// HandleSetPlaygroundProvider handles the set Playground provider MCP tool request -func HandleSetPlaygroundProvider(providerId *cliClient.ProviderID) (string, error) { - if err := actions.SetPlaygroundProvider(providerId); err != nil { - return "", fmt.Errorf("Failed to set Playground provider: %w", err) - } - - return fmt.Sprintf("Successfully set the provider %q", *providerId), nil -} diff --git a/src/pkg/agent/tools/testdata/.defang/test-stack b/src/pkg/agent/tools/testdata/.defang/test-stack new file mode 100644 index 000000000..c6892b4fa --- /dev/null +++ b/src/pkg/agent/tools/testdata/.defang/test-stack @@ -0,0 +1,2 @@ +DEFANG_PROVIDER=aws +AWS_REGION=us-test-2 diff --git a/src/pkg/agent/tools/tools.go b/src/pkg/agent/tools/tools.go index 251aa51cf..a4d6733da 100644 --- a/src/pkg/agent/tools/tools.go +++ b/src/pkg/agent/tools/tools.go @@ -1,280 +1,101 @@ package tools import ( - "context" - "strings" - "time" - "github.com/DefangLabs/defang/src/pkg/agent/common" - "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/modes" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -var workingDirectoryOption = mcp.WithString("working_directory", - mcp.Description("Path to project's working directory"), - mcp.Required(), + "github.com/DefangLabs/defang/src/pkg/elicitations" + "github.com/firebase/genkit/go/ai" ) -var multipleComposeFilesOptions = mcp.WithArray("compose_file_paths", - mcp.Description("Path(s) to docker-compose files"), - mcp.Items(map[string]string{"type": "string"}), -) - -func CollectTools(cluster string, providerId *client.ProviderID, cli CLIInterface) []server.ServerTool { - tools := []server.ServerTool{ - { - Tool: mcp.NewTool("login", - mcp.WithDescription("Login to Defang"), - ), - Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - output, err := HandleLoginTool(ctx, cluster, cli) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to login", err), err - } - return mcp.NewToolResultText(output), nil +func CollectDefangTools(ec elicitations.Controller, config StackConfig) []ai.Tool { + return []ai.Tool{ + ai.NewTool[ServicesParams, string]( + "services", + "List deployed services for the project in the current working directory", + func(ctx *ai.ToolContext, params ServicesParams) (string, error) { + loader, err := common.ConfigureAgentLoader(params.LoaderParams) + if err != nil { + return "Failed to configure loader", err + } + var cli CLIInterface = &DefaultToolCLI{} + return HandleServicesTool(ctx.Context, loader, cli, ec, config) }, - }, - { - Tool: mcp.NewTool("services", - mcp.WithDescription("List deployed services for the project in the current working directory"), - workingDirectoryOption, - multipleComposeFilesOptions, - ), - Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - loader, err := common.ConfigureLoader(request) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to configure loader", err), err - } - output, err := HandleServicesTool(ctx, loader, providerId, cluster, cli) + ), + ai.NewTool("deploy", + "Initiate deployment of the application defined in the docker-compose files in the current working directory", + func(ctx *ai.ToolContext, params DeployParams) (string, error) { + loader, err := common.ConfigureAgentLoader(params.LoaderParams) if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to list services", err), err + return "Failed to configure loader", err } - return mcp.NewToolResultText(output), nil + cli := &DefaultToolCLI{} + return HandleDeployTool(ctx.Context, loader, cli, ec, config) }, - }, - { - Tool: mcp.NewTool("deploy", - mcp.WithDescription("Deploy services using defang"), - workingDirectoryOption, - multipleComposeFilesOptions, - ), - Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - loader, err := common.ConfigureLoader(request) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to configure loader", err), err - } - output, err := HandleDeployTool(ctx, loader, providerId, cluster, cli) + ), + ai.NewTool("destroy", + "Destroy the deployed application defined in the docker-compose files in the current working directory", + func(ctx *ai.ToolContext, params DestroyParams) (string, error) { + loader, err := common.ConfigureAgentLoader(params.LoaderParams) if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to deploy services", err), err + return "Failed to configure loader", err } - return mcp.NewToolResultText(output), nil + cli := &DefaultToolCLI{} + return HandleDestroyTool(ctx.Context, loader, cli, ec, config) }, - }, - { - Tool: mcp.NewTool("destroy", - mcp.WithDescription("Destroy deployed services for the project in the current working directory"), - workingDirectoryOption, - multipleComposeFilesOptions, - ), - Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - loader, err := common.ConfigureLoader(request) + ), + ai.NewTool("logs", + "Fetch logs for the application in pages of up to 100 lines. You can use the 'since' and 'until' parameters to page through logs by time.", + func(ctx *ai.ToolContext, params LogsParams) (string, error) { + loader, err := common.ConfigureAgentLoader(params.LoaderParams) if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to configure loader", err), err + return "Failed to configure loader", err } - output, err := HandleDestroyTool(ctx, loader, providerId, cluster, cli) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to destroy services", err), err - } - return mcp.NewToolResultText(output), nil - }, - }, - { - Tool: mcp.NewTool("logs", - mcp.WithDescription("Fetch logs for a deployment."), - workingDirectoryOption, - mcp.WithString("deployment_id", - mcp.Description("The deployment ID for which to fetch logs"), - ), - mcp.WithString("since", - mcp.Description("The start time in RFC3339 format (e.g., 2006-01-02T15:04:05Z07:00)"), - mcp.Required(), - mcp.DefaultString(time.Now().Add(-1*time.Hour).Format(time.RFC3339)), - ), - mcp.WithString("until", - mcp.Description("The end time in RFC3339 format (e.g., 2006-01-02T15:04:05Z07:00)"), - mcp.Required(), - mcp.DefaultString(time.Now().Format(time.RFC3339)), - ), - ), - Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - loader, err := common.ConfigureLoader(request) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to configure loader", err), err - } - params := ParseLogsParams(request) - output, err := HandleLogsTool(ctx, loader, params, cluster, providerId, cli) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to fetch logs", err), err - } - return mcp.NewToolResultText(output), nil + cli := &DefaultToolCLI{} + return HandleLogsTool(ctx.Context, loader, params, cli, ec, config) }, - }, - { - Tool: mcp.NewTool("estimate", - mcp.WithDescription("Estimate the cost of deployed a Defang project."), - workingDirectoryOption, - multipleComposeFilesOptions, - mcp.WithString("provider", - mcp.Description("The cloud provider to estimate costs for. Supported options are AWS or GCP"), - mcp.DefaultString(strings.ToUpper(providerId.String())), - mcp.Enum("AWS", "GCP"), - ), - mcp.WithString("deployment_mode", - mcp.Description("The deployment mode for the estimate. Options are: "+strings.Join(modes.AllDeploymentModes(), ", ")), - mcp.DefaultString("AFFORDABLE"), - mcp.Enum(modes.AllDeploymentModes()...), - ), - ), - Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - loader, err := common.ConfigureLoader(request) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to configure loader", err), err - } - params, err := ParseEstimateParams(request, providerId) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to parse estimate parameters", err), err - } - output, err := HandleEstimateTool(ctx, loader, params, cluster, cli) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to estimate costs", err), err - } - return mcp.NewToolResultText(output), nil - }, - }, - { - Tool: mcp.NewTool("set_config", - mcp.WithDescription("Tail logs for a deployment."), - workingDirectoryOption, - multipleComposeFilesOptions, - mcp.WithString("key", - mcp.Description("The config key to set"), - ), - mcp.WithString("value", - mcp.Description("The config value to set"), - ), - ), - Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - loader, err := common.ConfigureLoader(request) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to configure loader", err), err - } - params, err := ParseSetConfigParams(request) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to parse set config parameters", err), err - } - output, err := HandleSetConfig(ctx, loader, params, providerId, cluster, cli) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to set config", err), err - } - return mcp.NewToolResultText(output), nil - }, - }, - { - Tool: mcp.NewTool("remove_config", - mcp.WithDescription("Remove a config variable from the defang project"), - workingDirectoryOption, - multipleComposeFilesOptions, - mcp.WithString("key", - mcp.Description("The config key to remove"), - ), - ), - Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - loader, err := common.ConfigureLoader(request) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to configure loader", err), err - } - params, err := ParseRemoveConfigParams(request) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to parse remove config parameters", err), err - } - output, err := HandleRemoveConfigTool(ctx, loader, params, providerId, cluster, cli) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to remove config", err), err - } - return mcp.NewToolResultText(output), nil - }, - }, - { - Tool: mcp.NewTool("list_configs", - mcp.WithDescription("List config variables for the defang project"), - workingDirectoryOption, - multipleComposeFilesOptions, - ), - Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - loader, err := common.ConfigureLoader(request) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to configure loader", err), err - } - output, err := HandleListConfigTool(ctx, loader, providerId, cluster, cli) + ), + ai.NewTool("estimate", + "Estimate the cost of deploying a Defang project to AWS or GCP", + func(ctx *ai.ToolContext, params EstimateParams) (string, error) { + loader, err := common.ConfigureAgentLoader(params.LoaderParams) if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to list config", err), err + return "Failed to configure loader", err } - return mcp.NewToolResultText(output), nil + cli := &DefaultToolCLI{} + return HandleEstimateTool(ctx.Context, loader, params, cli, config) }, - }, - { - Tool: mcp.NewTool("set_aws_provider", - mcp.WithDescription("Set the AWS provider for the defang project"), - workingDirectoryOption, - mcp.WithString("accessKeyId", - mcp.Description("Your AWS Access Key ID"), - ), - mcp.WithString("secretAccessKey", - mcp.Description("Your AWS Secret Access Key"), - ), - mcp.WithString("region", - mcp.Description("Your AWS Region"), - ), - ), - Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - output, err := HandleSetAWSProvider(ctx, request, providerId, cluster) + ), + ai.NewTool("set_config", + "Set a config variable for the defang project", + func(ctx *ai.ToolContext, params SetConfigParams) (string, error) { + loader, err := common.ConfigureAgentLoader(params.LoaderParams) if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to set AWS provider", err), err + return "Failed to configure loader", err } - return mcp.NewToolResultText(output), nil + cli := &DefaultToolCLI{} + return HandleSetConfig(ctx.Context, loader, params, cli, ec, config) }, - }, - { - Tool: mcp.NewTool("set_gcp_provider", - mcp.WithDescription("Set the GCP provider for the defang project"), - workingDirectoryOption, - mcp.WithString("gcpProjectId", - mcp.Description("Your GCP Project ID"), - ), - ), - Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - output, err := HandleSetGCPProvider(ctx, request, providerId, cluster) + ), + ai.NewTool("remove_config", + "Remove a config variable from the defang project", + func(ctx *ai.ToolContext, params RemoveConfigParams) (string, error) { + loader, err := common.ConfigureAgentLoader(params.LoaderParams) if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to set GCP provider", err), err + return "Failed to configure loader", err } - return mcp.NewToolResultText(output), nil + cli := &DefaultToolCLI{} + return HandleRemoveConfigTool(ctx.Context, loader, params, cli, ec, config) }, - }, - { - Tool: mcp.NewTool("set_playground_provider", - mcp.WithDescription("Set the Playground provider for the defang project"), - workingDirectoryOption, - ), - Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - output, err := HandleSetPlaygroundProvider(providerId) + ), + ai.NewTool("list_configs", + "List config variables for the defang project", + func(ctx *ai.ToolContext, params ListConfigsParams) (string, error) { + loader, err := common.ConfigureAgentLoader(params.LoaderParams) if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to set Playground provider", err), err + return "Failed to configure loader", err } - return mcp.NewToolResultText(output), nil + cli := &DefaultToolCLI{} + return HandleListConfigTool(ctx.Context, loader, cli, ec, config) }, - }, + ), } - return tools } diff --git a/src/pkg/cli/compose/fixup_test.go b/src/pkg/cli/compose/fixup_test.go index 1d8951d89..89ca089f2 100644 --- a/src/pkg/cli/compose/fixup_test.go +++ b/src/pkg/cli/compose/fixup_test.go @@ -2,17 +2,23 @@ package compose import ( "encoding/json" + "strings" "testing" "github.com/DefangLabs/defang/src/pkg" "github.com/DefangLabs/defang/src/pkg/cli/client" composeTypes "github.com/compose-spec/compose-go/v2/types" + "github.com/stretchr/testify/assert" ) func TestFixup(t *testing.T) { - testAllComposeFiles(t, func(t *testing.T, path string) { + testAllComposeFiles(t, func(t *testing.T, name, path string) { loader := NewLoader(WithPath(path)) proj, err := loader.LoadProject(t.Context()) + if strings.HasPrefix(name, "invalid-") { + assert.Error(t, err, "Expected error for invalid compose file: %s", path) + return + } if err != nil { t.Fatal(err) } diff --git a/src/pkg/cli/compose/load_content_test.go b/src/pkg/cli/compose/load_content_test.go index a9f17deec..3d81ee0bc 100644 --- a/src/pkg/cli/compose/load_content_test.go +++ b/src/pkg/cli/compose/load_content_test.go @@ -1,13 +1,20 @@ package compose import ( + "strings" "testing" + + "github.com/stretchr/testify/assert" ) func TestRoundTrip(t *testing.T) { - testAllComposeFiles(t, func(t *testing.T, path string) { + testAllComposeFiles(t, func(t *testing.T, name, path string) { loader := NewLoader(WithPath(path)) p, err := loader.LoadProject(t.Context()) + if strings.HasPrefix(name, "invalid-") { + assert.Error(t, err, "Expected error for invalid compose file: %s", path) + return + } if err != nil { t.Fatal(err) } diff --git a/src/pkg/cli/compose/loader_test.go b/src/pkg/cli/compose/loader_test.go index e23df6314..9304edeb0 100644 --- a/src/pkg/cli/compose/loader_test.go +++ b/src/pkg/cli/compose/loader_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "regexp" + "strings" "testing" "github.com/DefangLabs/defang/src/pkg" @@ -12,9 +13,13 @@ import ( ) func TestLoader(t *testing.T) { - testAllComposeFiles(t, func(t *testing.T, path string) { + testAllComposeFiles(t, func(t *testing.T, name, path string) { loader := NewLoader(WithPath(path)) proj, err := loader.LoadProject(t.Context()) + if strings.HasPrefix(name, "invalid-") { + assert.Error(t, err, "Expected error for invalid compose file: %s", path) + return + } if err != nil { t.Fatal(err) } @@ -31,7 +36,7 @@ func TestLoader(t *testing.T) { }) } -func testAllComposeFiles(t *testing.T, f func(t *testing.T, path string)) { +func testAllComposeFiles(t *testing.T, f func(t *testing.T, name, path string)) { t.Helper() composeRegex := regexp.MustCompile(`^(?i)(docker-)?compose.ya?ml$`) @@ -43,7 +48,7 @@ func testAllComposeFiles(t *testing.T, f func(t *testing.T, path string)) { t.Run(path, func(t *testing.T) { t.Helper() t.Log(path) - f(t, path) + f(t, filepath.Base(filepath.Dir(path)), path) }) return nil }) diff --git a/src/pkg/cli/compose/validation_test.go b/src/pkg/cli/compose/validation_test.go index 19569c489..d64b210b3 100644 --- a/src/pkg/cli/compose/validation_test.go +++ b/src/pkg/cli/compose/validation_test.go @@ -16,6 +16,7 @@ import ( defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" "github.com/aws/smithy-go/ptr" composeTypes "github.com/compose-spec/compose-go/v2/types" + "github.com/stretchr/testify/assert" ) func TestValidationAndConvert(t *testing.T) { @@ -35,13 +36,17 @@ func TestValidationAndConvert(t *testing.T) { return configs.Names, nil } - testAllComposeFiles(t, func(t *testing.T, path string) { + testAllComposeFiles(t, func(t *testing.T, name, path string) { logs := new(bytes.Buffer) term.DefaultTerm = term.NewTerm(os.Stdin, logs, logs) options := LoaderOptions{ConfigPaths: []string{path}} loader := Loader{options: options} project, err := loader.LoadProject(t.Context()) + if strings.HasPrefix(name, "invalid-") { + assert.Error(t, err, "Expected error for invalid compose file: %s", path) + return + } if err != nil { t.Fatal(err) } diff --git a/src/pkg/cli/debug.go b/src/pkg/cli/debug.go deleted file mode 100644 index fee79a63c..000000000 --- a/src/pkg/cli/debug.go +++ /dev/null @@ -1,311 +0,0 @@ -package cli - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "github.com/AlecAivazis/survey/v2" - "github.com/DefangLabs/defang/src/pkg" - "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/cli/compose" - "github.com/DefangLabs/defang/src/pkg/dryrun" - "github.com/DefangLabs/defang/src/pkg/term" - "github.com/DefangLabs/defang/src/pkg/track" - "github.com/DefangLabs/defang/src/pkg/types" - defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" - "google.golang.org/protobuf/types/known/timestamppb" -) - -// Arbitrary limit on the maximum number of files to process to avoid walking the entire drive and we have limited -// context window for the LLM also. -const maxFiles = 20 - -var ( - errFileLimitReached = errors.New("file limit reached") - patterns = []string{"*.js", "*.ts", "*.py", "*.go", "requirements.txt", "package.json", "go.mod"} // TODO: add patterns for other languages -) - -type DebugConfig struct { - Deployment types.ETag - FailedServices []string - ModelId string - Project *compose.Project - Provider client.Provider - Since time.Time - Until time.Time -} - -func (dc DebugConfig) String() string { - cmd := "debug" - if dc.Deployment != "" { - cmd += " --deployment=" + dc.Deployment - } - if dc.ModelId != "" { - cmd += " --model=" + dc.ModelId - } - if !dc.Since.IsZero() { - cmd += " --since=" + dc.Since.UTC().Format(time.RFC3339Nano) - } - if !dc.Until.IsZero() { - cmd += " --until=" + dc.Until.UTC().Format(time.RFC3339Nano) - } - if dc.Project.WorkingDir != "" { - cmd += " --cwd=" + dc.Project.WorkingDir - } - if dc.Project != nil { - cmd += " --project-name=" + dc.Project.Name - } - if len(dc.FailedServices) > 0 { - cmd += " " + strings.Join(dc.FailedServices, " ") - } - // TODO: do we need to add --provider= or rely on the Fabric-supplied value? - return cmd -} - -func InteractiveDebugDeployment(ctx context.Context, client client.FabricClient, debugConfig DebugConfig) error { - return interactiveDebug(ctx, client, debugConfig, nil) -} - -func InteractiveDebugForClientError(ctx context.Context, client client.FabricClient, project *compose.Project, clientErr error) error { - return interactiveDebug(ctx, client, DebugConfig{Project: project}, clientErr) -} - -func interactiveDebug(ctx context.Context, client client.FabricClient, debugConfig DebugConfig, clientErr error) error { - var aiDebug bool - if err := survey.AskOne(&survey.Confirm{ - Message: "Would you like to debug the deployment with AI?", - Help: "This will send logs and artifacts to our backend and attempt to diagnose the issue and provide a solution.", - }, &aiDebug, survey.WithStdio(term.DefaultTerm.Stdio())); err != nil { - track.Evt("Debug Prompt Failed", P("etag", debugConfig.Deployment), P("reason", err), P("loadErr", clientErr)) - return err - } else if !aiDebug { - track.Evt("Debug Prompt Skipped", P("etag", debugConfig.Deployment), P("loadErr", clientErr)) - return err - } - - track.Evt("Debug Prompt Accepted", P("etag", debugConfig.Deployment), P("loadErr", clientErr)) - - if clientErr != nil { - if err := debugComposeFileLoadError(ctx, client, debugConfig.Project, clientErr); err != nil { - term.Warnf("Failed to debug compose file load: %v", err) - return err - } - } else if debugConfig.Deployment != "" { - if err := DebugDeployment(ctx, client, debugConfig); err != nil { - term.Warnf("Failed to debug deployment: %v", err) - return err - } - } else { - return errors.New("no information to use for debugger") - } - - var goodBad bool - if err := survey.AskOne(&survey.Confirm{ - Message: "Was the debugging helpful?", - Help: "Please provide feedback to help us improve the debugging experience.", - }, &goodBad); err != nil { - track.Evt("Debug Feedback Prompt Failed", P("etag", debugConfig.Deployment), P("reason", err), P("loadErr", clientErr)) - } else { - track.Evt("Debug Feedback Prompt Answered", P("etag", debugConfig.Deployment), P("feedback", goodBad), P("loadErr", clientErr)) - } - return nil -} - -func DebugDeployment(ctx context.Context, client client.FabricClient, debugConfig DebugConfig) error { - term.Debugf("Invoking AI debugger for deployment %q", debugConfig.Deployment) - - files := findMatchingProjectFiles(debugConfig.Project, debugConfig.FailedServices) - - if dryrun.DoDryRun { - return dryrun.ErrDryRun - } - - var sinceTs, untilTs *timestamppb.Timestamp - if pkg.IsValidTime(debugConfig.Since) { - sinceTs = timestamppb.New(debugConfig.Since) - } - if pkg.IsValidTime(debugConfig.Until) { - until := debugConfig.Until.Add(time.Millisecond) // add a millisecond to make it inclusive - untilTs = timestamppb.New(until) - } - req := defangv1.DebugRequest{ - Etag: debugConfig.Deployment, - Files: files, - ModelId: debugConfig.ModelId, - Project: debugConfig.Project.Name, - Services: debugConfig.FailedServices, - Since: sinceTs, - Until: untilTs, - } - err := debugConfig.Provider.QueryForDebug(ctx, &req) - if err != nil { - return err - } - - resp, err := client.Debug(ctx, &req) - if err != nil { - return err - } - - printDebugReport(resp) - return nil -} - -func debugComposeFileLoadError(ctx context.Context, client client.FabricClient, project *compose.Project, loadErr error) error { - term.Debugf("Invoking AI debugger for load error: %v", loadErr) - - files := findMatchingProjectFiles(project, nil) - - if dryrun.DoDryRun { - return dryrun.ErrDryRun - } - - req := defangv1.DebugRequest{ - Files: files, - Project: project.Name, - Logs: loadErr.Error(), - } - - resp, err := client.Debug(ctx, &req) - if err != nil { - return err - } - - printDebugReport(resp) - return nil -} - -func printDebugReport(resp *defangv1.DebugResponse) { - term.Debugf("Got debug response %s", resp.Uuid) - term.Println() - term.Println("=================") - term.Println("Debugging Summary") - term.Println("=================") - term.Println(resp.General) - term.Println() - term.Println() - - for counter, service := range resp.Issues { - term.Println("-------------------") - term.Println(fmt.Sprintf("Issue #%d", counter+1)) - term.Println("-------------------") - term.Println(service.Details) - term.Println() - term.Println() - - if (len(service.CodeChanges)) > 0 { - for _, changes := range service.CodeChanges { - term.Println(fmt.Sprintf("Suggested %s:", changes.File)) - term.Println("-------------------") - term.Println(changes.Change) - term.Println() - term.Println() - } - } - } -} - -func readFile(basepath, path string) *defangv1.File { - content, err := os.ReadFile(path) - if err != nil { - term.Debug("failed to read file:", err) - return nil - } - if path, err = filepath.Rel(basepath, path); err != nil { - path = filepath.Base(path) - } - return &defangv1.File{ - Name: path, - Content: string(content), - } -} - -func getServices(project *compose.Project, names []string) compose.Services { - // project.GetServices(…) aborts if any service is not found, so we filter them out ourselves - if len(names) == 0 { - return project.Services - } - services := compose.Services{} - for _, s := range names { - if svc, err := project.GetService(s); err != nil { - term.Debug("skipped for debugging:", err) - } else { - services[s] = svc - } - } - return services -} - -func findMatchingProjectFiles(project *compose.Project, services []string) []*defangv1.File { - var files []*defangv1.File - - for _, path := range project.ComposeFiles { - if file := readFile(project.WorkingDir, path); file != nil { - files = append(files, file) - } - } - - for _, service := range getServices(project, services) { - if service.Build != nil { - files = append(files, findMatchingFiles(project.WorkingDir, service.Build.Context, service.Build.Dockerfile)...) - } - // TODO: also consider other files, like .dockerignore, .env, etc. - } - - return files -} - -func IsProjectFile(basename string) bool { - return filepathMatchAny(patterns, basename) -} - -func filepathMatchAny(patterns []string, name string) bool { - for _, pattern := range patterns { - matched, err := filepath.Match(pattern, name) - if err != nil { - term.Debug("error matching pattern:", err) - continue - } - if matched { - return true // file matched, no need to check other patterns - } - } - return false -} - -func findMatchingFiles(basepath, context, dockerfile string) []*defangv1.File { - var files []*defangv1.File - - if file := readFile(basepath, filepath.Join(context, dockerfile)); file != nil { - files = append(files, file) - } - - err := compose.WalkContextFolder(context, dockerfile, func(path string, info os.DirEntry, slashPath string) error { - if info.IsDir() { - return nil // continue to next file/directory - } - - if len(files) >= maxFiles { - term.Debug("file limit reached, stopping search") - return errFileLimitReached - } - - if IsProjectFile(info.Name()) { - if file := readFile(basepath, path); file != nil { - files = append(files, file) - } - } - return nil - }) - - if err != nil && err != errFileLimitReached { - term.Debug("error walking the path:", err) - } - - return files -} diff --git a/src/pkg/cli/debug_test.go b/src/pkg/cli/debug_test.go deleted file mode 100644 index df8255ba8..000000000 --- a/src/pkg/cli/debug_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package cli - -import ( - "context" - "errors" - "testing" - - "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/cli/compose" - defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" - "github.com/compose-spec/compose-go/v2/types" -) - -func TestFindMathingProjectFiles(t *testing.T) { - project, err := compose.NewLoader(compose.WithPath("../../testdata/debugproj/compose.yaml")).LoadProject(t.Context()) - if err != nil { - t.Fatal(err) - } - - // Test that the correct files are found for debugging: compose.yaml + only files from the failing service - files := findMatchingProjectFiles(project, []string{"failing", "failing-image"}) - expected := []*defangv1.File{ - {Name: "compose.yaml", Content: "services:\n failing:\n build: ./app\n ok:\n build: .\n"}, - {Name: "app/Dockerfile", Content: "FROM scratch"}, - {Name: "app/main.js", Content: "// This file should be sent to the debugger"}, - } - if len(files) != len(expected) { - t.Fatalf("expected %d files, got %d", len(expected), len(files)) - } - for i, file := range files { - if file.Name != expected[i].Name { - t.Errorf("expected file name %q, got: %q", expected[i].Name, file.Name) - } - if file.Content != expected[i].Content { - t.Errorf("expected file content %q, got: %q", expected[i].Content, file.Content) - } - } -} - -type MustHaveProjectNameQueryProvider struct { - client.Provider -} - -func (m MustHaveProjectNameQueryProvider) QueryForDebug(ctx context.Context, req *defangv1.DebugRequest) error { - if req.Project == "" { - return errors.New("project name is missing") - } - return nil -} - -type MockDebugFabricClient struct { - client.FabricClient -} - -func (m MockDebugFabricClient) Debug(ctx context.Context, req *defangv1.DebugRequest) (*defangv1.DebugResponse, error) { - return &defangv1.DebugResponse{}, nil -} - -func TestQueryHasProject(t *testing.T) { - project, err := compose.NewLoader(compose.WithPath("../../testdata/debugproj/compose.yaml")).LoadProject(t.Context()) - if err != nil { - t.Fatal(err) - } - - var mockClient = MockDebugFabricClient{} - var debugConfig = DebugConfig{ - Deployment: "etag", - FailedServices: []string{"service"}, - Project: project, - Provider: MustHaveProjectNameQueryProvider{}, - } - if err := DebugDeployment(t.Context(), mockClient, debugConfig); err != nil { - t.Errorf("expected no error, got %v", err) - } - - debugConfig.Project.Name = "" - - if err := DebugDeployment(t.Context(), mockClient, debugConfig); err == nil { - t.Error("expected error, got nil") - } else { - if err.Error() != "project name is missing" { - t.Errorf("expected error %q, got %q", "project name is missing", err.Error()) - } - } -} - -func TestDebugProject(t *testing.T) { - project := &compose.Project{ - Name: "project", - WorkingDir: "workingdir", - Environment: types.Mapping{}, - ComposeFiles: []string{"composefile"}, - } - - loadErr := errors.New("load error") - fabricClient := MockDebugFabricClient{} - - t.Run("with load error", func(t *testing.T) { - if err := debugComposeFileLoadError(t.Context(), fabricClient, project, loadErr); err != nil { - t.Errorf("expected no error, got %v", err) - } - }) -} diff --git a/src/pkg/cli/getServices.go b/src/pkg/cli/getServices.go index 6f3af8c8e..2bf2a7288 100644 --- a/src/pkg/cli/getServices.go +++ b/src/pkg/cli/getServices.go @@ -89,7 +89,7 @@ func UpdateServiceStates(ctx context.Context, serviceInfos []*defangv1.ServiceIn // Use the regular net/http package to make the request without retries req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - term.Errorf("Failed to create healthcheck request for %q at %s: %s", serviceInfo.Service.Name, url, err.Error()) + term.Errorf("failed to create healthcheck request for %q at %s: %s", serviceInfo.Service.Name, url, err.Error()) return } term.Debugf("[%s] checking health at %s", serviceInfo.Service.Name, url) diff --git a/src/pkg/debug/debug.go b/src/pkg/debug/debug.go new file mode 100644 index 000000000..a234032b8 --- /dev/null +++ b/src/pkg/debug/debug.go @@ -0,0 +1,199 @@ +package debug + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/AlecAivazis/survey/v2" + "github.com/DefangLabs/defang/src/pkg" + "github.com/DefangLabs/defang/src/pkg/agent" + "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/cli/compose" + "github.com/DefangLabs/defang/src/pkg/term" + "github.com/DefangLabs/defang/src/pkg/track" + "github.com/DefangLabs/defang/src/pkg/types" +) + +var P = track.P + +type DebugConfig struct { + ProviderID *client.ProviderID + Stack *string + Deployment types.ETag + FailedServices []string + Project *compose.Project + Since time.Time + Until time.Time +} + +func (dc DebugConfig) String() string { + cmd := "debug" + if dc.Deployment != "" { + cmd += " --deployment=" + dc.Deployment + } + if !dc.Since.IsZero() { + cmd += " --since=" + dc.Since.UTC().Format(time.RFC3339Nano) + } + if !dc.Until.IsZero() { + cmd += " --until=" + dc.Until.UTC().Format(time.RFC3339Nano) + } + if dc.Project != nil { + cmd += " --project-name=" + dc.Project.Name + if dc.Project.WorkingDir != "" { + cmd += " --cwd=" + dc.Project.WorkingDir + } + } + if len(dc.FailedServices) > 0 { + cmd += " " + strings.Join(dc.FailedServices, " ") + } + // TODO: do we need to add --provider= or rely on the Fabric-supplied value? + return cmd +} + +type Surveyor interface { + AskOne(q survey.Prompt, response interface{}, opts ...survey.AskOpt) error +} + +type surveyor struct{} + +func (s *surveyor) AskOne(q survey.Prompt, response interface{}, opts ...survey.AskOpt) error { + return survey.AskOne(q, response, opts...) +} + +type DebugAgent interface { + StartWithMessage(ctx context.Context, prompt string) error +} + +type Debugger struct { + agent DebugAgent + surveyor Surveyor +} + +func NewDebugger(ctx context.Context, addr string, providerID *client.ProviderID, stack *string) (*Debugger, error) { + agent, err := agent.New(ctx, addr, providerID, stack) + if err != nil { + return nil, err + } + return &Debugger{ + agent: agent, + surveyor: &surveyor{}, + }, nil +} + +func (d *Debugger) DebugDeployment(ctx context.Context, debugConfig DebugConfig) error { + if debugConfig.Deployment == "" { + return errors.New("no information to use for debugger") + } + return d.promptAndTrackDebugSession(func() error { + return d.agent.StartWithMessage(ctx, buildDeploymentDebugPrompt(debugConfig)) + }, "Debug Deployment", P("etag", debugConfig.Deployment)) +} + +func (d *Debugger) DebugDeploymentError(ctx context.Context, debugConfig DebugConfig, deployErr error) error { + return d.promptAndTrackDebugSession(func() error { + prompt := buildDeploymentDebugPrompt(debugConfig) + " The error encountered was: " + deployErr.Error() + return d.agent.StartWithMessage(ctx, prompt) + }, "Debug Deployment Error", P("etag", debugConfig.Deployment), P("deployErr", deployErr)) +} + +func (d *Debugger) DebugComposeLoadError(ctx context.Context, debugConfig DebugConfig, loadErr error) error { + return d.promptAndTrackDebugSession(func() error { + prompt := "The following error occurred while loading the compose file. Help troubleshoot and recommend a solution.\n\n" + loadErr.Error() + return d.agent.StartWithMessage(ctx, prompt) + }, "Debug Load", P("etag", debugConfig.Deployment), P("composeErr", loadErr)) +} + +func (d *Debugger) promptAndTrackDebugSession(fn func() error, eventName string, eventProperty ...track.Property) error { + track.Evt("Debug Prompted", eventProperty...) + track.Evt(eventName+" Prompted", eventProperty...) + aiDebug, err := d.promptForPermission() + if err != nil { + track.Evt(eventName+" Prompt Failed", append([]track.Property{P("reason", err)}, eventProperty...)...) + return err + } + if !aiDebug { + track.Evt(eventName+" Prompt Skipped", eventProperty...) + return nil + } + track.Evt(eventName+" Prompt Accepted", eventProperty...) + + err = fn() + if err != nil { + return err + } + + good, err := d.promptForFeedback() + if err != nil { + track.Evt(eventName+" Feedback Prompt Failed", append([]track.Property{P("reason", err)}, eventProperty...)...) + return err + } + track.Evt(eventName+" Feedback Prompt Answered", append([]track.Property{P("feedback", good)}, eventProperty...)...) + return nil +} + +func (d *Debugger) promptForPermission() (bool, error) { + var aiDebug bool + err := d.surveyor.AskOne(&survey.Confirm{ + Message: "Would you like to debug the deployment with AI?", + Help: "This will send logs and artifacts to our backend and attempt to diagnose the issue and provide a solution.", + }, &aiDebug, survey.WithStdio(term.DefaultTerm.Stdio())) + if err != nil { + return false, err + } + + return aiDebug, err +} + +func (d *Debugger) promptForFeedback() (bool, error) { + var good bool + err := d.surveyor.AskOne(&survey.Confirm{ + Message: "Was the debugging helpful?", + Help: "Please provide feedback to help us improve the debugging experience.", + }, &good, survey.WithStdio(term.DefaultTerm.Stdio())) + if err != nil { + return false, err + } + + return good, err +} + +func buildDeploymentDebugPrompt(debugConfig DebugConfig) string { + prompt := "An error occurred while deploying this project" + if debugConfig.ProviderID == nil { + prompt += " with Defang." + } else { + prompt += fmt.Sprintf(" to %s with Defang.", debugConfig.ProviderID.Name()) + } + + prompt += " Help troubleshoot and recommend a solution. Look at the logs to understand what happened." + + if debugConfig.Deployment != "" { + prompt += fmt.Sprintf(" The deployment ID is %q.", debugConfig.Deployment) + } + + if len(debugConfig.FailedServices) > 0 { + prompt += fmt.Sprintf(" The services that failed to deploy are: %v.", debugConfig.FailedServices) + } + if pkg.IsValidTime(debugConfig.Since) { + prompt += fmt.Sprintf(" The deployment started at %s.", debugConfig.Since.String()) + } + if pkg.IsValidTime(debugConfig.Until) { + prompt += fmt.Sprintf(" The deployment finished at %s.", debugConfig.Until.String()) + } + + if debugConfig.Project != nil { + yaml, err := debugConfig.Project.MarshalYAML() + if err != nil { + term.Println("Failed to marshal compose project to YAML for debug:", err) + } + prompt += fmt.Sprintf( + "The compose files are at %s. The compose file is as follows:\n\n%s", + debugConfig.Project.ComposeFiles, + yaml, + ) + } + return prompt +} diff --git a/src/pkg/debug/debug_test.go b/src/pkg/debug/debug_test.go new file mode 100644 index 000000000..3d254126c --- /dev/null +++ b/src/pkg/debug/debug_test.go @@ -0,0 +1,184 @@ +package debug + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/AlecAivazis/survey/v2" + "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/cli/compose" + "github.com/DefangLabs/defang/src/pkg/term" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type mockAgent struct { + mock.Mock +} + +func (m *mockAgent) StartWithMessage(ctx context.Context, prompt string) error { + m.Called(ctx, prompt) + return nil +} + +type mockSurveyor struct { + response bool +} + +func (s *mockSurveyor) AskOne(q survey.Prompt, response interface{}, opts ...survey.AskOpt) error { + b, ok := response.(*bool) + if !ok { + panic("response must be a *bool for this mock") + } + *b = s.response + return nil +} + +func TestDebugDeployment(t *testing.T) { + ctx := context.Background() + mockAgent := &mockAgent{} + + providerID := client.ProviderAWS + project := compose.Project{} + + tests := []struct { + name string + debugConfig DebugConfig + expectedPrompt string + permission bool + }{ + { + name: "User declines to debug", + debugConfig: DebugConfig{ + Deployment: "test-deployment", + }, + expectedPrompt: "", + permission: false, + }, + { + name: "User agrees to debug", + debugConfig: DebugConfig{ + Deployment: "test-deployment", + }, + expectedPrompt: "An error occurred while deploying this project with Defang. Help troubleshoot and recommend a solution. Look at the logs to understand what happened. The deployment ID is \"test-deployment\".", + permission: true, + }, + { + name: "With rich DebugConfig", + debugConfig: DebugConfig{ + Deployment: "test-deployment", + ProviderID: &providerID, + Since: time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC), + Until: time.Date(2025, 1, 2, 4, 5, 6, 0, time.UTC), + FailedServices: []string{"backend"}, + Project: &project, + }, + expectedPrompt: "An error occurred while deploying this project to AWS with Defang. Help troubleshoot and recommend a solution. Look at the logs to understand what happened. The deployment ID is \"test-deployment\". The services that failed to deploy are: [backend]. The deployment started at 2025-01-02 03:04:05 +0000 UTC. The deployment finished at 2025-01-02 04:05:06 +0000 UTC.The compose files are at []. The compose file is as follows:\n\nservices: {}\n", + permission: true, + }, + } + + for _, tt := range tests { + mockSurveyor := &mockSurveyor{ + response: tt.permission, + } + debugger := &Debugger{ + agent: mockAgent, + surveyor: mockSurveyor, + } + t.Run(tt.name, func(t *testing.T) { + mockAgent.ExpectedCalls = nil + mockAgent.Calls = nil + + if tt.permission { + mockAgent.On("StartWithMessage", ctx, tt.expectedPrompt).Return(nil) + } + + err := debugger.DebugDeployment(ctx, tt.debugConfig) + assert.NoError(t, err, "DebugDeployment should not return an error") + + if tt.permission { + mockAgent.AssertCalled(t, "StartWithMessage", ctx, tt.expectedPrompt) + } else { + mockAgent.AssertNotCalled(t, "StartWithMessage", mock.Anything, mock.Anything) + } + }) + } +} + +func TestDebugComposeLoadError(t *testing.T) { + ctx := context.Background() + mockAgent := &mockAgent{} + + tests := []struct { + name string + debugConfig DebugConfig + expectedPrompt string + permission bool + }{ + { + name: "User declines to debug", + debugConfig: DebugConfig{ + Deployment: "load-error-deployment", + }, + expectedPrompt: "", + permission: false, + }, + { + name: "User agrees to debug", + debugConfig: DebugConfig{ + Deployment: "load-error-deployment", + }, + expectedPrompt: "The following error occurred while loading the compose file. Help troubleshoot and recommend a solution.\n\nvalidating %s/compose.yaml: additional properties 'foo' not allowed", + permission: true, + }, + } + + for _, tt := range tests { + mockSurveyor := &mockSurveyor{ + response: tt.permission, + } + debugger := &Debugger{ + agent: mockAgent, + surveyor: mockSurveyor, + } + t.Run(tt.name, func(t *testing.T) { + mockAgent.ExpectedCalls = nil + mockAgent.Calls = nil + + t.Chdir("../../testdata/invalid-no-services") + + // Build expected prompt with current working directory + var expectedPrompt string + if tt.permission && tt.expectedPrompt != "" { + cwd, err := os.Getwd() + assert.NoError(t, err, "Getwd should not return an error") + expectedPrompt = fmt.Sprintf(tt.expectedPrompt, cwd) + mockAgent.On("StartWithMessage", ctx, expectedPrompt).Return(nil) + } + + loader := compose.NewLoader() + + _, loadErr := loader.LoadProject(ctx) + if loadErr != nil { + term.Error("Cannot load project:", loadErr) + project, err := loader.CreateProjectForDebug() + assert.NoError(t, err, "CreateProjectForDebug should not return an error") + + err = debugger.DebugComposeLoadError(ctx, DebugConfig{ + Project: project, + }, loadErr) + assert.NoError(t, err, "DebugComposeLoadError should not return an error") + } + + if tt.permission { + mockAgent.AssertCalled(t, "StartWithMessage", ctx, expectedPrompt) + } else { + mockAgent.AssertNotCalled(t, "StartWithMessage", mock.Anything, mock.Anything) + } + }) + } +} diff --git a/src/pkg/elicitations/elicitations.go b/src/pkg/elicitations/elicitations.go new file mode 100644 index 000000000..8759eb452 --- /dev/null +++ b/src/pkg/elicitations/elicitations.go @@ -0,0 +1,81 @@ +package elicitations + +import ( + "context" + "fmt" +) + +type Controller interface { + RequestString(ctx context.Context, message, field string) (string, error) + RequestStringWithDefault(ctx context.Context, message, field, defaultValue string) (string, error) + RequestEnum(ctx context.Context, message, field string, options []string) (string, error) +} + +type Client interface { + Request(ctx context.Context, req Request) (Response, error) +} + +type controller struct { + client Client +} + +type Request struct { + Message string + Schema map[string]any +} + +type Response struct { + Action string + Content map[string]any +} + +func NewController(client Client) Controller { + return &controller{ + client: client, + } +} + +func (c *controller) RequestString(ctx context.Context, message, field string) (string, error) { + return c.requestField(ctx, message, field, map[string]any{ + "type": "string", + "description": field, + }) +} + +func (c *controller) RequestStringWithDefault(ctx context.Context, message, field, defaultValue string) (string, error) { + return c.requestField(ctx, message, field, map[string]any{ + "type": "string", + "description": field, + "default": defaultValue, + }) +} + +func (c *controller) RequestEnum(ctx context.Context, message, field string, options []string) (string, error) { + return c.requestField(ctx, message, field, map[string]any{ + "type": "string", + "description": field, + "enum": options, + }) +} + +func (c *controller) requestField(ctx context.Context, message, field string, schema map[string]any) (string, error) { + response, err := c.client.Request(ctx, Request{ + Message: message, + Schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + field: schema, + }, + "required": []string{field}, + }, + }) + if err != nil { + return "", fmt.Errorf("failed to elicit %s: %w", field, err) + } + value, ok := response.Content[field].(string) + if !ok { + return "", fmt.Errorf("invalid %s value", field) + } + + return value, nil +} diff --git a/src/pkg/elicitations/survey.go b/src/pkg/elicitations/survey.go new file mode 100644 index 000000000..ebe7b07c1 --- /dev/null +++ b/src/pkg/elicitations/survey.go @@ -0,0 +1,133 @@ +package elicitations + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/AlecAivazis/survey/v2" + "github.com/DefangLabs/defang/src/pkg/term" +) + +type surveyClient struct { + stdin term.FileReader + stdout term.FileWriter + stderr io.Writer +} + +func NewSurveyClient(stdin term.FileReader, stdout term.FileWriter, stderr io.Writer) *surveyClient { + return &surveyClient{ + stdin: stdin, + stdout: stdout, + stderr: stderr, + } +} + +func (c *surveyClient) Request(_ context.Context, req Request) (Response, error) { + var response = make(map[string]any, 0) + questions, err := prepareQuestions(req) + if err != nil { + return Response{ + Action: "cancel", + }, err + } + + fmt.Fprintln(c.stdout, req.Message) + err = survey.Ask( + questions, + &response, + survey.WithStdio(c.stdin, c.stdout, c.stderr), + ) + if err != nil { + return Response{ + Action: "cancel", + }, err + } + + content := make(map[string]any, 0) + for k, v := range response { + answer, ok := v.(survey.OptionAnswer) + if ok { + content[k] = answer.Value + continue + } + + answerStr, ok := v.(string) + if ok { + content[k] = answerStr + continue + } + + return Response{ + Action: "cancel", + }, errors.New("invalid answer type") + } + + return Response{ + Action: "accept", + Content: content, + }, nil +} + +func prepareQuestions(req Request) ([]*survey.Question, error) { + questions := []*survey.Question{} + schemaPropMap, ok := req.Schema["properties"].(map[string]any) + if !ok { + return nil, errors.New("invalid schema properties") + } + for key, prop := range schemaPropMap { + question, err := questionFromSchemaProp(key, prop) + if err != nil { + return nil, err + } + questions = append(questions, question) + } + return questions, nil +} + +func questionFromSchemaProp(key string, prop any) (*survey.Question, error) { + propMap, ok := prop.(map[string]any) + if !ok { + return nil, errors.New("invalid property schema") + } + description, ok := propMap["description"].(string) + if !ok { + description = key + } + if propMap["enum"] != nil { + var options []string + switch enumValues := propMap["enum"].(type) { + case []string: + options = enumValues + case []any: + for _, v := range enumValues { + s, ok := v.(string) + if !ok { + return nil, errors.New("invalid enum value type") + } + options = append(options, s) + } + default: + return nil, errors.New("invalid enum values") + } + return &survey.Question{ + Name: key, + Prompt: &survey.Select{ + Message: description, + Options: options, + }, + }, nil + } else { + inputPrompt := &survey.Input{ + Message: description, + } + if defaultValue, ok := propMap["default"].(string); ok { + inputPrompt.Default = defaultValue + } + return &survey.Question{ + Name: key, + Prompt: inputPrompt, + }, nil + } +} diff --git a/src/pkg/elicitations/survey_test.go b/src/pkg/elicitations/survey_test.go new file mode 100644 index 000000000..d0c892bc6 --- /dev/null +++ b/src/pkg/elicitations/survey_test.go @@ -0,0 +1,103 @@ +package elicitations + +// write tests for questionFromSchemaProp +import ( + "testing" + + "github.com/AlecAivazis/survey/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQuestionFromSchemaProp(t *testing.T) { + tests := []struct { + name string + key string + propMap map[string]any + expectedQ *survey.Question + expectedError string + }{ + { + name: "string_type", + key: "username", + propMap: map[string]any{ + "type": "string", + "description": "Enter your username", + }, + expectedQ: &survey.Question{ + Name: "username", + Prompt: &survey.Input{ + Message: "Enter your username", + }, + }, + }, + { + name: "string_type_with_default", + key: "username", + propMap: map[string]any{ + "type": "string", + "description": "Enter your username", + "default": "admin", + }, + expectedQ: &survey.Question{ + Name: "username", + Prompt: &survey.Input{ + Message: "Enter your username", + Default: "admin", + }, + }, + }, + { + name: "enum_type", + key: "color", + propMap: map[string]any{ + "type": "string", + "description": "Choose a color", + "enum": []string{"red", "green", "blue"}, + }, + expectedQ: &survey.Question{ + Name: "color", + Prompt: &survey.Select{ + Message: "Choose a color", + Options: []string{"red", "green", "blue"}, + }, + }, + }, + { + name: "invalid_enum_type", + key: "color", + propMap: map[string]any{ + "type": "string", + "description": "Choose a color", + "enum": "not-an-array", + }, + expectedError: "invalid enum values", + }, + { + name: "missing_description", + key: "email", + propMap: map[string]any{ + "type": "string", + }, + expectedQ: &survey.Question{ + Name: "email", + Prompt: &survey.Input{ + Message: "email", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, err := questionFromSchemaProp(tt.key, tt.propMap) + if tt.expectedError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedQ, q) + } + }) + } +} diff --git a/src/pkg/mcp/actions/awsBYOC_key.go b/src/pkg/mcp/actions/awsBYOC_key.go deleted file mode 100644 index 3ebabafca..000000000 --- a/src/pkg/mcp/actions/awsBYOC_key.go +++ /dev/null @@ -1,29 +0,0 @@ -package actions - -// Check if the provided AWS access key ID is valid -// https://medium.com/@TalBeerySec/a-short-note-on-aws-key-id-f88cc4317489 -func IsValidAWSKey(key string) bool { - // Define accepted AWS access key prefixes - acceptedPrefixes := map[string]bool{ - "ABIA": true, - "ACCA": true, - "AGPA": true, - "AIDA": true, - "AKPA": true, - "AKIA": true, - "ANPA": true, - "ANVA": true, - "APKA": true, - "AROA": true, - "ASCA": true, - "ASIA": true, - } - - if len(key) < 16 { - return false - } - - prefix := key[:4] - _, ok := acceptedPrefixes[prefix] - return ok -} diff --git a/src/pkg/mcp/actions/awsBYOC_key_test.go b/src/pkg/mcp/actions/awsBYOC_key_test.go deleted file mode 100644 index 441b35431..000000000 --- a/src/pkg/mcp/actions/awsBYOC_key_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package actions - -import ( - "testing" -) - -func TestIsValidAWSKey_ValidKeys(t *testing.T) { - validKeys := []string{ - "AKIA12345678901234", - "AIDA12345678901234", - "ASIA12345678901234", - "APKA12345678901234", - "AROA12345678901234", - "ASCA12345678901234", - } - for _, key := range validKeys { - if !IsValidAWSKey(key) { - t.Errorf("expected key %q to be valid", key) - } - } -} - -func TestIsValidAWSKey_InvalidKeys(t *testing.T) { - invalidKeys := []string{ - "", // empty - "AKIA1234", // too short - "AKIA", // too short - "AKIA1234567890", // too short - "AKI12345678901234", // prefix too short - "ZZZZ12345678901234", // invalid prefix - } - for _, key := range invalidKeys { - if IsValidAWSKey(key) { - t.Errorf("expected key %q to be invalid", key) - } - } -} diff --git a/src/pkg/mcp/actions/setAWSBYOCProvider.go b/src/pkg/mcp/actions/setAWSBYOCProvider.go deleted file mode 100644 index 152ec0d90..000000000 --- a/src/pkg/mcp/actions/setAWSBYOCProvider.go +++ /dev/null @@ -1,71 +0,0 @@ -package actions - -import ( - "context" - "errors" - "os" - - "github.com/DefangLabs/defang/src/pkg/agent/common" - "github.com/DefangLabs/defang/src/pkg/cli" - "github.com/DefangLabs/defang/src/pkg/cli/client" -) - -func SetAWSByocProvider(ctx context.Context, providerId *client.ProviderID, cluster string, accessKeyId string, secretKey string, region string) error { - // Can never be nil or empty due to RequiredArgument - if IsValidAWSKey(accessKeyId) { - err := os.Setenv("AWS_ACCESS_KEY_ID", accessKeyId) - if err != nil { - return err - } - - if secretKey == "" { - return errors.New("AWS_SECRET_ACCESS_KEY is required") - } - - err = os.Setenv("AWS_SECRET_ACCESS_KEY", secretKey) - if err != nil { - return err - } - - if region == "" { - return errors.New("AWS_REGION is required") - } - - err = os.Setenv("AWS_REGION", region) - if err != nil { - return err - } - } else { - err := os.Setenv("AWS_PROFILE", accessKeyId) - if err != nil { - return err - } - - if region != "" { - err = os.Setenv("AWS_REGION", region) - if err != nil { - return err - } - } - } - - fabric, err := cli.Connect(ctx, cluster) - if err != nil { - return err - } - - _, err = common.CheckProviderConfigured(ctx, fabric, client.ProviderAWS, "", "", 0) - if err != nil { - return err - } - - *providerId = client.ProviderAWS - - //FIXME: Should not be setting both the global and env var - err = os.Setenv("DEFANG_PROVIDER", "aws") - if err != nil { - return err - } - - return nil -} diff --git a/src/pkg/mcp/actions/setGCPBYOCProvider.go b/src/pkg/mcp/actions/setGCPBYOCProvider.go deleted file mode 100644 index 8fdc9370a..000000000 --- a/src/pkg/mcp/actions/setGCPBYOCProvider.go +++ /dev/null @@ -1,37 +0,0 @@ -package actions - -import ( - "context" - "os" - - "github.com/DefangLabs/defang/src/pkg/agent/common" - "github.com/DefangLabs/defang/src/pkg/cli" - "github.com/DefangLabs/defang/src/pkg/cli/client" -) - -func SetGCPByocProvider(ctx context.Context, providerId *client.ProviderID, cluster string, projectID string) error { - err := os.Setenv("GCP_PROJECT_ID", projectID) - if err != nil { - return err - } - - fabric, err := cli.Connect(ctx, cluster) - if err != nil { - return err - } - - _, err = common.CheckProviderConfigured(ctx, fabric, client.ProviderGCP, "", "", 0) - if err != nil { - return err - } - - *providerId = client.ProviderGCP - - //FIXME: Should not be setting both the global var and env var - err = os.Setenv("DEFANG_PROVIDER", "gcp") - if err != nil { - return err - } - - return nil -} diff --git a/src/pkg/mcp/actions/setPlaygroundProvider.go b/src/pkg/mcp/actions/setPlaygroundProvider.go deleted file mode 100644 index 4d578f5b6..000000000 --- a/src/pkg/mcp/actions/setPlaygroundProvider.go +++ /dev/null @@ -1,13 +0,0 @@ -package actions - -import ( - "os" - - "github.com/DefangLabs/defang/src/pkg/cli/client" -) - -// thin wrapper to set playground provider to match other provider set ups -func SetPlaygroundProvider(providerId *client.ProviderID) error { - *providerId = client.ProviderDefang - return os.Setenv("DEFANG_PROVIDER", "defang") -} diff --git a/src/pkg/mcp/elicitations.go b/src/pkg/mcp/elicitations.go new file mode 100644 index 000000000..567dc568e --- /dev/null +++ b/src/pkg/mcp/elicitations.go @@ -0,0 +1,43 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/DefangLabs/defang/src/pkg/elicitations" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +type mcpElicitationsController struct { + server *server.MCPServer +} + +func NewMCPElicitationsController(server *server.MCPServer) *mcpElicitationsController { + return &mcpElicitationsController{ + server: server, + } +} + +func (c *mcpElicitationsController) Request(ctx context.Context, req elicitations.Request) (elicitations.Response, error) { + result, err := c.server.RequestElicitation(ctx, mcp.ElicitationRequest{ + Params: mcp.ElicitationParams{ + Message: req.Message, + RequestedSchema: req.Schema, + }, + }) + if err != nil { + return elicitations.Response{}, err + } + + // cast result.Content to map[string]string + contentMap, ok := result.Content.(map[string]any) + if !ok { + return elicitations.Response{}, fmt.Errorf("invalid eliciation response content type, got %T", result.Content) + } + + return elicitations.Response{ + Action: string(result.Action), + Content: contentMap, + }, nil +} diff --git a/src/pkg/mcp/mcp_server.go b/src/pkg/mcp/mcp_server.go index 9b87ddd93..5208de5eb 100644 --- a/src/pkg/mcp/mcp_server.go +++ b/src/pkg/mcp/mcp_server.go @@ -5,23 +5,21 @@ import ( "fmt" "github.com/DefangLabs/defang/src/pkg/agent/common" - "github.com/DefangLabs/defang/src/pkg/agent/tools" - "github.com/DefangLabs/defang/src/pkg/mcp/prompts" + agentTools "github.com/DefangLabs/defang/src/pkg/agent/tools" + "github.com/DefangLabs/defang/src/pkg/elicitations" "github.com/DefangLabs/defang/src/pkg/mcp/resources" + "github.com/DefangLabs/defang/src/pkg/mcp/tools" "github.com/DefangLabs/defang/src/pkg/term" "github.com/DefangLabs/defang/src/pkg/track" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" - // NewDefangMCPServer returns a new MCPServer instance with all resources, tools, and prompts registered. + // NewDefangMCPServer returns a new MCPServer instance with all resources, tools registered. cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" ) -func prepareInstructions(defangTools []server.ServerTool) string { +func prepareInstructions() string { instructions := "Defang provides tools for deploying web applications to cloud providers (AWS, GCP, Digital Ocean) using a compose.yaml file." - for _, tool := range defangTools { - instructions += "\n\n" + tool.Tool.Name + " - " + tool.Tool.Description - } return instructions } @@ -47,33 +45,35 @@ func (t *ToolTracker) TrackTool(name string, handler server.ToolHandlerFunc) ser } } -func NewDefangMCPServer(version string, cluster string, providerID *cliClient.ProviderID, client MCPClient, cli tools.CLIInterface) (*server.MCPServer, error) { +type StackConfig = tools.StackConfig + +func NewDefangMCPServer(version string, client MCPClient, cli agentTools.CLIInterface, config StackConfig) (*server.MCPServer, error) { // Setup knowledge base if err := SetupKnowledgeBase(); err != nil { return nil, fmt.Errorf("failed to setup knowledge base: %w", err) } - defangTools := tools.CollectTools(cluster, providerID, cli) s := server.NewMCPServer( "Deploy with Defang", version, server.WithResourceCapabilities(true, true), - server.WithPromptCapabilities(true), server.WithToolCapabilities(true), - server.WithInstructions(prepareInstructions(defangTools)), + server.WithInstructions(prepareInstructions()), ) resources.SetupResources(s) - prompts.SetupPrompts(s, cluster, providerID) // This is used to pass down information of what MCP client we are using common.MCPDevelopmentClient = string(client) toolTracker := ToolTracker{ - providerId: providerID, - cluster: cluster, + providerId: config.ProviderID, + cluster: config.Cluster, client: common.MCPDevelopmentClient, } + elicitationsClient := NewMCPElicitationsController(s) + ec := elicitations.NewController(elicitationsClient) + defangTools := tools.CollectTools(ec, config) for i := range defangTools { defangTools[i].Handler = toolTracker.TrackTool(defangTools[i].Tool.Name, defangTools[i].Handler) } diff --git a/src/pkg/mcp/prompts/awsBYOC.go b/src/pkg/mcp/prompts/awsBYOC.go deleted file mode 100644 index a9d3be260..000000000 --- a/src/pkg/mcp/prompts/awsBYOC.go +++ /dev/null @@ -1,54 +0,0 @@ -package prompts - -import ( - "context" - - "github.com/DefangLabs/defang/src/pkg/agent/common" - "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/mcp/actions" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -func setupAwsByocPrompt(s *server.MCPServer, cluster string, providerId *client.ProviderID) { - awsBYOCPrompt := mcp.NewPrompt("AWS Setup", - mcp.WithPromptDescription("Setup for AWS"), - - mcp.WithArgument("AWS Credential", - mcp.ArgumentDescription("Your AWS Access Key ID or AWS Profile Name"), - mcp.RequiredArgument(), - ), - - mcp.WithArgument("AWS_SECRET_ACCESS_KEY", - mcp.ArgumentDescription("Your AWS Secret Access Key"), - ), - - mcp.WithArgument("AWS_REGION", - mcp.ArgumentDescription("Your AWS Region"), - ), - ) - - s.AddPrompt(awsBYOCPrompt, awsByocPromptHandler(cluster, providerId)) -} - -// awsByocPromptHandler is extracted for testability -func awsByocPromptHandler(cluster string, providerId *client.ProviderID) func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - return func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - awsID := req.Params.Arguments["AWS Credential"] - region := common.GetStringArg(req.Params.Arguments, "AWS_REGION", "") - awsSecret := common.GetStringArg(req.Params.Arguments, "AWS_SECRET_ACCESS_KEY", "") - if err := actions.SetAWSByocProvider(ctx, providerId, cluster, awsID, awsSecret, region); err != nil { - return nil, err - } - - return &mcp.GetPromptResult{ - Description: "AWS BYOC Setup Complete", - Messages: []mcp.PromptMessage{ - { - Role: mcp.RoleUser, - Content: mcp.NewTextContent(common.PostPrompt), - }, - }, - }, nil - } -} diff --git a/src/pkg/mcp/prompts/gcpBYOC.go b/src/pkg/mcp/prompts/gcpBYOC.go deleted file mode 100644 index 86090837e..000000000 --- a/src/pkg/mcp/prompts/gcpBYOC.go +++ /dev/null @@ -1,44 +0,0 @@ -package prompts - -import ( - "context" - - "github.com/DefangLabs/defang/src/pkg/agent/common" - "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/mcp/actions" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -func setupGcpByocPrompt(s *server.MCPServer, cluster string, providerId *client.ProviderID) { - gcpBYOCPrompt := mcp.NewPrompt("GCP Setup", - mcp.WithPromptDescription("Setup for GCP"), - - mcp.WithArgument("GCP_PROJECT_ID", - mcp.ArgumentDescription("Your GCP Project ID"), - mcp.RequiredArgument(), - ), - ) - - s.AddPrompt(gcpBYOCPrompt, gcpByocPromptHandler(cluster, providerId)) -} - -// gcpByocPromptHandler is extracted for testability -func gcpByocPromptHandler(cluster string, providerId *client.ProviderID) func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - return func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - projectID := req.Params.Arguments["GCP_PROJECT_ID"] - if err := actions.SetGCPByocProvider(ctx, providerId, cluster, projectID); err != nil { - return nil, err - } - - return &mcp.GetPromptResult{ - Description: "GCP BYOC Setup Complete", - Messages: []mcp.PromptMessage{ - { - Role: mcp.RoleUser, - Content: mcp.NewTextContent(common.PostPrompt), - }, - }, - }, nil - } -} diff --git a/src/pkg/mcp/prompts/playgroundSetup.go b/src/pkg/mcp/prompts/playgroundSetup.go deleted file mode 100644 index 894830a2b..000000000 --- a/src/pkg/mcp/prompts/playgroundSetup.go +++ /dev/null @@ -1,37 +0,0 @@ -package prompts - -import ( - "context" - - "github.com/DefangLabs/defang/src/pkg/agent/common" - "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/mcp/actions" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -func playgroundPromptHandler(providerId *client.ProviderID) func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - return func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - if err := actions.SetPlaygroundProvider(providerId); err != nil { - return nil, err - } - - return &mcp.GetPromptResult{ - Description: "Defang playground Setup Complete", - Messages: []mcp.PromptMessage{ - { - Role: mcp.RoleUser, - Content: mcp.NewTextContent(common.PostPrompt), - }, - }, - }, nil - } -} - -func setupPlaygroundPrompt(s *server.MCPServer, providerId *client.ProviderID) { - playgroundPrompt := mcp.NewPrompt("Playground Setup", - mcp.WithPromptDescription("Setup for Playground"), - ) - - s.AddPrompt(playgroundPrompt, playgroundPromptHandler(providerId)) -} diff --git a/src/pkg/mcp/prompts/prompts.go b/src/pkg/mcp/prompts/prompts.go deleted file mode 100644 index b90695997..000000000 --- a/src/pkg/mcp/prompts/prompts.go +++ /dev/null @@ -1,18 +0,0 @@ -package prompts - -import ( - "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/mark3labs/mcp-go/server" -) - -// SetupPrompts configures and adds all prompts to the MCP server -func SetupPrompts(s *server.MCPServer, cluster string, providerId *client.ProviderID) { - //AWS BYOC - setupAwsByocPrompt(s, cluster, providerId) - - //GCP BYOC - setupGcpByocPrompt(s, cluster, providerId) - - //Playground - setupPlaygroundPrompt(s, providerId) -} diff --git a/src/pkg/mcp/tests/client_test.go b/src/pkg/mcp/tests/client_test.go deleted file mode 100644 index b8bfa3b64..000000000 --- a/src/pkg/mcp/tests/client_test.go +++ /dev/null @@ -1,577 +0,0 @@ -package mcp_test - -import ( - "context" - "fmt" - "net/http/httptest" - "os" - "strings" - "testing" - "time" - - "github.com/bufbuild/connect-go" - m3client "github.com/mark3labs/mcp-go/client" - m3mcp "github.com/mark3labs/mcp-go/mcp" - "google.golang.org/protobuf/types/known/emptypb" - - "github.com/DefangLabs/defang/src/pkg/agent/tools" - cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/mcp" - typepb "github.com/DefangLabs/defang/src/protos/google/type" - defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" - "github.com/DefangLabs/defang/src/protos/io/defang/v1/defangv1connect" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// mockFabricService implements the FabricControllerHandler for testing -type mockFabricService struct { - defangv1connect.UnimplementedFabricControllerHandler - configValues map[string]string - - // Call tracking for each RPC method - deleteSecretsCalled bool - putSecretCalled bool - listSecretsCalled bool - getServicesCalled bool - estimateCalled bool - previewCalled bool - deployCalled bool - destroyCalled bool - - canIUseCalled bool - whoAmICalled bool - tailCalled bool - putDeploymentCalled bool - getDelegateSubdomainZoneCalled bool - getPlaygroundProjectDomainCalled bool -} - -func (m *mockFabricService) resetFlags() { - m.deleteSecretsCalled = false - m.putSecretCalled = false - m.listSecretsCalled = false - m.getServicesCalled = false - m.estimateCalled = false - m.previewCalled = false - m.deployCalled = false - m.destroyCalled = false - m.canIUseCalled = false - m.whoAmICalled = false - m.tailCalled = false - m.putDeploymentCalled = false - m.getDelegateSubdomainZoneCalled = false - m.getPlaygroundProjectDomainCalled = false -} - -func (m *mockFabricService) CanIUse(ctx context.Context, req *connect.Request[defangv1.CanIUseRequest]) (*connect.Response[defangv1.CanIUseResponse], error) { - m.canIUseCalled = true - return connect.NewResponse(&defangv1.CanIUseResponse{CdImage: "beta", Gpu: true}), nil -} - -func (m *mockFabricService) WhoAmI(context.Context, *connect.Request[emptypb.Empty]) (*connect.Response[defangv1.WhoAmIResponse], error) { - m.whoAmICalled = true - return connect.NewResponse(&defangv1.WhoAmIResponse{ - Tenant: "default", - Tier: defangv1.SubscriptionTier_HOBBY, - }), nil -} - -func (m *mockFabricService) DeleteSecrets(ctx context.Context, req *connect.Request[defangv1.Secrets]) (*connect.Response[emptypb.Empty], error) { - m.deleteSecretsCalled = true - for _, name := range req.Msg.Names { - delete(m.configValues, name) - } - return connect.NewResponse(&emptypb.Empty{}), nil -} - -func (m *mockFabricService) PutSecret(ctx context.Context, req *connect.Request[defangv1.PutConfigRequest]) (*connect.Response[emptypb.Empty], error) { - m.putSecretCalled = true - m.configValues[req.Msg.Name] = req.Msg.Value - return connect.NewResponse(&emptypb.Empty{}), nil -} - -func (m *mockFabricService) ListSecrets(context.Context, *connect.Request[defangv1.ListConfigsRequest]) (*connect.Response[defangv1.Secrets], error) { - m.listSecretsCalled = true - var resp = defangv1.Secrets{} - for name := range m.configValues { - resp.Names = append(resp.Names, name) - } - return connect.NewResponse(&resp), nil -} - -func (m *mockFabricService) GetDelegateSubdomainZone(ctx context.Context, req *connect.Request[defangv1.GetDelegateSubdomainZoneRequest]) (*connect.Response[defangv1.DelegateSubdomainZoneResponse], error) { - m.getDelegateSubdomainZoneCalled = true - return connect.NewResponse(&defangv1.DelegateSubdomainZoneResponse{ - Zone: "mock-zone.example.com", - }), nil -} - -func (m *mockFabricService) GetServices(ctx context.Context, req *connect.Request[defangv1.GetServicesRequest]) (*connect.Response[defangv1.GetServicesResponse], error) { - m.getServicesCalled = true - return connect.NewResponse(&defangv1.GetServicesResponse{ - Services: []*defangv1.ServiceInfo{ - { - Status: "running", - PublicFqdn: "http://mock-service.example.com", - Service: &defangv1.Service{ - Name: "hello", - }, - }, - }, - }), nil -} - -func (m *mockFabricService) Estimate(ctx context.Context, req *connect.Request[defangv1.EstimateRequest]) (*connect.Response[defangv1.EstimateResponse], error) { - m.estimateCalled = true - return connect.NewResponse(&defangv1.EstimateResponse{ - Provider: defangv1.Provider_AWS, - Region: "us-west-2", - Subtotal: &typepb.Money{ - CurrencyCode: "USD", - Units: 42, - Nanos: 420000000, - }, - LineItems: []*defangv1.EstimateLineItem{ - { - Description: "hello service", - Unit: "month", - Quantity: 1, - Cost: &typepb.Money{ - CurrencyCode: "USD", - Units: 21, - Nanos: 210000000, - }, - Service: []string{"hello"}, - }, - { - Description: "world service", - Unit: "month", - Quantity: 1, - Cost: &typepb.Money{ - CurrencyCode: "USD", - Units: 21, - Nanos: 210000000, - }, - Service: []string{"world"}, - }, - }, - }), nil -} - -func (m *mockFabricService) Tail(ctx context.Context, req *connect.Request[defangv1.TailRequest], stream *connect.ServerStream[defangv1.TailResponse]) error { - m.tailCalled = true - // Send a single empty TailResponse for testing - return stream.Send(&defangv1.TailResponse{}) -} - -func (m *mockFabricService) Preview(ctx context.Context, req *connect.Request[defangv1.PreviewRequest]) (*connect.Response[defangv1.PreviewResponse], error) { - m.previewCalled = true - return connect.NewResponse(&defangv1.PreviewResponse{}), nil -} - -func (m *mockFabricService) Deploy(ctx context.Context, req *connect.Request[defangv1.DeployRequest]) (*connect.Response[defangv1.DeployResponse], error) { - m.deployCalled = true - return connect.NewResponse(&defangv1.DeployResponse{ - Services: []*defangv1.ServiceInfo{ - { - Status: "mock-deployed", - PublicFqdn: "http://mock-service.example.com", - Service: &defangv1.Service{ - Name: "hello", - }, - }, - }, - Etag: "mock-etag", - }), nil -} - -func (m *mockFabricService) PutDeployment(ctx context.Context, req *connect.Request[defangv1.PutDeploymentRequest]) (*connect.Response[emptypb.Empty], error) { - m.putDeploymentCalled = true - return connect.NewResponse(&emptypb.Empty{}), nil -} - -func (m *mockFabricService) Destroy(ctx context.Context, req *connect.Request[defangv1.DestroyRequest]) (*connect.Response[defangv1.DestroyResponse], error) { - m.destroyCalled = true - return connect.NewResponse(&defangv1.DestroyResponse{ - Etag: "mock-destroy-etag", - }), nil -} - -func (m *mockFabricService) GetPlaygroundProjectDomain(ctx context.Context, req *connect.Request[emptypb.Empty]) (*connect.Response[defangv1.GetPlaygroundProjectDomainResponse], error) { - m.getPlaygroundProjectDomainCalled = true - return connect.NewResponse(&defangv1.GetPlaygroundProjectDomainResponse{ - Domain: "mock-playground.example.com", - }), nil -} - -// End mockFabricService methods - -// Test helpers -func setupTest(t *testing.T, tmpDir string) *m3client.Client { - t.Helper() - // Create a minimal mock knowledge_base resource in the test's working directory - resourceDir := tmpDir + "/knowledge_base" - _ = os.WriteFile(resourceDir+"/README.md", []byte("# Mock Knowledge Base\nThis is a test stub."), 0644) - mockFabric = &mockFabricService{ - configValues: make(map[string]string), - } - fabricServer := startMockFabricServer(mockFabric) - t.Cleanup(fabricServer.Close) - mcpClient, err := startInProcessMCPServer(t.Context(), fabricServer) - if err != nil { - t.Fatalf("failed to start in-process MCP server: %v", err) - } - t.Cleanup(mcpClient.cancel) - return mcpClient.client -} - -func assertCalled(t *testing.T, called bool, name string) { - t.Helper() - require.True(t, called, "%s was not called on the mock fabric service", name) -} - -// createTestProjectDir returns a temp directory with a simple compose.yaml and a cleanup function. -func createTestProjectDir(t *testing.T) string { - t.Helper() - tempDir := t.TempDir() - composeContent := `services: - hello: - image: busybox - command: ["echo", "hello world"] -` - composePath := tempDir + "/compose.yaml" - if err := os.WriteFile(composePath, []byte(composeContent), 0644); err != nil { - t.Fatalf("failed to write compose.yaml: %v", err) - } - return tempDir -} - -type testClient struct { - fabricServer *httptest.Server - client *m3client.Client - serverInfo *m3mcp.InitializeResult - cancel context.CancelFunc -} - -// test suite variables -var ( - projectDir string - mcpClient *m3client.Client -) - -var expectedToolsList = []string{ - "login", - "services", - "deploy", - "destroy", - "logs", - "estimate", - "set_config", - "remove_config", - "list_configs", - "set_aws_provider", - "set_gcp_provider", - "set_playground_provider", -} - -func startMockFabricServer(mockService *mockFabricService) *httptest.Server { - _, handler := defangv1connect.NewFabricControllerHandler(mockService) - return httptest.NewServer(handler) -} - -type cliWithoutBrowser struct { - tools.DefaultToolCLI -} - -func (cliWithoutBrowser) OpenBrowser(url string) error { - // no-op to avoid opening a browser during tests - return nil -} - -// startInProcessMCPServer sets up an in-process MCP server and returns a connected client and a cleanup function. -func startInProcessMCPServer(ctx context.Context, fabric *httptest.Server) (*testClient, error) { - providerId := cliClient.ProviderDefang - cluster := strings.TrimPrefix(fabric.URL, "http://") - srv, err := mcp.NewDefangMCPServer("0.0.1-test", cluster, &providerId, "", cliWithoutBrowser{}) - if err != nil { - return nil, fmt.Errorf("failed to create MCP server: %v", err) - } - - client, err := m3client.NewInProcessClient(srv) - if err != nil { - return nil, fmt.Errorf("failed to create MCP client: %v", err) - } - - ctxWithTimeout, cancel := context.WithTimeout(ctx, 5*time.Second) - - if err := client.Start(ctxWithTimeout); err != nil { - cancel() - return nil, fmt.Errorf("failed to start MCP client: %v", err) - } - - // Initialize the client - initRequest := m3mcp.InitializeRequest{} - initRequest.Params.ProtocolVersion = m3mcp.LATEST_PROTOCOL_VERSION - initRequest.Params.ClientInfo = m3mcp.Implementation{ - Name: "Defang MCP Client", - Version: "0.0.1-test", - } - initRequest.Params.Capabilities = m3mcp.ClientCapabilities{} - - serverInfo, err := client.Initialize(ctxWithTimeout, initRequest) - if err != nil { - cancel() - return nil, fmt.Errorf("failed to initialize MCP client: %v", err) - } - - return &testClient{ - client: client, - fabricServer: fabric, - serverInfo: serverInfo, - cancel: cancel, - }, nil -} - -var mockFabric *mockFabricService - -func TestInProcessMCPServer(t *testing.T) { - TestInProcessMCPServer_Setup := func(t *testing.T) { - listResourcesReq := m3mcp.ListResourcesRequest{} - resList, _ := mcpClient.ListResources(t.Context(), listResourcesReq) - if len(resList.Resources) != 2 { - t.Fatalf("expected two resources initially, got %d", len(resList.Resources)) - } - if resList.Resources[0].Name != "defang_dockerfile_and_compose_examples" || resList.Resources[1].Name != "knowledge_base" { - t.Fatalf("unexpected resource names: %+v", resList.Resources) - } - - listToolsReq := m3mcp.ListToolsRequest{} - toolListResp, _ := mcpClient.ListTools(t.Context(), listToolsReq) - if len(toolListResp.Tools) != len(expectedToolsList) { - t.Fatalf("expected number of tools: got %d, want %d", len(toolListResp.Tools), len(expectedToolsList)) - } - missingTools := []string{} - for _, expected := range expectedToolsList { - found := false - for _, tool := range toolListResp.Tools { - if tool.Name == expected { - found = true - break - } - } - if !found { - missingTools = append(missingTools, expected) - } - } - if len(missingTools) > 0 { - t.Fatalf("missing expected tools: %v", missingTools) - } - } - - // Test functions - // TestInProcessMCPServer_Services := func(t *testing.T) { - // result, err := mcpClient.CallTool(t.Context(), m3mcp.CallToolRequest{ - // Params: m3mcp.CallToolParams{ - // Name: "services", - // Arguments: map[string]interface{}{ - // "working_directory": projectDir, - // }, - // }, - // }) - // require.NoError(t, err, "Services tool error") - // assert.True(t, !result.IsError, "Services tool IsError") - // assertCalled(t, mockFabric.getServicesCalled, "services (GetServices)") - // found := false - // for _, content := range result.Content { - // if text, ok := content.(m3mcp.TextContent); ok { - // if strings.Contains(text.Text, "hello") { - // found = true - // break - // } - // } - // } - // assertCalled(t, found, "Expected service name 'hello' in services tool output") - // } - - TestInProcessMCPServer_Estimate := func(t *testing.T) { - mockFabric.estimateCalled = false - start := time.Now() - result, err := mcpClient.CallTool(t.Context(), m3mcp.CallToolRequest{ - Params: m3mcp.CallToolParams{ - Name: "estimate", - Arguments: map[string]interface{}{ - "working_directory": projectDir, - "provider": "AWS", - "deployment_mode": "AFFORDABLE", - }, - }, - }) - t.Logf("Estimate tool call took: %v", time.Since(start)) - t.Logf("Estimate tool error: %v", err) - t.Logf("MockFabric.estimateCalled: %v", mockFabric.estimateCalled) - require.NoError(t, err, "Estimate tool error") - assert.True(t, !result.IsError, "Estimate tool IsError") - assertCalled(t, mockFabric.estimateCalled, "estimate (Estimate)") - found := false - for _, content := range result.Content { - if text, ok := content.(m3mcp.TextContent); ok { - if strings.Contains(text.Text, "42.42") { - found = true - break - } - } - } - assertCalled(t, found, "Expected cost '42.42' in estimate tool output") - } - - TestInProcessMCPServer_Login := func(t *testing.T) { - const dummyToken = "Testing.Token.1234" - // set as if logged in - t.Setenv("DEFANG_ACCESS_TOKEN", dummyToken) - - // Call the login tool - result, err := mcpClient.CallTool(t.Context(), m3mcp.CallToolRequest{ - Params: m3mcp.CallToolParams{ - Name: "login", - Arguments: map[string]interface{}{ - "working_directory": ".", - }, - }, - }) - require.NoError(t, err, "Login tool error") - assert.True(t, !result.IsError, "Login tool IsError") - } - - TestInProcessMCPServer_Config := func(t *testing.T) { - var configName = "TEST_VAR" - _, err := mcpClient.CallTool(t.Context(), m3mcp.CallToolRequest{ - Params: m3mcp.CallToolParams{ - Name: "set_config", - Arguments: map[string]interface{}{ - "working_directory": projectDir, - "name": configName, - "value": "test_value", - }, - }, - }) - if err != nil { - t.Fatalf("set_config tool failed: %v", err) - } - assertCalled(t, mockFabric.putSecretCalled, "set_config (PutSecret)") - - mockFabric.listSecretsCalled = false - result, err := mcpClient.CallTool(t.Context(), m3mcp.CallToolRequest{ - Params: m3mcp.CallToolParams{ - Name: "list_configs", - Arguments: map[string]interface{}{ - "working_directory": projectDir, - }, - }, - }) - if err != nil { - t.Fatalf("list_configs tool failed: %v", err) - } - assertCalled(t, mockFabric.listSecretsCalled, "list_configs (ListSecrets)") - if len(result.Content) == 0 { - t.Fatalf("List Configs tool returned empty content") - } - textContent, ok := result.Content[0].(m3mcp.TextContent) - if !ok { - t.Fatalf("Expected TextContent type in result.Content[0], got %T", result.Content[0]) - } - contentStr := textContent.Text - if !strings.Contains(contentStr, configName) { - t.Fatalf("Expected config name %q not found in list_configs output: %s", configName, contentStr) - } - - _, err = mcpClient.CallTool(t.Context(), m3mcp.CallToolRequest{ - Params: m3mcp.CallToolParams{ - Name: "remove_config", - Arguments: map[string]interface{}{ - "working_directory": projectDir, - "name": configName, - }, - }, - }) - if err != nil { - t.Fatalf("remove_config tool failed: %v", err) - } - assertCalled(t, mockFabric.deleteSecretsCalled, "remove_config (DeleteSecrets)") - - result, err = mcpClient.CallTool(t.Context(), m3mcp.CallToolRequest{ - Params: m3mcp.CallToolParams{ - Name: "list_configs", - Arguments: map[string]interface{}{ - "working_directory": projectDir, - }, - }, - }) - if err != nil { - t.Fatalf("list_configs tool failed: %v", err) - } - assertCalled(t, mockFabric.listSecretsCalled, "list_configs (ListSecrets after delete)") - textContent, ok = result.Content[0].(m3mcp.TextContent) - if !ok { - t.Fatalf("Expected TextContent type in result.Content[0], got %T", result.Content[0]) - } - contentStr = textContent.Text - if strings.Contains(contentStr, configName) { - t.Fatalf("Not expected config name %q not found in list_configs output: %s", configName, contentStr) - } - } - - TestInProcessMCPServer_DeployAndDestroy := func(t *testing.T) { - const dummyToken = "Testing.Token.1234" - t.Setenv("DEFANG_ACCESS_TOKEN", dummyToken) - - result, err := mcpClient.CallTool(t.Context(), m3mcp.CallToolRequest{ - Params: m3mcp.CallToolParams{ - Name: "deploy", - Arguments: map[string]interface{}{ - "working_directory": projectDir, - }, - }, - }) - t.Logf("Deploy tool error: %v", err) - t.Logf("MockFabric.deployCalled: %v", mockFabric.deployCalled) - require.NoError(t, err, "Deploy tool error") - assert.True(t, !result.IsError, "Deploy tool IsError") - assertCalled(t, mockFabric.deployCalled, "deploy (Deploy)") - - _, err = mcpClient.CallTool(t.Context(), m3mcp.CallToolRequest{ - Params: m3mcp.CallToolParams{ - Name: "destroy", - Arguments: map[string]interface{}{ - "working_directory": projectDir, - }, - }, - }) - require.NoError(t, err, "Destroy tool error") - assertCalled(t, mockFabric.destroyCalled, "destroy (Destroy)") - } - - // Suite-level setup - projectDir = createTestProjectDir(t) - mcpClient = setupTest(t, projectDir) - - // Test functions - tests := []struct { - name string - fn func(t *testing.T) - }{ - // {"TestInProcessMCPServer_SetAWSBYOCProvider", TestInProcessMCPServer_SetAWSBYOCProvider}, - // {"TestInProcessMCPServer_SetGCPBYOCProvider", TestInProcessMCPServer_SetGCPBYOCProvider}, - {"TestInProcessMCPServer_DeployAndDestroy", TestInProcessMCPServer_DeployAndDestroy}, - {"TestInProcessMCPServer_Setup", TestInProcessMCPServer_Setup}, - {"TestInProcessMCPServer_Login", TestInProcessMCPServer_Login}, - {"TestInProcessMCPServer_Config", TestInProcessMCPServer_Config}, - {"TestInProcessMCPServer_Estimate", TestInProcessMCPServer_Estimate}, - // TODO: this test was failing on main, so commenting it out. need to mock provider. - // {"TestInProcessMCPServer_Services", TestInProcessMCPServer_Services}, - } - for _, tc := range tests { - mockFabric.resetFlags() - t.Run(tc.name, tc.fn) - } -} diff --git a/src/pkg/mcp/tools/tools.go b/src/pkg/mcp/tools/tools.go new file mode 100644 index 000000000..faf6f86c1 --- /dev/null +++ b/src/pkg/mcp/tools/tools.go @@ -0,0 +1,91 @@ +package tools + +import ( + "context" + + agentTools "github.com/DefangLabs/defang/src/pkg/agent/tools" + "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/elicitations" + "github.com/firebase/genkit/go/ai" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +type StackConfig struct { + Cluster string + ProviderID *client.ProviderID + Stack *string +} + +func translateSchema(schema map[string]any) mcp.ToolInputSchema { + if schema == nil { + return mcp.ToolInputSchema{ + Type: "object", + Properties: map[string]any{}, + Required: []string{}, + } + } + + schemaType, ok := schema["type"].(string) + if !ok { + schemaType = "object" + } + schemaProperties, ok := schema["properties"].(map[string]any) + if !ok { + schemaProperties = map[string]any{} + } + + var schemaRequired []string + if reqRaw, ok := schema["required"].([]any); ok { + for _, r := range reqRaw { + if s, ok := r.(string); ok { + schemaRequired = append(schemaRequired, s) + } + } + } else if req, ok := schema["required"].([]string); ok { + schemaRequired = req + } + + return mcp.ToolInputSchema{ + Type: schemaType, + Properties: schemaProperties, + Required: schemaRequired, + } +} + +func translateGenKitToolsToMCP(genkitTools []ai.Tool) []server.ServerTool { + var translatedTools []server.ServerTool + for _, t := range genkitTools { + def := t.Definition() + inputSchema := translateSchema(def.InputSchema) + translatedTools = append(translatedTools, server.ServerTool{ + Tool: mcp.Tool{ + Name: t.Name(), + Description: def.Description, + InputSchema: inputSchema, + }, + Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + result, err := t.RunRaw(ctx, request.GetArguments()) + if err != nil { + return mcp.NewToolResultErrorFromErr("Tool execution failed", err), nil + } + output, ok := result.(string) + if !ok { + return mcp.NewToolResultError("Tool returned unexpected result type"), nil + } + return mcp.NewToolResultText(output), nil + }, + }) + } + + return translatedTools +} + +func CollectTools(ec elicitations.Controller, config StackConfig) []server.ServerTool { + genkitTools := agentTools.CollectDefangTools(ec, agentTools.StackConfig{ + Cluster: config.Cluster, + ProviderID: config.ProviderID, + Stack: config.Stack, + }) + return translateGenKitToolsToMCP(genkitTools) +} diff --git a/src/pkg/migrate/heroku.go b/src/pkg/migrate/heroku.go index 24f8c54b7..94ea2953a 100644 --- a/src/pkg/migrate/heroku.go +++ b/src/pkg/migrate/heroku.go @@ -276,7 +276,7 @@ func (h *HerokuClient) GetPGInfo(ctx context.Context, addonID string) (PGInfo, e func herokuGet[T any](ctx context.Context, h *HerokuClient, url string) (T, error) { req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { - return *new(T), fmt.Errorf("Failed to create request: %v", err) + return *new(T), fmt.Errorf("failed to create request: %v", err) } // Set headers diff --git a/src/pkg/stacks/stacks.go b/src/pkg/stacks/stacks.go index d83b4c153..d1a36ac6e 100644 --- a/src/pkg/stacks/stacks.go +++ b/src/pkg/stacks/stacks.go @@ -23,7 +23,7 @@ type StackParameters struct { var validStackName = regexp.MustCompile(`^[a-z][a-z0-9]*$`) -const dotDefang = ".defang" +const directory = ".defang" func MakeDefaultName(providerId client.ProviderID, region string) string { compressedRegion := strings.ReplaceAll(region, "-", "") @@ -43,7 +43,7 @@ func Create(params StackParameters) (string, error) { return "", err } - if err := os.Mkdir(dotDefang, 0700); err != nil && !errors.Is(err, os.ErrExist) { + if err := os.Mkdir(directory, 0700); err != nil && !errors.Is(err, os.ErrExist) { return "", err } filename := filename(params.Name) @@ -83,7 +83,7 @@ type StackListItem struct { } func List() ([]StackListItem, error) { - files, err := os.ReadDir(dotDefang) + files, err := os.ReadDir(directory) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil @@ -93,7 +93,7 @@ func List() ([]StackListItem, error) { var stacks []StackListItem for _, file := range files { - filename := filepath.Join(dotDefang, file.Name()) + filename := filepath.Join(directory, file.Name()) content, err := os.ReadFile(filename) if err != nil { term.Warnf("Skipping unreadable stack file %s: %v\n", filename, err) @@ -174,5 +174,35 @@ func Remove(name string) error { } func filename(stackname string) string { - return filepath.Join(dotDefang, stackname) + return filepath.Join(directory, stackname) +} + +func Read(name string) (*StackParameters, error) { + path, err := filepath.Abs(filepath.Join(directory, name)) + if err != nil { + return nil, err + } + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("could not read stack %q from %q: %w", name, path, err) + } + parsed, err := Parse(string(content)) + if err != nil { + return nil, err + } + parsed.Name = name + return &parsed, nil +} + +func Load(name string) error { + path, err := filepath.Abs(filepath.Join(directory, name)) + if err != nil { + return err + } + if err := godotenv.Load(path); err != nil { + return fmt.Errorf("could not load stack %q from %q %w", name, path, err) + } + + term.Debugf("loaded globals from %s", path) + return nil } diff --git a/src/pkg/stacks/stacks_test.go b/src/pkg/stacks/stacks_test.go index ac887d57d..f89d0275b 100644 --- a/src/pkg/stacks/stacks_test.go +++ b/src/pkg/stacks/stacks_test.go @@ -150,9 +150,9 @@ func TestList(t *testing.T) { t.Run("stacks present", func(t *testing.T) { t.Chdir(t.TempDir()) // Create dummy stack files - os.Mkdir(dotDefang, 0700) - os.Create(filepath.Join(dotDefang, "stack1")) - os.Create(filepath.Join(dotDefang, "stack2")) + os.Mkdir(directory, 0700) + os.Create(filepath.Join(directory, "stack1")) + os.Create(filepath.Join(directory, "stack2")) stacks, err := List() if err != nil { @@ -298,3 +298,84 @@ DEFANG_MODE=affordable }) } } + +func TestRead(t *testing.T) { + t.Run("read existing stack", func(t *testing.T) { + t.Chdir(t.TempDir()) + // Create dummy stack file + stackName := "stacktoread" + expectedParams := StackParameters{ + Name: stackName, + Provider: cliClient.ProviderAWS, + Region: "us-west-2", + Mode: modes.ModeAffordable, + } + _, err := Create(expectedParams) + if err != nil { + t.Errorf("Setup Create() error = %v", err) + } + + params, err := Read(stackName) + if err != nil { + t.Errorf("Read() error = %v", err) + } + if params.Provider != expectedParams.Provider || + params.Region != expectedParams.Region || + params.Mode != expectedParams.Mode { + t.Errorf("Read() = %v, want %v", params, expectedParams) + } + }) +} + +func TestLoad(t *testing.T) { + t.Run("load existing stack sets env vars", func(t *testing.T) { + os.Unsetenv("DEFANG_PROVIDER") + os.Unsetenv("GCP_LOCATION") + + t.Chdir(t.TempDir()) + // Create dummy stack file + stackName := "stacktoload" + expectedParams := StackParameters{ + Name: stackName, + Provider: cliClient.ProviderGCP, + Region: "us-central1", + } + _, err := Create(expectedParams) + if err != nil { + t.Errorf("Setup Create() error = %v", err) + } + + err = Load(stackName) + if err != nil { + t.Errorf("Load() error = %v", err) + } + assert.Equal(t, os.Getenv("DEFANG_PROVIDER"), expectedParams.Provider.String()) + assert.Equal(t, os.Getenv("GCP_LOCATION"), expectedParams.Region) + }) + + t.Run("load existing stack does not overwrite env vars", func(t *testing.T) { + t.Setenv("DEFANG_PROVIDER", "aws") + t.Setenv("AWS_REGION", "us-west-2") + + t.Chdir(t.TempDir()) + // Create dummy stack file + stackName := "stacktoload" + stackParams := StackParameters{ + Name: stackName, + Provider: cliClient.ProviderGCP, + Region: "us-central1", + } + _, err := Create(stackParams) + if err != nil { + t.Errorf("Setup Create() error = %v", err) + } + + err = Load(stackName) + if err != nil { + t.Errorf("Load() error = %v", err) + } + assert.Equal(t, os.Getenv("DEFANG_PROVIDER"), "aws") + assert.Equal(t, os.Getenv("AWS_REGION"), "us-west-2") + assert.Equal(t, os.Getenv("GCP_LOCATION"), stackParams.Region) + }) +} diff --git a/src/pkg/utils.go b/src/pkg/utils.go index c64a45a4c..764652bf0 100644 --- a/src/pkg/utils.go +++ b/src/pkg/utils.go @@ -153,7 +153,7 @@ func Compare(actual []byte, goldenFile string) error { golden, err := os.ReadFile(goldenFile) if err != nil { if !os.IsNotExist(err) { - return fmt.Errorf("Failed to read golden file: %w", err) + return fmt.Errorf("failed to read golden file: %w", err) } return os.WriteFile(goldenFile, actual, 0644) } else { diff --git a/src/testdata/invalid-no-services/compose.yaml b/src/testdata/invalid-no-services/compose.yaml new file mode 100644 index 000000000..20e9ff3fe --- /dev/null +++ b/src/testdata/invalid-no-services/compose.yaml @@ -0,0 +1 @@ +foo: bar