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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
budgets and is unchanged.

### Fixed
### Fixed
- Accept all standard truthy values for the `CI` environment variable
(`true`, `True`, `TRUE`, `1`, `yes`), not only the exact lowercase
string `"true"`. AppVeyor sets `CI=True`; some Kubernetes pipelines
use `CI=1`. Mismatches previously caused `layerx IMAGE` to open the
interactive TUI instead of running CI checks on those platforms.
- `FormatSignedBytes(math.MinInt64)` no longer produces a double-minus
result. Negating `MinInt64` overflows in two's-complement; the
magnitude is now computed via `uint64` arithmetic, which is
overflow-safe.
- The file-tree renderer in the TUI now clamps the scroll offset to the
current file list length before slicing, guarding against a future
out-of-bounds panic if any code path shrinks the list without
resetting the offset.
- Podman connection resolution now checks `CONTAINER_CONNECTION` and the
active Podman connection before falling back to `DOCKER_HOST`. The
previous order meant a legacy `DOCKER_HOST` in the environment would
silently shadow an explicit `CONTAINER_CONNECTION=staging` override.
- Bound the decompressed byte count walked by the per-layer tar reader in
`findFileInLayer`. Previously a crafted gzip stream (tiny compressed
input expanding to a huge tar body) could make the tar walker consume
Expand Down
9 changes: 8 additions & 1 deletion cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,14 @@ layerx asks the engine to write the image ID to a temporary file via

On a successful build, layerx hands the image ID off to the TUI exactly as
"layerx IMAGE" would. On a failed build, layerx exits with the engine's
exit code and does not launch the TUI.`,
exit code and does not launch the TUI.

Flag ordering: layerx flags (--engine, --no-cache, --json) must appear
BEFORE "build" in the invocation — arguments after "build" are forwarded
verbatim to the engine and layerx never sees them:

layerx --engine podman build -t myimage . # correct
layerx build --engine podman -t myimage . # wrong: --engine forwarded to engine`,
Example: ` # Build the current directory and inspect the result
layerx build -t myimage .

Expand Down
10 changes: 5 additions & 5 deletions cmd/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ func init() {
cachePruneCmd.Flags().BoolVar(&flagCacheAll, "all", false,
"remove every entry under the cache directory")
cachePruneCmd.Flags().BoolVar(&flagCacheDryRun, "dry-run", false,
"print actions without removing (default when no other flag is set)")
"preview removals without deleting (implicit when no other flag is given)")
cachePruneCmd.MarkFlagsMutuallyExclusive("older-than", "all")

cacheCmd.AddCommand(cacheListCmd)
Expand Down Expand Up @@ -160,10 +160,10 @@ func runCachePrune(cmd *cobra.Command, _ []string) error {
// On a bare `prune` (no flags), nothing was actually removed. Hint at
// the commands that would, so users don't think the dry-run did the job.
if bareDryRun && len(res.Removed) > 0 {
fmt.Fprintln(cmd.OutOrStdout(),
"\nThis was a dry run. Re-run with --all to remove every entry,")
fmt.Fprintln(cmd.OutOrStdout(),
"or with --older-than DURATION (e.g. 7d, 12h, 2w) to remove a subset.")
fmt.Fprintln(cmd.OutOrStdout(), "\nThis was a dry run. To remove entries, use:")
fmt.Fprintln(cmd.OutOrStdout(), " layerx cache prune --all remove all entries")
fmt.Fprintln(cmd.OutOrStdout(), " layerx cache prune --older-than DURATION remove entries older than e.g. 7d, 12h, 2w")
fmt.Fprintln(cmd.OutOrStdout(), " layerx cache prune --older-than 7d --dry-run preview what --older-than would remove")
}

// Exit 1 only when the prune accomplished nothing AND a partial
Expand Down
3 changes: 2 additions & 1 deletion cmd/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,9 @@ func TestCachePrune_AllDryRun_DoesNotTouchDisk(t *testing.T) {
assert.Contains(t, out, "freeing")
// Bare prune is purely a dry run; the hint must point at the commands
// that actually remove entries so users don't think prune ran.
assert.Contains(t, out, "--all to remove every entry")
assert.Contains(t, out, "layerx cache prune --all")
assert.Contains(t, out, "--older-than DURATION")
assert.Contains(t, out, "--dry-run preview")

// Disk untouched.
for _, d := range []string{digestA, digestB} {
Expand Down
7 changes: 5 additions & 2 deletions cmd/compare.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ var (

var compareCmd = &cobra.Command{
Use: "compare [flags] OLD_IMAGE NEW_IMAGE",
Short: "Compare two images and surface size/efficiency deltas",
Short: "Compare two images and surface size/efficiency deltas (exit 1 on regression)",
Long: `Compare two images and report size, efficiency, layer, file, and waste
deltas in a deterministic, CI-friendly text report.

