diff --git a/cmd/create.go b/cmd/create.go new file mode 100644 index 0000000..7189c71 --- /dev/null +++ b/cmd/create.go @@ -0,0 +1,163 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "get.pme.sh/pmesh/config" + "get.pme.sh/pmesh/service" + "get.pme.sh/pmesh/ui" + "get.pme.sh/pmesh/util" + "github.com/alecthomas/chroma/v2/quick" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +type GeneratedManifest struct { + ServiceRoot string `yaml:"service_root,omitempty"` // Service root directory + Services util.OrderedMap[string, any] `yaml:"services,omitempty"` // Services + Server map[string]any `yaml:"server,omitempty"` // Virtual hosts + Hosts []string `yaml:"hosts,omitempty"` // Hostname to IP mapping +} + +func exists(path ...string) bool { + _, err := os.Stat(filepath.Join(path...)) + return err == nil +} + +func autoGenerateManifest(cwd string) []byte { + gm := GeneratedManifest{ + ServiceRoot: ".", + } + for _, delimiter := range []string{"packages", "services", "apps", "pkg"} { + if exists(cwd, delimiter) { + gm.ServiceRoot = delimiter + break + } + } + + validator := func(path string) error { + res, err := os.ReadDir(filepath.Join(cwd, path)) + if err == nil { + for _, file := range res { + if file.IsDir() { + return nil + } + } + return fmt.Errorf("no directories found in %q", path) + } + return err + } + + gm.ServiceRoot = ui.PromptString("Where do your services live?", gm.ServiceRoot, "", validator) + gm.ServiceRoot = filepath.Clean(gm.ServiceRoot) + if gm.ServiceRoot == "." { + gm.ServiceRoot = "" + } + + sroot := filepath.Join(cwd, gm.ServiceRoot) + files, err := os.ReadDir(sroot) + if err != nil { + ui.ExitWithError(err) + } + + svcNameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")) + for _, file := range files { + if file.IsDir() { + var options []yaml.Node + for tag, value := range service.Registry.Tags { + if advisor, ok := value.Instance.(service.Advisor); ok { + if advice := advisor.Advise(filepath.Join(sroot, file.Name())); advice != nil { + node := yaml.Node{} + if err := node.Encode(advice); err == nil { + node.Tag = "!" + tag + options = append(options, node) + } + } + } + } + + { + node := yaml.Node{} + node.Encode(struct{}{}) + node.Tag = "!FS" + options = append(options, node) + node = yaml.Node{} + node.Encode(map[string]any{ + "run": "start --arg1 --arg2", + "build": []string{ + "build --arg1 --arg2", + "then --another", + }, + }) + node.Tag = "!App" + options = append(options, node) + } + + var valueSelect []string + for _, node := range options { + valueSelect = append(valueSelect, node.Tag[1:]) + } + valueSelect = append(valueSelect, "Skip") + + query := fmt.Sprintf("What type of service is %s?", svcNameStyle.Render(file.Name())) + result := ui.PromptSelect(query, valueSelect) + + for _, node := range options { + if node.Tag[1:] == result { + gm.Services.Set(file.Name(), node) + break + } + } + } + } + + domain := ui.PromptString("What is your domain?", "example.com", "", nil) + devDomain := ui.PromptString("What is your development domain?", "example.local", "", nil) + + router := []map[string]string{} + gm.Services.ForEach(func(k string, _ any) { + route := map[string]string{} + route[k+"."+domain+"/"] = k + router = append(router, route) + gm.Hosts = append(gm.Hosts, k+"."+devDomain) + }) + + gm.Server = map[string]any{ + domain + ", " + devDomain: map[string]any{ + "router": router, + }, + } + + buf := &strings.Builder{} + enc := yaml.NewEncoder(buf) + enc.SetIndent(2) + err = enc.Encode(gm) + if err != nil { + ui.ExitWithError(err) + } + + if err := quick.Highlight(os.Stdout, buf.String(), "yaml", "terminal256", "monokai"); err != nil { + fmt.Println(buf.String()) + } + + return []byte(buf.String()) +} + +func init() { + config.RootCommand.AddCommand(&cobra.Command{ + Use: "create", + Short: "Create a new manifest", + Run: func(cmd *cobra.Command, args []string) { + manifest := autoGenerateManifest(".") + manifestPath := filepath.Join(".", "pm3.yml") + + if ui.PromptSelect("Save to "+manifestPath+"?", []string{"Yes", "No"}) == "Yes" { + _ = os.WriteFile(manifestPath, manifest, 0644) + } + }, + }) +} diff --git a/enats/gateway.go b/enats/gateway.go index 65aa06e..51b1515 100644 --- a/enats/gateway.go +++ b/enats/gateway.go @@ -22,7 +22,7 @@ type Client struct { } type Gateway struct { - *Client + Client Server *autonats.Server url string @@ -58,12 +58,6 @@ func New() (r *Gateway) { return } func (r *Gateway) Open(ctx context.Context) (err error) { - select { - case <-ctx.Done(): - return ctx.Err() - case <-r.Server.Ready(): - } - r.Client = &Client{} if r.Server == nil { if strings.HasPrefix(r.url, "nats://") { r.Client.Conn, err = nats.Connect(r.url) @@ -77,6 +71,11 @@ func (r *Gateway) Open(ctx context.Context) (err error) { ) } } else { + select { + case <-ctx.Done(): + return ctx.Err() + case <-r.Server.Ready(): + } r.Client.Conn, err = r.Server.Connect() } if err != nil { @@ -153,8 +152,8 @@ func (r *Gateway) Open(ctx context.Context) (err error) { } func (r *Gateway) Close(ctx context.Context) (err error) { - if cli := r.Client; cli != nil { - r.Client = nil + if cli := r.Client; cli.Conn != nil { + r.Client.Conn = nil select { case <-ctx.Done(): case err = <-lo.Async(cli.Drain): diff --git a/glob/hash.go b/glob/hash.go index 8676fea..dc87859 100644 --- a/glob/hash.go +++ b/glob/hash.go @@ -3,7 +3,6 @@ package glob import ( "context" "crypto/sha1" - "encoding/binary" "encoding/hex" "hash" "hash/crc32" @@ -26,16 +25,6 @@ func hasher() hash.Hash { return sha1.New() } } -func checksum(data []byte) (r Checksum) { - if hashSize == crc32.Size { - u32 := crc32.Checksum(data, tbl) - binary.LittleEndian.PutUint32(r[:], u32) - } else { - h := sha1.Sum(data) - copy(r[:], h[:]) - } - return -} type Checksum [hashSize]byte @@ -46,34 +35,12 @@ func (d Checksum) String() string { return hex.EncodeToString(d[:]) } -type HashKind uint8 - -const ( - HashContent HashKind = iota - HashStat HashKind = iota -) - -func hashAppend(h hash.Hash, file *File, kind HashKind) { - stat, err := os.Stat(file.Location) +func hashAppend(h hash.Hash, file *File) { h.Write([]byte(file.Location)) + f, err := os.Open(file.Location) if err == nil { - u1 := uint64(stat.Size()) - u2 := uint64(stat.Mode()) - u2 <<= 32 - u2 ^= uint64(stat.ModTime().UnixNano()) - - buf := [16]byte{} - binary.LittleEndian.PutUint64(buf[:8], u1) - binary.LittleEndian.PutUint64(buf[8:], u2) - h.Write(buf[:]) - - if kind == HashContent { - f, err := os.Open(file.Location) - if err == nil { - defer f.Close() - io.Copy(h, f) - } - } + defer f.Close() + io.Copy(h, f) } } func normalLoc(location string) string { @@ -99,22 +66,24 @@ func (l *HashList) Dir(prefix string) Checksum { } i, _ := slices.BinarySearch(l.StableList, prefix) - subist := l.StableList[i:] - - buf := make([]byte, hashSize*len(subist)) - written := 0 + h := hasher() for _, loc := range l.StableList[i:] { if !strings.HasPrefix(loc, prefix) { break } - h := [hashSize]byte(l.HashMap[loc]) - copy(buf[written:], h[:]) - written += hashSize + h.Write(l.HashMap[loc].Slice()) + } + return Checksum(h.Sum(nil)) +} +func (l *HashList) All() Checksum { + h := hasher() + for _, hash := range l.HashMap { + h.Write(hash.Slice()) } - return checksum(buf[:written]) + return Checksum(h.Sum(nil)) } -func ReduceToHash(ch <-chan *File, kind HashKind) (l *HashList) { +func ReduceToHash(ch <-chan *File) (l *HashList) { l = &HashList{ HashMap: make(map[string]Checksum), } @@ -126,7 +95,7 @@ func ReduceToHash(ch <-chan *File, kind HashKind) (l *HashList) { defer wg.Done() var dig Checksum h := hasher() - hashAppend(h, file, kind) + hashAppend(h, file) copy(dig[:], h.Sum(nil)) loc := normalLoc(file.Location) @@ -142,9 +111,9 @@ func ReduceToHash(ch <-chan *File, kind HashKind) (l *HashList) { return } -func HashContext(ctx context.Context, dir string, kind HashKind, opts ...Option) *HashList { - return ReduceToHash(WalkContext(ctx, dir, opts...), kind) +func HashContext(ctx context.Context, dir string, opts ...Option) *HashList { + return ReduceToHash(WalkContext(ctx, dir, opts...)) } -func Hash(dir string, kind HashKind, opts ...Option) *HashList { - return HashContext(bgCtx, dir, kind, opts...) +func Hash(dir string, opts ...Option) *HashList { + return HashContext(bgCtx, dir, opts...) } diff --git a/lb/upstream.go b/lb/upstream.go index 8fec20e..83ae723 100644 --- a/lb/upstream.go +++ b/lb/upstream.go @@ -1,8 +1,6 @@ package lb import ( - "bytes" - "io" "net/http" "net/http/httputil" "strings" @@ -11,7 +9,6 @@ import ( "unsafe" "get.pme.sh/pmesh/netx" - "get.pme.sh/pmesh/util" ) type Upstream struct { @@ -134,8 +131,7 @@ func NewHttpUpstreamTransport(address string, director func(r *http.Request), tr // Ask load balancer to handle the error. if hnd := ctx.LoadBalancer.OnErrorResponse(ctx, r); hnd != nil { - go util.DrainClose(r.Body) - r.Body = io.NopCloser(bytes.NewReader(nil)) + r.Body.Close() return SuppressedHttpError{hnd} } return nil diff --git a/lyml/nodes.go b/lyml/nodes.go index 60a9071..e400548 100644 --- a/lyml/nodes.go +++ b/lyml/nodes.go @@ -10,7 +10,6 @@ import ( "strings" "get.pme.sh/pmesh/luae" - "github.com/samber/lo" lua "github.com/yuin/gopher-lua" "gopkg.in/yaml.v3" diff --git a/service/svc_app.go b/service/svc_app.go index d23a36a..363ab04 100644 --- a/service/svc_app.go +++ b/service/svc_app.go @@ -256,7 +256,7 @@ func (app *AppService) BuildApp(c context.Context, force bool) (chk glob.Checksu // Create a checksum. // - chk = glob.Hash(app.Root, glob.HashStat, glob.IgnoreArtifacts(), glob.AddGitIgnores(app.Root)).Dir(app.Root) + chk = glob.Hash(app.Root, glob.IgnoreArtifacts(), glob.AddGitIgnores(app.Root)).All() // Check the cache. // diff --git a/service/svc_wrappers.go b/service/svc_wrappers.go index cf4b15a..3f657d4 100644 --- a/service/svc_wrappers.go +++ b/service/svc_wrappers.go @@ -12,11 +12,29 @@ import ( "get.pme.sh/pmesh/util" ) +type Advisor interface { + Advise(path string) any +} + +func exists(path ...string) bool { + _, err := os.Stat(filepath.Join(path...)) + return err == nil +} + type JsApp struct { AppService `yaml:",inline"` Index string `yaml:"index,omitempty"` } +func (app *JsApp) Advise(path string) any { + if !exists(path, "package.json") && exists(path, "index.js") { + return map[string]any{ + "index": "index.js", + } + } + return nil +} + func (app *JsApp) Prepare(opt Options) error { if err := app.AppService.Prepare(opt); err != nil { return err @@ -41,6 +59,13 @@ type NpmApp struct { NoInstall bool `yaml:"no_install,omitempty"` } +func (app *NpmApp) Advise(path string) any { + if exists(path, "package.json") { + return struct{}{} + } + return nil +} + func (app *NpmApp) Prepare(opt Options) error { if err := app.AppService.Prepare(opt); err != nil { return err @@ -109,6 +134,13 @@ type PyApp struct { Main string `yaml:"main,omitempty"` // Main file to run } +func (app *PyApp) Advise(path string) any { + if exists(path, "requirements.txt") && !exists(path, "app.py") { + return struct{}{} + } + return nil +} + func (app *PyApp) Prepare(opt Options) error { if err := app.AppService.Prepare(opt); err != nil { return err @@ -155,6 +187,13 @@ type FlaskApp struct { PyApp `yaml:",inline"` } +func (app *FlaskApp) Advise(path string) any { + if exists(path, "requirements.txt") && exists(path, "app.py") { + return struct{}{} + } + return nil +} + func (app *FlaskApp) Prepare(opt Options) error { if err := app.PyApp.Prepare(opt); err != nil { return err @@ -171,6 +210,13 @@ type GoApp struct { Main string `yaml:"main,omitempty"` } +func (app *GoApp) Advise(path string) any { + if exists(path, "main.go") { + return struct{}{} + } + return nil +} + func (app *GoApp) Prepare(opt Options) error { if err := app.AppService.Prepare(opt); err != nil { return err diff --git a/session/manifest.go b/session/manifest.go index 792f9cc..e4dcc79 100644 --- a/session/manifest.go +++ b/session/manifest.go @@ -2,7 +2,6 @@ package session import ( "context" - "errors" "os" "path/filepath" "slices" @@ -12,11 +11,11 @@ import ( "get.pme.sh/pmesh/lyml" "get.pme.sh/pmesh/netx" "get.pme.sh/pmesh/service" + "get.pme.sh/pmesh/util" "get.pme.sh/pmesh/vhttp" "get.pme.sh/pmesh/xlog" "github.com/nats-io/nats.go/jetstream" - "github.com/samber/lo" "gopkg.in/yaml.v3" ) @@ -139,58 +138,17 @@ func (h *HostsLine) UnmarshalYAML(node *yaml.Node) error { return node.Decode(&h.Hostname) } -type OrderedMap[K comparable, V any] []lo.Tuple2[K, V] - -func (m *OrderedMap[K, V]) Set(key K, value V) { - for i, kv := range *m { - if kv.A == key { - (*m)[i].B = value - return - } - } - *m = append(*m, lo.Tuple2[K, V]{A: key, B: value}) -} -func (m OrderedMap[K, V]) Get(key K) (v V, ok bool) { - for _, kv := range m { - if kv.A == key { - return kv.B, true - } - } - return -} -func (m OrderedMap[K, V]) ForEach(fn func(k K, v V)) { - for _, kv := range m { - fn(kv.A, kv.B) - } -} -func (m *OrderedMap[K, V]) UnmarshalYAML(node *yaml.Node) error { - if node.Kind != yaml.MappingNode { - return errors.New("expected a map") - } - for i := 0; i < len(node.Content); i += 2 { - var kv lo.Tuple2[K, V] - if e := node.Content[i].Decode(&kv.A); e != nil { - return e - } - if e := node.Content[i+1].Decode(&kv.B); e != nil { - return e - } - *m = append(*m, kv) - } - return nil -} - type Manifest struct { - Root string `yaml:"root,omitempty"` // Root directory - ServiceRoot string `yaml:"service_root,omitempty"` // Service root directory - Services OrderedMap[string, service.Service] `yaml:"services,omitempty"` // Services - Server map[string]*Server `yaml:"server,omitempty"` // Virtual hosts - IPInfo IPInfoOptions `yaml:"ipinfo,omitempty"` // IP information provider - Env map[string]string `yaml:"env,omitempty"` // Environment variables - Runners map[string]*Runner `yaml:"runners,omitempty"` // Runners - Jet JetManifest `yaml:"jet,omitempty"` // JetStream configuration - Hosts []HostsLine `yaml:"hosts,omitempty"` // Hostname to IP mapping - CustomErrors string `yaml:"custom_errors,omitempty"` // Path to custom error pages + Root string `yaml:"root,omitempty"` // Root directory + ServiceRoot string `yaml:"service_root,omitempty"` // Service root directory + Services util.OrderedMap[string, service.Service] `yaml:"services,omitempty"` // Services + Server map[string]*Server `yaml:"server,omitempty"` // Virtual hosts + IPInfo IPInfoOptions `yaml:"ipinfo,omitempty"` // IP information provider + Env map[string]string `yaml:"env,omitempty"` // Environment variables + Runners map[string]*Runner `yaml:"runners,omitempty"` // Runners + Jet JetManifest `yaml:"jet,omitempty"` // JetStream configuration + Hosts []HostsLine `yaml:"hosts,omitempty"` // Hostname to IP mapping + CustomErrors string `yaml:"custom_errors,omitempty"` // Path to custom error pages } func LoadManifest(manifestPath string) (*Manifest, error) { diff --git a/session/session.go b/session/session.go index a6ce291..f7c618f 100644 --- a/session/session.go +++ b/session/session.go @@ -111,7 +111,7 @@ func (s *Session) ResolveService(sv string) vhttp.Handler { return service } func (s *Session) ResolveNats() *enats.Client { - return s.Nats.Client + return &s.Nats.Client } func New(path string) (s *Session, err error) { diff --git a/util/ordered_map.go b/util/ordered_map.go new file mode 100644 index 0000000..10a8ab1 --- /dev/null +++ b/util/ordered_map.go @@ -0,0 +1,63 @@ +package util + +import ( + "errors" + + "github.com/samber/lo" + "gopkg.in/yaml.v3" +) + +type OrderedMap[K comparable, V any] []lo.Tuple2[K, V] + +func (m *OrderedMap[K, V]) Set(key K, value V) { + for i, kv := range *m { + if kv.A == key { + (*m)[i].B = value + return + } + } + *m = append(*m, lo.Tuple2[K, V]{A: key, B: value}) +} +func (m OrderedMap[K, V]) Get(key K) (v V, ok bool) { + for _, kv := range m { + if kv.A == key { + return kv.B, true + } + } + return +} +func (m OrderedMap[K, V]) ForEach(fn func(k K, v V)) { + for _, kv := range m { + fn(kv.A, kv.B) + } +} +func (m *OrderedMap[K, V]) UnmarshalYAML(node *yaml.Node) error { + if node.Kind != yaml.MappingNode { + return errors.New("expected a map") + } + for i := 0; i < len(node.Content); i += 2 { + var kv lo.Tuple2[K, V] + if e := node.Content[i].Decode(&kv.A); e != nil { + return e + } + if e := node.Content[i+1].Decode(&kv.B); e != nil { + return e + } + *m = append(*m, kv) + } + return nil +} +func (m OrderedMap[K, V]) MarshalYAML() (any, error) { + node := &yaml.Node{Kind: yaml.MappingNode} + for _, kv := range m { + var k, v yaml.Node + if e := k.Encode(kv.A); e != nil { + return nil, e + } + if e := v.Encode(kv.B); e != nil { + return nil, e + } + node.Content = append(node.Content, &k, &v) + } + return node, nil +} diff --git a/variant/registry.go b/variant/registry.go index 214d4f6..26e02dc 100644 --- a/variant/registry.go +++ b/variant/registry.go @@ -22,12 +22,13 @@ type InlineUnmarshaler interface { type Factory = func(node *yaml.Node) (any, error) type Registration struct { - generic func(node *yaml.Node) (any, error) - string func(str string) (any, error) + FromNode func(node *yaml.Node) (any, error) + FromString func(str string) (any, error) + Instance any } type Registry[I any] struct { - tags map[string]*Registration + Tags map[string]*Registration } var fmtErrRejectedMatch = "failed to decode as [%s]" @@ -74,14 +75,14 @@ func combineErrs(w error, a ...error) (err error) { func (r *Registry[I]) Unmarshal(node *yaml.Node) (res I, err error) { if node.Tag != "" && !strings.HasPrefix(node.Tag, "!!") { - if reg, ok := r.tags[node.Tag[1:]]; ok { + if reg, ok := r.Tags[node.Tag[1:]]; ok { // Adjust the tag to the correct type node.Tag = "" node.Tag = node.ShortTag() // Call the unmarshaler var result any - if result, err = reg.generic(node); err == nil { + if result, err = reg.FromNode(node); err == nil { res = result.(I) } return @@ -96,8 +97,8 @@ func (r *Registry[I]) Unmarshal(node *yaml.Node) (res I, err error) { if !strings.Contains(str, " ") { // Prioritize the tag with the same name - if reg, ok := r.tags[str]; ok { - if sfc := reg.string; sfc != nil { + if reg, ok := r.Tags[str]; ok { + if sfc := reg.FromString; sfc != nil { result, serr := sfc(str) if serr == nil { res = result.(I) @@ -107,8 +108,8 @@ func (r *Registry[I]) Unmarshal(node *yaml.Node) (res I, err error) { } } - for _, reg := range r.tags { - if sfc := reg.string; sfc != nil { + for _, reg := range r.Tags { + if sfc := reg.FromString; sfc != nil { result, serr := sfc(str) if serr == nil { res = result.(I) @@ -122,8 +123,8 @@ func (r *Registry[I]) Unmarshal(node *yaml.Node) (res I, err error) { // Last resort if node.Kind != yaml.MappingNode { - for _, reg := range r.tags { - if result, e := reg.generic(node); e == nil { + for _, reg := range r.Tags { + if result, e := reg.FromNode(node); e == nil { return result.(I), nil } else { err = combineErrs(err, e) @@ -141,10 +142,12 @@ func (r *Registry[I]) Define(tag string, defaults func() any) { if _, ok := defaultValue.(I); !ok { panic("defaults must implement the interface") } - reg := &Registration{} - r.tags[tag] = reg + reg := &Registration{ + Instance: defaultValue, + } + r.Tags[tag] = reg - reg.generic = func(node *yaml.Node) (res any, err error) { + reg.FromNode = func(node *yaml.Node) (res any, err error) { result := defaults() if node.Kind == yaml.ScalarNode { // If the scalar is null, return defaults (empty map) @@ -170,7 +173,7 @@ func (r *Registry[I]) Define(tag string, defaults func() any) { } if _, ok := defaultValue.(InlineUnmarshaler); ok { - reg.string = func(str string) (res any, err error) { + reg.FromString = func(str string) (res any, err error) { result := defaults() if iu, ok := result.(InlineUnmarshaler); ok { err = iu.UnmarshalInline(str) @@ -183,7 +186,7 @@ func (r *Registry[I]) Define(tag string, defaults func() any) { func NewRegistry[IFace any]() *Registry[IFace] { reg := &Registry[IFace]{ - tags: make(map[string]*Registration), + Tags: make(map[string]*Registration), } return reg } diff --git a/vhttp/client.go b/vhttp/client.go index 1e88e01..c95f94f 100644 --- a/vhttp/client.go +++ b/vhttp/client.go @@ -14,6 +14,7 @@ import ( "get.pme.sh/pmesh/netx" "get.pme.sh/pmesh/rate" "get.pme.sh/pmesh/ray" + "get.pme.sh/pmesh/util" "get.pme.sh/pmesh/xlog" ) @@ -127,6 +128,7 @@ func (blockedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { type rstHandler struct{} func (rstHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + defer util.DrainClose(r.Body) netx.ResetRequestConn(w) } diff --git a/vhttp/server.go b/vhttp/server.go index e1c00c7..a77f47f 100644 --- a/vhttp/server.go +++ b/vhttp/server.go @@ -86,7 +86,7 @@ selector: break selector } } - logger.Trace().Dur("time", time.Since(t0)).Msg("Request timing") + logger.Trace().EmbedObject(xlog.EnhanceRequest(r)).Msgf("Request took %s", time.Since(t0)) // If no match, 404 if !handled {