diff --git a/docs/examples/provider.go b/docs/examples/provider.go index 6223a0f2481..e500e8d5c51 100644 --- a/docs/examples/provider.go +++ b/docs/examples/provider.go @@ -17,11 +17,13 @@ package main import ( + "encoding/json" "fmt" "os" "time" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) func main() { @@ -43,16 +45,27 @@ func composeCommand() *cobra.Command { TraverseChildren: true, } c.PersistentFlags().String("project-name", "", "compose project name") // unused - c.AddCommand(&cobra.Command{ + upCmd := &cobra.Command{ Use: "up", Run: up, Args: cobra.ExactArgs(1), - }) - c.AddCommand(&cobra.Command{ + } + upCmd.Flags().String("type", "", "Database type (mysql, postgres, etc.)") + _ = upCmd.MarkFlagRequired("type") + upCmd.Flags().Int("size", 10, "Database size in GB") + upCmd.Flags().String("name", "", "Name of the database to be created") + _ = upCmd.MarkFlagRequired("name") + + downCmd := &cobra.Command{ Use: "down", Run: down, Args: cobra.ExactArgs(1), - }) + } + downCmd.Flags().String("name", "", "Name of the database to be deleted") + _ = downCmd.MarkFlagRequired("name") + + c.AddCommand(upCmd, downCmd) + c.AddCommand(metadataCommand(upCmd, downCmd)) return c } @@ -72,3 +85,58 @@ func up(_ *cobra.Command, args []string) { func down(_ *cobra.Command, _ []string) { fmt.Printf(`{ "type": "error", "message": "Permission error" }%s`, lineSeparator) } + +func metadataCommand(upCmd, downCmd *cobra.Command) *cobra.Command { + return &cobra.Command{ + Use: "metadata", + Run: func(cmd *cobra.Command, _ []string) { + metadata(upCmd, downCmd) + }, + Args: cobra.NoArgs, + } +} + +func metadata(upCmd, downCmd *cobra.Command) { + metadata := ProviderMetadata{} + metadata.Description = "Manage services on AwesomeCloud" + metadata.Up = commandParameters(upCmd) + metadata.Down = commandParameters(downCmd) + jsonMetadata, err := json.Marshal(metadata) + if err != nil { + panic(err) + } + fmt.Println(string(jsonMetadata)) +} + +func commandParameters(cmd *cobra.Command) CommandMetadata { + cmdMetadata := CommandMetadata{} + cmd.Flags().VisitAll(func(f *pflag.Flag) { + _, isRequired := f.Annotations[cobra.BashCompOneRequiredFlag] + cmdMetadata.Parameters = append(cmdMetadata.Parameters, Metadata{ + Name: f.Name, + Description: f.Usage, + Required: isRequired, + Type: f.Value.Type(), + Default: f.DefValue, + }) + }) + return cmdMetadata +} + +type ProviderMetadata struct { + Description string `json:"description"` + Up CommandMetadata `json:"up"` + Down CommandMetadata `json:"down"` +} + +type CommandMetadata struct { + Parameters []Metadata `json:"parameters"` +} + +type Metadata struct { + Name string `json:"name"` + Description string `json:"description"` + Required bool `json:"required"` + Type string `json:"type"` + Default string `json:"default,omitempty"` +} diff --git a/docs/extension.md b/docs/extension.md index f0d5cb9e9b7..8991b63ca80 100644 --- a/docs/extension.md +++ b/docs/extension.md @@ -20,6 +20,7 @@ the resource(s) needed to run a service. options: type: mysql size: 256 + name: myAwesomeCloudDB ``` `provider.type` tells Compose the binary to run, which can be either: @@ -104,8 +105,72 @@ into its runtime environment. ## Down lifecycle `down` lifecycle is equivalent to `up` with the ` compose --project-name down ` command. -The provider is responsible for releasing all resources associated with the service. +The provider is responsible for releasing all resources associated with the service. + +## Provide metadata about options + +Compose extensions *MAY* optionally implement a `metadata` subcommand to provide information about the parameters accepted by the `up` and `down` commands. + +The `metadata` subcommand takes no parameters and returns a JSON structure on the `stdout` channel that describes the parameters accepted by both the `up` and `down` commands, including whether each parameter is mandatory or optional. + +```console +awesomecloud compose metadata +``` + +The expected JSON output format is: +```json +{ + "description": "Manage services on AwesomeCloud", + "up": { + "parameters": [ + { + "name": "type", + "description": "Database type (mysql, postgres, etc.)", + "required": true, + "type": "string" + }, + { + "name": "size", + "description": "Database size in GB", + "required": false, + "type": "integer", + "default": "10" + }, + { + "name": "name", + "description": "Name of the database to be created", + "required": true, + "type": "string" + } + ] + }, + "down": { + "parameters": [ + { + "name": "name", + "description": "Name of the database to be removed", + "required": true, + "type": "string" + } + ] + } +} +``` +The top elements are: +- `description`: Human-readable description of the provider +- `up`: Object describing the parameters accepted by the `up` command +- `down`: Object describing the parameters accepted by the `down` command + +And for each command parameter, you should include the following properties: +- `name`: The parameter name (without `--` prefix) +- `description`: Human-readable description of the parameter +- `required`: Boolean indicating if the parameter is mandatory +- `type`: Parameter type (`string`, `integer`, `boolean`, etc.) +- `default`: Default value (optional, only for non-required parameters) +- `enum`: List of possible values supported by the parameter separated by `,` (optional, only for parameters with a limited set of values) + +This metadata allows Compose and other tools to understand the provider's interface and provide better user experience, such as validation, auto-completion, and documentation generation. ## Examples -See [example](examples/provider.go) for illustration on implementing this API in a command line \ No newline at end of file +See [example](examples/provider.go) for illustration on implementing this API in a command line diff --git a/pkg/compose/plugins.go b/pkg/compose/plugins.go index bbf2c2ff698..756a4b09eed 100644 --- a/pkg/compose/plugins.go +++ b/pkg/compose/plugins.go @@ -17,6 +17,7 @@ package compose import ( + "bytes" "context" "encoding/json" "errors" @@ -24,11 +25,13 @@ import ( "io" "os" "os/exec" + "path/filepath" "strings" "github.com/compose-spec/compose-go/v2/types" "github.com/docker/cli/cli-plugins/manager" "github.com/docker/cli/cli-plugins/socket" + "github.com/docker/cli/cli/config" "github.com/docker/compose/v2/pkg/progress" "github.com/sirupsen/logrus" "github.com/spf13/cobra" @@ -42,10 +45,11 @@ type JsonMessage struct { } const ( - ErrorType = "error" - InfoType = "info" - SetEnvType = "setenv" - DebugType = "debug" + ErrorType = "error" + InfoType = "info" + SetEnvType = "setenv" + DebugType = "debug" + providerMetadataDirectory = "compose/providers" ) func (s *composeService) runPlugin(ctx context.Context, project *types.Project, service types.ServiceConfig, command string) error { @@ -56,7 +60,10 @@ func (s *composeService) runPlugin(ctx context.Context, project *types.Project, return err } - cmd := s.setupPluginCommand(ctx, project, service, plugin, command) + cmd, err := s.setupPluginCommand(ctx, project, service, plugin, command) + if err != nil { + return err + } variables, err := s.executePlugin(ctx, cmd, command, service) if err != nil { @@ -160,13 +167,27 @@ func (s *composeService) getPluginBinaryPath(provider string) (path string, err return path, err } -func (s *composeService) setupPluginCommand(ctx context.Context, project *types.Project, service types.ServiceConfig, path, command string) *exec.Cmd { +func (s *composeService) setupPluginCommand(ctx context.Context, project *types.Project, service types.ServiceConfig, path, command string) (*exec.Cmd, error) { + cmdOptionsMetadata := s.getPluginMetadata(path, service.Provider.Type) + var currentCommandMetadata CommandMetadata + switch command { + case "up": + currentCommandMetadata = cmdOptionsMetadata.Up + case "down": + currentCommandMetadata = cmdOptionsMetadata.Down + } + commandMetadataIsEmpty := len(currentCommandMetadata.Parameters) == 0 provider := *service.Provider + if err := currentCommandMetadata.CheckRequiredParameters(provider); !commandMetadataIsEmpty && err != nil { + return nil, err + } args := []string{"compose", "--project-name", project.Name, command} for k, v := range provider.Options { for _, value := range v { - args = append(args, fmt.Sprintf("--%s=%s", k, value)) + if _, ok := currentCommandMetadata.GetParameter(k); commandMetadataIsEmpty || ok { + args = append(args, fmt.Sprintf("--%s=%s", k, value)) + } } } args = append(args, service.Name) @@ -196,5 +217,73 @@ func (s *composeService) setupPluginCommand(ctx context.Context, project *types. carrier := propagation.MapCarrier{} otel.GetTextMapPropagator().Inject(ctx, &carrier) cmd.Env = append(cmd.Env, types.Mapping(carrier).Values()...) - return cmd + return cmd, nil +} + +func (s *composeService) getPluginMetadata(path, command string) ProviderMetadata { + cmd := exec.Command(path, "compose", "metadata") + stdout := &bytes.Buffer{} + cmd.Stdout = stdout + + if err := cmd.Run(); err != nil { + logrus.Debugf("failed to start plugin metadata command: %v", err) + return ProviderMetadata{} + } + + var metadata ProviderMetadata + if err := json.Unmarshal(stdout.Bytes(), &metadata); err != nil { + output, _ := io.ReadAll(stdout) + logrus.Debugf("failed to decode plugin metadata: %v - %s", err, output) + return ProviderMetadata{} + } + // Save metadata into docker home directory to be used by Docker LSP tool + // Just log the error as it's not a critical error for the main flow + metadataDir := filepath.Join(config.Dir(), providerMetadataDirectory) + if err := os.MkdirAll(metadataDir, 0o700); err == nil { + metadataFilePath := filepath.Join(metadataDir, command+".json") + if err := os.WriteFile(metadataFilePath, stdout.Bytes(), 0o600); err != nil { + logrus.Debugf("failed to save plugin metadata: %v", err) + } + } else { + logrus.Debugf("failed to create plugin metadata directory: %v", err) + } + return metadata +} + +type ProviderMetadata struct { + Description string `json:"description"` + Up CommandMetadata `json:"up"` + Down CommandMetadata `json:"down"` +} + +type CommandMetadata struct { + Parameters []ParameterMetadata `json:"parameters"` +} + +type ParameterMetadata struct { + Name string `json:"name"` + Description string `json:"description"` + Required bool `json:"required"` + Type string `json:"type"` + Default string `json:"default,omitempty"` +} + +func (c CommandMetadata) GetParameter(paramName string) (ParameterMetadata, bool) { + for _, p := range c.Parameters { + if p.Name == paramName { + return p, true + } + } + return ParameterMetadata{}, false +} + +func (c CommandMetadata) CheckRequiredParameters(provider types.ServiceProviderConfig) error { + for _, p := range c.Parameters { + if p.Required { + if _, ok := provider.Options[p.Name]; !ok { + return fmt.Errorf("required parameter %q is missing from provider %q definition", p.Name, provider.Type) + } + } + } + return nil }