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: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ bin/
cover.out
coverage.out
coverage.html
go.work.local
go.work.local.sum
*.test
*.prof
vendor/
Expand Down
8 changes: 8 additions & 0 deletions .mockery.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,11 @@ packages:
structname: MockService
interfaces:
Service:
github.com/meigma/imgcli/internal/publish:
config:
dir: internal/publish/mocks
filename: uploads_client.go
pkgname: mocks
structname: MockUploadsClient
interfaces:
UploadsClient:
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ require (
github.com/charmbracelet/colorprofile v0.4.2
github.com/gofrs/flock v0.13.0
github.com/lxc/incus-os/incus-osd v0.0.0-20260505023852-d32ba1f13f6f
github.com/meigma/imgcli/schemas v0.0.0-20260504225557-fa97d8c3fe0c
github.com/meigma/imgcli/schemas v0.0.0-20260505154605-5bbbe47a1e06
github.com/meigma/imgsrv v0.0.0-20260505181350-0de592b46f88
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
Expand Down Expand Up @@ -53,7 +54,7 @@ require (
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zitadel/oidc/v3 v3.47.5 // indirect
Expand Down
10 changes: 6 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,10 @@ github.com/lxc/incus/v7 v7.0.0 h1:xLz1Q1Xk+yCNL148MFBOSWWrzJVOS1N6PcS0zd8usSc=
github.com/lxc/incus/v7 v7.0.0/go.mod h1:Dxu4id/fVr+OmFPQt9tU3fu4E8LhW89NeFxCtjPLCdo=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/meigma/imgcli/schemas v0.0.0-20260504225557-fa97d8c3fe0c h1:Uhz9SD1P/JEqDCuAxDL7AKbevYy2CTUP4i+OgFEwkdc=
github.com/meigma/imgcli/schemas v0.0.0-20260504225557-fa97d8c3fe0c/go.mod h1:d5JPNaAIyFEh8Evcgqi4ng1hW6K+BLQNIpbiz4XfX/M=
github.com/meigma/imgcli/schemas v0.0.0-20260505154605-5bbbe47a1e06 h1:v/8R2UInUH6gsz099SZQwPapXkFRh+Q5p3fSplImoTw=
github.com/meigma/imgcli/schemas v0.0.0-20260505154605-5bbbe47a1e06/go.mod h1:vgdSiTx7yikg0x4QmozD0dh4AYM90ZVALn0k45amZuQ=
github.com/meigma/imgsrv v0.0.0-20260505181350-0de592b46f88 h1:ebNLYGiyABFvk7LS43DkkSonxcolqqPqkAjSMaRqodM=
github.com/meigma/imgsrv v0.0.0-20260505181350-0de592b46f88/go.mod h1:aRf/9hRpxhb53jgUmZdo7XVOqQkEn2FEzVqmGEZtx3I=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
Expand Down Expand Up @@ -105,8 +107,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
Expand Down
63 changes: 45 additions & 18 deletions internal/cli/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,24 +30,12 @@ func newBuildCommand(rt *runtime) *cobra.Command {
return err
}

if rt.usesDefaultIncusOSCache() {
return withLockedCache(cmd.Context(), rt.config, func(
catalog incusosprovider.Catalog,
downloader incusosprovider.Downloader,
) error {
ports, portsErr := rt.incusOSBuildPorts(catalog, downloader)
if portsErr != nil {
return portsErr
}
return runIncusOSBuild(cmd.Context(), config, ports, rt.opts.stdout())
})
}

ports, err := rt.incusOSBuildPorts(nil, nil)
result, err := rt.runIncusOSBuild(cmd.Context(), config)
if err != nil {
return err
}
return runIncusOSBuild(cmd.Context(), config, ports, rt.opts.stdout())

return printBuildArtifacts(rt.opts.stdout(), result)
},
}
}
Expand Down Expand Up @@ -101,12 +89,47 @@ func (rt *runtime) incusOSBuildPorts(
return ports, nil
}

func (rt *runtime) runIncusOSBuild(
ctx context.Context,
config imgschemas.Config,
) (providers.BuildResult, error) {
if rt.usesDefaultIncusOSCache() {
var result providers.BuildResult
err := withLockedCache(ctx, rt.config, func(
catalog incusosprovider.Catalog,
downloader incusosprovider.Downloader,
) error {
ports, portsErr := rt.incusOSBuildPorts(catalog, downloader)
if portsErr != nil {
return portsErr
}

buildResult, buildErr := runIncusOSBuild(ctx, config, ports)
if buildErr != nil {
return buildErr
}
result = buildResult
return nil
})
if err != nil {
return providers.BuildResult{}, err
}

return result, nil
}

ports, err := rt.incusOSBuildPorts(nil, nil)
if err != nil {
return providers.BuildResult{}, err
}
return runIncusOSBuild(ctx, config, ports)
}

