Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
8 changes: 8 additions & 0 deletions pkg/cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
}
Expand Down
334 changes: 334 additions & 0 deletions pkg/cmd/buildercmd.go
Original file line number Diff line number Diff line change
@@ -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: "<id>",
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: "<id>",
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: "<id>",
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 <id>", action)
}
return args[0], nil
}
Loading
Loading