Expand Down Expand Up @@ -103,7 +103,10 @@ See "layerx --help" for details on --engine and --no-cache.`,
layerx compare --no-cache myapp:prev myapp:next

# CI gate: fails non-zero on regression
layerx compare myapp:prev myapp:next || echo "image regressed"`,
layerx compare myapp:prev myapp:next || echo "image regressed"

# Script-friendly: extract the machine-parseable verdict line
layerx compare myapp:prev myapp:next | grep '^verdict:' # ok | regression | noop`,
Args: compareArgs,
RunE: runCompareCmd,
SilenceErrors: true,
Expand Down
4 changes: 2 additions & 2 deletions cmd/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ func init() {
}

var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion script",
Use: "completion <shell>",
Short: "Generate shell completion script for bash, zsh, fish, or powershell",
Long: `Generate an autocompletion script for the specified shell.

The script enables tab completion for subcommands, flags, and image
Expand Down
3 changes: 3 additions & 0 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ Refuses to overwrite an existing .layerx.yaml unless --force is set.`,
func init() {
initCmd.Flags().StringVar(&flagInitFlavour, "flavour", "",
fmt.Sprintf("starter config flavour (%s)", flavourList()))
initCmd.Flags().StringVar(&flagInitFlavour, "flavor", "",
fmt.Sprintf("starter config flavour (%s)", flavourList()))
_ = initCmd.Flags().MarkHidden("flavor")
initCmd.Flags().BoolVar(&flagInitForce, "force", false,
"overwrite an existing .layerx.yaml")
rootCmd.AddCommand(initCmd)
Expand Down
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ func runInspect(cmd *cobra.Command, args []string) error {

noCache := noCacheRequested()

if os.Getenv("CI") == "true" {
if v := os.Getenv("CI"); v != "" && v != "0" && !strings.EqualFold(v, "false") {
if err := validateCLIThresholdFlags(ciCmd); err != nil {
return err
}
Expand Down
41 changes: 22 additions & 19 deletions image/engine/podman.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,6 @@ func (r *PodmanResolver) Resolve() (Endpoint, error) {
if h := r.env("CONTAINER_HOST"); h != "" {
return Endpoint{Host: h, Source: "env:CONTAINER_HOST"}, nil
}
if h := r.env("DOCKER_HOST"); h != "" {
return Endpoint{Host: h, Source: "env:DOCKER_HOST"}, nil
}

// Read whichever file exists. json first, since 4.4+ writes both and
// treats the json file as authoritative.
Expand All @@ -65,25 +62,31 @@ func (r *PodmanResolver) Resolve() (Endpoint, error) {
requested = defaultName
origin = cfgSource // e.g. "podman-connections.json" or "containers.conf"
}
if requested == "" {
return Endpoint{}, nil
if requested != "" {
uri, ok := conns[requested]
if !ok {
names := make([]string, 0, len(conns))
for k := range conns {
names = append(names, k)
}
nameSort(names)
return Endpoint{}, &ErrConnectionNotFound{
Engine: "podman",
Requested: requested,
Available: names,
Origin: origin,
}
}
return Endpoint{Host: uri, Source: "podman-connection:" + requested}, nil
}

uri, ok := conns[requested]
if !ok {
names := make([]string, 0, len(conns))
for k := range conns {
names = append(names, k)
}
nameSort(names)
return Endpoint{}, &ErrConnectionNotFound{
Engine: "podman",
Requested: requested,
Available: names,
Origin: origin,
}
// DOCKER_HOST as a last-resort back-compat fallback: only consulted when
// no Podman-specific env var or config file named a connection.
if h := r.env("DOCKER_HOST"); h != "" {
return Endpoint{Host: h, Source: "env:DOCKER_HOST"}, nil
}
return Endpoint{Host: uri, Source: "podman-connection:" + requested}, nil

return Endpoint{}, nil
}

// loadConnections reads whichever of the two Podman config files exists on
Expand Down
10 changes: 6 additions & 4 deletions image/engine/podman_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,14 @@ func TestPodmanResolver_ContainerHostEnvWins(t *testing.T) {

func TestPodmanResolver_DockerHostEnvHonouredForBackCompat(t *testing.T) {
// Users who scripted DOCKER_HOST=$(podman system connection list...)
// against v1.4.x layerx must keep working. CONTAINER_HOST takes
// priority over DOCKER_HOST when both are set (matches Podman CLI
// behaviour), but DOCKER_HOST alone still wins over on-disk config.
// against v1.4.x layerx must keep working. DOCKER_HOST is a last-resort
// fallback: it wins when no Podman-specific env var or config file named
// a connection (empty Default and no CONTAINER_CONNECTION).
fs := newMemFS("/h", "/h/.config")
// Connections file present but Default is empty — no named connection
// selected, so resolution falls through to DOCKER_HOST.
fs.putStr("/h/.config/containers/podman-connections.json", `{
"Connection":{"Default":"prod","Connections":{"prod":{"URI":"unused"}}}}`)
"Connection":{"Default":"","Connections":{"prod":{"URI":"unused"}}}}`)

r := newPodmanResolverWithDeps(staticEnv(map[string]string{
"DOCKER_HOST": "tcp://legacy:2375",
Expand Down
25 changes: 24 additions & 1 deletion image/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,37 @@ func FormatBytes(b int64) string {
}
}

func formatUnsignedBytes(b uint64) string {
const (
kb = 1024
mb = kb * 1024
gb = mb * 1024
)
switch {
case b >= gb:
return fmt.Sprintf("%.1f GB", float64(b)/float64(gb))
case b >= mb:
return fmt.Sprintf("%.1f MB", float64(b)/float64(mb))
case b >= kb:
return fmt.Sprintf("%.1f KB", float64(b)/float64(kb))
default:
return fmt.Sprintf("%d B", b)
}
}

// FormatSignedBytes formats b like FormatBytes but with an explicit sign
// for non-zero values: "+12 MB", "-3.0 MB", "0 B". Used for net deltas.
func FormatSignedBytes(b int64) string {
if b == 0 {
return "0 B"
}
if b < 0 {
return "-" + FormatBytes(-b)
// -math.MinInt64 overflows in two's-complement; use uint64 for the magnitude.
mag := uint64(-b)
if b == -1<<63 { // math.MinInt64 without importing math
mag = 1 << 63
}
return "-" + formatUnsignedBytes(mag)
}
return "+" + FormatBytes(b)
}
Expand Down
3 changes: 3 additions & 0 deletions tui/filetree.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ func renderTreeBody(in treePaneInput) (body string, hasAbove, hasBelow bool) {
}
}
} else {
if in.offset > len(in.files) {
in.offset = len(in.files)
}
end := max(min(in.offset+contentHeight, len(in.files)), in.offset)
visible := in.files[in.offset:end]

Expand Down
Loading