diff --git a/api/apps.go b/api/apps.go index fdeaded..01e2fc5 100644 --- a/api/apps.go +++ b/api/apps.go @@ -151,7 +151,7 @@ func (c *Client) InspectApp(ctx context.Context, feedID string, appID string) (* } type LaunchParams struct { - Template string + Template io.Reader Env map[string]string Namespace string Detach bool @@ -168,10 +168,11 @@ func (c *Client) Launch(ctx context.Context, lp LaunchParams, out io.Writer) err buf := bytes.Buffer{} mw := multipart.NewWriter(&buf) - err := mw.WriteField("docker-compose.yml", lp.Template) + w, err := mw.CreateFormField("docker-compose.yml") if err != nil { return err } + io.Copy(w, lp.Template) ew, err := mw.CreateFormField(".env") if err != nil { diff --git a/compose/parser.go b/compose/parser.go index 20299c8..4fc9878 100644 --- a/compose/parser.go +++ b/compose/parser.go @@ -1,8 +1,10 @@ package compose import ( - "gopkg.in/yaml.v3" + "io" "os" + + "gopkg.in/yaml.v3" ) func ParseFile(filename string) (*File, error) { @@ -10,9 +12,13 @@ func ParseFile(filename string) (*File, error) { if err != nil { return nil, err } + defer f.Close() + return Parse(f) +} - var file = &File{} - err = yaml.NewDecoder(f).Decode(file) +func Parse(r io.Reader) (*File, error) { + file := &File{} + err := yaml.NewDecoder(r).Decode(file) if err != nil { return nil, err } diff --git a/compose/spec.go b/compose/spec.go index 9c7d5e2..0a43504 100644 --- a/compose/spec.go +++ b/compose/spec.go @@ -1,10 +1,137 @@ package compose +import ( + "fmt" + "path" + "strings" +) + type File struct { - Version string - Services map[string]Service + Version string + Services map[string]Service `yaml:"services"` + Volumes map[string]any `yaml:"volumes"` + Properties map[string]any `yaml:",inline"` } type Service struct { - Image string + Image string + Volumes []string + Properties map[string]any `yaml:",inline"` +} + +// VolumeDirective +type VolumeDirective struct { + Source, Destination string + Options string + OriginalSource string +} + +// IsLocal reports whether the path is a local path +// the matching depends on . being the first character. +// TODO: see how docker does it +func (vd VolumeDirective) IsLocal() bool { + return strings.HasPrefix(vd.OriginalSource, ".") +} + +func (vd VolumeDirective) String() string { + b := strings.Builder{} + + b.WriteString(vd.Source) + b.WriteString(":") + b.WriteString(vd.Destination) + if vd.Options != "" { + b.WriteString(":") + b.WriteString(vd.Options) + } + + return b.String() +} + +func (vd VolumeDirective) withSource(newSource string) VolumeDirective { + vd.Source = newSource + return vd +} + +func (s Service) parseVolumes() ([]VolumeDirective, error) { + out := make([]VolumeDirective, 0, len(s.Volumes)) + for _, v := range s.Volumes { + vd, err := parseVolume(v) + if err != nil { + return nil, err + } + out = append(out, vd) + } + + return out, nil +} + +// Rewrite rewrites the local paths in the compose spec to remote paths, +// based on the volume name passed in +func (s Service) Rewrite(volPrefix string) (Service, []VolumeDirective, error) { + v, err := s.parseVolumes() + if err != nil { + return s, nil, err + } + + newV := v + for i := range v { + if !v[i].IsLocal() { + continue + } + + newV[i] = v[i].withSource(path.Join(volPrefix, v[i].Source)) + } + + newVol := make([]string, len(newV)) + for i := range newV { + newVol[i] = newV[i].String() + } + + s.Volumes = newVol + return s, v, nil +} + +func parseVolume(s string) (VolumeDirective, error) { + parts := strings.Split(s, ":") + if len(parts) < 2 { + return VolumeDirective{}, fmt.Errorf("could not parse volume directive, only got 1 part, at least 2 are required") + } + + v := VolumeDirective{ + Source: parts[0], + OriginalSource: parts[0], + Destination: parts[1], + } + + if len(parts) > 2 { + opt := strings.Join(parts[2:], ":") + v.Options = opt + } + return v, nil +} + +// rewriteComposeLocal takes in a compose file, +// it parses the volumes section. +func (f *File) Rewrite(volumeName string) (*File, []VolumeDirective, error) { + volumePaths := []VolumeDirective{} + for k := range f.Services { + s, p, err := f.Services[k].Rewrite(path.Join(volumeName, k)) + if err != nil { + return nil, nil, fmt.Errorf("error rewriting service %q: %w", k, err) + } + + for _, path := range p { + if path.IsLocal() { + volumePaths = append(volumePaths, path) + } + } + + f.Services[k] = s + } + + if len(volumePaths) > 0 { + f.Volumes[volumeName] = struct{}{} + } + + return f, volumePaths, nil } diff --git a/compose/spec_test.go b/compose/spec_test.go new file mode 100644 index 0000000..451dd64 --- /dev/null +++ b/compose/spec_test.go @@ -0,0 +1,45 @@ +package compose_test + +import ( + "strings" + "testing" + + "github.com/kraudcloud/cli/compose" + "gopkg.in/yaml.v3" +) + +const s = `version: '3.9' +services: + a: + image: abc:latest + container_name: abc + labels: + abc: d + volumes: + - ./script.sh:mount/it/there + - volume:/var/data +` + +func TestRewrite(t *testing.T) { + file := &compose.File{} + err := yaml.NewDecoder(strings.NewReader(s)).Decode(file) + if err != nil { + t.Errorf("error parsing file: %s", err) + return + } + + for i := range file.Services { + s, _, err := file.Services[i].Rewrite("magic") + if err != nil { + t.Errorf("error rewriting service: %s", err) + } + file.Services[i] = s + } + + out, err := yaml.Marshal(file) + if err != nil { + t.Errorf("error rewriting service: %s", err) + } + + t.Log(string(out)) +} diff --git a/up.go b/up.go index 0291954..e77c8f4 100644 --- a/up.go +++ b/up.go @@ -2,14 +2,18 @@ package main import ( "bytes" + "context" "fmt" + "io" "os" "path/filepath" "github.com/kraudcloud/cli/api" + "github.com/kraudcloud/cli/compose" "github.com/kraudcloud/cli/compose/envparser" "github.com/mitchellh/colorstring" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) func UpCMD() *cobra.Command { @@ -35,18 +39,20 @@ func UpCMD() *cobra.Command { return nil } - template, err := os.ReadFile(file) + template, err := os.Open(file) if err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "error reading docker-compose file: %v\n", err) return err } + defer template.Close() // needed env neededVars - neededVars, err := envparser.ParseTemplateVars(bytes.NewReader(template)) + neededVars, err := envparser.ParseTemplateVars(template) if err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "error getting needed env vars: %v\n", err) return err } + template.Seek(0, 0) loaders := []envparser.EnvLoader{ envparser.LoadKV(env), @@ -92,10 +98,15 @@ func UpCMD() *cobra.Command { } } - detach, _ := cmd.Flags().GetBool("detach") + apply, newTemplate, err := rewriteComposeLocal(template, namespace) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "error rewriting local template: %s", err) + return nil + } + detach, _ := cmd.Flags().GetBool("detach") err = API().Launch(cmd.Context(), api.LaunchParams{ - Template: string(template), + Template: newTemplate, Env: env, Namespace: namespace, Detach: detach, @@ -106,6 +117,11 @@ func UpCMD() *cobra.Command { return nil } + err = apply(cmd.Context()) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "error creating volumes to inject local files into: %s", err) + } + return nil }, } @@ -130,3 +146,36 @@ func dockerComposeFile() string { return "" } + +// rewriteComposeLocal takes in a compose file, +// it parses the volumes section. +// It generates an application function and a new spec +// that must be handled *before* applying +func rewriteComposeLocal(r io.Reader, namespace string) (func(ctx context.Context) error, *bytes.Buffer, error) { + f, err := compose.Parse(r) + if err != nil { + return nil, nil, err + } + + newF, paths, err := f.Rewrite("__local__") + if err != nil { + return nil, nil, fmt.Errorf("error rewriting compose file from local paths: %w", err) + } + + out := &bytes.Buffer{} + err = yaml.NewEncoder(out).Encode(newF) + if err != nil { + return nil, nil, fmt.Errorf("error reincoding the file: %w", err) + } + + return func(ctx context.Context) error { + for _, p := range paths { + err := API().UploadDir(ctx, namespace, p.OriginalSource, p.Destination) + if err != nil { + return fmt.Errorf("failed to upload %q to %q: %w", p.OriginalSource, p.Destination, err) + } + } + + return nil + }, out, nil +}