diff --git a/go.mod b/go.mod index 226e5d1..423ffd4 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/google/go-containerregistry v0.20.7 github.com/gorilla/websocket v1.5.3 github.com/itchyny/json2yaml v0.1.4 - github.com/kernel/hypeman-go v0.22.1-0.20260804210000-b0c2df62be94 + github.com/kernel/hypeman-go v0.23.0 github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/env v1.1.0 github.com/knadh/koanf/providers/file v1.2.1 diff --git a/go.sum b/go.sum index ef31241..6109527 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnV github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/itchyny/json2yaml v0.1.4 h1:/pErVOXGG5iTyXHi/QKR4y3uzhLjGTEmmJIy97YT+k8= github.com/itchyny/json2yaml v0.1.4/go.mod h1:6iudhBZdarpjLFRNj+clWLAkGft+9uCcjAZYXUH9eGI= -github.com/kernel/hypeman-go v0.22.1-0.20260804210000-b0c2df62be94 h1:9lNZxwf6sWx9vScQ0lKAWkHKFLeGiMaoGBxBOhQcePE= -github.com/kernel/hypeman-go v0.22.1-0.20260804210000-b0c2df62be94/go.mod h1:of8qI/nef2OPLzt0EMlIRbMdJHEvuc4yWG8g/ioNg48= +github.com/kernel/hypeman-go v0.23.0 h1:07JJgYhApTAJFofx1iGRbiIn2O4un8Pm7L27WI4OiSM= +github.com/kernel/hypeman-go v0.23.0/go.mod h1:of8qI/nef2OPLzt0EMlIRbMdJHEvuc4yWG8g/ioNg48= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= diff --git a/pkg/cmd/build.go b/pkg/cmd/build.go index 9599fc3..d6d5722 100644 --- a/pkg/cmd/build.go +++ b/pkg/cmd/build.go @@ -58,6 +58,11 @@ Examples: Name: "base-image-digest", Usage: "Pinned base image digest for reproducible builds", }, + &cli.StringFlag{ + Name: "builder", + Aliases: []string{"builder-id"}, + Usage: "Builder ID whose persistent cache disk backs this build (builds on the same builder are serialized)", + }, &cli.StringFlag{ Name: "cache-scope", Usage: "Tenant-specific cache key prefix", @@ -170,6 +175,9 @@ func handleBuild(ctx context.Context, cmd *cli.Command) error { if v := cmd.String("base-image-digest"); v != "" { params.BaseImageDigest = hypeman.Opt(v) } + if v := cmd.String("builder"); v != "" { + params.BuilderID = hypeman.Opt(v) + } if v := cmd.String("cache-scope"); v != "" { params.CacheScope = hypeman.Opt(v) } diff --git a/pkg/cmd/buildercmd.go b/pkg/cmd/buildercmd.go new file mode 100644 index 0000000..06daa12 --- /dev/null +++ b/pkg/cmd/buildercmd.go @@ -0,0 +1,334 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var builderCmd = cli.Command{ + Name: "builder", + Aliases: []string{"builders"}, + Usage: "Manage persistent build cache builders", + Description: `Manage builders, which own a persistent cache disk that backs builds. + +A builder runs one build at a time; builds targeting the same builder are +serialized. Pass a builder to 'hypeman build' with --builder to reuse its cache +across builds. + +Examples: + # Create a builder with a 100GB cache disk + hypeman builder create --name ci --disk-size 100 + + # List builders + hypeman builder list + + # Build using a builder's cache + hypeman build --builder ci-builder-id ./myapp + + # Reset a builder's cache disk without losing its identity + hypeman builder prune ci-builder-id`, + Commands: []*cli.Command{ + &builderCreateCmd, + &builderListCmd, + &builderGetCmd, + &builderDeleteCmd, + &builderPruneCmd, + }, + HideHelpCommand: true, +} + +var builderCreateCmd = cli.Command{ + Name: "create", + Usage: "Create a builder and its cache disk", + Description: "Creates a builder and its cache disk. One build at a time runs per builder.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "id", + Usage: "Optional caller-supplied identifier (auto-generated if not provided)", + }, + &cli.StringFlag{ + Name: "name", + Usage: "Optional non-unique display name", + }, + &cli.IntFlag{ + Name: "disk-size", + Aliases: []string{"disk-size-gb"}, + Usage: "Cache disk size in gigabytes (omit to use the server default)", + }, + &cli.StringSliceFlag{ + Name: "tag", + Usage: "Set builder tag key-value pair (KEY=VALUE, can be repeated)", + }, + }, + Action: handleBuilderCreate, + HideHelpCommand: true, +} + +var builderListCmd = cli.Command{ + Name: "list", + Usage: "List builders", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "quiet", + Aliases: []string{"q"}, + Usage: "Only display builder IDs", + }, + &cli.StringSliceFlag{ + Name: "tag", + Usage: "Filter by tag key-value pair (KEY=VALUE, can be repeated)", + }, + }, + Action: handleBuilderList, + HideHelpCommand: true, +} + +var builderGetCmd = cli.Command{ + Name: "get", + Usage: "Get builder details", + ArgsUsage: "", + Action: handleBuilderGet, + HideHelpCommand: true, +} + +var builderDeleteCmd = cli.Command{ + Name: "delete", + Aliases: []string{"rm"}, + Usage: "Delete a builder", + Description: "Permanently deletes a builder and its cache disk.", + ArgsUsage: "", + Action: handleBuilderDelete, + HideHelpCommand: true, +} + +var builderPruneCmd = cli.Command{ + Name: "prune", + Usage: "Reset a builder's cache disk", + Description: "Resets the builder's cache disk. The builder transitions to pruning, then ready. Builder identity is preserved.", + ArgsUsage: "", + Action: handleBuilderPrune, + HideHelpCommand: true, +} + +func handleBuilderCreate(ctx context.Context, cmd *cli.Command) error { + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + params := hypeman.BuilderNewParams{} + if v := cmd.String("id"); v != "" { + params.ID = hypeman.Opt(v) + } + if v := cmd.String("name"); v != "" { + params.Name = hypeman.Opt(v) + } + if cmd.IsSet("disk-size") { + params.DiskSizeGB = hypeman.Opt(int64(cmd.Int("disk-size"))) + } + tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) + for _, malformed := range malformedTags { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag: %s\n", malformed) + } + if len(tags) > 0 { + params.Tags = tags + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + // WithResponseBodyInto captures the raw body but leaves the typed result nil, so + // only request it for the formats that render the response verbatim. + if format != "auto" && format != "" { + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + if _, err := client.Builders.New(ctx, params, opts...); err != nil { + return err + } + return ShowJSON(os.Stdout, "builder create", gjson.ParseBytes(res), format, transform) + } + + builder, err := client.Builders.New(ctx, params, opts...) + if err != nil { + return err + } + + fmt.Println(builder.ID) + return nil +} + +func handleBuilderList(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) + } + + params := hypeman.BuilderListParams{} + tags, malformedTags := parseKeyValueSpecs(cmd.StringSlice("tag")) + for _, malformed := range malformedTags { + fmt.Fprintf(os.Stderr, "Warning: ignoring malformed tag filter: %s\n", malformed) + } + if len(tags) > 0 { + params.Tags = tags + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + if format != "auto" && format != "" { + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + if _, err := client.Builders.List(ctx, params, opts...); err != nil { + return err + } + return ShowJSON(os.Stdout, "builder list", gjson.ParseBytes(res), format, transform) + } + + builders, err := client.Builders.List(ctx, params, opts...) + if err != nil { + return err + } + + if cmd.Bool("quiet") { + for _, b := range *builders { + fmt.Println(b.ID) + } + return nil + } + + return showBuilderListTable(*builders) +} + +func showBuilderListTable(builders []hypeman.Builder) error { + if len(builders) == 0 { + fmt.Fprintln(os.Stderr, "No builders found.") + return nil + } + + table := NewTableWriter(os.Stdout, "ID", "NAME", "STATUS", "DISK", "ACTIVE BUILD", "QUEUED", "LAST USED", "CREATED") + table.TruncOrder = []int{0, 1, 4} // ID first, then NAME, ACTIVE BUILD + + for _, b := range builders { + name := b.Name + if name == "" { + name = "-" + } + + activeBuild := b.ActiveBuildID + if activeBuild == "" { + activeBuild = "-" + } + + table.AddRow( + b.ID, + name, + string(b.Status), + fmt.Sprintf("%d GB", b.DiskSizeGB), + activeBuild, + fmt.Sprintf("%d", len(b.QueuedBuilds)), + FormatTimeAgo(b.LastUsedAt), + FormatTimeAgo(b.CreatedAt), + ) + } + + table.Render() + return nil +} + +func handleBuilderGet(ctx context.Context, cmd *cli.Command) error { + id, err := requireBuilderID(cmd, "get") + if err != nil { + return err + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + if _, err := client.Builders.Get(ctx, id, opts...); err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "builder get", obj, format, transform) +} + +func handleBuilderDelete(ctx context.Context, cmd *cli.Command) error { + id, err := requireBuilderID(cmd, "delete") + if err != nil { + return err + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + if err := client.Builders.Delete(ctx, id, opts...); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Deleted builder %s\n", id) + return nil +} + +func handleBuilderPrune(ctx context.Context, cmd *cli.Command) error { + id, err := requireBuilderID(cmd, "prune") + if err != nil { + return err + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + if format != "auto" && format != "" { + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + if _, err := client.Builders.Prune(ctx, id, opts...); err != nil { + return err + } + return ShowJSON(os.Stdout, "builder prune", gjson.ParseBytes(res), format, transform) + } + + builder, err := client.Builders.Prune(ctx, id, opts...) + if err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Pruning builder %s (status: %s)\n", id, builder.Status) + return nil +} + +func requireBuilderID(cmd *cli.Command, action string) (string, error) { + args := cmd.Args().Slice() + if len(args) < 1 { + return "", fmt.Errorf("builder ID required\nUsage: hypeman builder %s ", action) + } + return args[0], nil +} diff --git a/pkg/cmd/buildercmd_test.go b/pkg/cmd/buildercmd_test.go new file mode 100644 index 0000000..5cb0e71 --- /dev/null +++ b/pkg/cmd/buildercmd_test.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuilderCommandStructure(t *testing.T) { + assert.Equal(t, "builder", builderCmd.Name) + assert.Contains(t, builderCmd.Aliases, "builders") + + subcommandNames := make([]string, len(builderCmd.Commands)) + for i, cmd := range builderCmd.Commands { + subcommandNames[i] = cmd.Name + } + + assert.Contains(t, subcommandNames, "create") + assert.Contains(t, subcommandNames, "list") + assert.Contains(t, subcommandNames, "get") + assert.Contains(t, subcommandNames, "delete") + assert.Contains(t, subcommandNames, "prune") +} + +func TestBuilderCreateCmdFlags(t *testing.T) { + flagNames := make([]string, 0) + for _, flag := range builderCreateCmd.Flags { + flagNames = append(flagNames, flag.Names()...) + } + + assert.Contains(t, flagNames, "id") + assert.Contains(t, flagNames, "name") + assert.Contains(t, flagNames, "disk-size") + assert.Contains(t, flagNames, "tag") +} + +func TestBuildCmdHasBuilderFlag(t *testing.T) { + flagNames := make([]string, 0) + for _, flag := range buildCmd.Flags { + flagNames = append(flagNames, flag.Names()...) + } + + assert.Contains(t, flagNames, "builder") + assert.Contains(t, flagNames, "builder-id") +} diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 2129b93..14f266b 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -69,6 +69,7 @@ func init() { }, Commands: []*cli.Command{ &buildCmd, + &builderCmd, &execCmd, &cpCmd, &pullCmd,