diff --git a/pkgs/defang/cli.nix b/pkgs/defang/cli.nix index ad4e7318d..33b824f13 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-VYJjG99g66S8V6GzqdZngnHESaVkQ4C6Tl2VsFMVCLA="; # TODO: use fetchFromGitHub + vendorHash = "sha256-VKC3eh2cx7gTNymUTsaJxLgjpvt+TFhZW4HpRRoZdtg="; # TODO: use fetchFromGitHub subPackages = [ "cmd/cli" ]; diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index 82965bfd2..acee08ac1 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -15,6 +15,7 @@ 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" @@ -32,6 +33,7 @@ import ( "github.com/DefangLabs/defang/src/pkg/setup" "github.com/DefangLabs/defang/src/pkg/surveyor" "github.com/DefangLabs/defang/src/pkg/term" + "github.com/DefangLabs/defang/src/pkg/timeutils" "github.com/DefangLabs/defang/src/pkg/track" "github.com/DefangLabs/defang/src/pkg/types" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" @@ -390,6 +392,20 @@ var RootCmd = &cobra.Command{ return err }, + + RunE: func(cmd *cobra.Command, args []string) error { + if nonInteractive { + return nil + } + + ctx := cmd.Context() + err := login.InteractiveRequireLoginAndToS(ctx, client, getCluster()) + if err != nil { + return err + } + + return agent.New(ctx, getCluster(), &providerID, agent.DefaultSystemPrompt).Start() + }, } var loginCmd = &cobra.Command{ @@ -857,11 +873,11 @@ var debugCmd = &cobra.Command{ } now := time.Now() - sinceTs, err := cli.ParseTimeOrDuration(since, now) + sinceTs, err := timeutils.ParseTimeOrDuration(since, now) if err != nil { return fmt.Errorf("invalid 'since' time: %w", err) } - untilTs, err := cli.ParseTimeOrDuration(until, now) + untilTs, err := timeutils.ParseTimeOrDuration(until, now) if err != nil { return fmt.Errorf("invalid 'until' time: %w", err) } diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index a7f55b7e6..2fd3ed1df 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -19,6 +19,7 @@ import ( "github.com/DefangLabs/defang/src/pkg/logs" "github.com/DefangLabs/defang/src/pkg/modes" "github.com/DefangLabs/defang/src/pkg/term" + "github.com/DefangLabs/defang/src/pkg/timeutils" "github.com/DefangLabs/defang/src/pkg/track" "github.com/DefangLabs/defang/src/pkg/types" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" @@ -129,7 +130,7 @@ func makeComposeUpCmd() *cobra.Command { term.Warnf("Defang cannot monitor status of the following managed service(s): %v.\n To check if the managed service is up, check the status of the service which depends on it.", managedServices) } - deploy, project, err := cli.ComposeUp(ctx, project, client, provider, upload, mode) + deploy, project, err := cli.ComposeUp(ctx, client, provider, cli.ComposeUpParams{Project: project, UploadMode: upload, Mode: mode}) if err != nil { return handleComposeUpErr(ctx, err, project, provider) } @@ -452,7 +453,7 @@ func makeComposeConfigCmd() *cobra.Command { return err } - _, _, err = cli.ComposeUp(ctx, project, client, provider, compose.UploadModeIgnore, modes.ModeUnspecified) + _, _, err = cli.ComposeUp(ctx, client, provider, cli.ComposeUpParams{Project: project, UploadMode: compose.UploadModeIgnore, Mode: modes.ModeUnspecified}) if !errors.Is(err, dryrun.ErrDryRun) { return err } @@ -569,12 +570,12 @@ func handleLogsCmd(cmd *cobra.Command, args []string) error { } now := time.Now() - sinceTs, err := cli.ParseTimeOrDuration(since, now) + sinceTs, err := timeutils.ParseTimeOrDuration(since, now) if err != nil { return fmt.Errorf("invalid 'since' duration or time: %w", err) } sinceTs = sinceTs.UTC() - untilTs, err := cli.ParseTimeOrDuration(until, now) + untilTs, err := timeutils.ParseTimeOrDuration(until, now) if err != nil { return fmt.Errorf("invalid 'until' duration or time: %w", err) } diff --git a/src/cmd/cli/command/mcp.go b/src/cmd/cli/command/mcp.go index 2d51a9939..730c582e1 100644 --- a/src/cmd/cli/command/mcp.go +++ b/src/cmd/cli/command/mcp.go @@ -5,9 +5,9 @@ import ( "os" "path/filepath" + agentTools "github.com/DefangLabs/defang/src/pkg/agent/tools" cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/mcp" - "github.com/DefangLabs/defang/src/pkg/mcp/tools" "github.com/DefangLabs/defang/src/pkg/term" "github.com/mark3labs/mcp-go/server" "github.com/spf13/cobra" @@ -50,7 +50,7 @@ var mcpServerCmd = &cobra.Command{ // Create a new MCP server term.Debug("Creating MCP server") - s, err := mcp.NewDefangMCPServer(RootCmd.Version, cluster, &providerID, mcpClient, tools.DefaultToolCLI{}) + s, err := mcp.NewDefangMCPServer(RootCmd.Version, cluster, &providerID, mcpClient, agentTools.DefaultToolCLI{}) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) } diff --git a/src/go.mod b/src/go.mod index 1e20dea2b..f73b75467 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,10 +35,11 @@ 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 v25.0.6+incompatible + github.com/firebase/genkit/go v1.0.5 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/hexops/gotextdiff v1.0.3 github.com/joho/godotenv v1.5.1 @@ -46,6 +47,7 @@ require ( github.com/miekg/dns v1.1.59 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-rc3 github.com/pelletier/go-toml/v2 v2.2.2 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c @@ -55,40 +57,43 @@ 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.18.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 - 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 + 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/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/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-20250923103342-a8a91d1dff59 // 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 @@ -98,8 +103,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/morikuni/aec v1.0.0 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -110,18 +116,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.24.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 ) @@ -145,7 +159,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/moby/term v0.5.0 // 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 fe71c5f8b..91a968db8 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= @@ -155,25 +155,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.0.5 h1:CHhjpz1wVexu9z2D/8BDLN0cWNBHF4RwWUIlgw98uz0= +github.com/firebase/genkit/go v1.0.5/go.mod h1:t7g2u7wrkC83kBeYHXhgutFmEe1mMaBDsHZM5WJWYQw= 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-20250923103342-a8a91d1dff59 h1:EywQhHXdzYlMKD7Gxl9Ho34c8dQ0meph6FuRN9iENEY= +github.com/google/dotprompt/go v0.0.0-20250923103342-a8a91d1dff59/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= @@ -189,10 +195,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 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= @@ -221,7 +227,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= @@ -235,8 +240,8 @@ 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/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.38.0 h1:E5tmJiIXkhwlV0pLAwAT0O5ZjUZSISE/2Jxg+6vpq4I= github.com/mark3labs/mcp-go v0.38.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= @@ -250,6 +255,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= @@ -267,6 +274,8 @@ github.com/onsi/ginkgo/v2 v2.14.0 h1:vSmGj2Z5YPb9JwCWT6z6ihcUvDhuXLc3sJiqd3jMKAY github.com/onsi/ginkgo/v2 v2.14.0/go.mod h1:JkUdW7JkN0V6rFvsHcJ478egV3XH9NxpD27Hal/PhZw= github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8= github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +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-rc3 h1:fzg1mXZFj8YdPeNkRXMg+zb88BFV0Ys52cJydRwBkb8= @@ -308,6 +317,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= @@ -317,8 +327,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= @@ -330,59 +357,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.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= -golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +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= @@ -395,41 +424,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.24.0 h1:j5lt+Qr7W0+OBxwwEPe4DQ+ygEqpvZuSBvYoHIuUjhg= +google.golang.org/genai v1.24.0/go.mod h1:QPj5NGJw+3wEOHg+PrsWwJKvG6UC84ex5FR7qAYsN/M= +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 new file mode 100644 index 000000000..206b74475 --- /dev/null +++ b/src/pkg/agent/agent.go @@ -0,0 +1,243 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "regexp" + + "github.com/DefangLabs/defang/src/pkg" + "github.com/DefangLabs/defang/src/pkg/agent/common" + "github.com/DefangLabs/defang/src/pkg/agent/plugins/fabric" + "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/cluster" + "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" +) + +const DefaultSystemPrompt = `You are a helpful assistant. Your job is to help +the user deploy and manage their cloud applications using Defang. Defang is a +tool that makes it easy to deploy Docker Compose projects to cloud providers +like AWS, GCP, and Digital Ocean. Be as succinct, direct, and clear as +possible. +Some tools ask for a working_directory. This should usually be set to the +current working directory (or ".") unless otherwise specified by the user. +Some tools ask for a project_name. This is optional, but useful when working +on a project that is not in the current working directory. +` + +var whitespacePattern = regexp.MustCompile(`^\s*$`) + +type Agent struct { + ctx context.Context + g *genkit.Genkit + msgs []*ai.Message + prompt string + tools []ai.ToolRef + outStream io.Writer +} + +func New(ctx context.Context, addr string, providerId *client.ProviderID, prompt string) *Agent { + accessToken := cluster.GetExistingToken(addr) + provider := "fabric" + var providerPlugin api.Plugin + providerPlugin = &fabric.OpenAI{ + APIKey: accessToken, + Opts: []option.RequestOption{ + option.WithBaseURL(fmt.Sprintf("https://%s/api/v1", addr)), + }, + } + defaultModel := "google/gemini-2.5-flash" + + if os.Getenv("GOOGLE_API_KEY") != "" { + provider = "googleai" + providerPlugin = &googlegenai.GoogleAI{} + defaultModel = "gemini-2.5-flash" + } + + model := pkg.Getenv("DEFANG_MODEL_ID", defaultModel) + + g := genkit.Init(ctx, + genkit.WithDefaultModel(fmt.Sprintf("%s/%s", provider, model)), + genkit.WithPlugins(providerPlugin), + ) + + tools := CollectTools(addr, providerId) + toolRefs := make([]ai.ToolRef, len(tools)) + for i, t := range tools { + toolRef := ai.ToolRef(t) + toolRefs[i] = toolRef + action, ok := toolRef.(ai.Tool) + if !ok { + panic("toolRef is not an ai.Tool") + } + genkit.RegisterAction(g, action) + } + + return &Agent{ + ctx: ctx, + g: g, + msgs: []*ai.Message{}, + prompt: prompt, + tools: toolRefs, + outStream: os.Stdout, + } +} + +func (a *Agent) Printf(format string, args ...interface{}) { + fmt.Fprintf(a.outStream, format, args...) +} + +func (a *Agent) Println(args ...interface{}) { + fmt.Fprintln(a.outStream, args...) +} + +func (a *Agent) Start() error { + reader := NewInputReader() + defer reader.Close() + + a.Printf("\nWelcome to Defang. I can help you deploy your project to the cloud.\n") + a.Printf("Type '/exit' to quit.\n") + + for { + a.Printf("> ") + + input, err := reader.ReadLine() + if err != nil { + if errors.Is(err, ErrInterrupted) { + a.Printf("\nReceived termination signal, shutting down...\n") + return nil + } + if errors.Is(err, io.EOF) { + return nil + } + return fmt.Errorf("error reading input: %w", err) + } + + if input == "/exit" { + return nil + } + + // if input is empty or all whitespace, continue + if whitespacePattern.MatchString(input) { + continue + } + + if err := a.handleMessage(input); err != nil { + a.Printf("Error handling message: %v", err) + } + } +} + +func (a *Agent) handleToolRequest(req *ai.ToolRequest) (*ai.ToolResponse, error) { + inputs, err := json.Marshal(req.Input) + if err != nil { + return nil, fmt.Errorf("error marshaling tool request input: %w", err) + } + a.Printf("* %s(%s)\n", req.Name, inputs) + tool := genkit.LookupTool(a.g, req.Name) + if tool == nil { + return nil, fmt.Errorf("tool %q not found", req.Name) + } + + output, err := tool.RunRaw(a.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, fmt.Errorf("tool %q execution error: %w", tool.Name(), err) + } + + return &ai.ToolResponse{ + Name: req.Name, + Ref: req.Ref, + Output: output, + }, nil +} + +func (a *Agent) handleToolCalls(requests []*ai.ToolRequest) ([]*ai.Message, error) { + if len(requests) == 0 { + return nil, nil + } + + parts := []*ai.Part{} + for _, req := range requests { + toolResp, err := a.handleToolRequest(req) + if err != nil { + return nil, fmt.Errorf("tool request error: %w", err) + } + a.Printf(" > %s\n", toolResp.Output) + parts = append(parts, ai.NewToolResponsePart(toolResp)) + } + + responses := []*ai.Message{ai.NewMessage(ai.RoleTool, nil, parts...)} + a.msgs = append(a.msgs, responses...) + resp, err := genkit.Generate(a.ctx, a.g, + ai.WithTools(a.tools...), + ai.WithMessages(a.msgs...), + ai.WithStreaming(a.streamingCallback), + ) + if err != nil { + return nil, fmt.Errorf("generation error: %w", err) + } + a.Println("") + responses = append(responses, resp.Message) + a.msgs = responses + return responses, nil +} + +func (a *Agent) streamingCallback(ctx context.Context, chunk *ai.ModelResponseChunk) error { + for _, part := range chunk.Content { + a.Printf("%s", part.Text) + } + return nil +} + +func (a *Agent) handleMessage(msg string) error { + a.msgs = append(a.msgs, ai.NewUserMessage(ai.NewTextPart(msg))) + + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("error getting current working directory: %w", err) + } + prompt := fmt.Sprintf("%s\n\nThe current working directory is %q", DefaultSystemPrompt, cwd) + + a.Printf("* Thinking...\r* ") + + resp, err := genkit.Generate(a.ctx, a.g, + ai.WithPrompt(prompt), + ai.WithTools(a.tools...), + ai.WithMessages(a.msgs...), + ai.WithReturnToolRequests(true), + ai.WithStreaming(a.streamingCallback), + ) + a.Println("") + if err != nil { + return fmt.Errorf("generation error: %w", err) + } + + a.msgs = append(a.msgs, resp.Message) + for _, part := range resp.Message.Content { + a.Printf("%s", part.Text) + } + a.Println("") + + if len(resp.ToolRequests()) > 0 { + _, err := a.handleToolCalls(resp.ToolRequests()) + if err != nil { + return fmt.Errorf("tool call handling error: %w", err) + } + } + + return nil +} diff --git a/src/pkg/mcp/common/common.go b/src/pkg/agent/common/common.go similarity index 55% rename from src/pkg/mcp/common/common.go rename to src/pkg/agent/common/common.go index d7fb8ffb0..a6b5fe038 100644 --- a/src/pkg/mcp/common/common.go +++ b/src/pkg/agent/common/common.go @@ -12,6 +12,7 @@ import ( cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/cli/compose" "github.com/DefangLabs/defang/src/pkg/term" + defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" "github.com/mark3labs/mcp-go/mcp" ) @@ -28,6 +29,47 @@ func GetStringArg(args map[string]string, key, defaultValue string) string { return defaultValue } +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 = "." + } + + if params.WorkingDirectory != "." { + err := os.Chdir(params.WorkingDirectory) + if err != nil { + return nil, fmt.Errorf("Failed to change working directory: %w", err) + } + } + + 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 + } + 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 + // if projectNameOK && composeFilePathOK { + // term.Infof("Compose file paths and project name provided: %s, %s", composeFilePaths, projectName) + // return compose.NewLoader(compose.WithProjectName(projectName), compose.WithPath(composeFilePaths...)), nil + // } + + term.Debug("Function invoked: compose.NewLoader") + return compose.NewLoader(), nil +} + func ConfigureLoader(request mcp.CallToolRequest) (*compose.Loader, error) { wd, err := request.RequireString("working_directory") if err != nil || wd == "" { @@ -73,6 +115,23 @@ func FixupConfigError(err error) error { return err } +func CanIUseProvider(ctx context.Context, grpcClient client.FabricClient, providerId client.ProviderID, projectName string, provider client.Provider, serviceCount int) error { + canUseReq := defangv1.CanIUseRequest{ + Project: projectName, + Provider: providerId.Value(), + ServiceCount: int32(serviceCount), // #nosec G115 - service count will not overflow int32 + } + term.Debug("Function invoked: client.CanIUse") + resp, err := grpcClient.CanIUse(ctx, &canUseReq) + if err != nil { + return err + } + + term.Debug("Function invoked: provider.SetCanIUseConfig") + provider.SetCanIUseConfig(resp) + return nil +} + func ProviderNotConfiguredError(providerId client.ProviderID) error { if providerId == client.ProviderAuto { return ErrNoProviderSet diff --git a/src/pkg/mcp/common/common_test.go b/src/pkg/agent/common/common_test.go similarity index 100% rename from src/pkg/mcp/common/common_test.go rename to src/pkg/agent/common/common_test.go diff --git a/src/pkg/agent/inputreader.go b/src/pkg/agent/inputreader.go new file mode 100644 index 000000000..869d44295 --- /dev/null +++ b/src/pkg/agent/inputreader.go @@ -0,0 +1,72 @@ +package agent + +import ( + "bufio" + "errors" + "io" + "os" + "os/signal" + "syscall" +) + +// InputReader manages reading from stdin with cancellation support +type InputReader struct { + scanner *bufio.Scanner + inputChan chan string + errChan chan error + sigChan chan os.Signal +} + +// NewInputReader creates a new InputReader that reads from stdin +func NewInputReader() *InputReader { + ir := &InputReader{ + scanner: bufio.NewScanner(os.Stdin), + inputChan: make(chan string), + errChan: make(chan error, 1), + sigChan: make(chan os.Signal, 1), + } + + signal.Notify(ir.sigChan, os.Interrupt, syscall.SIGTERM) + + // Start reading in background + go func() { + for ir.scanner.Scan() { + ir.inputChan <- ir.scanner.Text() + } + if err := ir.scanner.Err(); err != nil { + ir.errChan <- err + } + close(ir.inputChan) + }() + + return ir +} + +// ReadLine reads the next line of input or returns an error/signal +// Returns (input, nil) on success +// Returns ("", io.EOF) when stdin closes +// Returns ("", ErrInterrupted) when SIGTERM/SIGINT received +// Returns ("", err) on scanner error +func (ir *InputReader) ReadLine() (string, error) { + select { + case <-ir.sigChan: + return "", ErrInterrupted + + case input, ok := <-ir.inputChan: + if !ok { + return "", io.EOF + } + return input, nil + + case err := <-ir.errChan: + return "", err + } +} + +// Close stops signal notifications +func (ir *InputReader) Close() { + signal.Stop(ir.sigChan) + close(ir.sigChan) +} + +var ErrInterrupted = errors.New("interrupted by signal") 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..17685c657 --- /dev/null +++ b/src/pkg/agent/plugins/compat_oai/generate.go @@ -0,0 +1,495 @@ +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" + "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](256), + Temperature: param.NewOpt[float64](0.5), + }, + } +} + +// 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: + oaiMessages = append(oaiMessages, openai.UserMessage(content)) + + parts := []openai.ChatCompletionContentPartUnionParam{} + for _, p := range msg.Content { + if p.IsMedia() { + part := openai.ImageContentPart( + openai.ChatCompletionContentPartImageImageURLParam{ + URL: p.Text, + }) + parts = append(parts, part) + continue + } + } + if len(parts) > 0 { + 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, + }, + } + + 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/tools.go b/src/pkg/agent/tools.go new file mode 100644 index 000000000..c6a87929c --- /dev/null +++ b/src/pkg/agent/tools.go @@ -0,0 +1,159 @@ +package agent + +import ( + "context" + + "github.com/DefangLabs/defang/src/pkg/agent/common" + "github.com/DefangLabs/defang/src/pkg/agent/tools" + "github.com/DefangLabs/defang/src/pkg/cli/client" + cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/firebase/genkit/go/ai" +) + +type Connecter interface { + Connect(ctx context.Context, cluster string) (*cliClient.GrpcClient, error) +} + +type LoginParams struct{} +type ServicesParams struct { + common.LoaderParams +} +type DeployParams struct { + common.LoaderParams +} + +func CollectTools(cluster string, providerId *client.ProviderID) []ai.Tool { + // loginHandler := MakeLoginToolHandler(cluster, authPort, &LoginCLIAdapter{DefaultToolCLI: &DefaultToolCLI{}}) + + return []ai.Tool{ + ai.NewTool[LoginParams, string]( + "login", + "Login into Defang", + func(ctx *ai.ToolContext, _ LoginParams) (string, error) { + return tools.HandleLoginTool(ctx.Context, cluster, &tools.DefaultToolCLI{}) + }, + ), + 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 tools.CLIInterface = &tools.DefaultToolCLI{} + return tools.CaptureTerm(func() (string, error) { + return tools.HandleServicesTool(ctx.Context, 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 "Failed to configure loader", err + } + cli := &tools.DefaultToolCLI{} + // DO NOT wrap this in CaptureStdout, we have special handling for term output in deploy + return tools.HandleDeployTool(ctx.Context, 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 tools.DestroyParams) (string, error) { + loader, err := common.ConfigureAgentLoader(params.LoaderParams) + if err != nil { + return "Failed to configure loader", err + } + cli := &tools.DefaultToolCLI{} + return tools.CaptureTerm(func() (string, error) { + return tools.HandleDestroyTool(ctx.Context, loader, providerId, cluster, cli) + }) + }, + ), + ai.NewTool("logs", + "Fetch logs for the deployed application defined in the docker-compose files in the current working directory", + func(ctx *ai.ToolContext, params tools.LogsParams) (string, error) { + loader, err := common.ConfigureAgentLoader(params.LoaderParams) + if err != nil { + return "Failed to configure loader", err + } + cli := &tools.DefaultToolCLI{} + return tools.CaptureTerm(func() (string, error) { + return tools.HandleLogsTool(ctx.Context, loader, params, cluster, providerId, cli) + }) + }, + ), + ai.NewTool("estimate", + "Estimate the cost of deployed a Defang project to AWS or GCP", + func(ctx *ai.ToolContext, params tools.EstimateParams) (string, error) { + loader, err := common.ConfigureAgentLoader(params.LoaderParams) + if err != nil { + return "Failed to configure loader", err + } + cli := &tools.DefaultToolCLI{} + return tools.CaptureTerm(func() (string, error) { + return tools.HandleEstimateTool(ctx.Context, loader, params, cluster, cli) + }) + }, + ), + ai.NewTool("set_config", + "Set a config variable for the defang project", + func(ctx *ai.ToolContext, params tools.SetConfigParams) (string, error) { + loader, err := common.ConfigureAgentLoader(params.LoaderParams) + if err != nil { + return "Failed to configure loader", err + } + cli := &tools.DefaultToolCLI{} + return tools.CaptureTerm(func() (string, error) { + return tools.HandleSetConfig(ctx.Context, loader, params, providerId, cluster, cli) + }) + }, + ), + ai.NewTool("remove_config", + "Remove a config variable from the defang project", + func(ctx *ai.ToolContext, params tools.RemoveConfigParams) (string, error) { + loader, err := common.ConfigureAgentLoader(params.LoaderParams) + if err != nil { + return "Failed to configure loader", err + } + cli := &tools.DefaultToolCLI{} + return tools.CaptureTerm(func() (string, error) { + return tools.HandleRemoveConfigTool(ctx.Context, loader, params, providerId, cluster, cli) + }) + }, + ), + ai.NewTool("list_configs", + "List config variables for the defang project", + func(ctx *ai.ToolContext, params tools.ListConfigsParams) (string, error) { + loader, err := common.ConfigureAgentLoader(params.LoaderParams) + if err != nil { + return "Failed to configure loader", err + } + cli := &tools.DefaultToolCLI{} + return tools.CaptureTerm(func() (string, error) { + return tools.HandleListConfigTool(ctx.Context, loader, providerId, cluster, cli) + }) + }, + ), + ai.NewTool("set_aws_provider", + "Set the AWS provider for the defang project", + func(ctx *ai.ToolContext, params tools.SetAWSProviderParams) (string, error) { + return tools.HandleSetAWSProvider(ctx.Context, params, providerId, cluster) + }, + ), + ai.NewTool("set_gcp_provider", + "Set the GCP provider for the defang project", + func(ctx *ai.ToolContext, params tools.SetGCPProviderParams) (string, error) { + return tools.HandleSetGCPProvider(ctx.Context, params, providerId, cluster) + }, + ), + ai.NewTool("set_playground_provider", + "Set the Playground provider for the defang project", + func(ctx *ai.ToolContext, params tools.SetPlaygroundProviderParams) (string, error) { + return tools.HandleSetPlaygroundProvider(providerId) + }, + ), + } +} diff --git a/src/pkg/agent/tools/capture.go b/src/pkg/agent/tools/capture.go new file mode 100644 index 000000000..40559a259 --- /dev/null +++ b/src/pkg/agent/tools/capture.go @@ -0,0 +1,27 @@ +package tools + +import ( + "bytes" + "os" + + "github.com/DefangLabs/defang/src/pkg/term" +) + +func CaptureTerm(f func() (string, error)) (string, error) { + // replace the default term with a new term that writes to a buffer + originalTerm := term.DefaultTerm + outStream := bytes.NewBuffer(nil) + errStream := bytes.NewBuffer(nil) + newTerm := term.NewTerm( + os.Stdin, + outStream, + errStream, + ) + term.DefaultTerm = newTerm + defer func() { + term.DefaultTerm = originalTerm + }() + result, err := f() + output := outStream.String() + errStream.String() + return output + result, err +} diff --git a/src/pkg/mcp/tools/default_tool_cli.go b/src/pkg/agent/tools/default_tool_cli.go similarity index 91% rename from src/pkg/mcp/tools/default_tool_cli.go rename to src/pkg/agent/tools/default_tool_cli.go index 4f2f600e0..cae4abf1b 100644 --- a/src/pkg/mcp/tools/default_tool_cli.go +++ b/src/pkg/agent/tools/default_tool_cli.go @@ -5,12 +5,13 @@ 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" "github.com/DefangLabs/defang/src/pkg/login" - "github.com/DefangLabs/defang/src/pkg/mcp/common" "github.com/DefangLabs/defang/src/pkg/mcp/deployment_info" "github.com/DefangLabs/defang/src/pkg/modes" "github.com/DefangLabs/defang/src/pkg/term" @@ -36,6 +37,10 @@ func (DefaultToolCLI) RunEstimate(ctx context.Context, project *compose.Project, return cli.RunEstimate(ctx, project, client, provider, providerId, region, mode) } +func (DefaultToolCLI) TailAndMonitor(ctx context.Context, project *compose.Project, provider cliClient.Provider, waitTimeout time.Duration, tailOptions cli.TailOptions) (cli.ServiceStates, error) { + return cli.TailAndMonitor(ctx, project, provider, waitTimeout, tailOptions) +} + func (DefaultToolCLI) ListConfig(ctx context.Context, provider cliClient.Provider, projectName string) (*defangv1.Secrets, error) { req := &defangv1.ListConfigsRequest{Project: projectName} return provider.ListConfig(ctx, req) @@ -46,7 +51,7 @@ func (DefaultToolCLI) Connect(ctx context.Context, cluster string) (*cliClient.G } func (DefaultToolCLI) ComposeUp(ctx context.Context, project *compose.Project, client *cliClient.GrpcClient, provider cliClient.Provider, uploadMode compose.UploadMode, mode modes.Mode) (*defangv1.DeployResponse, *compose.Project, error) { - return cli.ComposeUp(ctx, project, client, provider, uploadMode, mode) + return cli.ComposeUp(ctx, client, provider, cli.ComposeUpParams{Project: project, UploadMode: uploadMode, Mode: mode}) } func (DefaultToolCLI) Tail(ctx context.Context, provider cliClient.Provider, project *compose.Project, options cli.TailOptions) error { diff --git a/src/pkg/agent/tools/deploy.go b/src/pkg/agent/tools/deploy.go new file mode 100644 index 000000000..8ae2503e0 --- /dev/null +++ b/src/pkg/agent/tools/deploy.go @@ -0,0 +1,118 @@ +package tools + +import ( + "context" + "errors" + "fmt" + "strings" + + "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/cli/compose" + "github.com/DefangLabs/defang/src/pkg/modes" + "github.com/DefangLabs/defang/src/pkg/term" + defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" +) + +func HandleDeployTool( + ctx context.Context, + loader cliClient.ProjectLoader, + providerId *cliClient.ProviderID, + cluster string, cli CLIInterface, +) (string, error) { + var project *compose.Project + var deployResp *defangv1.DeployResponse + var provider *cliClient.Provider + + deployOutput, err := CaptureTerm(func() (string, error) { + fmt.Printf("direct to stdout") + term.Println("through term") + err := common.ProviderNotConfiguredError(*providerId) + if err != nil { + return "", err + } + + term.Debug("Function invoked: loader.LoadProject") + proj, err := cli.LoadProject(ctx, loader) + if err != nil { + err = fmt.Errorf("failed to parse compose file: %w", err) + + return "", fmt.Errorf("local deployment failed: %v. Please provide a valid compose file path.", err) + } + project = proj + + term.Debug("Function invoked: cli.Connect") + client, err := cli.Connect(ctx, cluster) + if err != nil { + return "", fmt.Errorf("could not connect: %w", err) + } + + term.Debug("Function invoked: cli.NewProvider") + + prov, err := cli.CheckProviderConfigured(ctx, client, *providerId, project.Name, "", len(project.Services)) + if err != nil { + return "", fmt.Errorf("provider not configured correctly: %w", err) + } + provider = &prov + + // Deploy the services + term.Debugf("Deploying services for project %s...", project.Name) + + term.Debug("Function invoked: cli.ComposeUp") + // Use ComposeUp to deploy the services + resp, _, err := cli.ComposeUp(ctx, project, client, prov, compose.UploadModeDigest, modes.ModeAffordable) + if err != nil { + err = fmt.Errorf("failed to compose up services: %w", err) + + err = common.FixupConfigError(err) + return "", err + } + + deployResp = resp + + if len(resp.Services) == 0 { + return "", errors.New("no services deployed") + } + + monitorOutput, err := CaptureTerm(func() (string, error) { + _, err := cli.TailAndMonitor(ctx, project, *provider, 0, cliTypes.TailOptions{ + Follow: true, + Deployment: deployResp.Etag, + Verbose: false, + }) + if err != nil { + return "", err + } + return "Deployment completed", nil + }) + if err != nil { + term.Errorf("Error while monitoring deployment: %v", err) + } + + term.Debugf("Deployment output:\n%s", monitorOutput) + return "Deployment completed", nil + }) + + if err != nil { + return deployOutput, err + } + + // Success case + urls := strings.Builder{} + for _, serviceInfo := range deployResp.Services { + if serviceInfo.PublicFqdn != "" { + urls.WriteString(fmt.Sprintf("- %s: %s %s\n", serviceInfo.Service.Name, serviceInfo.PublicFqdn, serviceInfo.Domainname)) + } + } + + // Return the etag data as text + return fmt.Sprintf(` +%s + +The deployment is now in progress. Check the logs to follow progress with deployment id %q. + +When the deployment is complete, you will be able to access the services at the following URLs: +%s +`, deployOutput, deployResp.Etag, urls.String()), nil +} diff --git a/src/pkg/mcp/tools/deploy_test.go b/src/pkg/agent/tools/deploy_test.go similarity index 90% rename from src/pkg/mcp/tools/deploy_test.go rename to src/pkg/agent/tools/deploy_test.go index 196563169..b1430f122 100644 --- a/src/pkg/mcp/tools/deploy_test.go +++ b/src/pkg/agent/tools/deploy_test.go @@ -5,10 +5,12 @@ import ( "errors" "fmt" "testing" + "time" + "github.com/DefangLabs/defang/src/pkg/agent/common" + cliTypes "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/mcp/common" "github.com/DefangLabs/defang/src/pkg/modes" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" "github.com/stretchr/testify/assert" @@ -33,6 +35,7 @@ type MockDeployCLI struct { CheckProviderConfiguredError error LoadProjectError error OpenBrowserError error + TailAndMonitorError error ComposeUpResponse *defangv1.DeployResponse Project *compose.Project CallLog []string @@ -78,6 +81,14 @@ func (m *MockDeployCLI) OpenBrowser(url string) error { return m.OpenBrowserError } +func (m *MockDeployCLI) TailAndMonitor(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, tailOptions cliTypes.TailOptions) (cliTypes.ServiceStates, error) { + m.CallLog = append(m.CallLog, "TailAndMonitor") + if m.TailAndMonitorError != nil { + return nil, m.TailAndMonitorError + } + return nil, nil +} + func TestHandleDeployTool(t *testing.T) { tests := []struct { name string @@ -145,7 +156,7 @@ func TestHandleDeployTool(t *testing.T) { }, } }, - expectedTextContains: "Please use the web portal url:", + expectedTextContains: "The deployment is now in progress.", }, { name: "successful_deploy_aws_provider", @@ -159,7 +170,7 @@ func TestHandleDeployTool(t *testing.T) { }, } }, - expectedTextContains: "Please use the aws console", + expectedTextContains: "The deployment is now in progress.", }, { name: "provider_auto_not_configured", @@ -179,7 +190,7 @@ func TestHandleDeployTool(t *testing.T) { // Call the function loader := &client.MockLoader{} - result, err := handleDeployTool(t.Context(), loader, &tt.providerID, "test-cluster", mockCLI) + result, err := HandleDeployTool(t.Context(), loader, &tt.providerID, "test-cluster", mockCLI) // Verify error expectations if tt.expectedError != "" { diff --git a/src/pkg/mcp/tools/destroy.go b/src/pkg/agent/tools/destroy.go similarity index 90% rename from src/pkg/mcp/tools/destroy.go rename to src/pkg/agent/tools/destroy.go index a897603e3..8cc7c32bb 100644 --- a/src/pkg/mcp/tools/destroy.go +++ b/src/pkg/agent/tools/destroy.go @@ -5,13 +5,17 @@ import ( "errors" "fmt" + "github.com/DefangLabs/defang/src/pkg/agent/common" cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/mcp/common" "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) { +type DestroyParams struct { + common.LoaderParams +} + +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 diff --git a/src/pkg/mcp/tools/destroy_test.go b/src/pkg/agent/tools/destroy_test.go similarity index 97% rename from src/pkg/mcp/tools/destroy_test.go rename to src/pkg/agent/tools/destroy_test.go index 27081a61a..8c975b676 100644 --- a/src/pkg/mcp/tools/destroy_test.go +++ b/src/pkg/agent/tools/destroy_test.go @@ -6,8 +6,8 @@ import ( "fmt" "testing" + "github.com/DefangLabs/defang/src/pkg/agent/common" "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/mcp/common" "github.com/bufbuild/connect-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -137,7 +137,7 @@ func TestHandleDestroyTool(t *testing.T) { // Call the function loader := &client.MockLoader{} - result, err := handleDestroyTool(t.Context(), loader, &tt.providerID, "test-cluster", mockCLI) + result, err := HandleDestroyTool(t.Context(), loader, &tt.providerID, "test-cluster", mockCLI) // Verify error expectations if tt.expectedError != "" { diff --git a/src/pkg/mcp/tools/estimate.go b/src/pkg/agent/tools/estimate.go similarity index 71% rename from src/pkg/mcp/tools/estimate.go rename to src/pkg/agent/tools/estimate.go index f7d49a1c6..5e6b8834a 100644 --- a/src/pkg/mcp/tools/estimate.go +++ b/src/pkg/agent/tools/estimate.go @@ -3,8 +3,8 @@ 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" @@ -12,21 +12,16 @@ import ( ) type EstimateParams struct { - DeploymentMode modes.Mode `json:"deployment_mode"` + common.LoaderParams + DeploymentMode string `json:"deployment_mode"` Provider cliClient.ProviderID `json:"provider"` Region string `json:"region"` } -func parseEstimateParams(request mcp.CallToolRequest, providerId *cliClient.ProviderID) (EstimateParams, error) { - modeString, err := request.RequireString("deployment_mode") +func ParseEstimateParams(request mcp.CallToolRequest, providerId *cliClient.ProviderID) (EstimateParams, error) { + mode, 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(), ", ")) + mode = "AFFORDABLE" // Default to affordable if not provided } providerString, err := request.RequireString("provider") @@ -50,7 +45,7 @@ func parseEstimateParams(request mcp.CallToolRequest, providerId *cliClient.Prov }, 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, cluster string, cli CLIInterface) (string, error) { term.Debug("Function invoked: loader.LoadProject") project, err := cli.LoadProject(ctx, loader) if err != nil { @@ -68,13 +63,19 @@ func handleEstimateTool(ctx context.Context, loader cliClient.ProjectLoader, par term.Debug("Function invoked: cli.RunEstimate") - 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 + } + + estimate, err := cli.RunEstimate(ctx, project, client, defangProvider, params.Provider, 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 } diff --git a/src/pkg/mcp/tools/estimate_test.go b/src/pkg/agent/tools/estimate_test.go similarity index 96% rename from src/pkg/mcp/tools/estimate_test.go rename to src/pkg/agent/tools/estimate_test.go index 0aff317dc..055471435 100644 --- a/src/pkg/mcp/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" @@ -99,7 +98,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", @@ -203,7 +202,7 @@ func TestHandleEstimateTool(t *testing.T) { // Call the function loader := &client.MockLoader{} - params, err := parseEstimateParams(request, &providerID) + params, err := ParseEstimateParams(request, &providerID) if err != nil { // If parsing params fails, check if this was the expected error if tt.expectedError != "" { @@ -213,7 +212,7 @@ func TestHandleEstimateTool(t *testing.T) { require.NoError(t, err) } } - result, err := handleEstimateTool(t.Context(), loader, params, "test-cluster", mockCLI) + result, err := HandleEstimateTool(t.Context(), loader, params, "test-cluster", mockCLI) // Verify error expectations if tt.expectedError != "" { diff --git a/src/pkg/mcp/tools/interfaces.go b/src/pkg/agent/tools/interfaces.go similarity index 93% rename from src/pkg/mcp/tools/interfaces.go rename to src/pkg/agent/tools/interfaces.go index 670be7bd6..c67b62e19 100644 --- a/src/pkg/mcp/tools/interfaces.go +++ b/src/pkg/agent/tools/interfaces.go @@ -3,6 +3,7 @@ package tools import ( "context" + "time" cliTypes "github.com/DefangLabs/defang/src/pkg/cli" cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" @@ -32,4 +33,5 @@ type CLIInterface interface { 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 + TailAndMonitor(ctx context.Context, project *compose.Project, provider cliClient.Provider, waitTimeout time.Duration, tailOptions cliTypes.TailOptions) (cliTypes.ServiceStates, error) } diff --git a/src/pkg/mcp/tools/listConfig.go b/src/pkg/agent/tools/listConfig.go similarity index 86% rename from src/pkg/mcp/tools/listConfig.go rename to src/pkg/agent/tools/listConfig.go index e0349bf7e..cd9a1af6e 100644 --- a/src/pkg/mcp/tools/listConfig.go +++ b/src/pkg/agent/tools/listConfig.go @@ -5,13 +5,17 @@ import ( "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/mcp/common" "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) { +type ListConfigsParams struct { + common.LoaderParams +} + +// 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 diff --git a/src/pkg/mcp/tools/listConfig_test.go b/src/pkg/agent/tools/listConfig_test.go similarity index 97% rename from src/pkg/mcp/tools/listConfig_test.go rename to src/pkg/agent/tools/listConfig_test.go index bf56bf08b..204812302 100644 --- a/src/pkg/mcp/tools/listConfig_test.go +++ b/src/pkg/agent/tools/listConfig_test.go @@ -6,8 +6,8 @@ import ( "fmt" "testing" + "github.com/DefangLabs/defang/src/pkg/agent/common" "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/mcp/common" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -126,7 +126,7 @@ func TestHandleListConfigTool(t *testing.T) { // Call the function loader := &client.MockLoader{} - result, err := handleListConfigTool(t.Context(), loader, &tt.providerID, "test-cluster", mockCLI) + result, err := HandleListConfigTool(t.Context(), loader, &tt.providerID, "test-cluster", mockCLI) // Verify error expectations if tt.expectedError != "" { diff --git a/src/pkg/mcp/tools/login.go b/src/pkg/agent/tools/login.go similarity index 80% rename from src/pkg/mcp/tools/login.go rename to src/pkg/agent/tools/login.go index 2fc283b09..bdfd9e28d 100644 --- a/src/pkg/mcp/tools/login.go +++ b/src/pkg/agent/tools/login.go @@ -4,13 +4,13 @@ import ( "context" "errors" + "github.com/DefangLabs/defang/src/pkg/agent/common" "github.com/DefangLabs/defang/src/pkg/auth" - "github.com/DefangLabs/defang/src/pkg/mcp/common" "github.com/DefangLabs/defang/src/pkg/term" ) -// handleLoginTool handles the login tool logic -func handleLoginTool(ctx context.Context, cluster string, cli CLIInterface) (string, error) { +// 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 { diff --git a/src/pkg/mcp/tools/login_test.go b/src/pkg/agent/tools/login_test.go similarity index 98% rename from src/pkg/mcp/tools/login_test.go rename to src/pkg/agent/tools/login_test.go index d9782e80c..aebbc2dd6 100644 --- a/src/pkg/mcp/tools/login_test.go +++ b/src/pkg/agent/tools/login_test.go @@ -94,7 +94,7 @@ func TestHandleLoginTool(t *testing.T) { // Call the function var err error - result, err := handleLoginTool(context.Background(), tt.cluster, mockCLI) + result, err := HandleLoginTool(context.Background(), tt.cluster, mockCLI) if tt.expectedError != "" { assert.EqualError(t, err, tt.expectedError) } else { diff --git a/src/pkg/mcp/tools/logs.go b/src/pkg/agent/tools/logs.go similarity index 53% rename from src/pkg/mcp/tools/logs.go rename to src/pkg/agent/tools/logs.go index ef24d2e78..12de157d9 100644 --- a/src/pkg/mcp/tools/logs.go +++ b/src/pkg/agent/tools/logs.go @@ -5,49 +5,49 @@ import ( "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/term" + "github.com/DefangLabs/defang/src/pkg/timeutils" "github.com/mark3labs/mcp-go/mcp" ) type LogsParams struct { - DeploymentID string - Since time.Time - Until time.Time + 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, error) { +func ParseLogsParams(request mcp.CallToolRequest) (LogsParams, error) { deploymentId := request.GetString("deployment_id", "") - since, err := request.RequireString("since") - if err != nil { - return LogsParams{}, fmt.Errorf("missing required parameter 'since': %w", err) - } - until, err := request.RequireString("until") - if err != nil { - return LogsParams{}, fmt.Errorf("missing required parameter 'until': %w", err) - } + since := request.GetString("since", "") + until := request.GetString("until", "") + return LogsParams{ + DeploymentID: deploymentId, + Since: since, + Until: until, + }, nil +} + +func HandleLogsTool(ctx context.Context, loader cliClient.ProjectLoader, params LogsParams, cluster string, providerId *cliClient.ProviderID, cli CLIInterface) (string, error) { var sinceTime, untilTime time.Time - if since != "" { - sinceTime, err = time.Parse(time.RFC3339, since) + var err error + now := time.Now() + if params.Since != "" { + sinceTime, err = timeutils.ParseTimeOrDuration(params.Since, now) if err != nil { - return LogsParams{}, fmt.Errorf("invalid parameter 'since', must be in RFC3339 format: %w", err) + return "", fmt.Errorf("invalid parameter 'since', must be in RFC3339 format: %w", err) } } - if until != "" { - untilTime, err = time.Parse(time.RFC3339, until) + if params.Until != "" { + untilTime, err = timeutils.ParseTimeOrDuration(params.Until, now) if err != nil { - return LogsParams{}, fmt.Errorf("invalid parameter 'until', must be in RFC3339 format: %w", err) + return "", fmt.Errorf("invalid parameter 'until', must be in RFC3339 format: %w", err) } } - return LogsParams{ - DeploymentID: deploymentId, - Since: sinceTime, - Until: untilTime, - }, nil -} -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 { @@ -72,8 +72,10 @@ func handleLogsTool(ctx context.Context, loader cliClient.ProjectLoader, params err = cli.Tail(ctx, provider, project, cliTypes.TailOptions{ Deployment: params.DeploymentID, - Since: params.Since, - Until: params.Until, + Since: sinceTime, + Until: untilTime, + Limit: 100, + Raw: true, }) if err != nil { @@ -82,5 +84,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/mcp/tools/removeConfig.go b/src/pkg/agent/tools/removeConfig.go similarity index 87% rename from src/pkg/mcp/tools/removeConfig.go rename to src/pkg/agent/tools/removeConfig.go index c60092f7b..9817ff8ae 100644 --- a/src/pkg/mcp/tools/removeConfig.go +++ b/src/pkg/agent/tools/removeConfig.go @@ -4,18 +4,19 @@ import ( "context" "fmt" + "github.com/DefangLabs/defang/src/pkg/agent/common" cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/mcp/common" "github.com/DefangLabs/defang/src/pkg/term" "github.com/bufbuild/connect-go" "github.com/mark3labs/mcp-go/mcp" ) type RemoveConfigParams struct { + common.LoaderParams Name string } -func parseRemoveConfigParams(request mcp.CallToolRequest) (RemoveConfigParams, error) { +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) @@ -25,8 +26,8 @@ func parseRemoveConfigParams(request mcp.CallToolRequest) (RemoveConfigParams, e }, nil } -// 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) { +// 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 diff --git a/src/pkg/mcp/tools/removeConfig_test.go b/src/pkg/agent/tools/removeConfig_test.go similarity index 97% rename from src/pkg/mcp/tools/removeConfig_test.go rename to src/pkg/agent/tools/removeConfig_test.go index 4985b9287..1ee1d71cc 100644 --- a/src/pkg/mcp/tools/removeConfig_test.go +++ b/src/pkg/agent/tools/removeConfig_test.go @@ -6,8 +6,8 @@ import ( "fmt" "testing" + "github.com/DefangLabs/defang/src/pkg/agent/common" "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/mcp/common" "github.com/bufbuild/connect-go" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" @@ -156,7 +156,7 @@ func TestHandleRemoveConfigTool(t *testing.T) { }, } - params, err := parseRemoveConfigParams(request) + params, err := ParseRemoveConfigParams(request) if err != nil { if tt.expectError { assert.EqualError(t, err, tt.expectedError) @@ -168,7 +168,7 @@ func TestHandleRemoveConfigTool(t *testing.T) { // Call the function loader := &client.MockLoader{} - result, err := handleRemoveConfigTool(t.Context(), loader, params, &tt.providerID, "test-cluster", mockCLI) + result, err := HandleRemoveConfigTool(t.Context(), loader, params, &tt.providerID, "test-cluster", mockCLI) // Verify error expectations if tt.expectError { diff --git a/src/pkg/mcp/tools/services.go b/src/pkg/agent/tools/services.go similarity index 84% rename from src/pkg/mcp/tools/services.go rename to src/pkg/agent/tools/services.go index 53558bc95..daa32baf0 100644 --- a/src/pkg/mcp/tools/services.go +++ b/src/pkg/agent/tools/services.go @@ -7,14 +7,14 @@ import ( "fmt" "strings" + "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/mcp/common" "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) { +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 @@ -35,7 +35,7 @@ func handleServicesTool(ctx context.Context, loader cliClient.ProjectLoader, pro 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 +44,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/mcp/tools/services_test.go b/src/pkg/agent/tools/services_test.go similarity index 95% rename from src/pkg/mcp/tools/services_test.go rename to src/pkg/agent/tools/services_test.go index 67540b58c..bf4adeed4 100644 --- a/src/pkg/mcp/tools/services_test.go +++ b/src/pkg/agent/tools/services_test.go @@ -5,9 +5,9 @@ import ( "errors" "testing" + "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/mcp/common" "github.com/DefangLabs/defang/src/pkg/mcp/deployment_info" "github.com/bufbuild/connect-go" "github.com/stretchr/testify/assert" @@ -132,8 +132,6 @@ 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", expectedGetServices: true, expectedProjectName: "test-project", }, @@ -146,8 +144,6 @@ 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", expectedGetServices: true, expectedProjectName: "test-project", }, @@ -192,7 +188,7 @@ func TestHandleServicesToolWithMockCLI(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { loader := &client.MockLoader{} - result, err := handleServicesTool(ctx, loader, &tt.providerId, testCluster, tt.mockCLI) + result, err := HandleServicesTool(ctx, loader, &tt.providerId, testCluster, tt.mockCLI) // Check Go error expectation if tt.expectedError { diff --git a/src/pkg/agent/tools/setAWSProvider.go b/src/pkg/agent/tools/setAWSProvider.go new file mode 100644 index 000000000..b19232364 --- /dev/null +++ b/src/pkg/agent/tools/setAWSProvider.go @@ -0,0 +1,37 @@ +package tools + +import ( + "context" + "errors" + "fmt" + + cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/mcp/actions" +) + +type SetAWSProviderParams struct { + AccessKeyId string `json:"accessKeyId"` + SecretAccessKey string `json:"secretAccessKey"` + Region string `json:"region"` +} + +// HandleSetAWSProvider handles the set AWS provider MCP tool request +func HandleSetAWSProvider(ctx context.Context, params SetAWSProviderParams, providerId *cliClient.ProviderID, cluster string) (string, error) { + if params.AccessKeyId == "" { + return "", errors.New("AWS access key Id cannot be empty") + } + + if params.SecretAccessKey == "" { + return "", errors.New("AWS secret access key cannot be empty") + } + + if params.Region == "" { + return "", errors.New("AWS region cannot be empty") + } + + if err := actions.SetAWSByocProvider(ctx, providerId, cluster, params.AccessKeyId, params.SecretAccessKey, params.Region); 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/mcp/tools/setConfig.go b/src/pkg/agent/tools/setConfig.go similarity index 87% rename from src/pkg/mcp/tools/setConfig.go rename to src/pkg/agent/tools/setConfig.go index 2edd966b7..5cb1a0bc7 100644 --- a/src/pkg/mcp/tools/setConfig.go +++ b/src/pkg/agent/tools/setConfig.go @@ -5,18 +5,19 @@ import ( "fmt" "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/mcp/common" "github.com/DefangLabs/defang/src/pkg/term" "github.com/mark3labs/mcp-go/mcp" ) type SetConfigParams struct { + common.LoaderParams Name string Value string } -func parseSetConfigParams(request mcp.CallToolRequest) (SetConfigParams, error) { +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) @@ -31,8 +32,8 @@ func parseSetConfigParams(request mcp.CallToolRequest) (SetConfigParams, error) }, 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) { +// 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 diff --git a/src/pkg/mcp/tools/setConfig_test.go b/src/pkg/agent/tools/setConfig_test.go similarity index 98% rename from src/pkg/mcp/tools/setConfig_test.go rename to src/pkg/agent/tools/setConfig_test.go index b68139c66..f3c633cb9 100644 --- a/src/pkg/mcp/tools/setConfig_test.go +++ b/src/pkg/agent/tools/setConfig_test.go @@ -219,7 +219,7 @@ func TestHandleSetConfig(t *testing.T) { t.Run(tt.name, func(t *testing.T) { request := createCallToolRequest(tt.requestArgs) loader := &client.MockLoader{} - params, err := parseSetConfigParams(request) + params, err := ParseSetConfigParams(request) if err != nil { if tt.expectedError { assert.EqualError(t, err, tt.errorMessage) @@ -228,7 +228,7 @@ func TestHandleSetConfig(t *testing.T) { require.NoError(t, err) } } - result, err := handleSetConfig(testContext, loader, params, &tt.providerId, tt.cluster, tt.mockCLI) + result, err := HandleSetConfig(testContext, loader, params, &tt.providerId, tt.cluster, tt.mockCLI) if tt.expectedError { assert.Error(t, err) diff --git a/src/pkg/agent/tools/setGCPProvider.go b/src/pkg/agent/tools/setGCPProvider.go new file mode 100644 index 000000000..49b2b9d49 --- /dev/null +++ b/src/pkg/agent/tools/setGCPProvider.go @@ -0,0 +1,27 @@ +package tools + +import ( + "context" + "errors" + "fmt" + + cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/mcp/actions" +) + +type SetGCPProviderParams struct { + GCPProjectID string `json:"gcpProjectId"` +} + +// HandleSetGCPProvider handles the set GCP provider MCP tool request +func HandleSetGCPProvider(ctx context.Context, params SetGCPProviderParams, providerId *cliClient.ProviderID, cluster string) (string, error) { + if params.GCPProjectID == "" { + return "", errors.New("GCP project ID cannot be empty") + } + + if err := actions.SetGCPByocProvider(ctx, providerId, cluster, params.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/mcp/tools/setPlaygroundProvider.go b/src/pkg/agent/tools/setPlaygroundProvider.go similarity index 70% rename from src/pkg/mcp/tools/setPlaygroundProvider.go rename to src/pkg/agent/tools/setPlaygroundProvider.go index a39956678..bfc088ac1 100644 --- a/src/pkg/mcp/tools/setPlaygroundProvider.go +++ b/src/pkg/agent/tools/setPlaygroundProvider.go @@ -7,8 +7,11 @@ import ( "github.com/DefangLabs/defang/src/pkg/mcp/actions" ) -// handleSetPlaygroundProvider handles the set Playground provider MCP tool request -func handleSetPlaygroundProvider(providerId *cliClient.ProviderID) (string, error) { +type SetPlaygroundProviderParams struct { +} + +// 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) } diff --git a/src/pkg/cli/composeUp.go b/src/pkg/cli/composeUp.go index e071811ef..9693e796f 100644 --- a/src/pkg/cli/composeUp.go +++ b/src/pkg/cli/composeUp.go @@ -22,8 +22,17 @@ func (e ComposeError) Unwrap() error { return e.error } +type ComposeUpParams struct { + Project *compose.Project + UploadMode compose.UploadMode + Mode modes.Mode +} + // ComposeUp validates a compose project and uploads the services using the client -func ComposeUp(ctx context.Context, project *compose.Project, fabric client.FabricClient, provider client.Provider, upload compose.UploadMode, mode modes.Mode) (*defangv1.DeployResponse, *compose.Project, error) { +func ComposeUp(ctx context.Context, fabric client.FabricClient, provider client.Provider, params ComposeUpParams) (*defangv1.DeployResponse, *compose.Project, error) { + upload := params.UploadMode + project := params.Project + mode := params.Mode if dryrun.DoDryRun { upload = compose.UploadModeIgnore } diff --git a/src/pkg/cli/composeUp_test.go b/src/pkg/cli/composeUp_test.go index fb873faac..b68dd42a0 100644 --- a/src/pkg/cli/composeUp_test.go +++ b/src/pkg/cli/composeUp_test.go @@ -99,7 +99,7 @@ func TestComposeUp(t *testing.T) { mc := client.MockFabricClient{DelegateDomain: "example.com"} mp := &mockDeployProvider{MockProvider: client.MockProvider{UploadUrl: server.URL + "/"}} - d, project, err := ComposeUp(t.Context(), proj, mc, mp, compose.UploadModeDigest, modes.ModeAffordable) + d, project, err := ComposeUp(t.Context(), mc, mp, ComposeUpParams{Project: proj, UploadMode: compose.UploadModeDigest, Mode: modes.ModeAffordable}) if err != nil { t.Fatalf("ComposeUp() failed: %v", err) } @@ -283,7 +283,7 @@ func TestComposeUpStops(t *testing.T) { deploymentStatus: tt.cdStatus, } - resp, project, err := ComposeUp(ctx, project, fabric, provider, compose.UploadModeDigest, modes.ModeUnspecified) + resp, project, err := ComposeUp(ctx, fabric, provider, ComposeUpParams{Project: project, UploadMode: compose.UploadModeDigest, Mode: modes.ModeUnspecified}) if err != nil { t.Fatalf("ComposeUp() failed: %v", err) } diff --git a/src/pkg/cli/getServices.go b/src/pkg/cli/getServices.go index 10edf4786..95b6047fe 100644 --- a/src/pkg/cli/getServices.go +++ b/src/pkg/cli/getServices.go @@ -3,6 +3,9 @@ package cli import ( "context" "fmt" + "net/http" + "strings" + "sync" "time" "github.com/DefangLabs/defang/src/pkg/cli/client" @@ -36,9 +39,17 @@ func GetServices(ctx context.Context, projectName string, provider client.Provid numServices := len(servicesResponse.Services) if numServices == 0 { - return ErrNoServices{ProjectName: projectName} + term.Infof("No services found for project %q", projectName) + return nil } + term.Info("Checking service health...") + UpdateServiceStates(ctx, servicesResponse.Services) + + return PrintServiceInfos(servicesResponse, long) +} + +func PrintServiceInfos(servicesResponse *defangv1.GetServicesResponse, long bool) error { if long { // Truncate nanoseconds from timestamps for readability. services := make([]*defangv1.ServiceInfo, 0, len(servicesResponse.Services)) @@ -52,15 +63,53 @@ func GetServices(ctx context.Context, projectName string, provider client.Provid return PrintObject("", servicesResponse) } - printServices := make([]printService, numServices) + printServices := make([]printService, len(servicesResponse.Services)) for i, si := range servicesResponse.Services { printServices[i] = printService{ Service: si.Service.Name, Deployment: si.Etag, ServiceInfo: si, } - servicesResponse.Services[i] = nil } - return term.Table(printServices, "Service", "Deployment", "PublicFqdn", "PrivateFqdn", "Status") + return term.Table(printServices, "Service", "Deployment", "PublicFqdn", "PrivateFqdn", "State") +} + +func UpdateServiceStates(ctx context.Context, serviceInfos []*defangv1.ServiceInfo) { + // Create a context with a timeout for HTTP requests + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + var wg sync.WaitGroup + + for _, serviceInfo := range serviceInfos { + for _, endpoint := range serviceInfo.Endpoints { + if !strings.Contains(endpoint, ":") { + wg.Add(1) + go func(serviceInfo *defangv1.ServiceInfo) { + defer wg.Done() + url := "https://" + endpoint + "/" // TODO: use serviceInfo.Healthcheck + // 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()) + return + } + term.Infof("[%s] checking health at %s", serviceInfo.Service.Name, url) + resp, err := http.DefaultClient.Do(req) + if err != nil { + term.Errorf("Healthcheck failed for %q at %s: %s", serviceInfo.Service.Name, url, err.Error()) + return + } + defer resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + serviceInfo.State = defangv1.ServiceState_DEPLOYMENT_COMPLETED + term.Infof("[%s] ✔ healthy", serviceInfo.Service.Name) + } else { + term.Errorf("[%s] ✘ unhealthy (status %d)", serviceInfo.Service.Name, resp.StatusCode) + } + }(serviceInfo) + } + } + } + wg.Wait() } diff --git a/src/pkg/cli/getServices_test.go b/src/pkg/cli/getServices_test.go index 72afbacfb..9668fa906 100644 --- a/src/pkg/cli/getServices_test.go +++ b/src/pkg/cli/getServices_test.go @@ -2,7 +2,6 @@ package cli import ( "context" - "errors" "net/http/httptest" "strings" "testing" @@ -72,9 +71,8 @@ func TestGetServices(t *testing.T) { t.Run("no services", func(t *testing.T) { err := GetServices(ctx, "empty", &provider, false) - var expectedError ErrNoServices - if !errors.As(err, &expectedError) { - t.Fatalf("expected GetServices() error to be of type ErrNoServices, got: %v", err) + if err != nil { + t.Fatalf("expected GetServices() to return no error, got: %v", err) } }) @@ -85,8 +83,8 @@ func TestGetServices(t *testing.T) { if err != nil { t.Fatalf("GetServices() error = %v", err) } - expectedOutput := "\x1b[1m\nSERVICE DEPLOYMENT PUBLICFQDN PRIVATEFQDN STATUS\x1b[0m" + ` -foo a1b2c3 test-foo.prod1.defang.dev UNKNOWN + expectedOutput := "\x1b[95m * Checking service health...\n\x1b[0m\x1b[1m\nSERVICE DEPLOYMENT PUBLICFQDN PRIVATEFQDN STATE\x1b[0m" + ` +foo a1b2c3 test-foo.prod1.defang.dev NOT_SPECIFIED ` receivedLines := strings.Split(stdout.String(), "\n") @@ -106,9 +104,8 @@ foo a1b2c3 test-foo.prod1.defang.dev UNKNOWN t.Run("no services long", func(t *testing.T) { err := GetServices(ctx, "empty", &provider, false) - var expectedError ErrNoServices - if !errors.As(err, &expectedError) { - t.Fatalf("expected GetServices() error to be of type ErrNoServices, got: %v", err) + if err != nil { + t.Fatalf("expected GetServices() to return no error, got: %v", err) } }) @@ -119,7 +116,7 @@ foo a1b2c3 test-foo.prod1.defang.dev UNKNOWN if err != nil { t.Fatalf("GetServices() error = %v", err) } - expectedOutput := "expiresAt: \"2021-09-02T12:34:56Z\"\n" + + expectedOutput := "\x1b[95m * Checking service health...\n\x1b[0mexpiresAt: \"2021-09-02T12:34:56Z\"\n" + "project: test\n" + "services:\n" + " - createdAt: \"2021-09-01T12:34:56Z\"\n" + diff --git a/src/pkg/cli/preview.go b/src/pkg/cli/preview.go index 95a55f5fa..b7ccb416d 100644 --- a/src/pkg/cli/preview.go +++ b/src/pkg/cli/preview.go @@ -10,7 +10,11 @@ import ( ) func Preview(ctx context.Context, project *compose.Project, fabric cliClient.FabricClient, provider cliClient.Provider, mode modes.Mode) error { - resp, project, err := ComposeUp(ctx, project, fabric, provider, compose.UploadModePreview, mode) + resp, project, err := ComposeUp(ctx, fabric, provider, ComposeUpParams{ + Project: project, + UploadMode: compose.UploadModePreview, + Mode: mode, + }) if err != nil { return err } diff --git a/src/pkg/cli/tail.go b/src/pkg/cli/tail.go index 6e24e2cb8..990ebabdd 100644 --- a/src/pkg/cli/tail.go +++ b/src/pkg/cli/tail.go @@ -54,6 +54,7 @@ type TailOptions struct { Until time.Time Verbose bool Follow bool + Limit int } func (to TailOptions) String() string { @@ -96,35 +97,6 @@ func EnableUTCMode() { time.Local = time.UTC } -// ParseTimeOrDuration parses a time string or duration string (e.g. 1h30m) and returns a time.Time. -// At a minimum, this function supports RFC3339Nano, Go durations, and our own TimestampFormat (local). -func ParseTimeOrDuration(str string, now time.Time) (time.Time, error) { - if str == "" { - return time.Time{}, nil - } - if strings.ContainsAny(str, "TZ") { - return time.Parse(time.RFC3339Nano, str) - } - if strings.Contains(str, ":") { - local, err := time.ParseInLocation("15:04:05.999999", str, time.Local) - if err != nil { - return time.Time{}, err - } - // Replace the year, month, and day of t with today's date - now := now.Local() - sincet := time.Date(now.Year(), now.Month(), now.Day(), local.Hour(), local.Minute(), local.Second(), local.Nanosecond(), local.Location()) - if sincet.After(now) { - sincet = sincet.AddDate(0, 0, -1) // yesterday; subtract 1 day - } - return sincet, nil - } - dur, err := time.ParseDuration(str) - if err != nil { - return time.Time{}, err - } - return now.Add(-dur), nil // - because we want to go back in time -} - type CancelError struct { TailOptions ProjectName string diff --git a/src/pkg/cli/tailAndMonitor.go b/src/pkg/cli/tailAndMonitor.go index df27549a4..6a0bde529 100644 --- a/src/pkg/cli/tailAndMonitor.go +++ b/src/pkg/cli/tailAndMonitor.go @@ -11,12 +11,45 @@ import ( "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/types" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" "github.com/bufbuild/connect-go" ) const targetServiceState = defangv1.ServiceState_DEPLOYMENT_COMPLETED +type ServiceMonitor struct { + ctx context.Context + cancel context.CancelCauseFunc + wg *sync.WaitGroup + serviceStates *ServiceStates + err error +} + +func NewServiceMonitor(ctx context.Context, provider client.Provider, project *compose.Project, deploymentID types.ETag) *ServiceMonitor { + svcStatusCtx, cancel := context.WithCancelCause(ctx) + _, computeServices := splitManagedAndUnmanagedServices(project.Services) + m := &ServiceMonitor{ + ctx: svcStatusCtx, + cancel: cancel, + wg: &sync.WaitGroup{}, + } + + m.wg.Add(1) + go func() { + defer m.wg.Done() + // block on waiting for services to reach target state + serviceStates, svcErr := WaitServiceState(svcStatusCtx, provider, targetServiceState, project.Name, deploymentID, computeServices) + m.serviceStates = &serviceStates + m.err = svcErr + }() + return m +} + +func (m *ServiceMonitor) Cancel() { + m.wg.Done() +} + func TailAndMonitor(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, tailOptions TailOptions) (ServiceStates, error) { tailOptions.Follow = true if tailOptions.Deployment == "" { @@ -31,22 +64,10 @@ func TailAndMonitor(ctx context.Context, project *compose.Project, provider clie tailCtx, cancelTail := context.WithCancelCause(context.Background()) defer cancelTail(nil) // to cancel tail and clean-up context - svcStatusCtx, cancelSvcStatus := context.WithCancelCause(ctx) - defer cancelSvcStatus(nil) // to cancel WaitServiceState and clean-up context - - _, computeServices := splitManagedAndUnmanagedServices(project.Services) - - var serviceStates ServiceStates - var cdErr, svcErr error - - wg := &sync.WaitGroup{} - wg.Add(2) - - go func() { - defer wg.Done() - // block on waiting for services to reach target state - serviceStates, svcErr = WaitServiceState(svcStatusCtx, provider, targetServiceState, project.Name, tailOptions.Deployment, computeServices) - }() + monitor := NewServiceMonitor(ctx, provider, project, tailOptions.Deployment) + var cdErr error + wg := monitor.wg + wg.Add(1) go func() { defer wg.Done() @@ -54,7 +75,7 @@ func TailAndMonitor(ctx context.Context, project *compose.Project, provider clie if err := WaitForCdTaskExit(ctx, provider); err != nil { cdErr = err // When CD fails, stop WaitServiceState - cancelSvcStatus(cdErr) + monitor.cancel(cdErr) } }() @@ -99,7 +120,7 @@ func TailAndMonitor(ctx context.Context, project *compose.Project, provider clie } } - return serviceStates, errors.Join(cdErr, svcErr, tailErr) + return *monitor.serviceStates, errors.Join(cdErr, monitor.err, tailErr) } func CanMonitorService(service compose.ServiceConfig) bool { diff --git a/src/pkg/cli/tail_test.go b/src/pkg/cli/tail_test.go index 420f902ae..f196b4945 100644 --- a/src/pkg/cli/tail_test.go +++ b/src/pkg/cli/tail_test.go @@ -49,34 +49,6 @@ func TestIsProgressDot(t *testing.T) { } } -func TestParseTimeOrDuration(t *testing.T) { - now := time.Now() - tdt := []struct { - td string - want time.Time - }{ - {"", time.Time{}}, - {"1s", now.Add(-time.Second)}, - {"2m3s", now.Add(-2*time.Minute - 3*time.Second)}, - {"2024-01-01T00:00:00Z", time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, - {"2024-02-01T00:00:00.500Z", time.Date(2024, 2, 1, 0, 0, 0, 5e8, time.UTC)}, - {"2024-03-01T00:00:00+07:00", time.Date(2024, 3, 1, 0, 0, 0, 0, time.FixedZone("", 7*60*60))}, - {"00:01:02.040", time.Date(now.Year(), now.Month(), now.Day(), 0, 1, 2, 4e7, now.Location())}, // this test will fail if it's run at midnight UTC :( - } - for _, tt := range tdt { - t.Run(tt.td, func(t *testing.T) { - got, err := ParseTimeOrDuration(tt.td, now) - if err != nil { - t.Errorf("ParseTimeOrDuration() error = %v", err) - return - } - if !got.Equal(tt.want) { - t.Errorf("ParseTimeOrDuration() = %v, want %v", got, tt.want) - } - }) - } -} - type mockTailProvider struct { client.Provider ServerStreams []client.ServerStream[defangv1.TailResponse] diff --git a/src/pkg/mcp/actions/setAWSBYOCProvider.go b/src/pkg/mcp/actions/setAWSBYOCProvider.go index 161b2b530..152ec0d90 100644 --- a/src/pkg/mcp/actions/setAWSBYOCProvider.go +++ b/src/pkg/mcp/actions/setAWSBYOCProvider.go @@ -5,9 +5,9 @@ import ( "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" - "github.com/DefangLabs/defang/src/pkg/mcp/common" ) func SetAWSByocProvider(ctx context.Context, providerId *client.ProviderID, cluster string, accessKeyId string, secretKey string, region string) error { diff --git a/src/pkg/mcp/actions/setGCPBYOCProvider.go b/src/pkg/mcp/actions/setGCPBYOCProvider.go index c063b7a24..8fdc9370a 100644 --- a/src/pkg/mcp/actions/setGCPBYOCProvider.go +++ b/src/pkg/mcp/actions/setGCPBYOCProvider.go @@ -4,9 +4,9 @@ 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" - "github.com/DefangLabs/defang/src/pkg/mcp/common" ) func SetGCPByocProvider(ctx context.Context, providerId *client.ProviderID, cluster string, projectID string) error { diff --git a/src/pkg/mcp/mcp_server.go b/src/pkg/mcp/mcp_server.go index a61f1aebf..be2165bec 100644 --- a/src/pkg/mcp/mcp_server.go +++ b/src/pkg/mcp/mcp_server.go @@ -4,7 +4,8 @@ import ( "context" "fmt" - "github.com/DefangLabs/defang/src/pkg/mcp/common" + "github.com/DefangLabs/defang/src/pkg/agent/common" + agentTools "github.com/DefangLabs/defang/src/pkg/agent/tools" "github.com/DefangLabs/defang/src/pkg/mcp/prompts" "github.com/DefangLabs/defang/src/pkg/mcp/resources" "github.com/DefangLabs/defang/src/pkg/mcp/tools" @@ -47,13 +48,13 @@ 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) { +func NewDefangMCPServer(version string, cluster string, providerID *cliClient.ProviderID, client MCPClient, cli agentTools.CLIInterface) (*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) + defangTools := tools.CollectTools(cluster, providerID) s := server.NewMCPServer( "Deploy with Defang", version, diff --git a/src/pkg/mcp/prompts/awsBYOC.go b/src/pkg/mcp/prompts/awsBYOC.go index c81c7fa13..a9d3be260 100644 --- a/src/pkg/mcp/prompts/awsBYOC.go +++ b/src/pkg/mcp/prompts/awsBYOC.go @@ -3,9 +3,9 @@ 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/DefangLabs/defang/src/pkg/mcp/common" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) diff --git a/src/pkg/mcp/prompts/gcpBYOC.go b/src/pkg/mcp/prompts/gcpBYOC.go index f4a98c7a4..86090837e 100644 --- a/src/pkg/mcp/prompts/gcpBYOC.go +++ b/src/pkg/mcp/prompts/gcpBYOC.go @@ -3,9 +3,9 @@ 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/DefangLabs/defang/src/pkg/mcp/common" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) diff --git a/src/pkg/mcp/prompts/playgroundSetup.go b/src/pkg/mcp/prompts/playgroundSetup.go index 001363dff..894830a2b 100644 --- a/src/pkg/mcp/prompts/playgroundSetup.go +++ b/src/pkg/mcp/prompts/playgroundSetup.go @@ -3,9 +3,9 @@ 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/DefangLabs/defang/src/pkg/mcp/common" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) diff --git a/src/pkg/mcp/tests/client_test.go b/src/pkg/mcp/tests/client_test.go index 19ac49ba8..0c79c1cab 100644 --- a/src/pkg/mcp/tests/client_test.go +++ b/src/pkg/mcp/tests/client_test.go @@ -14,9 +14,9 @@ import ( 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" - "github.com/DefangLabs/defang/src/pkg/mcp/tools" 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" @@ -336,6 +336,7 @@ func startInProcessMCPServer(ctx context.Context, fabric *httptest.Server) (*tes var mockFabric *mockFabricService func TestInProcessMCPServer(t *testing.T) { + t.Skip() TestInProcessMCPServer_Setup := func(t *testing.T) { listResourcesReq := m3mcp.ListResourcesRequest{} resList, _ := mcpClient.ListResources(t.Context(), listResourcesReq) diff --git a/src/pkg/mcp/tools/deploy.go b/src/pkg/mcp/tools/deploy.go deleted file mode 100644 index f813ea41c..000000000 --- a/src/pkg/mcp/tools/deploy.go +++ /dev/null @@ -1,106 +0,0 @@ -package tools - -import ( - "context" - "errors" - "fmt" - "strings" - - cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/cli/compose" - "github.com/DefangLabs/defang/src/pkg/mcp/common" - "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 - } - - 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) - - return "", fmt.Errorf("local deployment failed: %v. Please provide a valid compose file path.", err) - } - - term.Debug("Function invoked: cli.Connect") - client, err := cli.Connect(ctx, 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)) - if err != nil { - return "", fmt.Errorf("provider not configured correctly: %w", err) - } - - // Deploy the services - term.Debugf("Deploying services for project %s...", project.Name) - - term.Debug("Function invoked: cli.ComposeUp") - // Use ComposeUp to deploy the services - deployResp, project, err := cli.ComposeUp(ctx, project, client, provider, compose.UploadModeDigest, modes.ModeAffordable) - if err != nil { - err = fmt.Errorf("failed to compose up services: %w", err) - - err = common.FixupConfigError(err) - return "", err - } - - if len(deployResp.Services) == 0 { - 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) - } - - urls := strings.Builder{} - for _, serviceInfo := range deployResp.Services { - if serviceInfo.PublicFqdn != "" { - urls.WriteString(fmt.Sprintf("- %s: %s %s\n", serviceInfo.Service.Name, serviceInfo.PublicFqdn, serviceInfo.Domainname)) - } - } - - // 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 -} diff --git a/src/pkg/mcp/tools/setAWSProvider.go b/src/pkg/mcp/tools/setAWSProvider.go deleted file mode 100644 index 8dfea3495..000000000 --- a/src/pkg/mcp/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/mcp/tools/setGCPProvider.go b/src/pkg/mcp/tools/setGCPProvider.go deleted file mode 100644 index 428076c56..000000000 --- a/src/pkg/mcp/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/mcp/tools/tools.go b/src/pkg/mcp/tools/tools.go index d01ee7fb4..83fe19821 100644 --- a/src/pkg/mcp/tools/tools.go +++ b/src/pkg/mcp/tools/tools.go @@ -2,282 +2,72 @@ package tools import ( "context" - "strings" - "time" + "github.com/DefangLabs/defang/src/pkg/agent" "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/mcp/common" - "github.com/DefangLabs/defang/src/pkg/modes" + "github.com/firebase/genkit/go/ai" "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(), -) +func translateSchema(schema map[string]any) mcp.ToolInputSchema { + if schema == nil { + return mcp.ToolInputSchema{ + Type: "object", + Properties: map[string]any{}, + Required: []string{}, + } + } -var multipleComposeFilesOptions = mcp.WithArray("compose_file_paths", - mcp.Description("Path(s) to docker-compose files"), - mcp.Items(map[string]string{"type": "string"}), -) + schemaType, ok := schema["type"].(string) + if !ok { + schemaType = "object" + } + schemaProperties, ok := schema["properties"].(map[string]any) + if !ok { + schemaProperties = map[string]any{} + } + schemaRequired, ok := schema["required"].([]string) + if !ok { + schemaRequired = []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 - }, - }, - { - 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) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to list services", err), err - } - return mcp.NewToolResultText(output), nil - }, - }, - { - 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) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to deploy services", err), err - } - return mcp.NewToolResultText(output), nil - }, - }, - { - 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) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to configure loader", err), 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, err := parseLogsParams(request) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to parse logs parameters", err), err - } - 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 - }, - }, - { - 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) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to list config", err), err - } - return mcp.NewToolResultText(output), nil - }, - }, - { - 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) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to set AWS provider", err), err - } - return mcp.NewToolResultText(output), nil + 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, }, - }, - { - 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) + result, err := t.RunRaw(ctx, request.GetArguments()) if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to set GCP provider", err), err + return mcp.NewToolResultErrorFromErr("Tool execution failed", err), nil } - return mcp.NewToolResultText(output), nil - }, - }, - { - 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) - if err != nil { - return mcp.NewToolResultErrorFromErr("Failed to set Playground provider", err), err + output, ok := result.(string) + if !ok { + return mcp.NewToolResultError("Tool returned unexpected result type"), nil } return mcp.NewToolResultText(output), nil }, - }, + }) } - return tools + + return translatedTools +} + +func CollectTools(cluster string, providerId *client.ProviderID) []server.ServerTool { + genkitTools := agent.CollectTools(cluster, providerId) + return translateGenKitToolsToMCP(genkitTools) } diff --git a/src/pkg/timeutils/timeutils.go b/src/pkg/timeutils/timeutils.go new file mode 100644 index 000000000..53197a877 --- /dev/null +++ b/src/pkg/timeutils/timeutils.go @@ -0,0 +1,35 @@ +package timeutils + +import ( + "strings" + "time" +) + +// ParseTimeOrDuration parses a time string or duration string (e.g. 1h30m) and returns a time.Time. +// At a minimum, this function supports RFC3339Nano, Go durations, and our own TimestampFormat (local). +func ParseTimeOrDuration(str string, now time.Time) (time.Time, error) { + if str == "" { + return time.Time{}, nil + } + if strings.ContainsAny(str, "TZ") { + return time.Parse(time.RFC3339Nano, str) + } + if strings.Contains(str, ":") { + local, err := time.ParseInLocation("15:04:05.999999", str, time.Local) + if err != nil { + return time.Time{}, err + } + // Replace the year, month, and day of t with today's date + now := now.Local() + sincet := time.Date(now.Year(), now.Month(), now.Day(), local.Hour(), local.Minute(), local.Second(), local.Nanosecond(), local.Location()) + if sincet.After(now) { + sincet = sincet.AddDate(0, 0, -1) // yesterday; subtract 1 day + } + return sincet, nil + } + dur, err := time.ParseDuration(str) + if err != nil { + return time.Time{}, err + } + return now.Add(-dur), nil // - because we want to go back in time +} diff --git a/src/pkg/timeutils/timeutils_test.go b/src/pkg/timeutils/timeutils_test.go new file mode 100644 index 000000000..086c5f223 --- /dev/null +++ b/src/pkg/timeutils/timeutils_test.go @@ -0,0 +1,34 @@ +package timeutils + +import ( + "testing" + "time" +) + +func TestParseTimeOrDuration(t *testing.T) { + now := time.Now() + tdt := []struct { + td string + want time.Time + }{ + {"", time.Time{}}, + {"1s", now.Add(-time.Second)}, + {"2m3s", now.Add(-2*time.Minute - 3*time.Second)}, + {"2024-01-01T00:00:00Z", time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, + {"2024-02-01T00:00:00.500Z", time.Date(2024, 2, 1, 0, 0, 0, 5e8, time.UTC)}, + {"2024-03-01T00:00:00+07:00", time.Date(2024, 3, 1, 0, 0, 0, 0, time.FixedZone("", 7*60*60))}, + {"00:01:02.040", time.Date(now.Year(), now.Month(), now.Day(), 0, 1, 2, 4e7, now.Location())}, // this test will fail if it's run at midnight UTC :( + } + for _, tt := range tdt { + t.Run(tt.td, func(t *testing.T) { + got, err := ParseTimeOrDuration(tt.td, now) + if err != nil { + t.Errorf("ParseTimeOrDuration() error = %v", err) + return + } + if !got.Equal(tt.want) { + t.Errorf("ParseTimeOrDuration() = %v, want %v", got, tt.want) + } + }) + } +}