From dfb9adb2a50d8f111d876b439886b1559567f3c5 Mon Sep 17 00:00:00 2001 From: Ujjwal Singh Date: Fri, 15 May 2026 20:17:26 +0530 Subject: [PATCH 1/2] [FEAT] [CLI] Make Hatchet Lite version configurable added two new flags to hatchet server start: --tag (default "latest") to let users pin a specific hatchet-lite image version (e.g. --tag v0.83.1), and --pull-policy (default "always") which accepts "always", "missing", or "never" - aligned with Docker Compose pull_policy semantics. The core change is a new pullImageWithPolicy() method in the Docker driver that replaces the two hardcoded ImagePull calls. With "never" it skips the pull entirely and errors if the image isnt local, with "missing" it checks ImageInspect first and only pulls if needed, and "always" preserves existing behavior. The hatchet-lite image name is now built dynamically from the tag instead of being hardcoded to :latest. --- .../internal/drivers/docker/hatchet_lite.go | 97 ++++++++++++++++--- cmd/hatchet-cli/cli/server.go | 27 +++++- cmd/hatchet-cli/cli/worker.go | 2 +- 3 files changed, 109 insertions(+), 17 deletions(-) diff --git a/cmd/hatchet-cli/cli/internal/drivers/docker/hatchet_lite.go b/cmd/hatchet-cli/cli/internal/drivers/docker/hatchet_lite.go index 3edda2cb16..c91d17d367 100644 --- a/cmd/hatchet-cli/cli/internal/drivers/docker/hatchet_lite.go +++ b/cmd/hatchet-cli/cli/internal/drivers/docker/hatchet_lite.go @@ -33,6 +33,18 @@ func printInfo(message string) { fmt.Println(infoStyle.Render(fmt.Sprintf(" %s", message))) } +// PullPolicy controls when Docker images are pulled, mirroring Docker Compose's pull_policy. +type PullPolicy string + +const ( + // PullPolicyAlways always pulls the image from the registry (default, existing behavior). + PullPolicyAlways PullPolicy = "always" + // PullPolicyMissing only pulls if the image is not already available locally. + PullPolicyMissing PullPolicy = "missing" + // PullPolicyNever never pulls; the image must already exist locally. + PullPolicyNever PullPolicy = "never" +) + // hatchet lite opts const ( defaultpostgresName = "postgres" @@ -54,6 +66,8 @@ type HatchetLiteOpts struct { serviceName string overrideDashboardPort int overrideGrpcPort int + imageTag string + pullPolicy PullPolicy } func initDefaultHatchetLiteOpts() *HatchetLiteOpts { @@ -62,6 +76,8 @@ func initDefaultHatchetLiteOpts() *HatchetLiteOpts { hatchetName: defaulthatchetName, projectName: defaultprojectName, serviceName: defaultserviceName, + imageTag: "latest", + pullPolicy: PullPolicyAlways, } } @@ -133,6 +149,31 @@ func WithOverrideGrpcPort(port int) HatchetLiteOpt { } } +// WithImageTag sets the image tag for the hatchet-lite container (e.g. "v0.83.1"). +func WithImageTag(tag string) HatchetLiteOpt { + return func(o *HatchetLiteOpts) error { + if tag == "" { + return fmt.Errorf("image tag must not be empty") + } + + o.imageTag = tag + return nil + } +} + +// WithPullPolicy sets the image pull policy. Valid values are "always", "missing", and "never". +func WithPullPolicy(policy string) HatchetLiteOpt { + return func(o *HatchetLiteOpts) error { + switch PullPolicy(policy) { + case PullPolicyAlways, PullPolicyMissing, PullPolicyNever: + o.pullPolicy = PullPolicy(policy) + return nil + default: + return fmt.Errorf("invalid pull policy %q: must be \"always\", \"missing\", or \"never\"", policy) + } + } +} + func (d *DockerDriver) RunHatchetLite(ctx context.Context, opts ...HatchetLiteOpt) error { hatchetLiteOpts := initDefaultHatchetLiteOpts() @@ -249,14 +290,9 @@ func (d *DockerDriver) startPostgresContainer(ctx context.Context, opts *Hatchet imageName := "postgres:17" containerName := canonicalContainerName(opts.projectName, opts.postgresName) - out, err := d.apiClient.ImagePull(ctx, imageName, image.PullOptions{}) - if err != nil { + if err := d.pullImageWithPolicy(ctx, imageName, opts.pullPolicy); err != nil { return fmt.Errorf("could not pull image %s: %w", imageName, err) } - defer out.Close() - - // Display progress while pulling the image - displayImagePullProgress(out, imageName) // Get image details for proper labeling imageInspect, err := d.apiClient.ImageInspect(ctx, imageName) @@ -356,17 +392,12 @@ func (d *DockerDriver) stopPostgresContainer(ctx context.Context, opts *HatchetL } func (d *DockerDriver) startHatchetLiteContainer(ctx context.Context, opts *HatchetLiteOpts, networkId string, dashboardPort, grpcPort int, sharedLabels map[string]string) error { - imageName := "ghcr.io/hatchet-dev/hatchet/hatchet-lite:latest" + imageName := "ghcr.io/hatchet-dev/hatchet/hatchet-lite:" + opts.imageTag containerName := canonicalContainerName(opts.projectName, opts.hatchetName) - out, err := d.apiClient.ImagePull(ctx, imageName, image.PullOptions{}) - if err != nil { + if err := d.pullImageWithPolicy(ctx, imageName, opts.pullPolicy); err != nil { return fmt.Errorf("could not pull image %s: %w", imageName, err) } - defer out.Close() - - // Display progress while pulling the image - displayImagePullProgress(out, imageName) // Get image details for proper labeling imageInspect, err := d.apiClient.ImageInspect(ctx, imageName) @@ -552,3 +583,43 @@ func getSharedLabels(opts *HatchetLiteOpts) map[string]string { "com.docker.compose.project": opts.projectName, } } + +// pullImageWithPolicy pulls an image according to the specified pull policy. +func (d *DockerDriver) pullImageWithPolicy(ctx context.Context, imageName string, policy PullPolicy) error { + switch policy { + case PullPolicyNever: + // Verify the image exists locally. + _, err := d.apiClient.ImageInspect(ctx, imageName) + if err != nil { + return fmt.Errorf("image %s not found locally and pull policy is \"never\": %w", imageName, err) + } + + printInfo(fmt.Sprintf("Using local image %s (pull policy: never)", imageName)) + + return nil + case PullPolicyMissing: + // Only pull if the image is not present locally. + if _, err := d.apiClient.ImageInspect(ctx, imageName); err == nil { + printInfo(fmt.Sprintf("Image %s already exists locally, skipping pull (pull policy: missing)", imageName)) + + return nil + } + + // Image not found locally, fall through to pull. + printInfo(fmt.Sprintf("Image %s not found locally, pulling... (pull policy: missing)", imageName)) + case PullPolicyAlways: + // Always pull, this is the default behavior. + default: + return fmt.Errorf("unknown pull policy: %q", policy) + } + + out, err := d.apiClient.ImagePull(ctx, imageName, image.PullOptions{}) + if err != nil { + return fmt.Errorf("could not pull image %s: %w", imageName, err) + } + defer out.Close() + + displayImagePullProgress(out, imageName) + + return nil +} diff --git a/cmd/hatchet-cli/cli/server.go b/cmd/hatchet-cli/cli/server.go index c9f98126fa..b00f8b9ac5 100644 --- a/cmd/hatchet-cli/cli/server.go +++ b/cmd/hatchet-cli/cli/server.go @@ -31,15 +31,26 @@ var startCmd = &cobra.Command{ hatchet server start --dashboard-port 9000 --grpc-port 8077 --project-name my-hatchet # Start server with custom profile name - hatchet server start --profile my-local`, + hatchet server start --profile my-local + + # Start server with a specific hatchet-lite version + hatchet server start --tag v0.83.1 + + # Start server without pulling images (use local images only) + hatchet server start --pull-policy never + + # Only pull images if they are not already available locally + hatchet server start --pull-policy missing`, Run: func(cmd *cobra.Command, args []string) { // Get flag values dashboardPort, _ := cmd.Flags().GetInt("dashboard-port") grpcPort, _ := cmd.Flags().GetInt("grpc-port") projectName, _ := cmd.Flags().GetString("project-name") profileName, _ := cmd.Flags().GetString("profile") + tag, _ := cmd.Flags().GetString("tag") + pullPolicy, _ := cmd.Flags().GetString("pull-policy") - result, err := startLocalServer(cmd, profileName, dashboardPort, grpcPort, projectName) + result, err := startLocalServer(cmd, profileName, dashboardPort, grpcPort, projectName, tag, pullPolicy) if err != nil { cli.Logger.Fatalf("%v", err) } @@ -94,7 +105,7 @@ type ServerStartResult struct { } // startLocalServer starts a local Hatchet server and returns connection details -func startLocalServer(cmd *cobra.Command, profileName string, dashboardPort, grpcPort int, projectName string) (*ServerStartResult, error) { +func startLocalServer(cmd *cobra.Command, profileName string, dashboardPort, grpcPort int, projectName, tag, pullPolicy string) (*ServerStartResult, error) { dockerDriver, err := docker.NewDockerDriver(cmd.Context()) if err != nil { return nil, fmt.Errorf("Docker is required to run a local server. Please ensure Docker is installed and running: %w", err) @@ -126,6 +137,14 @@ func startLocalServer(cmd *cobra.Command, profileName string, dashboardPort, grp opts = append(opts, docker.WithProjectName(projectName)) } + if tag != "" { + opts = append(opts, docker.WithImageTag(tag)) + } + + if pullPolicy != "" { + opts = append(opts, docker.WithPullPolicy(pullPolicy)) + } + err = dockerDriver.RunHatchetLite(cmd.Context(), opts...) if err != nil { return nil, fmt.Errorf("could not start hatchet-lite container: %w", err) @@ -183,6 +202,8 @@ func init() { startCmd.Flags().IntP("grpc-port", "g", 0, "Port for the Hatchet gRPC server (default: auto-detect starting at 7077)") startCmd.Flags().StringP("project-name", "p", "", "Docker project name for containers (default: hatchet-cli)") startCmd.Flags().StringP("profile", "n", "local", "Name for the local profile (default: local)") + startCmd.Flags().StringP("tag", "t", "latest", `Image tag for the hatchet-lite container (e.g. "v0.83.1")`) + startCmd.Flags().String("pull-policy", "always", `Image pull policy: "always", "missing", or "never"`) stopCmd.Flags().StringP("project-name", "p", "", "Docker project name for containers (default: hatchet-cli)") } diff --git a/cmd/hatchet-cli/cli/worker.go b/cmd/hatchet-cli/cli/worker.go index b56300634b..613e5e0232 100644 --- a/cmd/hatchet-cli/cli/worker.go +++ b/cmd/hatchet-cli/cli/worker.go @@ -314,7 +314,7 @@ func handleNoProfiles(cmd *cobra.Command) string { func startLocalServerAndCreateProfile(cmd *cobra.Command) string { fmt.Println(styles.InfoMessage("Starting local Hatchet server...")) - result, err := startLocalServer(cmd, "local", 0, 0, "") + result, err := startLocalServer(cmd, "local", 0, 0, "", "latest", "always") if err != nil { cli.Logger.Errorf("%v", err) fmt.Println() From 3cdce2afe092d8fd5c170be8a98b2a7797755ee9 Mon Sep 17 00:00:00 2001 From: Ujjwal Singh Date: Fri, 15 May 2026 23:53:49 +0530 Subject: [PATCH 2/2] fixes updated the PR to address all of the feedback --- .../internal/drivers/docker/hatchet_lite.go | 34 +++++------- cmd/hatchet-cli/cli/server.go | 52 ++++++++++--------- cmd/hatchet-cli/cli/worker.go | 2 +- 3 files changed, 41 insertions(+), 47 deletions(-) diff --git a/cmd/hatchet-cli/cli/internal/drivers/docker/hatchet_lite.go b/cmd/hatchet-cli/cli/internal/drivers/docker/hatchet_lite.go index c91d17d367..e7af069ab1 100644 --- a/cmd/hatchet-cli/cli/internal/drivers/docker/hatchet_lite.go +++ b/cmd/hatchet-cli/cli/internal/drivers/docker/hatchet_lite.go @@ -34,15 +34,12 @@ func printInfo(message string) { } // PullPolicy controls when Docker images are pulled, mirroring Docker Compose's pull_policy. -type PullPolicy string +type pullPolicy string const ( - // PullPolicyAlways always pulls the image from the registry (default, existing behavior). - PullPolicyAlways PullPolicy = "always" - // PullPolicyMissing only pulls if the image is not already available locally. - PullPolicyMissing PullPolicy = "missing" - // PullPolicyNever never pulls; the image must already exist locally. - PullPolicyNever PullPolicy = "never" + pullPolicyAlways pullPolicy = "always" + pullPolicyMissing pullPolicy = "missing" + pullPolicyNever pullPolicy = "never" ) // hatchet lite opts @@ -67,7 +64,7 @@ type HatchetLiteOpts struct { overrideDashboardPort int overrideGrpcPort int imageTag string - pullPolicy PullPolicy + pullPolicy pullPolicy } func initDefaultHatchetLiteOpts() *HatchetLiteOpts { @@ -77,7 +74,7 @@ func initDefaultHatchetLiteOpts() *HatchetLiteOpts { projectName: defaultprojectName, serviceName: defaultserviceName, imageTag: "latest", - pullPolicy: PullPolicyAlways, + pullPolicy: pullPolicyAlways, } } @@ -164,9 +161,9 @@ func WithImageTag(tag string) HatchetLiteOpt { // WithPullPolicy sets the image pull policy. Valid values are "always", "missing", and "never". func WithPullPolicy(policy string) HatchetLiteOpt { return func(o *HatchetLiteOpts) error { - switch PullPolicy(policy) { - case PullPolicyAlways, PullPolicyMissing, PullPolicyNever: - o.pullPolicy = PullPolicy(policy) + switch pullPolicy(policy) { + case pullPolicyAlways, pullPolicyMissing, pullPolicyNever: + o.pullPolicy = pullPolicy(policy) return nil default: return fmt.Errorf("invalid pull policy %q: must be \"always\", \"missing\", or \"never\"", policy) @@ -584,11 +581,9 @@ func getSharedLabels(opts *HatchetLiteOpts) map[string]string { } } -// pullImageWithPolicy pulls an image according to the specified pull policy. -func (d *DockerDriver) pullImageWithPolicy(ctx context.Context, imageName string, policy PullPolicy) error { +func (d *DockerDriver) pullImageWithPolicy(ctx context.Context, imageName string, policy pullPolicy) error { switch policy { - case PullPolicyNever: - // Verify the image exists locally. + case pullPolicyNever: _, err := d.apiClient.ImageInspect(ctx, imageName) if err != nil { return fmt.Errorf("image %s not found locally and pull policy is \"never\": %w", imageName, err) @@ -597,18 +592,15 @@ func (d *DockerDriver) pullImageWithPolicy(ctx context.Context, imageName string printInfo(fmt.Sprintf("Using local image %s (pull policy: never)", imageName)) return nil - case PullPolicyMissing: - // Only pull if the image is not present locally. + case pullPolicyMissing: if _, err := d.apiClient.ImageInspect(ctx, imageName); err == nil { printInfo(fmt.Sprintf("Image %s already exists locally, skipping pull (pull policy: missing)", imageName)) return nil } - // Image not found locally, fall through to pull. printInfo(fmt.Sprintf("Image %s not found locally, pulling... (pull policy: missing)", imageName)) - case PullPolicyAlways: - // Always pull, this is the default behavior. + case pullPolicyAlways: default: return fmt.Errorf("unknown pull policy: %q", policy) } diff --git a/cmd/hatchet-cli/cli/server.go b/cmd/hatchet-cli/cli/server.go index b00f8b9ac5..471f8433d9 100644 --- a/cmd/hatchet-cli/cli/server.go +++ b/cmd/hatchet-cli/cli/server.go @@ -50,7 +50,29 @@ var startCmd = &cobra.Command{ tag, _ := cmd.Flags().GetString("tag") pullPolicy, _ := cmd.Flags().GetString("pull-policy") - result, err := startLocalServer(cmd, profileName, dashboardPort, grpcPort, projectName, tag, pullPolicy) + opts := []docker.HatchetLiteOpt{} + + if dashboardPort != 0 { + opts = append(opts, docker.WithOverrideDashboardPort(dashboardPort)) + } + + if grpcPort != 0 { + opts = append(opts, docker.WithOverrideGrpcPort(grpcPort)) + } + + if projectName != "" { + opts = append(opts, docker.WithProjectName(projectName)) + } + + if tag != "" { + opts = append(opts, docker.WithImageTag(tag)) + } + + if pullPolicy != "" { + opts = append(opts, docker.WithPullPolicy(pullPolicy)) + } + + result, err := startLocalServer(cmd, profileName, opts...) if err != nil { cli.Logger.Fatalf("%v", err) } @@ -105,7 +127,7 @@ type ServerStartResult struct { } // startLocalServer starts a local Hatchet server and returns connection details -func startLocalServer(cmd *cobra.Command, profileName string, dashboardPort, grpcPort int, projectName, tag, pullPolicy string) (*ServerStartResult, error) { +func startLocalServer(cmd *cobra.Command, profileName string, opts ...docker.HatchetLiteOpt) (*ServerStartResult, error) { dockerDriver, err := docker.NewDockerDriver(cmd.Context()) if err != nil { return nil, fmt.Errorf("Docker is required to run a local server. Please ensure Docker is installed and running: %w", err) @@ -115,7 +137,7 @@ func startLocalServer(cmd *cobra.Command, profileName string, dashboardPort, grp var actualDashboardPort, actualGrpcPort int // Build options for RunHatchetLite - opts := []docker.HatchetLiteOpt{ + allOpts := append(opts, docker.WithCreateTokenCallback(func(tok string) { token = tok }), @@ -123,29 +145,9 @@ func startLocalServer(cmd *cobra.Command, profileName string, dashboardPort, grp actualDashboardPort = dashboard actualGrpcPort = grpc }), - } - - if dashboardPort != 0 { - opts = append(opts, docker.WithOverrideDashboardPort(dashboardPort)) - } - - if grpcPort != 0 { - opts = append(opts, docker.WithOverrideGrpcPort(grpcPort)) - } - - if projectName != "" { - opts = append(opts, docker.WithProjectName(projectName)) - } - - if tag != "" { - opts = append(opts, docker.WithImageTag(tag)) - } - - if pullPolicy != "" { - opts = append(opts, docker.WithPullPolicy(pullPolicy)) - } + ) - err = dockerDriver.RunHatchetLite(cmd.Context(), opts...) + err = dockerDriver.RunHatchetLite(cmd.Context(), allOpts...) if err != nil { return nil, fmt.Errorf("could not start hatchet-lite container: %w", err) } diff --git a/cmd/hatchet-cli/cli/worker.go b/cmd/hatchet-cli/cli/worker.go index 613e5e0232..6004d40800 100644 --- a/cmd/hatchet-cli/cli/worker.go +++ b/cmd/hatchet-cli/cli/worker.go @@ -314,7 +314,7 @@ func handleNoProfiles(cmd *cobra.Command) string { func startLocalServerAndCreateProfile(cmd *cobra.Command) string { fmt.Println(styles.InfoMessage("Starting local Hatchet server...")) - result, err := startLocalServer(cmd, "local", 0, 0, "", "latest", "always") + result, err := startLocalServer(cmd, "local") if err != nil { cli.Logger.Errorf("%v", err) fmt.Println()