func runIncusOSBuild(
ctx context.Context,
config imgschemas.Config,
ports incusOSBuildPorts,
output io.Writer,
) error {
) (providers.BuildResult, error) {
provider := incusosprovider.New(*config.Incusos, incusosprovider.Options{
Catalog: ports.catalog,
Downloader: ports.downloader,
Expand All @@ -121,9 +144,13 @@ func runIncusOSBuild(
OutputDir: buildOutputDir(config.Output),
})
if err != nil {
return err
return providers.BuildResult{}, err
}

return result, nil
}

func printBuildArtifacts(output io.Writer, result providers.BuildResult) error {
for _, artifact := range result.Artifacts {
if _, err := fmt.Fprintln(output, artifact.Path); err != nil {
return fmt.Errorf("write build artifact path: %w", err)
Expand Down
134 changes: 129 additions & 5 deletions internal/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"

"github.com/spf13/cobra"
"github.com/spf13/pflag"
Expand All @@ -29,6 +30,18 @@ const (
KeyCacheDir = "cache.dir"
// KeyCacheMaxSize is the Viper key for the maximum cache size before LRU pruning.
KeyCacheMaxSize = "cache.max-size"
// KeyImgsrvURL is the Viper key for the imgsrv API base URL used by publish.
KeyImgsrvURL = "imgsrv.url"
// KeyImgsrvToken is the Viper key for the optional imgsrv bearer token used by publish.
KeyImgsrvToken = "imgsrv.token" // #nosec G101 -- config key name, not a credential value.
// KeyPublishPartSize is the Viper key for the publish multipart upload part size.
KeyPublishPartSize = "publish.part-size"
// KeyPublishWait is the Viper key for waiting until uploaded blobs become CAS-ready.
KeyPublishWait = "publish.wait"
// KeyPublishTimeout is the Viper key for the publish wait timeout.
KeyPublishTimeout = "publish.timeout"
// KeyPublishPollInterval is the Viper key for the publish wait poll interval.
KeyPublishPollInterval = "publish.poll-interval"
)

const (
Expand All @@ -38,18 +51,31 @@ const (
flagNoColor = "no-color"
flagCacheDir = "cache-dir"
flagCacheMaxSize = "cache-max-size"

flagImgsrvURL = "imgsrv-url"
flagImgsrvToken = "imgsrv-token" // #nosec G101 -- flag name, not a credential value.
flagPublishPartSize = "publish-part-size"
flagPublishWait = "publish-wait"
flagPublishTimeout = "publish-timeout"
flagPublishPollInterval = "publish-poll-interval"
)

const (
defaultConfigDirName = "imgcli"
defaultConfigFileName = "config.yaml"
defaultLogLevel = "info"
defaultLogFormat = "text"
defaultCacheMaxSize = "10GB"
defaultConfigDirName = "imgcli"
defaultConfigFileName = "config.yaml"
defaultLogLevel = "info"
defaultLogFormat = "text"
defaultCacheMaxSize = "10GB"
defaultPublishPartSize = "64MB"
defaultPublishTimeout = "10m"
defaultPublishPollInterval = "2s"

cacheSizeKiBShift = 10
cacheSizeMiBShift = 20
cacheSizeGiBShift = 30

minPublishPartSizeBytes = int64(5 * (1 << cacheSizeMiBShift))
maxPublishPartSizeBytes = int64(5 * (1 << cacheSizeGiBShift))
)

// Config is the CLI edge configuration resolved from flags, environment, config file, and defaults.
Expand All @@ -73,6 +99,15 @@ type Config struct {
CacheMaxSizeBytes int64
}

type publishConfig struct {
imgsrvURL string
imgsrvToken string
partSizeBytes int64
wait bool
timeout time.Duration
pollInterval time.Duration
}

func configureViper(vp *viper.Viper) {
vp.SetEnvPrefix(envPrefix)
vp.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
Expand All @@ -83,6 +118,12 @@ func configureViper(vp *viper.Viper) {
vp.SetDefault(KeyNoColor, false)
vp.SetDefault(KeyCacheDir, "")
vp.SetDefault(KeyCacheMaxSize, defaultCacheMaxSize)
vp.SetDefault(KeyImgsrvURL, "")
vp.SetDefault(KeyImgsrvToken, "")
vp.SetDefault(KeyPublishPartSize, defaultPublishPartSize)
vp.SetDefault(KeyPublishWait, true)
vp.SetDefault(KeyPublishTimeout, defaultPublishTimeout)
vp.SetDefault(KeyPublishPollInterval, defaultPublishPollInterval)
}

func (rt *runtime) registerGlobalFlags(root *cobra.Command) error {
Expand Down Expand Up @@ -232,6 +273,52 @@ func parseSizeConfig(vp *viper.Viper, key string) (int64, error) {
return int64(size), nil
}

func loadPublishConfig(vp *viper.Viper) (publishConfig, error) {
partSizeBytes, err := parseSizeConfig(vp, KeyPublishPartSize)
if err != nil {
return publishConfig{}, err
}
timeout, err := parseDurationConfig(vp, KeyPublishTimeout)
if err != nil {
return publishConfig{}, err
}
pollInterval, err := parseDurationConfig(vp, KeyPublishPollInterval)
if err != nil {
return publishConfig{}, err
}

cfg := publishConfig{
imgsrvURL: strings.TrimSpace(vp.GetString(KeyImgsrvURL)),
imgsrvToken: strings.TrimSpace(vp.GetString(KeyImgsrvToken)),
partSizeBytes: partSizeBytes,
wait: vp.GetBool(KeyPublishWait),
timeout: timeout,
pollInterval: pollInterval,
}
if err := validatePublishConfig(cfg); err != nil {
return publishConfig{}, err
}

return cfg, nil
}

func parseDurationConfig(vp *viper.Viper, key string) (time.Duration, error) {
raw := strings.TrimSpace(vp.GetString(key))
if raw == "" {
return 0, fmt.Errorf("invalid %s %q: duration is required", key, raw)
}

duration, err := time.ParseDuration(raw)
if err != nil {
return 0, fmt.Errorf("invalid %s %q: %w", key, raw, err)
}
if duration <= 0 {
return 0, fmt.Errorf("invalid %s %q: duration must be positive", key, raw)
}

return duration, nil
}

func parseSizeLiteral(raw string) (int64, error) {
if raw == "" {
return 0, errors.New("size is required")
Expand Down Expand Up @@ -279,3 +366,40 @@ func validateConfig(cfg Config) error {
}
return nil
}

func validatePublishConfig(cfg publishConfig) error {
if cfg.imgsrvURL == "" {
return errors.New("publish requires imgsrv.url: set --imgsrv-url, IMGCLI_IMGSRV_URL, or config imgsrv.url")
}
if cfg.partSizeBytes < minPublishPartSizeBytes {
return fmt.Errorf(
"invalid %s %q: must be at least %s",
KeyPublishPartSize,
viperValueForError(cfg.partSizeBytes),
defaultSizeForError(minPublishPartSizeBytes),
)
}
if cfg.partSizeBytes > maxPublishPartSizeBytes {
return fmt.Errorf(
"invalid %s %q: must be at most %s",
KeyPublishPartSize,
viperValueForError(cfg.partSizeBytes),
defaultSizeForError(maxPublishPartSizeBytes),
)
}
return nil
}

func viperValueForError(value int64) string {
return strconv.FormatInt(value, 10)
}

func defaultSizeForError(value int64) string {
if value%(1<<cacheSizeGiBShift) == 0 {
return strconv.FormatInt(value/(1<<cacheSizeGiBShift), 10) + "GB"
}
if value%(1<<cacheSizeMiBShift) == 0 {
return strconv.FormatInt(value/(1<<cacheSizeMiBShift), 10) + "MB"
}
return strconv.FormatInt(value, 10) + "B"
}
4 changes: 4 additions & 0 deletions internal/cli/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"

"github.com/meigma/imgcli/internal/providers/incusos"
"github.com/meigma/imgcli/internal/publish"
)

// Options configures the root imgcli command.
Expand Down Expand Up @@ -35,6 +36,9 @@ type Options struct {

// IncusOSImageInjector writes IncusOS seed archives into source images. Nil selects the default image injector.
IncusOSImageInjector incusos.ImageInjector

// ImgsrvUploadsClient uploads artifacts to imgsrv. Nil selects the HTTP SDK client.
ImgsrvUploadsClient publish.UploadsClient
}

func (o Options) version() string {
Expand Down
Loading