diff --git a/docs/compozify.md b/docs/compozify.md index 9e1f894..2922b73 100644 --- a/docs/compozify.md +++ b/docs/compozify.md @@ -11,5 +11,6 @@ compozify is a tool mainly for converting docker run commands to docker compose ### SEE ALSO +* [compozify add-service](compozify_add-service.md) - Add a service to an existing docker-compose file * [compozify convert](compozify_convert.md) - convert docker run command to docker compose file diff --git a/docs/compozify_add-service.md b/docs/compozify_add-service.md new file mode 100644 index 0000000..6612fa2 --- /dev/null +++ b/docs/compozify_add-service.md @@ -0,0 +1,56 @@ +## compozify add-service + +Add a service to an existing docker-compose file + +### Synopsis + +Converts the docker run command to docker compose and adds as a new service to an existing docker-compose file. +If no file is specified, compozify will look for a docker compose file in the current directory. +If no file is found, compozify will create one in the current directory. +Expected file names are docker-compose.[yml,yaml], compose.[yml,yaml] + + +``` +compozify add-service [flags] DOCKER_RUN_COMMAND +``` + +### Examples + +``` + +# add service to existing docker-compose file in current directory +$ compozify add-service "docker run -i -t --rm alpine" + +# add service to existing docker-compose file +$ compozify add-service -f /path/to/docker-compose.yml "docker run -i -t --rm alpine" + +# write to file +$ compozify add-service -w -f /path/to/docker-compose.yml "docker run -i -t --rm alpine" + +# alternative usage specifying beginning of docker run command without quotes +$ compozify add-service -w -f /path/to/docker-compose.yml -- docker run -i -t --rm alpine + +# add service with custom name +$ compozify add-service -w -f /path/to/docker-compose.yml -n my-service "docker run -i -t --rm alpine" + +``` + +### Options + +``` + -f, --file string Compose file path + -h, --help help for add-service + -n, --service-name string Name of the service + -w, --write write to file +``` + +### Options inherited from parent commands + +``` + -v, --verbose verbose output +``` + +### SEE ALSO + +* [compozify](compozify.md) - compozify is a tool mainly for converting docker run commands to docker compose files + diff --git a/docs/compozify_convert.md b/docs/compozify_convert.md index 1aeca77..b4fcf7f 100644 --- a/docs/compozify_convert.md +++ b/docs/compozify_convert.md @@ -27,9 +27,10 @@ $ compozify convert -w -- docker run -i -t --rm alpine ### Options ``` - -h, --help help for convert - -o, --out string output file path (default "compose.yml") - -w, --write write to file + -a, --append-service append service to existing compose file. Requires --out flag + -h, --help help for convert + -o, --out string output file path (default "compose.yml") + -w, --write write to file ``` ### Options inherited from parent commands diff --git a/internal/commands/add-service.go b/internal/commands/add-service.go new file mode 100644 index 0000000..eacaf25 --- /dev/null +++ b/internal/commands/add-service.go @@ -0,0 +1,112 @@ +package commands + +import ( + "os" + + "github.com/rs/zerolog" + "github.com/spf13/cobra" + + "github.com/profclems/compozify/pkg/parser" +) + +type addServiceOpts struct { + Logger *zerolog.Logger + + File string + Command string + Write bool + ServiceName string +} + +func newAddServiceCmd(logger *zerolog.Logger) *cobra.Command { + opts := addServiceOpts{ + Logger: logger, + } + cmd := &cobra.Command{ + Use: "add-service [flags] DOCKER_RUN_COMMAND", + Short: "Add a service to an existing docker-compose file", + Long: `Converts the docker run command to docker compose and adds as a new service to an existing docker-compose file. +If no file is specified, compozify will look for a docker compose file in the current directory. +If no file is found, compozify will create one in the current directory. +Expected file names are docker-compose.[yml,yaml], compose.[yml,yaml] +`, + Example: ` +# add service to existing docker-compose file in current directory +$ compozify add-service "docker run -i -t --rm alpine" + +# add service to existing docker-compose file +$ compozify add-service -f /path/to/docker-compose.yml "docker run -i -t --rm alpine" + +# write to file +$ compozify add-service -w -f /path/to/docker-compose.yml "docker run -i -t --rm alpine" + +# alternative usage specifying beginning of docker run command without quotes +$ compozify add-service -w -f /path/to/docker-compose.yml -- docker run -i -t --rm alpine + +# add service with custom name +$ compozify add-service -w -f /path/to/docker-compose.yml -n my-service "docker run -i -t --rm alpine" +`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return cmd.Help() + } + opts.Command = args[0] + + return addServiceRun(&opts) + }, + } + + cmd.Flags().StringVarP(&opts.ServiceName, "service-name", "n", "", "Name of the service") + cmd.Flags().BoolVarP(&opts.Write, "write", "w", false, "write to file") + cmd.Flags().StringVarP(&opts.File, "file", "f", "", "Compose file path") + + return cmd +} + +func addServiceRun(opts *addServiceOpts) error { + readFile := opts.File != "" + if opts.File == "" { + opts.Logger.Info().Msg("No compose file specified. Searching for compose file in current directory") + expectedFiles := []string{"docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"} + + for _, file := range expectedFiles { + if _, err := os.Stat(file); err == nil { + opts.File = file + opts.Logger.Info().Msgf("Found compose file: %s", file) + break + } + } + + if opts.File == "" { + opts.Logger.Warn().Msg("No compose file found. Specify with --file or -f flag") + opts.File = defaultFilename + readFile = false + } + } + + var b []byte + var err error + + if readFile { + b, err = os.ReadFile(opts.File) + if err != nil { + return err + } + } + + p, err := parser.AppendToYAML(b, opts.Command) + if err != nil { + return err + } + + if opts.ServiceName != "" { + p.SetServiceName(opts.ServiceName) + } + + err = p.Parse() + if err != nil { + return err + } + + return printOutput(p, opts.Logger, opts.Write, opts.File) +} diff --git a/internal/commands/common.go b/internal/commands/common.go new file mode 100644 index 0000000..0a20ee6 --- /dev/null +++ b/internal/commands/common.go @@ -0,0 +1,31 @@ +package commands + +import ( + "fmt" + "os" + + "github.com/rs/zerolog" + + "github.com/profclems/compozify/pkg/parser" +) + +func printOutput(parser *parser.Parser, log *zerolog.Logger, writeToFile bool, path string) error { + writer := os.Stdout + var err error + if writeToFile { + log.Info().Msgf("Writing to file %s", path) + writer, err = os.Create(path) + if err != nil { + return err + } + defer func(writer *os.File) { + e := writer.Close() + if e != nil { + log.Error().Err(e).Msg("Error closing file") + } + err = e + }(writer) + } + _, err = fmt.Fprintf(writer, "%s", parser.String()) + return err +} diff --git a/internal/commands/convert.go b/internal/commands/convert.go index d46bab0..b3d2a93 100644 --- a/internal/commands/convert.go +++ b/internal/commands/convert.go @@ -2,7 +2,6 @@ package commands import ( "fmt" - "os" "strings" "github.com/rs/zerolog" @@ -14,9 +13,10 @@ import ( var defaultFilename = "compose.yml" type convertOpts struct { - Command string - Write bool - OutFilePath string + Command string + OutFilePath string + Write bool + AppendService bool Logger *zerolog.Logger } @@ -53,11 +53,16 @@ $ compozify convert -w -- docker run -i -t --rm alpine opts.Command = strings.Join(args, " ") } + if opts.AppendService && opts.OutFilePath == "" { + return fmt.Errorf("--append-service requires --out flag") + } + return convertRun(&opts) }, Args: cobra.MinimumNArgs(1), } + cmd.Flags().BoolVarP(&opts.AppendService, "append-service", "a", false, "append service to existing compose file. Requires --out flag") cmd.Flags().BoolVarP(&opts.Write, "write", "w", false, "write to file") cmd.Flags().StringVarP(&opts.OutFilePath, "out", "o", defaultFilename, "output file path") @@ -70,34 +75,27 @@ func convertRun(opts *convertOpts) (err error) { log.Info().Msg("Parsing Docker run command") log.Debug().Msgf("Docker run command: %s", opts.Command) - parser, err := parser.New(opts.Command) + if opts.AppendService { + log.Info().Msg("Appending service to existing compose file") + return addServiceRun(&addServiceOpts{ + Logger: log, + File: opts.OutFilePath, + Command: opts.Command, + Write: opts.Write, + }) + } + + p, err := parser.New(opts.Command) if err != nil { return err } log.Info().Msg("Generating Docker compose file") - err = parser.Parse() + err = p.Parse() if err != nil { return err } log.Info().Msg("Docker compose file generated") - writer := os.Stdout - if opts.Write { - log.Info().Msgf("Writing to file %s", opts.OutFilePath) - writer, err = os.Create(opts.OutFilePath) - if err != nil { - return err - } - defer func(writer *os.File) { - e := writer.Close() - if e != nil { - log.Error().Err(e).Msg("Error closing file") - } - err = e - }(writer) - } - fmt.Fprintf(writer, "%s", parser.String()) - - return err + return printOutput(p, log, opts.Write, opts.OutFilePath) } diff --git a/internal/commands/root.go b/internal/commands/root.go index f333ad8..a3323bb 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -25,6 +25,7 @@ func NewRootCmd(logger *zerolog.Logger, version version.Info) *cobra.Command { cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", version.IsDev(), "verbose output") cmd.AddCommand(newConvertCmd(logger)) + cmd.AddCommand(newAddServiceCmd(logger)) return cmd } diff --git a/pkg/parser/argv.go b/pkg/parser/argv.go index 155a48b..9a3e2ff 100644 --- a/pkg/parser/argv.go +++ b/pkg/parser/argv.go @@ -10,6 +10,9 @@ import ( // (same as most other *nix shells do). This is secure in the sense that it doesn't do any // executing or interpeting. func parseArgs(str string) ([]string, error) { + if str == "" { + return []string{}, nil + } var m []string var s string diff --git a/pkg/parser/parser.go b/pkg/parser/parser.go index 4c58e2f..433df71 100644 --- a/pkg/parser/parser.go +++ b/pkg/parser/parser.go @@ -17,8 +17,9 @@ var ( // Parser parses a docker run command into a docker compose file format. type Parser struct { - document *yaml.Node - version string + document *yaml.Node + version string + _serviceName string refs map[string]*yaml.Node vars *variables @@ -32,24 +33,25 @@ func (p *Parser) SetVersion(v string) { p.version = v } -// New creates a new Parser. -func New(s string) (*Parser, error) { - s = strings.TrimPrefix(strings.TrimSpace(s), "docker run") - if s == "" { - return nil, errors.New("empty docker command") - } +// SetServiceName sets the docker compose service name. +func (p *Parser) SetServiceName(name string) { + p._serviceName = name +} - p := &Parser{ - version: composeVersion, - refs: make(map[string]*yaml.Node), - vars: newVariables(), +// serviceName returns the docker compose service name. +func (p *Parser) serviceName() string { + if p._serviceName == "" { + return defaultServiceName } + return p._serviceName +} - command, err := parseArgs(s) +// New creates a new Parser. +func New(s string) (*Parser, error) { + p, err := newParser(s) if err != nil { - return nil, fmt.Errorf("failed to parse docker run command: %w", err) + return nil, err } - p.command = command containerTitleNode := &yaml.Node{ Kind: yaml.ScalarNode, @@ -101,6 +103,81 @@ func New(s string) (*Parser, error) { return p, nil } +// AppendToYAML converts a docker run command into a docker compose file format +// and appends it to an existing docker compose file. +// If the file is empty, it will create a new docker compose file. +func AppendToYAML(b []byte, command string) (*Parser, error) { + if len(b) == 0 { + return New(command) + } + + p, err := newParser(command) + if err != nil { + return nil, err + } + + var yamlDoc yaml.Node + + if err := yaml.Unmarshal(b, &yamlDoc); err != nil { + return nil, fmt.Errorf("failed to parse docker compose file: %w", err) + } + + p.document = &yamlDoc + + if p.document == nil || len(p.document.Content) == 0 { + return nil, errors.New("invalid docker compose file") + } + + for i, node := range p.document.Content[0].Content { + if strings.ToLower(node.Value) == "services" { + p.refs["^services"] = p.document.Content[0].Content[i+1] + break + } + } + + if p.refs["^services"] == nil { + return nil, errors.New("invalid docker compose file: missing services node") + } + + containerTitleNode := &yaml.Node{ + Kind: yaml.ScalarNode, + Value: defaultServiceName, + } + + containerNode := &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{}, + } + + p.refs["^services"].Content = append(p.refs["^services"].Content, containerTitleNode, containerNode) + p.refs["$service"] = containerNode + p.refs["$serviceTitleNode"] = containerTitleNode + + return p, nil +} + +// setup sets up the parser. +func newParser(s string) (*Parser, error) { + s = strings.TrimPrefix(strings.TrimSpace(s), "docker run") + if s == "" { + return nil, errors.New("empty docker command") + } + + p := &Parser{ + version: composeVersion, + refs: make(map[string]*yaml.Node), + vars: newVariables(), + } + + command, err := parseArgs(s) + if err != nil { + return nil, fmt.Errorf("failed to parse docker run command: %w", err) + } + p.command = command + + return p, nil +} + // Parse parses the docker run command into a docker compose file format. func (p *Parser) Parse() error { var parseErr error @@ -241,9 +318,12 @@ func (p *Parser) parseImage() error { // tag version, like just "glab" in the example above p.command = p.command[1:] // the rest are commands ns := strings.Split(image, "/") - serviceName := strings.SplitN(ns[len(ns)-1], ":", 2)[0] - p.refs["$serviceTitleNode"].Value = serviceName + if p.serviceName() == defaultServiceName { + p.SetServiceName(strings.SplitN(ns[len(ns)-1], ":", 2)[0]) + } + + p.refs["$serviceTitleNode"].Value = p.serviceName() p.refs["$service"].Content = append(p.refs["$service"].Content, imageNode...) if len(p.command) > 0 {