diff --git a/.gitignore b/.gitignore index cd3cb15..12c4508 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ /hypeman .env hypeman/** +bin/hypeman diff --git a/.stats.yml b/.stats.yml index 96e35f6..e91df4e 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 22 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fhypeman-da3f4038bb544acae375f44527f515dc58308f67822905258b155192041e65ed.yml -openapi_spec_hash: 4c7f6f453c20eda7fd8689e8917c65f9 -config_hash: a7d0557c72de54fd6baded5b189777c3 +configured_endpoints: 24 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fhypeman-51c1f6c7e28113c00cfcfea0595de40961dca2263b88bf2e47ef46b8ed458b07.yml +openapi_spec_hash: 07f24b9c8f0b757100655ac10d83b362 +config_hash: 510018ffa6ad6a17875954f66fe69598 diff --git a/README.md b/README.md index 1760030..3ef2285 100644 --- a/README.md +++ b/README.md @@ -37,21 +37,88 @@ go run cmd/hypeman/main.go # Pull an image hypeman pull nginx:alpine -# Run an instance (auto-pulls image if needed) -hypeman run nginx:alpine -hypeman run --name my-app -e PORT=3000 nginx:alpine +# Boot a new VM (auto-pulls image if needed) +hypeman run --name my-app nginx:alpine -# List running instances +# List running VMs hypeman ps -hypeman ps -a # show all instances +# show all VMs +hypeman ps -a + +# View logs of your app +# All commands support using VM name, ID, or partial ID +hypeman logs my-app +hypeman logs -f my-app + +# Execute a command in a running VM +hypeman exec my-app whoami +# Shell into the VM +hypeman exec -it my-app /bin/sh + +# VM lifecycle +# Turn off the VM +hypeman stop my-app +# Boot the VM that was turned off +hypeman start my-app +# Put the VM to sleep (paused) +hypeman standby my-app +# Awaken the VM (resumed) +hypeman restore my-app + +# Create a reverse proxy ("ingress") from the host to your VM +hypeman ingress create --name my-ingress my-app --hostname my-nginx-app --port 80 --host-port 8081 + +# List ingresses +hypeman ingress list + +# Curl nginx through your ingress +curl --header "Host: my-nginx-app" http://127.0.0.1:8081 + +# Delete an ingress +hypeman ingress delete my-ingress + +# Delete all VMs +hypeman rm --force --all +``` + +More ingress features: +- Automatic certs +- Subdomain-based routing + +```bash +# Make your VM if not already present +hypeman run --name my-app nginx:alpine + +# This requires configuring the Hypeman server with DNS credentials +# Change --hostname to a domain you own +hypeman ingress create --name my-tls-ingress my-app --hostname hello.hypeman-development.com -p 80 --host-port 7443 --tls + +# Curl through your TLS-terminating reverse proxy configuration +curl \ + --resolve hello.hypeman-development.com:7443:127.0.0.1 \ + https://hello.hypeman-development.com:7443 -# View logs -hypeman logs -hypeman logs -f # follow logs +# OR... Ingress also supports subdomain-based routing +hypeman ingress create --name my-tls-subdomain-ingress '{instance}' --hostname '{instance}.hypeman-development.com' -p 80 --host-port 8443 --tls -# Execute a command in a running instance -hypeman exec -- /bin/sh -hypeman exec -it # interactive shell +# Curling through the subdomain-based routing +curl \ + --resolve my-app.hypeman-development.com:8443:127.0.0.1 \ + https://my-app.hypeman-development.com:8443 + +# Delete all ingress +hypeman ingress delete --all +``` + +More logging features: +- Cloud Hypervisor logs +- Hypeman operational logs + +```bash +# View Cloud Hypervisor logs for your VM +hypeman logs --source vmm my-app +# View Hypeman logs for your VM +hypeman logs --source hypeman my-app ``` For details about specific commands, use the `--help` flag. diff --git a/go.mod b/go.mod index 3efa46f..910aef1 100644 --- a/go.mod +++ b/go.mod @@ -12,10 +12,9 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/itchyny/json2yaml v0.1.4 github.com/muesli/reflow v0.3.0 - github.com/onkernel/hypeman-go v0.5.0 + github.com/onkernel/hypeman-go v0.7.0 github.com/tidwall/gjson v1.18.0 github.com/tidwall/pretty v1.2.1 - github.com/tidwall/sjson v1.2.5 github.com/urfave/cli-docs/v3 v3.0.0-alpha6 github.com/urfave/cli/v3 v3.3.2 golang.org/x/term v0.37.0 @@ -60,6 +59,7 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/vbatts/tar-split v0.12.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect diff --git a/go.sum b/go.sum index a562a01..7d181a6 100644 --- a/go.sum +++ b/go.sum @@ -105,8 +105,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/onkernel/hypeman-go v0.5.0 h1:ILe+n18aN5MXx0ARxDJ/ZYqcX2MdfJqWrE4sn14gJ5I= -github.com/onkernel/hypeman-go v0.5.0/go.mod h1:BPT1yh0gbby1E+As/xLM3GVjw7752+2C5SaEiJV9rRc= +github.com/onkernel/hypeman-go v0.7.0 h1:KUeY4VGJtStA4+zkPtx7eDCO3rSznbIVoj4U6l+g50Q= +github.com/onkernel/hypeman-go v0.7.0/go.mod h1:BPT1yh0gbby1E+As/xLM3GVjw7752+2C5SaEiJV9rRc= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 1934a88..f683c76 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -74,6 +74,11 @@ func init() { &psCmd, &logsCmd, &rmCmd, + &stopCmd, + &startCmd, + &standbyCmd, + &restoreCmd, + &ingressCmd, { Name: "health", Category: "API RESOURCE", @@ -101,6 +106,8 @@ func init() { &instancesLogs, &instancesDelete, &instancesStandby, + &instancesStart, + &instancesStop, }, }, { diff --git a/pkg/cmd/ingresscmd.go b/pkg/cmd/ingresscmd.go new file mode 100644 index 0000000..e632eea --- /dev/null +++ b/pkg/cmd/ingresscmd.go @@ -0,0 +1,244 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/onkernel/hypeman-go" + "github.com/onkernel/hypeman-go/option" + "github.com/urfave/cli/v3" +) + +var ingressCmd = cli.Command{ + Name: "ingress", + Usage: "Manage ingresses", + Commands: []*cli.Command{ + &ingressCreateCmd, + &ingressListCmd, + &ingressDeleteCmd, + }, + HideHelpCommand: true, +} + +var ingressCreateCmd = cli.Command{ + Name: "create", + Usage: "Create an ingress for an instance", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "hostname", + Aliases: []string{"H"}, + Usage: "Hostname to match (exact match on Host header)", + Required: true, + }, + &cli.IntFlag{ + Name: "port", + Aliases: []string{"p"}, + Usage: "Target port on the instance", + Required: true, + }, + &cli.IntFlag{ + Name: "host-port", + Usage: "Host port to listen on (default: 80)", + Value: 80, + }, + &cli.BoolFlag{ + Name: "tls", + Usage: "Enable TLS termination (certificate auto-issued via ACME)", + }, + &cli.BoolFlag{ + Name: "redirect-http", + Usage: "Auto-create HTTP to HTTPS redirect (only applies when --tls is enabled)", + }, + &cli.StringFlag{ + Name: "name", + Usage: "Ingress name (auto-generated from hostname if not provided)", + }, + }, + Action: handleIngressCreate, + HideHelpCommand: true, +} + +var ingressListCmd = cli.Command{ + Name: "list", + Usage: "List ingresses", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "quiet", + Aliases: []string{"q"}, + Usage: "Only display ingress IDs", + }, + }, + Action: handleIngressList, + HideHelpCommand: true, +} + +var ingressDeleteCmd = cli.Command{ + Name: "delete", + Usage: "Delete an ingress", + ArgsUsage: "", + Action: handleIngressDelete, + HideHelpCommand: true, +} + +func handleIngressCreate(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("instance name or ID required\nUsage: hypeman ingress create --hostname --port ") + } + + instance := args[0] + hostname := cmd.String("hostname") + port := cmd.Int("port") + hostPort := cmd.Int("host-port") + tls := cmd.Bool("tls") + redirectHTTP := cmd.Bool("redirect-http") + name := cmd.String("name") + + // Auto-generate name from hostname if not provided + if name == "" { + name = generateIngressName(hostname) + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + params := hypeman.IngressNewParams{ + Name: name, + Rules: []hypeman.IngressRuleParam{ + { + Match: hypeman.IngressMatchParam{ + Hostname: hostname, + Port: hypeman.Int(int64(hostPort)), + }, + Target: hypeman.IngressTargetParam{ + Instance: instance, + Port: int64(port), + }, + Tls: hypeman.Bool(tls), + RedirectHTTP: hypeman.Bool(redirectHTTP), + }, + }, + } + + fmt.Fprintf(os.Stderr, "Creating ingress %s...\n", name) + + result, err := client.Ingresses.New(ctx, params, opts...) + if err != nil { + return err + } + + fmt.Println(result.ID) + return nil +} + +func handleIngressList(ctx context.Context, cmd *cli.Command) error { + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + ingresses, err := client.Ingresses.List(ctx, opts...) + if err != nil { + return err + } + + quietMode := cmd.Bool("quiet") + + if quietMode { + for _, ing := range *ingresses { + fmt.Println(ing.ID) + } + return nil + } + + if len(*ingresses) == 0 { + fmt.Fprintln(os.Stderr, "No ingresses found.") + return nil + } + + table := NewTableWriter(os.Stdout, "ID", "NAME", "HOSTNAME", "TARGET", "TLS", "CREATED") + for _, ing := range *ingresses { + // Extract first rule's hostname and target for display + hostname := "" + target := "" + tlsEnabled := "-" + if len(ing.Rules) > 0 { + rule := ing.Rules[0] + hostname = rule.Match.Hostname + target = fmt.Sprintf("%s:%d", rule.Target.Instance, rule.Target.Port) + if rule.Tls { + tlsEnabled = "yes" + } else { + tlsEnabled = "no" + } + } + + table.AddRow( + TruncateID(ing.ID), + TruncateString(ing.Name, 20), + TruncateString(hostname, 25), + target, + tlsEnabled, + FormatTimeAgo(ing.CreatedAt), + ) + } + table.Render() + + return nil +} + +func handleIngressDelete(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("ingress ID or name required\nUsage: hypeman ingress delete ") + } + + id := args[0] + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + err := client.Ingresses.Delete(ctx, id, opts...) + if err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Ingress %s deleted.\n", id) + return nil +} + +// generateIngressName generates an ingress name from hostname +func generateIngressName(hostname string) string { + // Replace dots with dashes + name := strings.ReplaceAll(hostname, ".", "-") + name = strings.ToLower(name) + + // Remove invalid characters (only allow a-z, 0-9, and -) + var cleaned strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + cleaned.WriteRune(r) + } + } + name = cleaned.String() + + // Trim leading/trailing dashes + name = strings.Trim(name, "-") + + // Add random suffix + suffix := randomSuffix(4) + return fmt.Sprintf("%s-%s", name, suffix) +} diff --git a/pkg/cmd/instance.go b/pkg/cmd/instance.go index 5cc41b5..7848592 100644 --- a/pkg/cmd/instance.go +++ b/pkg/cmd/instance.go @@ -173,6 +173,30 @@ var instancesStandby = cli.Command{ HideHelpCommand: true, } +var instancesStart = cli.Command{ + Name: "start", + Usage: "Start a stopped instance", + Flags: []cli.Flag{ + &requestflag.StringFlag{ + Name: "id", + }, + }, + Action: handleInstancesStart, + HideHelpCommand: true, +} + +var instancesStop = cli.Command{ + Name: "stop", + Usage: "Stop instance (graceful shutdown)", + Flags: []cli.Flag{ + &requestflag.StringFlag{ + Name: "id", + }, + }, + Action: handleInstancesStop, + HideHelpCommand: true, +} + func handleInstancesCreate(ctx context.Context, cmd *cli.Command) error { client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() @@ -402,3 +426,75 @@ func handleInstancesStandby(ctx context.Context, cmd *cli.Command) error { transform := cmd.Root().String("transform") return ShowJSON("instances standby", json, format, transform) } + +func handleInstancesStart(ctx context.Context, cmd *cli.Command) error { + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("id") && len(unusedArgs) > 0 { + cmd.Set("id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + ) + if err != nil { + return err + } + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Instances.Start( + ctx, + requestflag.CommandRequestValue[string](cmd, "id"), + options..., + ) + if err != nil { + return err + } + + json := gjson.Parse(string(res)) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON("instances start", json, format, transform) +} + +func handleInstancesStop(ctx context.Context, cmd *cli.Command) error { + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("id") && len(unusedArgs) > 0 { + cmd.Set("id", unusedArgs[0]) + unusedArgs = unusedArgs[1:] + } + if len(unusedArgs) > 0 { + return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) + } + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatComma, + ApplicationJSON, + ) + if err != nil { + return err + } + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Instances.Stop( + ctx, + requestflag.CommandRequestValue[string](cmd, "id"), + options..., + ) + if err != nil { + return err + } + + json := gjson.Parse(string(res)) + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + return ShowJSON("instances stop", json, format, transform) +} diff --git a/pkg/cmd/lifecycle.go b/pkg/cmd/lifecycle.go new file mode 100644 index 0000000..a297e6f --- /dev/null +++ b/pkg/cmd/lifecycle.go @@ -0,0 +1,159 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/onkernel/hypeman-go" + "github.com/onkernel/hypeman-go/option" + "github.com/urfave/cli/v3" +) + +var stopCmd = cli.Command{ + Name: "stop", + Usage: "Stop a running instance", + ArgsUsage: "", + Action: handleStop, + HideHelpCommand: true, +} + +var startCmd = cli.Command{ + Name: "start", + Usage: "Start a stopped instance", + ArgsUsage: "", + Action: handleStart, + HideHelpCommand: true, +} + +var standbyCmd = cli.Command{ + Name: "standby", + Usage: "Put an instance into standby (pause and snapshot)", + ArgsUsage: "", + Action: handleStandby, + HideHelpCommand: true, +} + +var restoreCmd = cli.Command{ + Name: "restore", + Usage: "Restore an instance from standby", + ArgsUsage: "", + Action: handleRestore, + HideHelpCommand: true, +} + +func handleStop(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("instance name or ID required\nUsage: hypeman stop ") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + instanceID, err := ResolveInstance(ctx, &client, args[0]) + if err != nil { + return err + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + fmt.Fprintf(os.Stderr, "Stopping %s...\n", args[0]) + + instance, err := client.Instances.Stop(ctx, instanceID, opts...) + if err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Stopped %s (state: %s)\n", instance.Name, instance.State) + return nil +} + +func handleStart(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("instance name or ID required\nUsage: hypeman start ") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + instanceID, err := ResolveInstance(ctx, &client, args[0]) + if err != nil { + return err + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + fmt.Fprintf(os.Stderr, "Starting %s...\n", args[0]) + + instance, err := client.Instances.Start(ctx, instanceID, opts...) + if err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Started %s (state: %s)\n", instance.Name, instance.State) + return nil +} + +func handleStandby(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("instance name or ID required\nUsage: hypeman standby ") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + instanceID, err := ResolveInstance(ctx, &client, args[0]) + if err != nil { + return err + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + fmt.Fprintf(os.Stderr, "Putting %s into standby...\n", args[0]) + + instance, err := client.Instances.Standby(ctx, instanceID, opts...) + if err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Standby %s (state: %s)\n", instance.Name, instance.State) + return nil +} + +func handleRestore(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) < 1 { + return fmt.Errorf("instance name or ID required\nUsage: hypeman restore ") + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + instanceID, err := ResolveInstance(ctx, &client, args[0]) + if err != nil { + return err + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + fmt.Fprintf(os.Stderr, "Restoring %s from standby...\n", args[0]) + + instance, err := client.Instances.Restore(ctx, instanceID, opts...) + if err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Restored %s (state: %s)\n", instance.Name, instance.State) + return nil +} diff --git a/pkg/cmd/logs.go b/pkg/cmd/logs.go index 9d4d200..22ca964 100644 --- a/pkg/cmd/logs.go +++ b/pkg/cmd/logs.go @@ -24,6 +24,11 @@ var logsCmd = cli.Command{ Usage: "Number of lines to show from the end of the logs", Value: 100, }, + &cli.StringFlag{ + Name: "source", + Aliases: []string{"s"}, + Usage: "Log source: app (default), vmm (Cloud Hypervisor), or hypeman (operations log)", + }, }, Action: handleLogs, HideHelpCommand: true, @@ -50,6 +55,9 @@ func handleLogs(ctx context.Context, cmd *cli.Command) error { if cmd.IsSet("tail") { params.Tail = hypeman.Opt(int64(cmd.Int("tail"))) } + if cmd.IsSet("source") { + params.Source = hypeman.InstanceLogsParamsSource(cmd.String("source")) + } var opts []option.RequestOption if cmd.Root().Bool("debug") { @@ -70,5 +78,3 @@ func handleLogs(ctx context.Context, cmd *cli.Command) error { return stream.Err() } - - diff --git a/pkg/cmd/run.go b/pkg/cmd/run.go index de75eea..7da4d6e 100644 --- a/pkg/cmd/run.go +++ b/pkg/cmd/run.go @@ -85,7 +85,7 @@ func handleRun(ctx context.Context, cmd *cli.Command) error { } // Wait for image to be ready (build is asynchronous) - if err := waitForImageReady(ctx, &client, image, imgInfo); err != nil { + if err := waitForImageReady(ctx, &client, imgInfo); err != nil { return err } @@ -150,7 +150,7 @@ func isNotFoundError(err error, target **hypeman.Error) bool { } // waitForImageReady polls image status until it becomes ready or failed -func waitForImageReady(ctx context.Context, client *hypeman.Client, imageName string, img *hypeman.Image) error { +func waitForImageReady(ctx context.Context, client *hypeman.Client, img *hypeman.Image) error { if img.Status == hypeman.ImageStatusReady { return nil } @@ -161,8 +161,8 @@ func waitForImageReady(ctx context.Context, client *hypeman.Client, imageName st return fmt.Errorf("image build failed") } - // Poll until ready - ticker := time.NewTicker(1 * time.Second) + // Poll until ready using the normalized image name from the API response + ticker := time.NewTicker(300 * time.Millisecond) defer ticker.Stop() // Show initial status @@ -173,7 +173,7 @@ func waitForImageReady(ctx context.Context, client *hypeman.Client, imageName st case <-ctx.Done(): return ctx.Err() case <-ticker.C: - updated, err := client.Images.Get(ctx, imageName) + updated, err := client.Images.Get(ctx, img.Name) if err != nil { return fmt.Errorf("failed to check image status: %w", err) }