diff --git a/README.md b/README.md index 711fd90..5a5666b 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,10 @@ b install gitlab.com/org/tool b install codeberg.org/user/app@v1.0 b install go://golang.org/x/tools/cmd/goimports b install docker://alpine/helm +b install docker://docker@cli # tag via @ (e.g. docker:cli image) +b install docker://docker@cli:/usr/local/bin/docker # explicit in-container path +b install oci://ghcr.io/org/img@v1 # daemonless, any OCI registry +b install oci://docker@cli:/usr/local/bin/docker # daemonless docker CLI b install "git:///home/user/myrepo:.scripts/tool" b install "git://github.com/org/repo:bin/app@v1.0" @@ -120,13 +124,18 @@ binaries: alias: renvsubst # alias to renvsubst kubectl: file: ../kc # custom path (relative to config) - # Install from any provider by ref (GitHub, GitLab, Gitea, go://, docker://, git://) + # Install from any provider by ref (GitHub, GitLab, Gitea, go://, docker://, oci://, git://) github.com/sharkdp/bat: version: v0.24.0 # Install from a git repo (local or remote) git:///home/user/myproject:.scripts/tool: git://github.com/org/repo:bin/app: version: v1.0 + # Install from an OCI registry (daemonless — works without docker) + oci://ghcr.io/org/img: + version: v1.0 + oci://docker:/usr/local/bin/docker: + version: cli envs: # Sync files from upstream git repos @@ -164,6 +173,8 @@ Set environment variables to authenticate with providers for higher rate limits | `GITLAB_TOKEN` | GitLab | | `GITEA_TOKEN` | Gitea / Forgejo (Codeberg) | +For `oci://` the daemonless client reuses your local registry auth from `~/.docker/config.json` (or the `DOCKER_CONFIG` override) — the same credentials `docker login` writes. +   ### 🐳 Using Docker diff --git a/docs/authentication.mdx b/docs/authentication.mdx index 5758f91..e3af9c5 100644 --- a/docs/authentication.mdx +++ b/docs/authentication.mdx @@ -51,6 +51,21 @@ A Gitea token is required for private repositories on Gitea or Forgejo instances export GITEA_TOKEN="..." ``` +## OCI Registry Authentication + +For `oci://` refs, `b` pulls images daemonlessly but reuses your local docker +credentials. It reads `~/.docker/config.json` (or the location pointed to by +`DOCKER_CONFIG`) and uses whichever helper is configured there. In other +words, if `docker login ghcr.io` works, `b install oci://ghcr.io/...` works too. + +```bash +# Log in once; b picks it up automatically +docker login ghcr.io +b install oci://ghcr.io/org/private-img +``` + +No `b`-specific environment variable is needed for OCI auth. + ## SSH Authentication For private repos, you can also use SSH keys instead of tokens. SSH authentication uses your system's ssh-agent and works for both binaries and env sync: diff --git a/docs/b/subcommands/install.mdx b/docs/b/subcommands/install.mdx index 24d4ea5..f4e2e52 100644 --- a/docs/b/subcommands/install.mdx +++ b/docs/b/subcommands/install.mdx @@ -139,6 +139,30 @@ b install "git:///home/user/myrepo:bin/tool" b install "git://../../shared-repo:scripts/deploy.sh" ``` +### Install from container images + +Use `docker://` to pull from a local container runtime, or `oci://` to pull +**daemonless** from any OCI registry (Docker Hub, ghcr.io, quay.io, private). + +```bash +# docker:// — requires a running docker/podman/nerdctl +b install docker://alpine/helm +b install docker://docker@cli # tag via @ (docker:cli image) +b install docker://docker@cli:/usr/local/bin/docker # explicit in-container path + +# oci:// — daemonless; works in CI containers without docker +b install oci://ghcr.io/org/img@v1 +b install oci://docker@cli:/usr/local/bin/docker +``` + +The syntax is: `[@][:/]` + +- **image** — An image reference, including optional registry (e.g., `alpine`, `ghcr.io/org/img`) +- **tag** (optional, after `@`) — Image tag; defaults to `latest`. Use `@` consistently with every other `b` provider rather than docker's native `image:tag` syntax +- **path** (optional, after `:/`) — Absolute path to the binary inside the image. When omitted, `b` searches `/usr/local/bin`, `/usr/bin`, `/bin`, and `/app` for a file named after the image's last segment + +The leading `/` on the path disambiguates it from an `image:tag` pasted from docker documentation. For private registries, `oci://` reads credentials from `~/.docker/config.json` (same as `docker login`); see the [authentication](/authentication) page. + ## Flags | Flag | Description | diff --git a/docs/glossary.mdx b/docs/glossary.mdx index 6c7885c..f7cea83 100644 --- a/docs/glossary.mdx +++ b/docs/glossary.mdx @@ -70,7 +70,7 @@ This glossary defines key terms and concepts used throughout the **b** documenta **Profile** - A named file set published in an upstream repo's `b.yaml` under the `profiles` section. Consumers discover profiles via `b env profiles` and install them via `b env add`, which copies the configuration into their local `envs`. -**Provider Reference** - A git-cloneable path used to install binaries or sync env files from GitHub (e.g., `github.com/org/repo`). +**Provider Reference** - A provider-specific reference used to install binaries or sync env files (e.g., `github.com/org/repo`). Supported prefixes include `go://` (Go install), `git://` (any git repo), `docker://` (via container runtime), and `oci://` (daemonless OCI pull). ## S diff --git a/go.mod b/go.mod index ccb2fc8..1b9ba95 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,12 @@ module github.com/fentas/b go 1.26 require ( - github.com/fatih/color v1.10.0 + github.com/fatih/color v1.15.0 github.com/fentas/goodies v0.0.0-20250628100539-67031d6c92c6 + github.com/google/go-containerregistry v0.21.5 github.com/jedib0t/go-pretty/v6 v6.5.6 github.com/jmespath-community/go-jmespath v1.1.1 - github.com/spf13/cobra v1.8.0 + github.com/spf13/cobra v1.10.2 github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/ulikunitz/xz v0.5.12 @@ -17,19 +18,30 @@ require ( require ( github.com/MakeNowJust/heredoc v1.0.0 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect + github.com/docker/cli v29.4.0+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/fatih/structs v1.1.0 // indirect github.com/goccy/go-yaml v1.11.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.8 // indirect - github.com/mattn/go-isatty v0.0.12 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect + github.com/vbatts/tar-split v0.12.2 // indirect golang.org/x/exp v0.0.0-20230314191032-db074128a8ec // indirect - golang.org/x/sys v0.16.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect + gotest.tools/v3 v3.5.2 // indirect k8s.io/utils v0.0.0-20240310230437-4693a0247e57 // indirect ) diff --git a/go.sum b/go.sum index 674652d..796cac7 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,16 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= +github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg= -github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= +github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM= +github.com/docker/cli v29.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fentas/goodies v0.0.0-20250628100539-67031d6c92c6 h1:fEKwzohcEvjnilKbOqvizP44S22kWNWSxhroaB4uT/4= @@ -17,34 +23,48 @@ github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7a github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/goccy/go-yaml v1.11.3 h1:B3W9IdWbvrUu2OYQGwvU1nZtvMQJPBKgBUuweJjLj6I= github.com/goccy/go-yaml v1.11.3/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.5 h1:KTJG9Pn/jC0VdZR6ctV3/jcN+q6/Iqlx0sTVz3ywZlM= +github.com/google/go-containerregistry v0.21.5/go.mod h1:ySvMuiWg+dOsRW0Hw8GYwfMwBlNRTmpYBFJPlkco5zU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jedib0t/go-pretty/v6 v6.5.6 h1:nKXVLqPfAwY7sWcYXdNZZZ2fjqDpAtj9UeWupgfUxSg= github.com/jedib0t/go-pretty/v6 v6.5.6/go.mod h1:5LQIxa52oJ/DlDSLv0HEkWOFMDGoWkJb9ss5KqPpJBg= github.com/jmespath-community/go-jmespath v1.1.1 h1:bFikPhsi/FdmlZhVgSCd2jj1e7G/rw+zyQfyg5UF+L4= github.com/jmespath-community/go-jmespath v1.1.1/go.mod h1:4gOyFJsR/Gk+05RgTKYrifT7tBPWD8Lubtb5jRrfy9I= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= -github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= -github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +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= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= -github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -56,14 +76,18 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= +github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/exp v0.0.0-20230314191032-db074128a8ec h1:pAv+d8BM2JNnNctsLJ6nnZ6NqXT8N4+eauvZSb3P0I0= golang.org/x/exp v0.0.0-20230314191032-db074128a8ec/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= @@ -72,5 +96,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= k8s.io/utils v0.0.0-20240310230437-4693a0247e57 h1:gbqbevonBh57eILzModw6mrkbwM0gQBEuevE/AaBsHY= k8s.io/utils v0.0.0-20240310230437-4693a0247e57/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= diff --git a/pkg/binary/download.go b/pkg/binary/download.go index 21e2c6e..f00d7cd 100644 --- a/pkg/binary/download.go +++ b/pkg/binary/download.go @@ -172,6 +172,13 @@ func (b *Binary) downloadViaProvider() error { } b.File = path return nil + case *provider.OCI: + path, err := pt.Install(b.ProviderRef, b.Version, destDir) + if err != nil { + return err + } + b.File = path + return nil case *provider.Git: path, err := pt.Install(b.ProviderRef, b.Version, destDir) if err != nil { diff --git a/pkg/cli/cli_extra_test.go b/pkg/cli/cli_extra_test.go index 3efaadc..84110b3 100644 --- a/pkg/cli/cli_extra_test.go +++ b/pkg/cli/cli_extra_test.go @@ -575,6 +575,10 @@ func TestParseBinaryArg(t *testing.T) { {"jq@jq-1.7", "jq", "jq-1.7"}, {"kubectl@v1.28.0", "kubectl", "v1.28.0"}, {"terraform", "terraform", ""}, + // docker:// / oci:// ":/" must not swallow @tag into version. + {"docker://docker@cli:/usr/local/bin/docker", "docker://docker:/usr/local/bin/docker", "cli"}, + {"oci://ghcr.io/org/img@v1:/bin/tool", "oci://ghcr.io/org/img:/bin/tool", "v1"}, + {"oci://alpine:/bin/busybox", "oci://alpine:/bin/busybox", ""}, } for _, tt := range tests { diff --git a/pkg/cli/install.go b/pkg/cli/install.go index 81527c8..6efb711 100644 --- a/pkg/cli/install.go +++ b/pkg/cli/install.go @@ -374,14 +374,11 @@ func (o *InstallOptions) updateLock(binaries []*binary.Binary) error { return lock.WriteLock(lockDir, lk, o.bVersion) } -// parseBinaryArg parses binary argument in format "name" or "name@version" +// parseBinaryArg parses binary argument in format "name" or "name@version". +// Delegates to provider.ParseRef which already handles the docker:// or oci:// +// quirk of preserving a ":/" suffix on name. func parseBinaryArg(arg string) (name, version string) { - parts := strings.SplitN(arg, "@", 2) - name = parts[0] - if len(parts) > 1 { - version = parts[1] - } - return + return provider.ParseRef(arg) } // parseSCPArg tries to parse an SCP-style env install: @@ -390,12 +387,18 @@ func parseBinaryArg(arg string) (name, version string) { // // Returns the parsed envInstall, how many additional args were consumed, and whether it matched. func parseSCPArg(arg string, remaining []string) (envInstall, int, bool) { + // docker:// and oci:// are always binary installs (never env SCP), even + // though they may contain ":/path" for in-container binary location. + if strings.HasPrefix(arg, "docker://") || strings.HasPrefix(arg, "oci://") { + return envInstall{}, 0, false + } + // Look for colon that signals SCP syntax — must come after a ref (contains /) - // and before a glob. Skip protocol prefixes (go://, docker://). + // and before a glob. Skip the "://" of protocol prefixes (go://, git://). colonIdx := -1 for i := range arg { if arg[i] == ':' { - // Skip protocol prefixes (e.g. go://, docker://) + // Skip protocol prefixes (e.g. go://, git://) if i+2 < len(arg) && arg[i+1] == '/' && arg[i+2] == '/' { continue } diff --git a/pkg/cli/install_test.go b/pkg/cli/install_test.go index 5bc213f..4304005 100644 --- a/pkg/cli/install_test.go +++ b/pkg/cli/install_test.go @@ -66,6 +66,15 @@ func TestParseSCPArg(t *testing.T) { arg: "kubectl", wantOk: false, }, + { + // docker:// / oci:// — never SCP (binary-path separator ":/") + arg: "docker://docker@cli:/usr/local/bin/docker", + wantOk: false, + }, + { + arg: "oci://ghcr.io/org/img@v1:/bin/tool", + wantOk: false, + }, } for _, tt := range tests { diff --git a/pkg/provider/docker.go b/pkg/provider/docker.go index 76f9f27..8510cfd 100644 --- a/pkg/provider/docker.go +++ b/pkg/provider/docker.go @@ -32,14 +32,20 @@ func (d *Docker) FetchRelease(ref, version string) (*Release, error) { // Install pulls the image, creates a container, copies the binary out, and cleans up. // searchPaths are the paths to search for the binary inside the container. +// If the ref includes ":/", that path is used as the single search path. func (d *Docker) Install(ref, version, destDir string, searchPaths []string) (string, error) { runtime, err := detectContainerRuntime() if err != nil { return "", err } - image := dockerImage(ref) + rest := strings.TrimPrefix(ref, "docker://") + image, refTag, inContainerPath := ParseImageRef(rest) + tag := version + if tag == "" { + tag = refTag + } if tag == "" { tag = "latest" } @@ -61,8 +67,10 @@ func (d *Docker) Install(ref, version, destDir string, searchPaths []string) (st containerID := strings.TrimSpace(string(out)) defer exec.Command(runtime, "rm", containerID).Run() - // Try to copy binary from known paths - if searchPaths == nil { + // Determine search paths: explicit ":/" overrides everything. + if inContainerPath != "" { + searchPaths = []string{inContainerPath} + } else if searchPaths == nil { searchPaths = []string{ "/usr/local/bin/" + name, "/usr/bin/" + name, @@ -90,15 +98,19 @@ func (d *Docker) Install(ref, version, destDir string, searchPaths []string) (st return "", fmt.Errorf("binary %q not found in image %s at paths: %v", name, imageRef, searchPaths) } +// dockerImage returns the image name (without tag/path) for legacy callers +// and tests. Prefer ParseImageRef for new code. func dockerImage(ref string) string { r := strings.TrimPrefix(ref, "docker://") - // Strip version (handled separately) - r, _ = ParseRef(r) - // Also strip docker-style tag after colon - if i := strings.LastIndex(r, ":"); i > 0 { - r = r[:i] + image, _, _ := ParseImageRef(r) + // Also strip docker-style "image:tag" when no explicit @ was given, but + // only when the ':' is after the last '/' so registry ports like + // "localhost:5000/org/image" are preserved. + lastSlash := strings.LastIndex(image, "/") + if i := strings.LastIndex(image, ":"); i > lastSlash && i > 0 { + image = image[:i] } - return r + return image } func detectContainerRuntime() (string, error) { diff --git a/pkg/provider/oci.go b/pkg/provider/oci.go new file mode 100644 index 0000000..741a6e9 --- /dev/null +++ b/pkg/provider/oci.go @@ -0,0 +1,265 @@ +package provider + +import ( + "archive/tar" + "fmt" + "io" + "os" + "path" + "path/filepath" + "runtime" + "strings" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +func init() { + Register(&OCI{}) +} + +// OCI extracts binaries from OCI images without a container runtime. +// Works with any OCI registry (Docker Hub, ghcr.io, quay.io, private). +// +// Syntax: +// +// oci://[@][:/] +// +// Examples: +// +// oci://alpine +// oci://ghcr.io/helm/helm@v3.18.6 +// oci://docker@cli:/usr/local/bin/docker +// +// The in-container path must begin with "/" so it is unambiguous with +// docker's own "image:tag" syntax (which we never use — tags go after "@"). +type OCI struct{} + +func (o *OCI) Name() string { return "oci" } + +func (o *OCI) Match(ref string) bool { + return strings.HasPrefix(ref, "oci://") +} + +func (o *OCI) LatestVersion(ref string) (string, error) { + return "latest", nil +} + +// FetchRelease is not used for OCI — use Install instead. +func (o *OCI) FetchRelease(ref, version string) (*Release, error) { + return nil, fmt.Errorf("oci provider does not use FetchRelease; use Install()") +} + +// Install pulls a platform-matching image manifest and extracts a single +// binary file without invoking any container runtime. +func (o *OCI) Install(ref, version, destDir string) (string, error) { + rest := strings.TrimPrefix(ref, "oci://") + image, refTag, inContainerPath := ParseImageRef(rest) + + tag := version + if tag == "" { + tag = refTag + } + if tag == "" { + tag = "latest" + } + binName := BinaryName(ref) + + nameRef, err := name.ParseReference(image + ":" + tag) + if err != nil { + return "", fmt.Errorf("parsing image ref %s:%s: %w", image, tag, err) + } + + // remote.Image handles manifest-list/index resolution internally using the + // provided platform (OS + arch + variant) so we don't need to reimplement + // platform matching here. + img, err := remote.Image(nameRef, + remote.WithAuthFromKeychain(authn.DefaultKeychain), + remote.WithPlatform(v1.Platform{ + OS: runtime.GOOS, + Architecture: runtime.GOARCH, + }), + ) + if err != nil { + return "", fmt.Errorf("fetching image %s: %w", nameRef, err) + } + + // Determine which paths to try inside the image. + var searchPaths []string + if inContainerPath != "" { + searchPaths = []string{inContainerPath} + } else { + searchPaths = []string{ + "/usr/local/bin/" + binName, + "/usr/bin/" + binName, + "/bin/" + binName, + "/app/" + binName, + } + } + + if err := os.MkdirAll(destDir, 0755); err != nil { + return "", err + } + dest := filepath.Join(destDir, binName) + + layers, err := img.Layers() + if err != nil { + return "", fmt.Errorf("reading layers: %w", err) + } + // Walk layers newest-first so later overrides win. Track OCI whiteouts + // from newer layers so we don't resurrect a file deleted in the final image. + whiteouts := make(map[string]bool) + for i := len(layers) - 1; i >= 0; i-- { + found, err := extractBinaryFromLayer(layers[i], searchPaths, dest, whiteouts) + if err != nil { + return "", err + } + if found { + if err := os.Chmod(dest, 0755); err != nil { + return "", err + } + return dest, nil + } + } + + return "", fmt.Errorf("binary %q not found in image %s at paths: %v", binName, nameRef, searchPaths) +} + +// extractBinaryFromLayer scans a layer's tar stream once, looking for any of +// searchPaths. Returns true (and writes to dest) when a match is found. +// Earlier entries in searchPaths take priority; once a higher-priority match +// is found, the scan stops. +// +// whiteouts tracks OCI whiteout markers (".wh.", ".wh..wh..opq") seen +// in newer layers so deleted files aren't resurrected from older ones. The +// map is updated in-place with whiteouts discovered in this layer. +func extractBinaryFromLayer(l v1.Layer, searchPaths []string, dest string, whiteouts map[string]bool) (bool, error) { + if len(searchPaths) == 0 { + return false, nil + } + + // Normalise candidates to absolute, cleaned form and assign priorities + // (index in searchPaths; lower is better). + targets := make(map[string]int, len(searchPaths)) + for i, sp := range searchPaths { + targets[path.Clean("/"+strings.TrimPrefix(sp, "/"))] = i + } + + rc, err := l.Uncompressed() + if err != nil { + return false, fmt.Errorf("reading layer contents: %w", err) + } + defer rc.Close() + + tr := tar.NewReader(rc) + bestPriority := len(searchPaths) // sentinel: nothing found yet + var tmpPath string + cleanup := func() { + if tmpPath != "" { + _ = os.Remove(tmpPath) + tmpPath = "" + } + } + + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + cleanup() + return false, fmt.Errorf("reading tar: %w", err) + } + + name := path.Clean("/" + strings.TrimPrefix(hdr.Name, "/")) + base := path.Base(name) + + // Record whiteouts from this (newer-than-caller) layer so older + // layers are prevented from resurrecting deleted paths. + if base == ".wh..wh..opq" { + // Opaque dir: everything in its parent is hidden from older layers. + // Use "/" as the sentinel for the root so isWhiteoutBlocked finds it. + dir := path.Dir(name) + if dir == "/" { + whiteouts["/"] = true + } else { + whiteouts[dir+"/"] = true + } + continue + } + if strings.HasPrefix(base, ".wh.") { + whiteouts[path.Join(path.Dir(name), strings.TrimPrefix(base, ".wh."))] = true + continue + } + + // Accept any regular file; some tar encodings use the legacy NUL + // typeflag (TypeRegA) which FileInfo.Mode().IsRegular() handles + // along with the modern '0' TypeReg. + if !hdr.FileInfo().Mode().IsRegular() { + continue + } + priority, ok := targets[name] + if !ok || priority >= bestPriority { + continue + } + // Skip candidates that a newer layer has whited out; keep looking + // for the next-best unblocked match in this same tar stream. + if isWhiteoutBlocked(name, whiteouts) { + continue + } + // Write to a temp file first; rename once we're confident this is + // the best match (since an even-higher-priority path may appear + // later in the same tar stream). + tmp, err := os.CreateTemp(filepath.Dir(dest), ".oci-extract-*") + if err != nil { + cleanup() + return false, fmt.Errorf("creating temp file: %w", err) + } + if _, err := io.Copy(tmp, tr); err != nil { + tmp.Close() + _ = os.Remove(tmp.Name()) + cleanup() + return false, fmt.Errorf("writing temp file: %w", err) + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmp.Name()) + cleanup() + return false, fmt.Errorf("closing temp file: %w", err) + } + cleanup() // remove any previous lower-priority candidate + tmpPath = tmp.Name() + bestPriority = priority + if bestPriority == 0 { + break // can't do better than the highest-priority path + } + } + + if tmpPath == "" { + return false, nil + } + if err := os.Rename(tmpPath, dest); err != nil { + _ = os.Remove(tmpPath) + return false, fmt.Errorf("moving extracted file into place: %w", err) + } + return true, nil +} + +// isWhiteoutBlocked reports whether target (or any ancestor dir marked opaque) +// has been whited out by a newer layer. +func isWhiteoutBlocked(target string, whiteouts map[string]bool) bool { + if target == "" || len(whiteouts) == 0 { + return false + } + if whiteouts[target] { + return true + } + // Walk ancestor directories to honour opaque whiteouts stored as "dir/". + for dir := path.Dir(target); dir != "/" && dir != "."; dir = path.Dir(dir) { + if whiteouts[dir+"/"] { + return true + } + } + return whiteouts["/"] +} diff --git a/pkg/provider/oci_extract_test.go b/pkg/provider/oci_extract_test.go new file mode 100644 index 0000000..4ee3a0b --- /dev/null +++ b/pkg/provider/oci_extract_test.go @@ -0,0 +1,251 @@ +package provider + +import ( + "archive/tar" + "bytes" + "io" + "os" + "path/filepath" + "testing" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/google/go-containerregistry/pkg/v1/types" +) + +// fakeLayer adapts an in-memory uncompressed tar stream into a v1.Layer for +// test purposes by delegating to tarball.LayerFromOpener. +func fakeLayer(t *testing.T, entries []tar.Header, contents map[string][]byte) v1.Layer { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for _, h := range entries { + hdr := h + body := contents[hdr.Name] + hdr.Size = int64(len(body)) + if err := tw.WriteHeader(&hdr); err != nil { + t.Fatalf("tar write header: %v", err) + } + if _, err := tw.Write(body); err != nil { + t.Fatalf("tar write body: %v", err) + } + } + if err := tw.Close(); err != nil { + t.Fatalf("tar close: %v", err) + } + layer, err := tarball.LayerFromOpener( + func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(buf.Bytes())), nil }, + tarball.WithMediaType(types.DockerLayer), + tarball.WithCompressionLevel(0), + ) + if err != nil { + t.Fatalf("LayerFromOpener: %v", err) + } + return layer +} + +func TestExtractBinaryFromLayer_FindsHighestPriority(t *testing.T) { + // Two candidates in the same layer; earlier searchPaths entry wins. + entries := []tar.Header{ + {Name: "usr/bin/docker", Typeflag: tar.TypeReg, Mode: 0755}, + {Name: "usr/local/bin/docker", Typeflag: tar.TypeReg, Mode: 0755}, + } + contents := map[string][]byte{ + "usr/bin/docker": []byte("low"), + "usr/local/bin/docker": []byte("high"), + } + layer := fakeLayer(t, entries, contents) + + dest := filepath.Join(t.TempDir(), "out") + searchPaths := []string{"/usr/local/bin/docker", "/usr/bin/docker"} + found, err := extractBinaryFromLayer(layer, searchPaths, dest, map[string]bool{}) + if err != nil { + t.Fatalf("extract: %v", err) + } + if !found { + t.Fatal("expected found=true") + } + body, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("read dest: %v", err) + } + if string(body) != "high" { + t.Errorf("got %q, want %q (highest-priority match)", body, "high") + } +} + +func TestExtractBinaryFromLayer_NoMatch(t *testing.T) { + entries := []tar.Header{ + {Name: "bin/busybox", Typeflag: tar.TypeReg, Mode: 0755}, + } + contents := map[string][]byte{"bin/busybox": []byte("bb")} + layer := fakeLayer(t, entries, contents) + + dest := filepath.Join(t.TempDir(), "out") + found, err := extractBinaryFromLayer(layer, []string{"/nope"}, dest, map[string]bool{}) + if err != nil { + t.Fatalf("extract: %v", err) + } + if found { + t.Error("expected found=false") + } + if _, err := os.Stat(dest); !os.IsNotExist(err) { + t.Error("dest should not be created when nothing matched") + } +} + +func TestExtractBinaryFromLayer_SkipsNonRegular(t *testing.T) { + // A symlink at the exact target path must not be copied as the binary. + entries := []tar.Header{ + {Name: "usr/bin/busybox", Typeflag: tar.TypeSymlink, Linkname: "busybox", Mode: 0777}, + } + layer := fakeLayer(t, entries, nil) + + dest := filepath.Join(t.TempDir(), "out") + found, err := extractBinaryFromLayer(layer, []string{"/usr/bin/busybox"}, dest, map[string]bool{}) + if err != nil { + t.Fatalf("extract: %v", err) + } + if found { + t.Error("expected symlink to be ignored (non-regular)") + } +} + +func TestExtractBinaryFromLayer_EmptySearchPaths(t *testing.T) { + layer := fakeLayer(t, nil, nil) + found, err := extractBinaryFromLayer(layer, nil, filepath.Join(t.TempDir(), "out"), map[string]bool{}) + if err != nil { + t.Fatalf("extract: %v", err) + } + if found { + t.Error("expected found=false for empty searchPaths") + } +} + +func TestExtractBinaryFromLayer_RespectsWhiteout(t *testing.T) { + // A whiteout from a newer layer blocks extraction of /usr/bin/tool + // from this (older) layer. + entries := []tar.Header{ + {Name: "usr/bin/tool", Typeflag: tar.TypeReg, Mode: 0755}, + } + contents := map[string][]byte{"usr/bin/tool": []byte("stale")} + layer := fakeLayer(t, entries, contents) + + dest := filepath.Join(t.TempDir(), "out") + whiteouts := map[string]bool{"/usr/bin/tool": true} + found, err := extractBinaryFromLayer(layer, []string{"/usr/bin/tool"}, dest, whiteouts) + if err != nil { + t.Fatalf("extract: %v", err) + } + if found { + t.Error("whiteout from newer layer should block extraction") + } + if _, err := os.Stat(dest); !os.IsNotExist(err) { + t.Error("dest should not be created when path is whited out") + } +} + +func TestExtractBinaryFromLayer_RecordsWhiteouts(t *testing.T) { + // A whiteout marker in this layer populates the shared map so older + // layers skip that path. + entries := []tar.Header{ + {Name: "usr/bin/.wh.deleted", Typeflag: tar.TypeReg, Mode: 0644}, + {Name: "opt/.wh..wh..opq", Typeflag: tar.TypeReg, Mode: 0644}, + } + layer := fakeLayer(t, entries, nil) + + whiteouts := map[string]bool{} + _, err := extractBinaryFromLayer(layer, []string{"/nope"}, filepath.Join(t.TempDir(), "out"), whiteouts) + if err != nil { + t.Fatalf("extract: %v", err) + } + if !whiteouts["/usr/bin/deleted"] { + t.Errorf("expected /usr/bin/deleted to be whited out, got %v", whiteouts) + } + if !whiteouts["/opt/"] { + t.Errorf("expected /opt/ opaque marker, got %v", whiteouts) + } +} + +func TestExtractBinaryFromLayer_FallsBackWhenFirstWhitedOut(t *testing.T) { + // Layer contains both candidates; the higher-priority one is whited out + // by a newer layer, so the lower-priority candidate must be extracted. + entries := []tar.Header{ + {Name: "usr/local/bin/tool", Typeflag: tar.TypeReg, Mode: 0755}, + {Name: "usr/bin/tool", Typeflag: tar.TypeReg, Mode: 0755}, + } + contents := map[string][]byte{ + "usr/local/bin/tool": []byte("blocked"), + "usr/bin/tool": []byte("fallback"), + } + layer := fakeLayer(t, entries, contents) + + dest := filepath.Join(t.TempDir(), "out") + whiteouts := map[string]bool{"/usr/local/bin/tool": true} + searchPaths := []string{"/usr/local/bin/tool", "/usr/bin/tool"} + found, err := extractBinaryFromLayer(layer, searchPaths, dest, whiteouts) + if err != nil { + t.Fatalf("extract: %v", err) + } + if !found { + t.Fatal("expected fallback candidate to be extracted") + } + body, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("read dest: %v", err) + } + if string(body) != "fallback" { + t.Errorf("got %q, want %q", body, "fallback") + } +} + +func TestExtractBinaryFromLayer_AcceptsLegacyRegular(t *testing.T) { + // Legacy NUL-typeflag regular file must be accepted via FileInfo.IsRegular(). + entries := []tar.Header{ + {Name: "usr/bin/tool", Typeflag: 0x00, Mode: 0755}, + } + layer := fakeLayer(t, entries, map[string][]byte{"usr/bin/tool": []byte("x")}) + dest := filepath.Join(t.TempDir(), "out") + found, err := extractBinaryFromLayer(layer, []string{"/usr/bin/tool"}, dest, map[string]bool{}) + if err != nil { + t.Fatalf("extract: %v", err) + } + if !found { + t.Error("legacy NUL typeflag regular file should be accepted") + } +} + +func TestExtractBinaryFromLayer_RootOpaqueBlocksEverything(t *testing.T) { + // An opaque whiteout at the image root ("/.wh..wh..opq") must be stored + // under the "/" sentinel so isWhiteoutBlocked hides older layers. + entries := []tar.Header{ + {Name: ".wh..wh..opq", Typeflag: tar.TypeReg, Mode: 0644}, + } + layer := fakeLayer(t, entries, nil) + whiteouts := map[string]bool{} + _, err := extractBinaryFromLayer(layer, []string{"/nope"}, filepath.Join(t.TempDir(), "out"), whiteouts) + if err != nil { + t.Fatalf("extract: %v", err) + } + if !whiteouts["/"] { + t.Errorf("expected root opaque to set whiteouts[\"/\"], got %v", whiteouts) + } +} + +func TestExtractBinaryFromLayer_OpaqueDirBlocksDescendant(t *testing.T) { + // An opaque-dir whiteout on "/usr/local/bin/" should block extraction of + // /usr/local/bin/tool from an older layer. + entries := []tar.Header{ + {Name: "usr/local/bin/tool", Typeflag: tar.TypeReg, Mode: 0755}, + } + layer := fakeLayer(t, entries, map[string][]byte{"usr/local/bin/tool": []byte("x")}) + + whiteouts := map[string]bool{"/usr/local/bin/": true} + found, err := extractBinaryFromLayer(layer, []string{"/usr/local/bin/tool"}, filepath.Join(t.TempDir(), "out"), whiteouts) + if err != nil { + t.Fatalf("extract: %v", err) + } + if found { + t.Error("opaque-dir whiteout should block extraction of descendants") + } +} diff --git a/pkg/provider/oci_test.go b/pkg/provider/oci_test.go new file mode 100644 index 0000000..35f6949 --- /dev/null +++ b/pkg/provider/oci_test.go @@ -0,0 +1,75 @@ +package provider + +import "testing" + +func TestOCIMatch(t *testing.T) { + o := &OCI{} + tests := []struct { + ref string + want bool + }{ + {"oci://alpine", true}, + {"oci://ghcr.io/org/img", true}, + {"oci://ghcr.io/org/img@v1:/bin/tool", true}, + {"docker://alpine", false}, + {"github.com/org/repo", false}, + } + for _, tt := range tests { + if got := o.Match(tt.ref); got != tt.want { + t.Errorf("OCI.Match(%q) = %v, want %v", tt.ref, got, tt.want) + } + } +} + +func TestOCIName(t *testing.T) { + o := &OCI{} + if o.Name() != "oci" { + t.Errorf("OCI.Name() = %q", o.Name()) + } +} + +func TestOCILatestVersion(t *testing.T) { + o := &OCI{} + v, err := o.LatestVersion("oci://alpine") + if err != nil { + t.Fatalf("LatestVersion() error = %v", err) + } + if v != "latest" { + t.Errorf("LatestVersion() = %q, want %q", v, "latest") + } +} + +func TestOCIFetchRelease(t *testing.T) { + o := &OCI{} + if _, err := o.FetchRelease("oci://alpine", "latest"); err == nil { + t.Error("expected error from OCI.FetchRelease") + } +} + +func TestParseImageRef(t *testing.T) { + tests := []struct { + in string + wantImage string + wantTag string + wantInContainer string + }{ + {"alpine", "alpine", "", ""}, + {"alpine@3.19", "alpine", "3.19", ""}, + {"docker@cli:/usr/local/bin/docker", "docker", "cli", "/usr/local/bin/docker"}, + {"ghcr.io/org/img@v1:/bin/tool", "ghcr.io/org/img", "v1", "/bin/tool"}, + {"alpine:/bin/busybox", "alpine", "", "/bin/busybox"}, + // Registry port is preserved, not mistaken for path. + {"localhost:5000/org/img@v1:/bin/tool", "localhost:5000/org/img", "v1", "/bin/tool"}, + {"localhost:5000/org/img", "localhost:5000/org/img", "", ""}, + // Docker-style "image:tag" is tolerated as a copy-paste convenience. + {"alpine:3.19", "alpine", "3.19", ""}, + {"ghcr.io/org/img:v1", "ghcr.io/org/img", "v1", ""}, + } + for _, tt := range tests { + img, tag, p := ParseImageRef(tt.in) + if img != tt.wantImage || tag != tt.wantTag || p != tt.wantInContainer { + t.Errorf("ParseImageRef(%q) = (%q, %q, %q), want (%q, %q, %q)", + tt.in, img, tag, p, tt.wantImage, tt.wantTag, tt.wantInContainer) + } + } +} diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index 8d8c873..7f2e79c 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -53,21 +53,98 @@ func Detect(ref string) (Provider, error) { // ParseRef splits a ref like "github.com/org/repo@v1.0" into // (github.com/org/repo, v1.0). Version may be empty. +// +// For docker:// or oci:// refs, the optional ":/" suffix +// is preserved on base (stripped only for the tag scan), and the tag ends +// up in version. Docker-style "image:tag" is also accepted as a copy-paste +// convenience — a ':' is treated as a tag separator when it occurs after +// the last '/' (so registry ports like "localhost:5000/org/img" still parse +// correctly). Examples: +// +// "docker://docker@cli:/usr/local/bin/docker" → +// ("docker://docker:/usr/local/bin/docker", "cli") +// "oci://ghcr.io/org/img:v1:/bin/tool" → +// ("oci://ghcr.io/org/img:/bin/tool", "v1") func ParseRef(ref string) (base, version string) { + if strings.HasPrefix(ref, "docker://") || strings.HasPrefix(ref, "oci://") { + imgPart, pathPart := SplitImagePath(ref) + // Prefer explicit "@tag". + if i := strings.LastIndex(imgPart, "@"); i > 0 { + return imgPart[:i] + pathPart, imgPart[i+1:] + } + // Tolerate docker-style "image:tag" — but only when the ':' is after + // the last '/' so registry ports are preserved. + lastSlash := strings.LastIndex(imgPart, "/") + if i := strings.LastIndex(imgPart, ":"); i > lastSlash && i > 0 { + return imgPart[:i] + pathPart, imgPart[i+1:] + } + return ref, "" + } if i := strings.LastIndex(ref, "@"); i > 0 { return ref[:i], ref[i+1:] } return ref, "" } +// SplitImagePath locates the ":/" suffix of a docker:// or oci:// ref +// and returns (imagePart, pathPart). pathPart is either empty or starts with +// ":/". Uses the last ":/" so registry ports (":443/") in the middle aren't +// mistaken for the path separator. Skips the protocol prefix's own ":/" so +// "oci://alpine" doesn't match on the scheme separator. +func SplitImagePath(ref string) (imagePart, pathPart string) { + start := 0 + if i := strings.Index(ref, "://"); i >= 0 { + start = i + 3 + } + if i := strings.LastIndex(ref[start:], ":/"); i >= 0 { + abs := start + i + return ref[:abs], ref[abs:] + } + return ref, "" +} + +// ParseImageRef parses a docker:// or oci:// ref into (image, tag, path). +// +// alpine → ("alpine", "", "") +// alpine@3.19 → ("alpine", "3.19", "") +// alpine:3.19 → ("alpine", "3.19", "") // docker-style, tolerated +// docker@cli:/usr/local/bin/docker → ("docker", "cli", "/usr/local/bin/docker") +// ghcr.io/org/img@v1:/bin/tool → ("ghcr.io/org/img", "v1", "/bin/tool") +// localhost:5000/org/img → ("localhost:5000/org/img", "", "") +// +// The prefix (docker:// or oci://) must already be stripped. Docker-style +// "image:tag" is accepted for convenience (a copy-paste from docker docs) +// but the preferred syntax remains "@tag" to stay consistent across providers. +func ParseImageRef(ref string) (image, tag, inContainerPath string) { + imagePart, pathPart := SplitImagePath(ref) + if pathPart != "" { + inContainerPath = pathPart[1:] // drop leading ":" + ref = imagePart + } + if i := strings.LastIndex(ref, "@"); i > 0 { + tag = ref[i+1:] + ref = ref[:i] + } else { + // Also accept docker-style "image:tag" — only when ':' is after the + // last '/' so registry ports ("localhost:5000/org/img") are preserved. + lastSlash := strings.LastIndex(ref, "/") + if i := strings.LastIndex(ref, ":"); i > lastSlash && i > 0 { + tag = ref[i+1:] + ref = ref[:i] + } + } + image = ref + return +} + // IsReleaseProvider returns true if the provider uses FetchRelease for downloads -// (i.e. GitHub, GitLab, Gitea). Returns false for go://, docker://, git://. +// (i.e. GitHub, GitLab, Gitea). Returns false for go://, docker://, oci://, git://. func IsReleaseProvider(p Provider) bool { if p == nil { return false } switch p.(type) { - case *GoInstall, *Docker, *Git: + case *GoInstall, *Docker, *OCI, *Git: return false default: return true @@ -91,6 +168,7 @@ func IsProviderRef(s string) bool { // // "go://github.com/jrhouston/tfk8s" → "tfk8s", // "docker://hashicorp/terraform" → "terraform" +// "docker://docker@cli:/usr/local/bin/docker" → "docker" func BinaryName(ref string) string { // git:// refs use the filepath part (after :) as the binary name if strings.HasPrefix(ref, "git://") { @@ -107,6 +185,21 @@ func BinaryName(ref string) string { } } + // docker:// or oci:// with ":/" — binary name is basename of path. + // Fall through to default derivation if the path is empty or a directory. + if strings.HasPrefix(ref, "docker://") || strings.HasPrefix(ref, "oci://") { + _, pathPart := SplitImagePath(ref) + if pathPart != "" { + p := pathPart[1:] // drop leading ":" + if p != "" && !strings.HasSuffix(p, "/") { + parts := strings.Split(p, "/") + if last := parts[len(parts)-1]; last != "" { + return last + } + } + } + } + // Strip protocol prefix r := ref if i := strings.Index(r, "://"); i >= 0 { @@ -116,8 +209,10 @@ func BinaryName(ref string) string { if i := strings.LastIndex(r, "@"); i > 0 { r = r[:i] } - // Strip colon (docker image:tag handled by version) - if i := strings.LastIndex(r, ":"); i > 0 { + // Strip docker-style "image:tag" — only when ":" occurs after the last "/" + // so registry ports like "localhost:5000/org/image" are preserved. + lastSlash := strings.LastIndex(r, "/") + if i := strings.LastIndex(r, ":"); i > lastSlash && i > 0 { r = r[:i] } // Last path segment diff --git a/pkg/provider/provider_test.go b/pkg/provider/provider_test.go index c0b9478..489d408 100644 --- a/pkg/provider/provider_test.go +++ b/pkg/provider/provider_test.go @@ -13,6 +13,18 @@ func TestParseRef(t *testing.T) { {"go://github.com/jrhouston/tfk8s@v0.1.8", "go://github.com/jrhouston/tfk8s", "v0.1.8"}, {"docker://hashicorp/terraform", "docker://hashicorp/terraform", ""}, {"gitlab.com/org/tool@v1.0", "gitlab.com/org/tool", "v1.0"}, + // docker:// / oci:// with ":/" must preserve path on base. + {"docker://docker@cli:/usr/local/bin/docker", "docker://docker:/usr/local/bin/docker", "cli"}, + {"oci://ghcr.io/org/img@v1:/bin/tool", "oci://ghcr.io/org/img:/bin/tool", "v1"}, + {"oci://alpine:/bin/busybox", "oci://alpine:/bin/busybox", ""}, + // Registry port must not be mistaken for the path separator. + {"oci://localhost:5000/org/img@v1:/bin/tool", "oci://localhost:5000/org/img:/bin/tool", "v1"}, + // Docker-style "image:tag" is tolerated and normalised same as @tag. + {"oci://alpine:3.19", "oci://alpine", "3.19"}, + {"oci://ghcr.io/org/img:v1", "oci://ghcr.io/org/img", "v1"}, + {"oci://ghcr.io/org/img:v1:/bin/tool", "oci://ghcr.io/org/img:/bin/tool", "v1"}, + // Registry port without tag stays as-is. + {"oci://localhost:5000/org/img", "oci://localhost:5000/org/img", ""}, } for _, tt := range tests { @@ -57,6 +69,12 @@ func TestBinaryName(t *testing.T) { {"docker://hashicorp/terraform", "terraform"}, {"gitlab.com/org/my-tool", "my-tool"}, {"codeberg.org/user/app", "app"}, + // ":/" takes the basename of the path as the binary name. + {"docker://docker@cli:/usr/local/bin/docker", "docker"}, + {"oci://ghcr.io/org/img@v1:/bin/my-tool", "my-tool"}, + {"oci://alpine", "alpine"}, + // Registry port must not be mistaken for path. + {"oci://localhost:5000/org/img", "img"}, } for _, tt := range tests {