diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 000000000..831a9d4ae --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,32 @@ +version: 2 + +env: + - VERSION={{ if index .Env "VERSION" }}{{ .Env.VERSION }}{{ else }}dev{{ end }} + +builds: + - id: hatchet + env: + - CGO_ENABLED=1 + main: ./cmd/hatchet/main.go + binary: hatchet + goos: + - linux + - windows + - darwin + ldflags: + - -s -w -X github.com/hatchet-dev/hatchet/cmd/hatchet/cli.Version={{ .Env.VERSION }} + +archives: + - formats: [tar.gz] + # this name template makes the OS and Arch compatible with the results of `uname`. + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + # use zip for windows archives + format_overrides: + - goos: windows + formats: [zip] diff --git a/cmd/hatchet-admin/cli/quickstart.go b/cmd/hatchet-admin/cli/quickstart.go index ef10939fa..cd774db02 100644 --- a/cmd/hatchet-admin/cli/quickstart.go +++ b/cmd/hatchet-admin/cli/quickstart.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" "sigs.k8s.io/yaml" + "github.com/hatchet-dev/hatchet/cmd/internal" "github.com/hatchet-dev/hatchet/pkg/config/database" "github.com/hatchet-dev/hatchet/pkg/config/loader" "github.com/hatchet-dev/hatchet/pkg/config/server" @@ -34,7 +35,13 @@ var quickstartCmd = &cobra.Command{ Use: "quickstart", Short: "Command used to setup a Hatchet instance", Run: func(cmd *cobra.Command, args []string) { - err := runQuickstart() + err := internal.RunQuickstart(&internal.QuickstartOpts{ + CertDir: certDir, + GeneratedConfigDir: generatedConfigDir, + Skip: skip, + Overwrite: overwrite, + ConfigDirectory: configDirectory, + }) if err != nil { red := color.New(color.FgRed) @@ -76,58 +83,6 @@ func init() { ) } -func runQuickstart() error { - generated, err := loadBaseConfigFiles() - - if err != nil { - return fmt.Errorf("could not get base config files: %w", err) - } - - if !shouldSkip(StageCerts) { - err := setupCerts(generated) - - if err != nil { - return fmt.Errorf("could not setup certs: %w", err) - } - } - - if !shouldSkip(StageKeys) { - err := generateKeys(generated) - - if err != nil { - return fmt.Errorf("could not generate keys: %w", err) - } - } - - err = writeGeneratedConfig(generated) - - if err != nil { - return fmt.Errorf("could not write generated config files: %w", err) - } - - if !shouldSkip(StageSeed) { - // reload config at this point - configLoader := loader.NewConfigLoader(configDirectory) - err = runSeed(configLoader) - - if err != nil { - return fmt.Errorf("could not run seed: %w", err) - } - } - - return nil -} - -func shouldSkip(stage string) bool { - for _, skipStage := range skip { - if stage == skipStage { - return true - } - } - - return false -} - //go:embed certs/cluster-cert.conf var ClusterCertConf []byte diff --git a/cmd/hatchet-admin/cli/seed.go b/cmd/hatchet-admin/cli/seed.go index 9ec435e95..673252146 100644 --- a/cmd/hatchet-admin/cli/seed.go +++ b/cmd/hatchet-admin/cli/seed.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/hatchet-dev/hatchet/cmd/hatchet-admin/cli/seed" + "github.com/hatchet-dev/hatchet/cmd/internal" "github.com/hatchet-dev/hatchet/pkg/config/loader" ) @@ -18,7 +18,7 @@ var seedCmd = &cobra.Command{ var err error configLoader := loader.NewConfigLoader(configDirectory) - err = runSeed(configLoader) + err = internal.RunSeed(configLoader) if err != nil { log.Printf("Fatal: could not run seed command: %v", err) @@ -30,16 +30,3 @@ var seedCmd = &cobra.Command{ func init() { rootCmd.AddCommand(seedCmd) } - -func runSeed(cf *loader.ConfigLoader) error { - // load the config - dc, err := cf.InitDataLayer() - - if err != nil { - panic(err) - } - - defer dc.Disconnect() // nolint: errcheck - - return seed.SeedDatabase(dc) -} diff --git a/cmd/hatchet/cli/config.go b/cmd/hatchet/cli/config.go new file mode 100644 index 000000000..c77c7cf50 --- /dev/null +++ b/cmd/hatchet/cli/config.go @@ -0,0 +1,43 @@ +package cli + +import ( + "fmt" + "log" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Manage Hatchet configuration", +} + +var setTokenCmd = &cobra.Command{ + Use: "set-token", + Short: "Set the API token", + Run: setToken, + Args: cobra.ExactArgs(1), +} + +func init() { + configCmd.AddCommand(setTokenCmd) + + rootCmd.AddCommand(configCmd) +} + +func setToken(cmd *cobra.Command, args []string) { + token := args[0] + + if token == "" { + log.Fatalf("Token cannot be empty") + } + + viper.Set("token", token) + err := viper.WriteConfig() + if err != nil { + log.Fatalf("Error setting token: %v", err) + } + + fmt.Println("Token set successfully") +} diff --git a/cmd/hatchet/cli/root.go b/cmd/hatchet/cli/root.go new file mode 100644 index 000000000..6301685a1 --- /dev/null +++ b/cmd/hatchet/cli/root.go @@ -0,0 +1,207 @@ +package cli + +import ( + "io/fs" + "log" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +type runtime string + +const ( + runtimeGo runtime = "go" + runtimePython runtime = "python" + runtimeTypeScript runtime = "typescript" +) + +var runtimeGlobWatcher = map[runtime]string{ + runtimeGo: "**/*.go", + runtimePython: "**/*.py", + runtimeTypeScript: "**/*.{ts,js}", +} + +var rootCmd = &cobra.Command{ + Use: "hatchet", + Short: "CLI for managing Hatchet workers", +} + +func Execute() { + viper.SetConfigName("config") + viper.SetConfigType("json") + viper.AddConfigPath("$HOME/.hatchet") + err := viper.ReadInConfig() + if err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + // create a new config file + err := os.MkdirAll(os.ExpandEnv("$HOME/.hatchet"), 0755) + if err != nil { + log.Fatalf("Error creating config directory: %v", err) + } + + err = os.WriteFile(os.ExpandEnv("$HOME/.hatchet/config.json"), []byte("{}"), 0644) + if err != nil { + log.Fatalf("Error creating config file: %v", err) + } + } else { + log.Fatalf("Error reading config file: %v", err) + } + } + + rootCmd.Execute() +} + +func detectRuntimes() []runtime { + pwd, err := os.Getwd() + if err != nil { + log.Fatalf("Error getting current directory: %v", err) + } + + var runtimes []runtime + + if couldBeGoRuntime(pwd) { + runtimes = append(runtimes, runtimeGo) + } + + if couldBePythonRuntime(pwd) { + runtimes = append(runtimes, runtimePython) + } + + if couldBeTypeScriptRuntime(pwd) { + runtimes = append(runtimes, runtimeTypeScript) + } + + return runtimes +} + +func couldBeGoRuntime(pwd string) bool { + if goModFile, err := pathExists(filepath.Join(pwd, "go.mod")); err != nil { + log.Printf("Error checking for go.mod file: %v", err) + } else if goModFile { + return true + } + + var containsGoFiles bool + + _ = filepath.Walk(pwd, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + + if filepath.Ext(path) == ".go" { + containsGoFiles = true + return filepath.SkipAll + } + + return nil + }) + + return containsGoFiles +} + +func couldBePythonRuntime(pwd string) bool { + if envFile, err := pathExists(filepath.Join(pwd, "environment.yml")); err != nil { + log.Printf("Error checking for environment.yml file: %v", err) + } else if envFile { + return true + } + + if requirementsFile, err := pathExists(filepath.Join(pwd, "requirements.txt")); err != nil { + log.Printf("Error checking for requirements.txt file: %v", err) + } else if requirementsFile { + return true + } + + if lockFile, err := pathExists(filepath.Join(pwd, "package-list.txt")); err != nil { + log.Printf("Error checking for package-list.txt file: %v", err) + } else if lockFile { + return true + } + + if pyprojectTOMLFile, err := pathExists(filepath.Join(pwd, "pyproject.toml")); err != nil { + log.Printf("Error checking for pyproject.toml file: %v", err) + } else if pyprojectTOMLFile { + return true + } + + var containsPythonFiles bool + + _ = filepath.Walk(pwd, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + + if filepath.Ext(path) == ".py" { + containsPythonFiles = true + return filepath.SkipAll + } + + return nil + }) + + return containsPythonFiles +} + +func couldBeTypeScriptRuntime(pwd string) bool { + if packageJSONFile, err := pathExists(filepath.Join(pwd, "package.json")); err != nil { + log.Printf("Error checking for package.json file: %v", err) + } else if packageJSONFile { + return true + } + + if yarnLockFile, err := pathExists(filepath.Join(pwd, "yarn.lock")); err != nil { + log.Printf("Error checking for yarn.lock file: %v", err) + } else if yarnLockFile { + return true + } + + if pnpmLockFile, err := pathExists(filepath.Join(pwd, "pnpm-lock.yaml")); err != nil { + log.Printf("Error checking for pnpm-lock.yaml file: %v", err) + } else if pnpmLockFile { + return true + } + + if bunbLockFile, err := pathExists(filepath.Join(pwd, "bun.lockb")); err != nil { + log.Printf("Error checking for bun.lockb file: %v", err) + } else if bunbLockFile { + return true + } + + if bunLockFile, err := pathExists(filepath.Join(pwd, "bun.lock")); err != nil { + log.Printf("Error checking for bun.lockb file: %v", err) + } else if bunLockFile { + return true + } + + if denoJSONFile, err := pathExists(filepath.Join(pwd, "deno.json")); err != nil { + log.Printf("Error checking for deno.json file: %v", err) + } else if denoJSONFile { + return true + } + + if denoJSONCFile, err := pathExists(filepath.Join(pwd, "deno.jsonc")); err != nil { + log.Printf("Error checking for deno.jsonc file: %v", err) + } else if denoJSONCFile { + return true + } + + var containsTypeScriptFiles bool + + _ = filepath.Walk(pwd, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + + if filepath.Ext(path) == ".ts" || filepath.Ext(path) == ".js" { + containsTypeScriptFiles = true + return filepath.SkipAll + } + + return nil + }) + + return containsTypeScriptFiles +} diff --git a/cmd/hatchet/cli/start-dev.go b/cmd/hatchet/cli/start-dev.go new file mode 100644 index 000000000..2d1397a73 --- /dev/null +++ b/cmd/hatchet/cli/start-dev.go @@ -0,0 +1,156 @@ +package cli + +import ( + "embed" + "fmt" + "log" + "os" + "path/filepath" + + embeddedpostgres "github.com/fergusstrange/embedded-postgres" + "github.com/hatchet-dev/hatchet/cmd/hatchet-migrate/migrate" + "github.com/hatchet-dev/hatchet/cmd/internal" + "github.com/hatchet-dev/hatchet/pkg/cmdutils" + "github.com/hatchet-dev/hatchet/pkg/config/loader" + "github.com/spf13/cobra" +) + +//go:embed dist +var distDir embed.FS + +var startDevCmd = &cobra.Command{ + Use: "start-dev", + Short: "Start a Hatchet Lite instance for local development", + Run: startDev, +} + +func init() { + rootCmd.AddCommand(startDevCmd) +} + +func startDev(cmd *cobra.Command, args []string) { + fmt.Println("setting up postgres ...") + + home, err := os.UserHomeDir() + if err != nil { + log.Fatalf("error getting user home directory: %v", err) + } + + hatchetDir := filepath.Join(home, ".hatchet") + + postgres := embeddedpostgres.NewDatabase( + embeddedpostgres.DefaultConfig(). + Version(embeddedpostgres.V15). + Username("hatchet"). + Password("hatchet"). + Database("hatchet"). + DataPath(filepath.Join(hatchetDir, "postgres-data")), + ) + err = postgres.Start() + if err != nil { + log.Fatalf("error starting postgres: %v", err) + } + defer postgres.Stop() + + // make sure to populate the dist directory + frontendDistDir := filepath.Join(hatchetDir, "dist") + err = os.MkdirAll(frontendDistDir, os.ModePerm) + if err != nil { + log.Fatalf("error creating dist directory: %v", err) + } + + // Extract embedded dist files to the hatchet dir + // This function recursively extracts files from the embedded FS + var extractDir func(string, string) error + extractDir = func(embedPath string, targetPath string) error { + entries, err := distDir.ReadDir(embedPath) + if err != nil { + return fmt.Errorf("error reading embedded directory %s: %v", embedPath, err) + } + + for _, entry := range entries { + entryEmbed := filepath.Join(embedPath, entry.Name()) + entryTarget := filepath.Join(targetPath, entry.Name()) + + if entry.IsDir() { + // Create the directory + err := os.MkdirAll(entryTarget, os.ModePerm) + if err != nil { + return fmt.Errorf("error creating directory %s: %v", entryTarget, err) + } + + // Recursively extract subdirectory + err = extractDir(entryEmbed, entryTarget) + if err != nil { + return err + } + } else { + // Read the embedded file + content, err := distDir.ReadFile(entryEmbed) + if err != nil { + return fmt.Errorf("error reading embedded file %s: %v", entryEmbed, err) + } + + // Write the file to disk + err = os.WriteFile(entryTarget, content, 0644) + if err != nil { + return fmt.Errorf("error writing file %s: %v", entryTarget, err) + } + } + } + + return nil + } + + // Start the extraction from the dist root + err = extractDir("dist", frontendDistDir) + if err != nil { + log.Fatalf("error extracting dist: %v", err) + } + + var envVars = map[string]string{ + "LITE_STATIC_ASSET_DIR": frontendDistDir, + "LITE_FRONTEND_PORT": "8081", + "LITE_RUNTIME_PORT": "8888", + "DATABASE_URL": "postgresql://hatchet:hatchet@localhost:5432/hatchet?sslmode=disable", + "DATABASE_POSTGRES_PORT": "5432", + "DATABASE_POSTGRES_HOST": "localhost", + "SERVER_GRPC_BIND_ADDRESS": "0.0.0.0", + "SERVER_GRPC_INSECURE": "t", + "SERVER_GRPC_BROADCAST_ADDRESS": "localhost:7077", + "SERVER_GRPC_PORT": "7077", + "SERVER_URL": "http://localhost:8888", + "SERVER_AUTH_SET_EMAIL_VERIFIED": "t", + "SERVER_DEFAULT_ENGINE_VERSION": "V1", + } + + for k, v := range envVars { + os.Setenv(k, v) + } + + ctx, cancel := cmdutils.NewInterruptContext() + defer cancel() + + fmt.Println("running migrations ...") + + migrate.RunMigrations(ctx) + + err = internal.RunQuickstart(&internal.QuickstartOpts{ + ConfigDirectory: filepath.Join(hatchetDir, "config"), + GeneratedConfigDir: filepath.Join(hatchetDir, "config"), + Overwrite: false, + CertDir: filepath.Join(hatchetDir, "certs"), + }) + if err != nil { + log.Fatalf("error setting up hatchet-lite config: %v", err) + } + + cf := loader.NewConfigLoader(filepath.Join(hatchetDir, "config")) + interruptCh := cmdutils.InterruptChan() + + fmt.Println("starting Hatchet Lite at http://localhost:8888 ...") + + if err := internal.StartLite(cf, interruptCh, Version); err != nil { + log.Fatalln("error starting Hatchet Lite:", err) + } +} diff --git a/cmd/hatchet/cli/start.go b/cmd/hatchet/cli/start.go new file mode 100644 index 000000000..f9390d78d --- /dev/null +++ b/cmd/hatchet/cli/start.go @@ -0,0 +1,233 @@ +package cli + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/hatchet-dev/hatchet/cmd/hatchet/glob" + "github.com/hatchet-dev/hatchet/pkg/cmdutils" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var startCmd = &cobra.Command{ + Use: "start", + Short: "Start a Hatchet worker", + Args: cobra.MinimumNArgs(1), + Run: startWorker, +} + +var reload bool + +func init() { + startCmd.PersistentFlags().BoolVarP(&reload, "reload", "r", false, "Reload the worker automatically when the source code changes") + + rootCmd.AddCommand(startCmd) +} + +var procCmd *exec.Cmd +var procLk sync.Mutex + +func startWorker(cmd *cobra.Command, args []string) { + apiToken := viper.GetString("token") + if apiToken == "" { + log.Fatalf("API token not set. Please set the API token by running `hatchet config set-token `") + } + + interruptChan := cmdutils.InterruptChan() + + if reload { + fmt.Println("Detecting runtime ...") + runtimes := detectRuntimes() + + for _, runtime := range runtimes { + fmt.Printf("Detected runtime: %s\n", runtime) + } + + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Fatal(err) + } + defer watcher.Close() + + go func() { + for { + select { + case <-interruptChan: + return + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Has(fsnotify.Write) { + fmt.Println("Detected file change") + fmt.Println("Restarting worker") + + killProcess() + + err := startProcess(args, apiToken) + if err != nil { + log.Fatalf("error restarting worker: %v", err) + } + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Println("watcher error:", err) + } + } + }() + + cwd, err := os.Getwd() + if err != nil { + log.Fatalf("error getting cwd: %v", err) + } + + for _, runtime := range runtimes { + runtimeGlob, err := glob.Parse(runtimeGlobWatcher[runtime]) + if err != nil { + log.Fatalf("error parsing runtime glob: %v", err) + } + + err = filepath.Walk(cwd, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if runtimeGlob.Match(path) { + watcher.Add(path) + } + + return nil + }) + + if err != nil { + log.Fatalf("error walking path: %v", err) + } + } + + fmt.Printf("Watching for changes to %v\n", watcher.WatchList()) + } + + fmt.Println("Starting worker") + + err := startProcess(args, apiToken) + if err != nil { + log.Fatalf("error starting worker: %v", err) + } + + <-interruptChan + + killProcess() +} + +func killProcess() { + procLk.Lock() + defer procLk.Unlock() + + if procCmd != nil { + fmt.Println("Stopping worker") + + // Create a process group for easier cleanup + pgid, err := syscall.Getpgid(procCmd.Process.Pid) + if err == nil { + // First try graceful shutdown with SIGTERM + _ = syscall.Kill(-pgid, syscall.SIGTERM) + + // Give it a chance to exit cleanly + done := make(chan error, 1) + go func() { + done <- procCmd.Wait() + }() + + select { + case <-done: + // Process exited, all good + case <-time.After(3 * time.Second): + // Process didn't exit in time, force kill + fmt.Println("Worker didn't exit gracefully, force killing") + _ = syscall.Kill(-pgid, syscall.SIGKILL) + <-done // Still wait for the process to be fully gone + } + } else { + // Fallback if we couldn't get the process group + _ = procCmd.Process.Signal(syscall.SIGTERM) + + // Wait for it to exit or force kill after timeout + done := make(chan error, 1) + go func() { + done <- procCmd.Wait() + }() + + select { + case <-done: + // Process exited, all good + case <-time.After(3 * time.Second): + // Process didn't exit in time, force kill + fmt.Println("Worker didn't exit gracefully, force killing") + _ = procCmd.Process.Kill() + <-done // Still wait for the process to be fully gone + } + } + + procCmd = nil + } +} + +func startProcess(args []string, apiToken string) error { + procLk.Lock() + defer procLk.Unlock() + + // If there's a running process, kill it first + if procCmd != nil { + procLk.Unlock() + killProcess() // This acquires the lock itself + procLk.Lock() + } + + procCmd = exec.Command(args[0], args[1:]...) + + // Make process its own process group so we can kill it and all children + procCmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } + + procCmd.Env = append(os.Environ(), fmt.Sprintf("HATCHET_CLIENT_TOKEN=%s", apiToken)) + procCmd.Stdout = os.Stdout + procCmd.Stderr = os.Stderr + + err := procCmd.Start() + if err != nil { + procCmd = nil + return fmt.Errorf("error starting worker: %v", err) + } + + // Don't wait here - we'll wait when killing or when the process exits itself + // This is a non-blocking start + go func() { + waitProc := procCmd // Capture the current process + err := waitProc.Wait() + procLk.Lock() + defer procLk.Unlock() + + // Only clear procCmd if it's still the same process we started + if procCmd != nil && procCmd == waitProc { + procCmd = nil + } + + if err != nil && !strings.Contains(err.Error(), "signal: killed") { + log.Printf("Worker exited with error: %v", err) + } + }() + + return nil +} diff --git a/cmd/hatchet/cli/utils.go b/cmd/hatchet/cli/utils.go new file mode 100644 index 000000000..025912691 --- /dev/null +++ b/cmd/hatchet/cli/utils.go @@ -0,0 +1,13 @@ +package cli + +import "os" + +func pathExists(path string) (bool, error) { + _, err := os.Stat(path) + + if os.IsNotExist(err) { + return false, nil + } + + return err == nil, err +} diff --git a/cmd/hatchet/cli/version.go b/cmd/hatchet/cli/version.go new file mode 100644 index 000000000..caafaab53 --- /dev/null +++ b/cmd/hatchet/cli/version.go @@ -0,0 +1,23 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// Version is the version of Hatchet CLI +// This will be overwritten at build time by goreleaser +var Version = "dev" + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version number of Hatchet", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println(Version) + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} diff --git a/cmd/hatchet/glob/glob.go b/cmd/hatchet/glob/glob.go new file mode 100644 index 000000000..f7c9a72d8 --- /dev/null +++ b/cmd/hatchet/glob/glob.go @@ -0,0 +1,352 @@ +// Taken from https://github.com/golang/tools/blob/master/gopls/internal/test/integration/fake/glob/glob.go + +package glob + +// Copyright 2023 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package glob implements an LSP-compliant glob pattern matcher for testing. + +import ( + "errors" + "fmt" + "strings" + "unicode/utf8" +) + +// A Glob is an LSP-compliant glob pattern, as defined by the spec: +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#documentFilter +// +// NOTE: this implementation is currently only intended for testing. In order +// to make it production ready, we'd need to: +// - verify it against the VS Code implementation +// - add more tests +// - microbenchmark, likely avoiding the element interface +// - resolve the question of what is meant by "character". If it's a UTF-16 +// code (as we suspect) it'll be a bit more work. +// +// Quoting from the spec: +// Glob patterns can have the following syntax: +// - `*` to match one or more characters in a path segment +// - `?` to match on one character in a path segment +// - `**` to match any number of path segments, including none +// - `{}` to group sub patterns into an OR expression. (e.g. `**/*.{ts,js}` +// matches all TypeScript and JavaScript files) +// - `[]` to declare a range of characters to match in a path segment +// (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …) +// - `[!...]` to negate a range of characters to match in a path segment +// (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but +// not `example.0`) +// +// Expanding on this: +// - '/' matches one or more literal slashes. +// - any other character matches itself literally. +type Glob struct { + elems []element // pattern elements +} + +// Parse builds a Glob for the given pattern, returning an error if the pattern +// is invalid. +func Parse(pattern string) (*Glob, error) { + g, _, err := parse(pattern, false) + return g, err +} + +func parse(pattern string, nested bool) (*Glob, string, error) { + g := new(Glob) + for len(pattern) > 0 { + switch pattern[0] { + case '/': + pattern = pattern[1:] + g.elems = append(g.elems, slash{}) + + case '*': + if len(pattern) > 1 && pattern[1] == '*' { + if (len(g.elems) > 0 && g.elems[len(g.elems)-1] != slash{}) || (len(pattern) > 2 && pattern[2] != '/') { + return nil, "", errors.New("** may only be adjacent to '/'") + } + pattern = pattern[2:] + g.elems = append(g.elems, starStar{}) + break + } + pattern = pattern[1:] + g.elems = append(g.elems, star{}) + + case '?': + pattern = pattern[1:] + g.elems = append(g.elems, anyChar{}) + + case '{': + var gs group + for pattern[0] != '}' { + pattern = pattern[1:] + g, pat, err := parse(pattern, true) + if err != nil { + return nil, "", err + } + if len(pat) == 0 { + return nil, "", errors.New("unmatched '{'") + } + pattern = pat + gs = append(gs, g) + } + pattern = pattern[1:] + g.elems = append(g.elems, gs) + + case '}', ',': + if nested { + return g, pattern, nil + } + pattern = g.parseLiteral(pattern, false) + + case '[': + pattern = pattern[1:] + if len(pattern) == 0 { + return nil, "", errBadRange + } + negate := false + if pattern[0] == '!' { + pattern = pattern[1:] + negate = true + } + low, sz, err := readRangeRune(pattern) + if err != nil { + return nil, "", err + } + pattern = pattern[sz:] + if len(pattern) == 0 || pattern[0] != '-' { + return nil, "", errBadRange + } + pattern = pattern[1:] + high, sz, err := readRangeRune(pattern) + if err != nil { + return nil, "", err + } + pattern = pattern[sz:] + if len(pattern) == 0 || pattern[0] != ']' { + return nil, "", errBadRange + } + pattern = pattern[1:] + g.elems = append(g.elems, charRange{negate, low, high}) + + default: + pattern = g.parseLiteral(pattern, nested) + } + } + return g, "", nil +} + +// helper for decoding a rune in range elements, e.g. [a-z] +func readRangeRune(input string) (rune, int, error) { + r, sz := utf8.DecodeRuneInString(input) + var err error + if r == utf8.RuneError { + // See the documentation for DecodeRuneInString. + switch sz { + case 0: + err = errBadRange + case 1: + err = errInvalidUTF8 + } + } + return r, sz, err +} + +var ( + errBadRange = errors.New("'[' patterns must be of the form [x-y]") + errInvalidUTF8 = errors.New("invalid UTF-8 encoding") +) + +func (g *Glob) parseLiteral(pattern string, nested bool) string { + var specialChars string + if nested { + specialChars = "*?{[/}," + } else { + specialChars = "*?{[/" + } + end := strings.IndexAny(pattern, specialChars) + if end == -1 { + end = len(pattern) + } + g.elems = append(g.elems, literal(pattern[:end])) + return pattern[end:] +} + +func (g *Glob) String() string { + var b strings.Builder + for _, e := range g.elems { + fmt.Fprint(&b, e) + } + return b.String() +} + +// element holds a glob pattern element, as defined below. +type element fmt.Stringer + +// element types. +type ( + slash struct{} // One or more '/' separators + literal string // string literal, not containing /, *, ?, {}, or [] + star struct{} // * + anyChar struct{} // ? + starStar struct{} // ** + group []*Glob // {foo, bar, ...} grouping + charRange struct { // [a-z] character range + negate bool + low, high rune + } +) + +func (s slash) String() string { return "/" } +func (l literal) String() string { return string(l) } +func (s star) String() string { return "*" } +func (a anyChar) String() string { return "?" } +func (s starStar) String() string { return "**" } +func (g group) String() string { + var parts []string + for _, g := range g { + parts = append(parts, g.String()) + } + return "{" + strings.Join(parts, ",") + "}" +} +func (r charRange) String() string { + return "[" + string(r.low) + "-" + string(r.high) + "]" +} + +// Match reports whether the input string matches the glob pattern. +func (g *Glob) Match(input string) bool { + return match(g.elems, input) +} + +func match(elems []element, input string) (ok bool) { + var elem interface{} + for len(elems) > 0 { + elem, elems = elems[0], elems[1:] + switch elem := elem.(type) { + case slash: + if len(input) == 0 || input[0] != '/' { + return false + } + for input[0] == '/' { + input = input[1:] + } + + case starStar: + // Special cases: + // - **/a matches "a" + // - **/ matches everything + // + // Note that if ** is followed by anything, it must be '/' (this is + // enforced by Parse). + if len(elems) > 0 { + elems = elems[1:] + } + + // A trailing ** matches anything. + if len(elems) == 0 { + return true + } + + // Backtracking: advance pattern segments until the remaining pattern + // elements match. + for len(input) != 0 { + if match(elems, input) { + return true + } + _, input = split(input) + } + return false + + case literal: + if !strings.HasPrefix(input, string(elem)) { + return false + } + input = input[len(elem):] + + case star: + var segInput string + segInput, input = split(input) + + elemEnd := len(elems) + for i, e := range elems { + if e == (slash{}) { + elemEnd = i + break + } + } + segElems := elems[:elemEnd] + elems = elems[elemEnd:] + + // A trailing * matches the entire segment. + if len(segElems) == 0 { + break + } + + // Backtracking: advance characters until remaining subpattern elements + // match. + matched := false + for i := range segInput { + if match(segElems, segInput[i:]) { + matched = true + break + } + } + if !matched { + return false + } + + case anyChar: + if len(input) == 0 || input[0] == '/' { + return false + } + input = input[1:] + + case group: + // Append remaining pattern elements to each group member looking for a + // match. + var branch []element + for _, m := range elem { + branch = branch[:0] + branch = append(branch, m.elems...) + branch = append(branch, elems...) + if match(branch, input) { + return true + } + } + return false + + case charRange: + if len(input) == 0 || input[0] == '/' { + return false + } + c, sz := utf8.DecodeRuneInString(input) + if c < elem.low || c > elem.high { + return false + } + input = input[sz:] + + default: + panic(fmt.Sprintf("segment type %T not implemented", elem)) + } + } + + return len(input) == 0 +} + +// split returns the portion before and after the first slash +// (or sequence of consecutive slashes). If there is no slash +// it returns (input, nil). +func split(input string) (first, rest string) { + i := strings.IndexByte(input, '/') + if i < 0 { + return input, "" + } + first = input[:i] + for j := i; j < len(input); j++ { + if input[j] != '/' { + return first, input[j:] + } + } + return first, "" +} diff --git a/cmd/hatchet/main.go b/cmd/hatchet/main.go new file mode 100644 index 000000000..1e1bfc2ec --- /dev/null +++ b/cmd/hatchet/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "fmt" + + "github.com/hatchet-dev/hatchet/cmd/hatchet/cli" +) + +func main() { + fmt.Printf(` + _ _ _ _ _ + | | | | __ _| |_ ___| |__ ___| |_ + | |_| |/ _` + "`" + ` | __/ __| '_ \ / _ \ __| + | _ | (_| | || (__| | | | __/ |_ + |_| |_|\__,_|\__\___|_| |_|\___|\__| + +`) + + cli.Execute() +} diff --git a/cmd/internal/lite.go b/cmd/internal/lite.go new file mode 100644 index 000000000..f73441981 --- /dev/null +++ b/cmd/internal/lite.go @@ -0,0 +1,119 @@ +package internal + +import ( + "fmt" + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strings" + "time" + + "github.com/hatchet-dev/hatchet/cmd/hatchet-api/api" + "github.com/hatchet-dev/hatchet/cmd/hatchet-engine/engine" + "github.com/hatchet-dev/hatchet/cmd/hatchet-lite/staticfileserver" + "github.com/hatchet-dev/hatchet/pkg/cmdutils" + "github.com/hatchet-dev/hatchet/pkg/config/loader" +) + +// runs a static file server, api and engine in the same process. +func StartLite(cf *loader.ConfigLoader, interruptCh <-chan interface{}, version string) error { + // read static asset directory and frontend URL from the environment + staticAssetDir := os.Getenv("LITE_STATIC_ASSET_DIR") + frontendPort := os.Getenv("LITE_FRONTEND_PORT") + runtimePort := os.Getenv("LITE_RUNTIME_PORT") + + if staticAssetDir == "" { + return fmt.Errorf("LITE_STATIC_ASSET_DIR environment variable is required") + } + + if frontendPort == "" { + return fmt.Errorf("LITE_FRONTEND_PORT environment variable is required") + } + + if runtimePort == "" { + runtimePort = "8082" + } + + // we hard code the msg queue kind to postgres + err := os.Setenv("SERVER_MSGQUEUE_KIND", "postgres") + + if err != nil { + return fmt.Errorf("error setting SERVER_MSGQUEUE_KIND to postgres: %w", err) + } + + feURL, err := url.Parse(fmt.Sprintf("http://localhost:%s", frontendPort)) + + if err != nil { + return fmt.Errorf("error parsing frontend URL: %w", err) + } + + _, sc, err := cf.CreateServerFromConfig(version) + + if err != nil { + return fmt.Errorf("error loading server config: %w", err) + } + + apiURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", sc.Runtime.Port)) + + if err != nil { + return fmt.Errorf("error parsing API URL: %w", err) + } + + // api process + go func() { + api.Start(cf, interruptCh, version) // nolint:errcheck + }() + + // static file server + go func() { + c := staticfileserver.NewStaticFileServer(staticAssetDir) + + s := &http.Server{ + Addr: fmt.Sprintf(":%s", frontendPort), + Handler: c, + ReadHeaderTimeout: 5 * time.Second, + } + + if err := s.ListenAndServe(); err != nil { + log.Printf("static file server failure: %s", err.Error()) + os.Exit(1) + } + }() + + ctx, cancel := cmdutils.NewInterruptContext() + defer cancel() + + go func() { + if err := engine.Run(ctx, cf, version); err != nil { + log.Printf("engine failure: %s", err.Error()) + os.Exit(1) + } + }() + + s := &http.Server{ + Addr: fmt.Sprintf(":%s", runtimePort), + ReadHeaderTimeout: 5 * time.Second, + Handler: &httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + if strings.HasPrefix(r.In.URL.Path, "/api") { + r.SetURL(apiURL) + } else { + r.SetURL(feURL) + } + }, + }, + } + + go func() { + if err := s.ListenAndServe(); err != nil { + log.Printf("reverse proxy failure: %s", err.Error()) + os.Exit(1) + } + }() + + <-interruptCh + + return nil +} diff --git a/cmd/internal/quickstart.go b/cmd/internal/quickstart.go new file mode 100644 index 000000000..a7614bd37 --- /dev/null +++ b/cmd/internal/quickstart.go @@ -0,0 +1,345 @@ +package internal + +import ( + _ "embed" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/fatih/color" + "sigs.k8s.io/yaml" + + "github.com/hatchet-dev/hatchet/pkg/config/database" + "github.com/hatchet-dev/hatchet/pkg/config/loader" + "github.com/hatchet-dev/hatchet/pkg/config/server" + "github.com/hatchet-dev/hatchet/pkg/encryption" + "github.com/hatchet-dev/hatchet/pkg/random" +) + +var certDir string +var generatedConfigDir string +var skip []string +var overwrite bool +var configDirectory string + +const ( + StageCerts string = "certs" + StageKeys string = "keys" + StageSeed string = "seed" +) + +type QuickstartOpts struct { + CertDir string + GeneratedConfigDir string + Skip []string + Overwrite bool + ConfigDirectory string +} + +func RunQuickstart(opts *QuickstartOpts) error { + if opts.CertDir == "" { + opts.CertDir = "./certs" + } + + if opts.GeneratedConfigDir == "" { + opts.GeneratedConfigDir = "./generated" + } + + certDir = opts.CertDir + generatedConfigDir = opts.GeneratedConfigDir + skip = opts.Skip + overwrite = opts.Overwrite + configDirectory = opts.ConfigDirectory + + generated, err := loadBaseConfigFiles() + + if err != nil { + return fmt.Errorf("could not get base config files: %w", err) + } + + if !shouldSkip(StageCerts) { + err := setupCerts(generated) + + if err != nil { + return fmt.Errorf("could not setup certs: %w", err) + } + } + + if !shouldSkip(StageKeys) { + err := generateKeys(generated) + + if err != nil { + return fmt.Errorf("could not generate keys: %w", err) + } + } + + err = writeGeneratedConfig(generated) + + if err != nil { + return fmt.Errorf("could not write generated config files: %w", err) + } + + if !shouldSkip(StageSeed) { + // reload config at this point + configLoader := loader.NewConfigLoader(configDirectory) + err = RunSeed(configLoader) + + if err != nil { + return fmt.Errorf("could not run seed: %w", err) + } + } + + return nil +} + +func shouldSkip(stage string) bool { + for _, skipStage := range skip { + if stage == skipStage { + return true + } + } + + return false +} + +//go:embed certs/cluster-cert.conf +var ClusterCertConf []byte + +//go:embed certs/internal-admin-client-cert.conf +var InternalAdminClientCertConf []byte + +//go:embed certs/worker-client-cert.conf +var WorkerClientCertConf []byte + +//go:embed certs/generate-x509-certs.sh +var GenerateCertsScript string + +type generatedConfigFiles struct { + sc *server.ServerConfigFile + dc *database.ConfigFile +} + +func setupCerts(generated *generatedConfigFiles) error { + color.New(color.FgGreen).Printf("Generating certificates in cert directory %s\n", certDir) + + // verify that bash and openssl are installed on the system + if !commandExists("openssl") { + return fmt.Errorf("openssl must be installed and available in your $PATH") + } + + if !commandExists("bash") { + return fmt.Errorf("bash must be installed and available in your $PATH") + } + + // write certificate config files to system + fullPathCertDir, err := filepath.Abs(certDir) + + if err != nil { + return err + } + + err = os.MkdirAll(fullPathCertDir, os.ModePerm) + + if err != nil { + return fmt.Errorf("could not create cert directory: %w", err) + } + + err = os.WriteFile(filepath.Join(fullPathCertDir, "./cluster-cert.conf"), ClusterCertConf, 0600) + + if err != nil { + return fmt.Errorf("could not create cluster-cert.conf file: %w", err) + } + + err = os.WriteFile(filepath.Join(fullPathCertDir, "./internal-admin-client-cert.conf"), InternalAdminClientCertConf, 0600) + + if err != nil { + return fmt.Errorf("could not create internal-admin-client-cert.conf file: %w", err) + } + + err = os.WriteFile(filepath.Join(fullPathCertDir, "./worker-client-cert.conf"), WorkerClientCertConf, 0600) + + if err != nil { + return fmt.Errorf("could not create worker-client-cert.conf file: %w", err) + } + + // if CA files don't exists, run the script to regenerate all certs + if overwrite || (!fileExists(filepath.Join(fullPathCertDir, "./ca.key")) || !fileExists(filepath.Join(fullPathCertDir, "./ca.cert"))) { + // run openssl commands + c := exec.Command("bash", "-s", "-", fullPathCertDir) + + c.Stdin = strings.NewReader(GenerateCertsScript) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + + err = c.Run() + + if err != nil { + return err + } + } + + generated.sc.TLS.TLSRootCAFile = filepath.Join(fullPathCertDir, "ca.cert") + generated.sc.TLS.TLSCertFile = filepath.Join(fullPathCertDir, "cluster.pem") + generated.sc.TLS.TLSKeyFile = filepath.Join(fullPathCertDir, "cluster.key") + + return nil +} + +func generateKeys(generated *generatedConfigFiles) error { + color.New(color.FgGreen).Printf("Generating encryption keys for Hatchet server\n") + + cookieHashKey, err := random.Generate(16) + + if err != nil { + return fmt.Errorf("could not generate hash key for instance: %w", err) + } + + cookieBlockKey, err := random.Generate(16) + + if err != nil { + return fmt.Errorf("could not generate block key for instance: %w", err) + } + + if overwrite || (generated.sc.Auth.Cookie.Secrets == "") { + generated.sc.Auth.Cookie.Secrets = fmt.Sprintf("%s %s", cookieHashKey, cookieBlockKey) + } + + // if using local keys, generate master key + if !generated.sc.Encryption.CloudKMS.Enabled { + masterKeyBytes, privateEc256, publicEc256, err := encryption.GenerateLocalKeys() + + if err != nil { + return err + } + + if overwrite || (generated.sc.Encryption.MasterKeyset == "") { + generated.sc.Encryption.MasterKeyset = string(masterKeyBytes) + } + + if overwrite || (generated.sc.Encryption.JWT.PublicJWTKeyset == "") || (generated.sc.Encryption.JWT.PrivateJWTKeyset == "") { + generated.sc.Encryption.JWT.PrivateJWTKeyset = string(privateEc256) + generated.sc.Encryption.JWT.PublicJWTKeyset = string(publicEc256) + } + } + + // generate jwt keys + if generated.sc.Encryption.CloudKMS.Enabled && (overwrite || (generated.sc.Encryption.JWT.PublicJWTKeyset == "") || (generated.sc.Encryption.JWT.PrivateJWTKeyset == "")) { + privateEc256, publicEc256, err := encryption.GenerateJWTKeysetsFromCloudKMS( + generated.sc.Encryption.CloudKMS.KeyURI, + []byte(generated.sc.Encryption.CloudKMS.CredentialsJSON), + ) + + if err != nil { + return err + } + + generated.sc.Encryption.JWT.PrivateJWTKeyset = string(privateEc256) + generated.sc.Encryption.JWT.PublicJWTKeyset = string(publicEc256) + } + + return nil +} + +func fileExists(filename string) bool { + info, err := os.Stat(filename) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +} + +func commandExists(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} + +func loadBaseConfigFiles() (*generatedConfigFiles, error) { + res := &generatedConfigFiles{} + var err error + + res.dc, err = loader.LoadDatabaseConfigFile(getFiles("database.yaml")...) + + if err != nil { + return nil, err + } + + res.sc, err = loader.LoadServerConfigFile(getFiles("server.yaml")...) + + if err != nil { + return nil, err + } + + return res, nil +} + +func getFiles(name string) [][]byte { + files := [][]byte{} + + basePath := filepath.Join(configDirectory, name) + + if fileExists(basePath) { + configFileBytes, err := os.ReadFile(basePath) + + if err != nil { + panic(err) + } + + files = append(files, configFileBytes) + } + + generatedPath := filepath.Join(generatedConfigDir, name) + + if fileExists(generatedPath) { + generatedFileBytes, err := os.ReadFile(filepath.Join(generatedConfigDir, name)) + + if err != nil { + panic(err) + } + + files = append(files, generatedFileBytes) + } + + return files +} + +func writeGeneratedConfig(generated *generatedConfigFiles) error { + color.New(color.FgGreen).Printf("Generating config files %s\n", generatedConfigDir) + + err := os.MkdirAll(generatedConfigDir, os.ModePerm) + + if err != nil { + return fmt.Errorf("could not create generated config directory: %w", err) + } + + databasePath := filepath.Join(generatedConfigDir, "./database.yaml") + + databaseConfigBytes, err := yaml.Marshal(generated.dc) + + if err != nil { + return err + } + + err = os.WriteFile(databasePath, databaseConfigBytes, 0600) + + if err != nil { + return fmt.Errorf("could not write database.yaml file: %w", err) + } + + serverPath := filepath.Join(generatedConfigDir, "./server.yaml") + + serverConfigBytes, err := yaml.Marshal(generated.sc) + + if err != nil { + return err + } + + err = os.WriteFile(serverPath, serverConfigBytes, 0600) + + if err != nil { + return fmt.Errorf("could not write server.yaml file: %w", err) + } + + return nil +} diff --git a/cmd/internal/seed.go b/cmd/internal/seed.go new file mode 100644 index 000000000..4e9299a63 --- /dev/null +++ b/cmd/internal/seed.go @@ -0,0 +1,19 @@ +package internal + +import ( + "github.com/hatchet-dev/hatchet/cmd/hatchet-admin/cli/seed" + "github.com/hatchet-dev/hatchet/pkg/config/loader" +) + +func RunSeed(cf *loader.ConfigLoader) error { + // load the config + dc, err := cf.InitDataLayer() + + if err != nil { + panic(err) + } + + defer dc.Disconnect() // nolint: errcheck + + return seed.SeedDatabase(dc) +} diff --git a/go.mod b/go.mod index 824a9cd15..38b2a5df5 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/Masterminds/semver/v3 v3.4.0 github.com/creasty/defaults v1.8.0 github.com/fatih/color v1.18.0 + github.com/fergusstrange/embedded-postgres v1.32.0 github.com/getkin/kin-openapi v0.132.0 github.com/go-co-op/gocron/v2 v2.16.3 github.com/google/go-github/v57 v57.0.0 @@ -108,6 +109,7 @@ require ( github.com/klauspost/compress v1.18.0 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mailru/easyjson v0.7.7 // indirect @@ -145,6 +147,7 @@ require ( github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect @@ -167,7 +170,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/exaring/otelpgx v0.9.3 - github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/fsnotify/fsnotify v1.8.0 github.com/getsentry/sentry-go v0.35.0 github.com/go-chi/chi v1.5.5 github.com/go-playground/validator/v10 v10.27.0 diff --git a/go.sum b/go.sum index a27253572..6acfad2e9 100644 --- a/go.sum +++ b/go.sum @@ -75,6 +75,8 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fergusstrange/embedded-postgres v1.32.0 h1:kh2ozEvAx2A0LoIJZEGNwHmoFTEQD243KrHjifcYGMo= +github.com/fergusstrange/embedded-postgres v1.32.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk= 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/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= @@ -386,6 +388,8 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= diff --git a/hack/dev/setup-hatchet-cli.sh b/hack/dev/setup-hatchet-cli.sh new file mode 100644 index 000000000..d7282f2d1 --- /dev/null +++ b/hack/dev/setup-hatchet-cli.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +set -euo pipefail + +echo "Building frontend..." +cd ./frontend/app && npm run build +cd ../../ +cp -r ./frontend/app/dist/ cmd/hatchet/cli/dist/ + +echo "Generating certs..." +sh ./hack/dev/generate-x509-certs.sh ./hack/dev/certs +cp -r ./hack/dev/certs/ ./cmd/internal/certs/ +cp ./hack/dev/generate-x509-certs.sh ./cmd/internal/certs/generate-x509-certs.sh + +echo "Building hatchet CLI..." +goreleaser build --single-target --snapshot --clean +sudo cp ./dist/hatchet_darwin_arm64_v8.0/hatchet /usr/local/bin/hatchet