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/go.mod b/go.mod index 3efa46f..a63589f 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.6.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 @@ -74,3 +74,5 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect google.golang.org/grpc v1.75.1 // indirect ) + +replace github.com/onkernel/hypeman-go => github.com/stainless-sdks/hypeman-go v0.0.0-20251210223055-431af203f52d diff --git a/go.sum b/go.sum index a562a01..0228341 100644 --- a/go.sum +++ b/go.sum @@ -105,8 +105,6 @@ 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/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= @@ -123,6 +121,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stainless-sdks/hypeman-go v0.0.0-20251210223055-431af203f52d h1:kZHZ9PflX6BMr1cP6jFT+PsR66Et4fVfHQJC1GJmDUg= +github.com/stainless-sdks/hypeman-go v0.0.0-20251210223055-431af203f52d/go.mod h1:BPT1yh0gbby1E+As/xLM3GVjw7752+2C5SaEiJV9rRc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 1934a88..9c07d48 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -74,6 +74,7 @@ func init() { &psCmd, &logsCmd, &rmCmd, + &ingressCmd, { Name: "health", Category: "API RESOURCE", @@ -101,6 +102,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/run.go b/pkg/cmd/run.go index de75eea..d0af373 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,7 +161,7 @@ func waitForImageReady(ctx context.Context, client *hypeman.Client, imageName st return fmt.Errorf("image build failed") } - // Poll until ready + // Poll until ready using the normalized image name from the API response ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() @@ -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) }