Compare commits

..

1 Commits

Author SHA1 Message Date
renovate-bot 68932b655a chore(deps): update module github.com/charmbracelet/log to v2 2026-06-04 06:01:31 +00:00
300 changed files with 26403 additions and 34920 deletions
+6 -11
View File
@@ -8,17 +8,12 @@ steps:
- make check
- name: xgettext-go
image: golang:1.26
environment:
GOPRIVATE: coopcloud.tech
commands:
- go run git.coopcloud.tech/toolshed/xgettext-go@latest
-o pkg/i18n/locales/abra.pot
--keyword=i18n.G
--keyword-ctx=i18n.GC
--sort-output
--add-comments-tag="translators"
$(find . -name "*.go" -not -path "*vendor*" | sort)
image: git.coopcloud.tech/toolshed/drone-xgettext-go:latest
settings:
keyword: i18n.G
keyword_ctx: i18n.GC
out: pkg/i18n/locales/abra.pot
comments_tag: translators
depends_on:
- make check
when:
-1
View File
@@ -1 +0,0 @@
vendor/** linguist-generated=true
+1 -1
View File
@@ -15,7 +15,7 @@ WORKDIR /app
RUN CGO_ENABLED=0 make build
FROM alpine:3.24
FROM alpine:3.23
RUN apk add --no-cache \
ca-certificates \
+9 -4
View File
@@ -1,5 +1,5 @@
ABRA := ./cmd/abra
XGETTEXT := go run git.coopcloud.tech/toolshed/xgettext-go@latest
XGETTEXT := ./bin/xgettext-go
COMMIT := $(shell git rev-list -1 HEAD)
GOPATH := $(shell go env GOPATH)
GOVERSION := 1.26
@@ -17,7 +17,7 @@ export GOPRIVATE=coopcloud.tech
all: format check build
run:
@go run -gcflags=$(GCFLAGS) -ldflags=$(LDFLAGS) $(ABRA) $(ARGS)
@go run -gcflags=$(GCFLAGS) -ldflags=$(LDFLAGS) $(ABRA)
install:
@go install -gcflags=$(GCFLAGS) -ldflags=$(LDFLAGS) $(ABRA)
@@ -62,8 +62,8 @@ update-po:
done
.PHONY: update-pot
update-pot:
@$(XGETTEXT) \
update-pot: $(XGETTEXT)
@${XGETTEXT} \
-o pkg/i18n/locales/$(DOMAIN).pot \
--keyword=i18n.G \
--keyword-ctx=i18n.GC \
@@ -71,6 +71,11 @@ update-pot:
--add-comments-tag="translators" \
$$(find . -name "*.go" -not -path "*vendor*" | sort)
${XGETTEXT}:
@mkdir -p ./bin && \
wget -O ./bin/xgettext-go https://git.coopcloud.tech/toolshed/xgettext-go/raw/branch/main/xgettext-go && \
chmod +x ./bin/xgettext-go
.PHONY: update-pot-po-metadata
update-pot-po-metadata:
@sed -i "s/charset=CHARSET/charset=UTF-8/g" pkg/i18n/locales/*.po pkg/i18n/locales/*.pot
+5 -6
View File
@@ -151,12 +151,11 @@ checkout as-is. Recipe commit hashes are also supported as values for
stackName := app.StackName()
deployOpts := stack.Deploy{
Composefiles: composeFiles,
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
Detach: false,
SendRegistryAuth: true,
Composefiles: composeFiles,
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
Detach: false,
}
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
if err != nil {
+1 -5
View File
@@ -112,11 +112,7 @@ var AppNewCommand = &cobra.Command{
}
}
if recipeVersion != "" {
if _, err := recipe.EnsureVersion(recipeVersion); err != nil {
log.Fatal(err)
}
} else if len(recipeVersions) > 0 {
if len(recipeVersions) > 0 {
latest := recipeVersions[len(recipeVersions)-1]
for tag := range latest {
recipeVersion = tag
+5 -6
View File
@@ -166,12 +166,11 @@ beforehand. See "abra app backup" for more.`),
stackName := app.StackName()
deployOpts := stack.Deploy{
Composefiles: composeFiles,
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
Detach: false,
SendRegistryAuth: true,
Composefiles: composeFiles,
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
Detach: false,
}
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
+5 -6
View File
@@ -178,12 +178,11 @@ beforehand. See "abra app backup" for more.`),
stackName := app.StackName()
deployOpts := stack.Deploy{
Composefiles: composeFiles,
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
Detach: false,
SendRegistryAuth: true,
Composefiles: composeFiles,
Namespace: stackName,
Prune: false,
ResolveImage: stack.ResolveImageAlways,
Detach: false,
}
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
-1
View File
@@ -19,6 +19,5 @@ var (
Minor bool
NoDomainChecks bool
Patch bool
PinDigests bool
ShowUnchanged bool
)
+1 -1
View File
@@ -98,7 +98,7 @@ func GetMainAppImage(recipe recipe.Recipe) (string, error) {
}
for _, service := range config.Services {
if service.Name == "app" {
img, err := reference.ParseNormalizedNamed(formatter.StripDigest(service.Image))
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return "", err
}
+1 -1
View File
@@ -322,7 +322,7 @@ func GetImageVersions(recipe recipePkg.Recipe) (map[string]string, error) {
continue
}
img, err := reference.ParseNormalizedNamed(formatter.StripDigest(service.Image))
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return services, err
}
+3 -30
View File
@@ -130,7 +130,7 @@ interface.`),
}
for _, service := range config.Services {
img, err := reference.ParseNormalizedNamed(formatter.StripDigest(service.Image))
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
log.Fatal(err)
}
@@ -293,31 +293,12 @@ interface.`),
}
}
if upgradeTag != "skip" {
var ok bool
var err error
var resolvedDigest string
if internal.PinDigests {
digest, dErr := client.GetImageDigest(img, upgradeTag)
if dErr != nil {
log.Fatal(i18n.G("failed to resolve digest for %s:%s: %s", image, upgradeTag, dErr.Error()))
}
resolvedDigest = digest
ok, err = recipe.UpdatePinnedTag(image, upgradeTag, digest)
} else {
ok, err = recipe.UpdateTag(image, upgradeTag)
}
ok, err := recipe.UpdateTag(image, upgradeTag)
if err != nil {
log.Fatal(err)
}
if ok {
if internal.PinDigests {
log.Info(i18n.G("tag upgraded and pinned from %s to %s@%s for %s", tag.String(), upgradeTag, resolvedDigest, image))
} else {
log.Info(i18n.G("tag upgraded from %s to %s for %s", tag.String(), upgradeTag, image))
}
log.Info(i18n.G("tag upgraded from %s to %s for %s", tag.String(), upgradeTag, image))
}
} else {
if !internal.NoInput {
@@ -436,12 +417,4 @@ func init() {
false,
i18n.G("commit changes"),
)
RecipeUpgradeCommand.Flags().BoolVarP(
&internal.PinDigests,
i18n.G("pindigests"),
i18n.GC("p", "pin container image by digest"),
false,
i18n.G("pin the container image version by manifest digest (image:tag@sha256:...)"),
)
}
+10 -12
View File
@@ -8,7 +8,7 @@ require (
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v1.0.0
github.com/charmbracelet/log/v2 v2.0.0
github.com/distribution/reference v0.6.0
github.com/docker/cli v28.4.0+incompatible
github.com/docker/docker v28.5.2+incompatible
@@ -19,9 +19,8 @@ require (
github.com/moby/sys/signal v0.7.1
github.com/moby/term v0.5.2
github.com/pkg/errors v0.9.1
github.com/regclient/regclient v0.11.5
github.com/schollz/progressbar/v3 v3.19.1
golang.org/x/term v0.45.0
github.com/schollz/progressbar/v3 v3.19.0
golang.org/x/term v0.43.0
gopkg.in/yaml.v3 v3.0.1
gotest.tools/v3 v3.5.2
)
@@ -74,11 +73,11 @@ require (
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
@@ -107,7 +106,6 @@ require (
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
@@ -126,10 +124,10 @@ require (
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
@@ -153,11 +151,11 @@ require (
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/spf13/cobra v1.10.2
github.com/spf13/cobra v1.10.1
github.com/stretchr/testify v1.11.1
github.com/theupdateframework/notary v0.7.0 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
golang.org/x/sys v0.47.0
golang.org/x/sys v0.45.0
)
replace github.com/docker/cli v28.4.0+incompatible => git.coopcloud.tech/toolshed/docker-cli v28.5.3-0.20260202112816-30df2d0b3a00+incompatible
+17 -26
View File
@@ -427,8 +427,6 @@ github.com/go-sql-driver/mysql v1.3.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus v0.0.0-20151105175453-c7fdd8b5cd55/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
github.com/godbus/dbus v0.0.0-20180201030542-885f9cc04c9c/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
@@ -586,8 +584,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
github.com/klauspost/compress v1.11.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.14.2/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
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/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
@@ -625,8 +623,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
@@ -701,8 +699,6 @@ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+
github.com/ncw/swift v1.0.47/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ZM=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/olareg/olareg v0.2.1 h1:RPHGIaqlVWPbKAsOYUj7e2WfEEW5M7F8I6hKMrwd4jU=
github.com/olareg/olareg v0.2.1/go.mod h1:dhr8QetC7U7jJ2m93oxDhEEOKCRbPgOK1oGyKfB4QNo=
github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
github.com/onsi/ginkgo v0.0.0-20151202141238-7f8ab55aaf3b/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@@ -800,8 +796,6 @@ github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/regclient/regclient v0.11.5 h1:OHRsXO0F3qHGfa4HEUv+EkMH9NXNcCTBKjNzyC/UhIA=
github.com/regclient/regclient v0.11.5/go.mod h1:DZUOfIT14WFTK2Pj4vjd93avy9O4Fdpjrf9ir23TbRE=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
@@ -814,8 +808,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/schollz/progressbar/v3 v3.19.1 h1:iv8BgwOvdML/S3p84uBpy/IMigv4U9594vPZYa2EdrU=
github.com/schollz/progressbar/v3 v3.19.1/go.mod h1:LFL7jqimKxfhero4K1eCkUr/6R39AgQeiPCJtlTWIW8=
github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
github.com/sclevine/spec v1.2.0/go.mod h1:W4J29eT/Kzv7/b9IWLB055Z+qvVC9vt0Arko24q7p+U=
github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo=
github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg=
@@ -865,8 +859,6 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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/sudo-bmitch/oci-digest v0.1.2 h1:are0qzWTsFZGZ3Uvdi9OSztJszSWaab6iqquMEEB7rw=
github.com/sudo-bmitch/oci-digest v0.1.2/go.mod h1:SH6l5OIe0islKBZBedjiPOeET/0QwGL+/oYfQt51uQo=
github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI=
@@ -878,8 +870,6 @@ github.com/theupdateframework/notary v0.7.0/go.mod h1:c9DRxcmhHmVLDay4/2fUYdISnH
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
@@ -976,8 +966,8 @@ golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9/go.mod h1:jdWPYTVW3xRLrWP
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -1053,8 +1043,8 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1149,13 +1139,14 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1165,8 +1156,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-24
View File
@@ -9,12 +9,8 @@ import (
"github.com/containers/image/docker"
"github.com/containers/image/types"
"github.com/distribution/reference"
"github.com/regclient/regclient"
"github.com/regclient/regclient/types/ref"
)
var rc = regclient.New()
// GetRegistryTags retrieves all tags of an image from a container registry.
func GetRegistryTags(img reference.Named) ([]string, error) {
var tags []string
@@ -32,23 +28,3 @@ func GetRegistryTags(img reference.Named) ([]string, error) {
return tags, nil
}
// GetImageDigest resolves the content digest (sha256:...) for a specific
// image:tag reference by querying the registry manifest. This gives us a
// digest to pin to while keeping support for multi-arch deployments: when
// the tag points at a manifest list/OCI index, the digest returned is the
// digest of that top-level index, so the pin still resolves correctly
// regardless of the deploying host's architecture.
func GetImageDigest(img reference.Named, tag string) (string, error) {
r, err := ref.New(fmt.Sprintf("%s:%s", img.Name(), tag))
if err != nil {
return "", fmt.Errorf("parsing reference %s:%s: %w", img.Name(), tag, err)
}
m, err := rc.ManifestHead(context.Background(), r)
if err != nil {
return "", fmt.Errorf("fetching manifest for %s:%s: %w", img.Name(), tag, err)
}
return m.GetDescriptor().Digest.String(), nil
}
+2 -2
View File
@@ -158,7 +158,7 @@ func GetImagesForStack(cl *dockerClient.Client, app appPkg.App) (map[string]stri
if service.Spec.TaskTemplate.ContainerSpec != nil {
imageName := service.Spec.TaskTemplate.ContainerSpec.Image
imageParsed, err := reference.ParseNormalizedNamed(formatter.StripDigest(imageName))
imageParsed, err := reference.ParseNormalizedNamed(imageName)
if err != nil {
log.Warn(err)
continue
@@ -281,7 +281,7 @@ func GatherImagesForDeploy(cl *dockerClient.Client, app appPkg.App, compose *com
newImages := make(map[string]string)
for _, service := range compose.Services {
imageParsed, err := reference.ParseNormalizedNamed(formatter.StripDigest(service.Image))
imageParsed, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
log.Warn(err)
continue
-5
View File
@@ -34,11 +34,6 @@ func SmallSHA(hash string) string {
return hash[:8]
}
// StripDigest removes the digest suffix from an image reference (e.g., "image:tag@sha256:..." -> "image:tag").
func StripDigest(image string) string {
return strings.Split(image, "@")[0]
}
// RemoveSha remove image sha from a string that are added in some docker outputs
func RemoveSha(str string) string {
return strings.Split(str, "@")[0]
-7
View File
@@ -6,13 +6,6 @@ import (
"github.com/stretchr/testify/assert"
)
func TestStripDigest(t *testing.T) {
assert.Equal(t, "ubuntu:latest", StripDigest("ubuntu:latest@sha256:1234567890abcdef"))
assert.Equal(t, "ubuntu:latest", StripDigest("ubuntu:latest"))
assert.Equal(t, "my-repo/my-image:v1", StripDigest("my-repo/my-image:v1@sha256:abcdef1234567890"))
assert.Equal(t, "my-repo/my-image", StripDigest("my-repo/my-image@sha256:abcdef1234567890"))
}
func TestBoldDirtyDefault(t *testing.T) {
assert.Equal(t, "foo", BoldDirtyDefault("foo"))
}
+83 -83
View File
@@ -7,7 +7,7 @@
msgid ""
msgstr "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-06-14 17:56+0200\n"
"POT-Creation-Date: 2026-04-11 11:34+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -214,7 +214,7 @@ msgstr ""
msgid "%s already exists?"
msgstr ""
#: ./cli/app/new.go:206
#: ./cli/app/new.go:202
#, c-format
msgid "%s created (version: %s)"
msgstr ""
@@ -299,7 +299,7 @@ msgstr ""
msgid "%s has no published versions?"
msgstr ""
#: ./cli/app/new.go:315
#: ./cli/app/new.go:311
#, c-format
msgid "%s has no secrets to generate, skipping..."
msgstr ""
@@ -339,17 +339,17 @@ msgstr ""
msgid "%s is missing the TYPE env var?"
msgstr ""
#: ./cli/app/rollback.go:310 ./cli/app/rollback.go:314
#: ./cli/app/rollback.go:309 ./cli/app/rollback.go:313
#, c-format
msgid "%s is not a downgrade for %s?"
msgstr ""
#: ./cli/app/upgrade.go:430 ./cli/app/upgrade.go:434
#: ./cli/app/upgrade.go:429 ./cli/app/upgrade.go:433
#, c-format
msgid "%s is not an upgrade for %s?"
msgstr ""
#: ./cli/app/env.go:146 ./cli/app/logs.go:65 ./cli/app/ps.go:62 ./cli/app/restart.go:100 ./cli/app/services.go:55 ./cli/app/undeploy.go:66 ./cli/app/upgrade.go:451
#: ./cli/app/env.go:146 ./cli/app/logs.go:65 ./cli/app/ps.go:62 ./cli/app/restart.go:100 ./cli/app/services.go:55 ./cli/app/undeploy.go:66 ./cli/app/upgrade.go:450
#, c-format
msgid "%s is not deployed?"
msgstr ""
@@ -369,7 +369,7 @@ msgstr ""
msgid "%s missing context, run \"abra server add %s\"?"
msgstr ""
#: ./cli/app/deploy.go:195 ./cli/app/upgrade.go:211
#: ./cli/app/deploy.go:194 ./cli/app/upgrade.go:210
#, c-format
msgid "%s missing from %s.env"
msgstr ""
@@ -404,17 +404,17 @@ msgstr ""
msgid "%s removed from pass store"
msgstr ""
#: ./cli/app/new.go:224
#: ./cli/app/new.go:220
#, c-format
msgid "%s requires secret generation before deploy, run \"abra app secret generate %s --all\""
msgstr ""
#: ./cli/app/new.go:228
#: ./cli/app/new.go:224
#, c-format
msgid "%s requires secret insertion before deploy (#generate=false)"
msgstr ""
#: ./cli/app/new.go:155
#: ./cli/app/new.go:151
#, c-format
msgid "%s sanitised as %s for new app"
msgstr ""
@@ -549,12 +549,12 @@ msgstr ""
msgid "%s: waiting %d seconds before next retry"
msgstr ""
#: ./cli/app/upgrade.go:425
#: ./cli/app/upgrade.go:424
#, c-format
msgid "'%s' is not a known version"
msgstr ""
#: ./cli/app/rollback.go:305 ./cli/app/upgrade.go:420
#: ./cli/app/rollback.go:304 ./cli/app/upgrade.go:419
#, c-format
msgid "'%s' is not a known version for %s"
msgstr ""
@@ -626,7 +626,7 @@ msgstr ""
msgid "Both local recipe and live deployment labels are shown."
msgstr ""
#: ./cli/app/backup.go:319 ./cli/app/backup.go:335 ./cli/app/check.go:95 ./cli/app/cmd.go:285 ./cli/app/cp.go:385 ./cli/app/deploy.go:415 ./cli/app/labels.go:143 ./cli/app/list.go:335 ./cli/app/new.go:411 ./cli/app/ps.go:213 ./cli/app/restart.go:163 ./cli/app/restore.go:138 ./cli/app/secret.go:569 ./cli/app/secret.go:609 ./cli/app/secret.go:633 ./cli/app/secret.go:641 ./cli/catalogue/catalogue.go:318 ./cli/recipe/lint.go:137
#: ./cli/app/backup.go:319 ./cli/app/backup.go:335 ./cli/app/check.go:95 ./cli/app/cmd.go:285 ./cli/app/cp.go:385 ./cli/app/deploy.go:414 ./cli/app/labels.go:143 ./cli/app/list.go:335 ./cli/app/new.go:407 ./cli/app/ps.go:213 ./cli/app/restart.go:163 ./cli/app/restore.go:138 ./cli/app/secret.go:569 ./cli/app/secret.go:609 ./cli/app/secret.go:633 ./cli/app/secret.go:641 ./cli/catalogue/catalogue.go:318 ./cli/recipe/lint.go:137
msgid "C"
msgstr ""
@@ -767,7 +767,7 @@ msgid "Creates a new app from a default recipe.\n"
"on your $PATH."
msgstr ""
#: ./cli/app/deploy.go:431 ./cli/app/new.go:387 ./cli/app/rollback.go:362 ./cli/app/upgrade.go:471
#: ./cli/app/deploy.go:430 ./cli/app/new.go:383 ./cli/app/rollback.go:361 ./cli/app/upgrade.go:470
msgid "D"
msgstr ""
@@ -878,7 +878,7 @@ msgid "Generate a report of all managed apps.\n"
"Use \"--status/-S\" flag to query all servers for the live deployment status."
msgstr ""
#: ./cli/app/new.go:321
#: ./cli/app/new.go:317
msgid "Generate app secrets?"
msgstr ""
@@ -1257,7 +1257,7 @@ msgstr ""
msgid "Run app commands"
msgstr ""
#: ./cli/app/backup.go:303 ./cli/app/list.go:296 ./cli/app/logs.go:109 ./cli/app/new.go:403
#: ./cli/app/backup.go:303 ./cli/app/list.go:296 ./cli/app/logs.go:109 ./cli/app/new.go:399
msgid "S"
msgstr ""
@@ -1265,7 +1265,7 @@ msgstr ""
msgid "SECRETS"
msgstr ""
#: ./cli/app/new.go:238
#: ./cli/app/new.go:234
msgid "SECRETS OVERVIEW"
msgstr ""
@@ -1298,7 +1298,7 @@ msgstr ""
msgid "STATUS"
msgstr ""
#: ./cli/app/new.go:346
#: ./cli/app/new.go:342
msgid "Select app server:"
msgstr ""
@@ -1330,7 +1330,7 @@ msgstr ""
msgid "Specify a server name"
msgstr ""
#: ./cli/app/new.go:297
#: ./cli/app/new.go:293
msgid "Specify app domain"
msgstr ""
@@ -1462,7 +1462,7 @@ msgid "To load completions:\n"
" # and source this file from your PowerShell profile."
msgstr ""
#: ./cli/app/deploy.go:455 ./cli/app/rollback.go:378 ./cli/app/upgrade.go:495
#: ./cli/app/deploy.go:454 ./cli/app/rollback.go:377 ./cli/app/upgrade.go:494
msgid "U"
msgstr ""
@@ -1835,7 +1835,7 @@ msgstr ""
msgid "attempting to run %s"
msgstr ""
#: ./cli/app/deploy.go:284 ./cli/app/upgrade.go:297
#: ./cli/app/deploy.go:283 ./cli/app/upgrade.go:296
#, c-format
msgid "attempting to run post deploy commands, saw: %s"
msgstr ""
@@ -1865,7 +1865,7 @@ msgstr ""
msgid "autocomplete failed: %s"
msgstr ""
#: ./cli/app/new.go:405
#: ./cli/app/new.go:401
msgid "automatically generate secrets"
msgstr ""
@@ -1915,7 +1915,7 @@ msgstr ""
#. no spaces in between
#. translators: `abra app cp` aliases. use a comma separated list of aliases with
#. no spaces in between
#: ./cli/app/backup.go:148 ./cli/app/cp.go:30 ./cli/app/deploy.go:439 ./cli/app/rollback.go:370 ./cli/app/upgrade.go:479
#: ./cli/app/backup.go:148 ./cli/app/cp.go:30 ./cli/app/deploy.go:438 ./cli/app/rollback.go:369 ./cli/app/upgrade.go:478
msgid "c"
msgstr ""
@@ -1971,7 +1971,7 @@ msgstr ""
msgid "cannot redeploy previous chaos version (%s), did you mean to use \"--chaos\"?"
msgstr ""
#: ./cli/app/deploy.go:389
#: ./cli/app/deploy.go:388
#, c-format
msgid "cannot redeploy previous chaos version (%s), did you mean to use \"--chaos\"?\n"
" to return to a regular release, specify a release tag, commit SHA or use \"--latest\""
@@ -1990,7 +1990,7 @@ msgstr ""
msgid "cannot use '[secret] [version]' and '--all' together"
msgstr ""
#: ./cli/app/deploy.go:331
#: ./cli/app/deploy.go:330
msgid "cannot use --chaos and --latest together"
msgstr ""
@@ -2014,11 +2014,11 @@ msgstr ""
msgid "cannot use [service] and --all-services/-a together"
msgstr ""
#: ./cli/app/deploy.go:323 ./cli/app/new.go:80
#: ./cli/app/deploy.go:322 ./cli/app/new.go:80
msgid "cannot use [version] and --chaos together"
msgstr ""
#: ./cli/app/deploy.go:327
#: ./cli/app/deploy.go:326
msgid "cannot use [version] and --latest together"
msgstr ""
@@ -2054,7 +2054,7 @@ msgstr ""
msgid "cfg"
msgstr ""
#: ./cli/app/backup.go:318 ./cli/app/backup.go:334 ./cli/app/check.go:94 ./cli/app/cmd.go:284 ./cli/app/cp.go:384 ./cli/app/deploy.go:414 ./cli/app/labels.go:142 ./cli/app/list.go:334 ./cli/app/new.go:410 ./cli/app/ps.go:212 ./cli/app/restart.go:162 ./cli/app/restore.go:137 ./cli/app/secret.go:568 ./cli/app/secret.go:608 ./cli/app/secret.go:632 ./cli/app/secret.go:640 ./cli/catalogue/catalogue.go:317 ./cli/recipe/lint.go:136
#: ./cli/app/backup.go:318 ./cli/app/backup.go:334 ./cli/app/check.go:94 ./cli/app/cmd.go:284 ./cli/app/cp.go:384 ./cli/app/deploy.go:413 ./cli/app/labels.go:142 ./cli/app/list.go:334 ./cli/app/new.go:406 ./cli/app/ps.go:212 ./cli/app/restart.go:162 ./cli/app/restore.go:137 ./cli/app/secret.go:568 ./cli/app/secret.go:608 ./cli/app/secret.go:632 ./cli/app/secret.go:640 ./cli/catalogue/catalogue.go:317 ./cli/recipe/lint.go:136
msgid "chaos"
msgstr ""
@@ -2068,7 +2068,7 @@ msgstr ""
msgid "check <domain> [flags]"
msgstr ""
#: ./cli/app/deploy.go:95 ./cli/app/undeploy.go:58 ./cli/app/upgrade.go:443
#: ./cli/app/deploy.go:95 ./cli/app/undeploy.go:58 ./cli/app/upgrade.go:442
#, c-format
msgid "checking whether %s is already deployed"
msgstr ""
@@ -2371,7 +2371,7 @@ msgstr ""
msgid "critical errors present in %s config"
msgstr ""
#: ./cli/app/rollback.go:300
#: ./cli/app/rollback.go:299
#, c-format
msgid "current deployment '%s' is not a known version for %s"
msgstr ""
@@ -2422,7 +2422,7 @@ msgstr ""
msgid "deploy labels stanza present"
msgstr ""
#: ./cli/app/deploy.go:449
#: ./cli/app/deploy.go:448
msgid "deploy latest recipe version"
msgstr ""
@@ -2524,11 +2524,11 @@ msgstr ""
msgid "dirty: %v, "
msgstr ""
#: ./cli/app/deploy.go:441 ./cli/app/rollback.go:372 ./cli/app/upgrade.go:481
#: ./cli/app/deploy.go:440 ./cli/app/rollback.go:371 ./cli/app/upgrade.go:480
msgid "disable converge logic checks"
msgstr ""
#: ./cli/app/deploy.go:433 ./cli/app/rollback.go:364 ./cli/app/upgrade.go:473
#: ./cli/app/deploy.go:432 ./cli/app/rollback.go:363 ./cli/app/upgrade.go:472
msgid "disable public DNS checks"
msgstr ""
@@ -2544,11 +2544,11 @@ msgstr ""
msgid "docker: is the daemon running / your user has docker permissions?"
msgstr ""
#: ./cli/app/new.go:386
#: ./cli/app/new.go:382
msgid "domain"
msgstr ""
#: ./cli/app/new.go:389
#: ./cli/app/new.go:385
msgid "domain name for app"
msgstr ""
@@ -2737,7 +2737,7 @@ msgstr ""
#. translators: `abra recipe fetch` aliases. use a comma separated list of aliases
#. with no spaces in between
#: ./cli/app/deploy.go:423 ./cli/app/env.go:325 ./cli/app/remove.go:163 ./cli/app/rollback.go:354 ./cli/app/secret.go:593 ./cli/app/upgrade.go:463 ./cli/app/volume.go:217 ./cli/recipe/fetch.go:20 ./cli/recipe/fetch.go:138
#: ./cli/app/deploy.go:422 ./cli/app/env.go:325 ./cli/app/remove.go:163 ./cli/app/rollback.go:353 ./cli/app/secret.go:593 ./cli/app/upgrade.go:462 ./cli/app/volume.go:217 ./cli/recipe/fetch.go:20 ./cli/recipe/fetch.go:138
msgid "f"
msgstr ""
@@ -2898,7 +2898,7 @@ msgstr ""
msgid "failed to resize tty, using default size"
msgstr ""
#: ./cli/app/new.go:138
#: ./cli/app/new.go:134
#, c-format
msgid "failed to retrieve latest commit for %s: %s"
msgstr ""
@@ -2988,7 +2988,7 @@ msgstr ""
msgid "final merged env values for %s are: %s"
msgstr ""
#: ./cli/app/deploy.go:422 ./cli/app/env.go:324 ./cli/app/remove.go:162 ./cli/app/rollback.go:353 ./cli/app/upgrade.go:462 ./cli/app/volume.go:216 ./cli/recipe/fetch.go:137
#: ./cli/app/deploy.go:421 ./cli/app/env.go:324 ./cli/app/remove.go:162 ./cli/app/rollback.go:352 ./cli/app/upgrade.go:461 ./cli/app/volume.go:216 ./cli/recipe/fetch.go:137
msgid "force"
msgstr ""
@@ -3202,7 +3202,7 @@ msgstr ""
msgid "id: %s, "
msgstr ""
#: ./cli/app/backup.go:321 ./cli/app/backup.go:337 ./cli/app/check.go:97 ./cli/app/cmd.go:287 ./cli/app/cp.go:387 ./cli/app/deploy.go:417 ./cli/app/labels.go:145 ./cli/app/list.go:337 ./cli/app/new.go:413 ./cli/app/ps.go:215 ./cli/app/restart.go:165 ./cli/app/restore.go:140 ./cli/app/secret.go:571 ./cli/app/secret.go:611 ./cli/app/secret.go:635 ./cli/app/secret.go:643 ./cli/catalogue/catalogue.go:320 ./cli/recipe/lint.go:139
#: ./cli/app/backup.go:321 ./cli/app/backup.go:337 ./cli/app/check.go:97 ./cli/app/cmd.go:287 ./cli/app/cp.go:387 ./cli/app/deploy.go:416 ./cli/app/labels.go:145 ./cli/app/list.go:337 ./cli/app/new.go:409 ./cli/app/ps.go:215 ./cli/app/restart.go:165 ./cli/app/restore.go:140 ./cli/app/secret.go:571 ./cli/app/secret.go:611 ./cli/app/secret.go:635 ./cli/app/secret.go:643 ./cli/catalogue/catalogue.go:320 ./cli/recipe/lint.go:139
msgid "ignore uncommitted recipes changes"
msgstr ""
@@ -3400,7 +3400,7 @@ msgstr ""
#. no spaces in between
#. translators: `abra recipe lint` aliases. use a comma separated list of
#. aliases with no spaces in between
#: ./cli/app/cmd.go:261 ./cli/app/deploy.go:447 ./cli/app/logs.go:20 ./cli/recipe/lint.go:17 ./cli/server/add.go:207
#: ./cli/app/cmd.go:261 ./cli/app/deploy.go:446 ./cli/app/logs.go:20 ./cli/recipe/lint.go:17 ./cli/server/add.go:207
msgid "l"
msgstr ""
@@ -3415,7 +3415,7 @@ msgstr ""
msgid "labels <domain> [flags]"
msgstr ""
#: ./cli/app/deploy.go:446 ./cli/app/list.go:186
#: ./cli/app/deploy.go:445 ./cli/app/list.go:186
msgid "latest"
msgstr ""
@@ -3752,7 +3752,7 @@ msgstr ""
msgid "no containers matching the %v filter found?"
msgstr ""
#: ./cli/app/new.go:306 ./cli/internal/validate.go:129
#: ./cli/app/new.go:302 ./cli/internal/validate.go:129
msgid "no domain provided"
msgstr ""
@@ -3779,7 +3779,7 @@ msgstr ""
msgid "no recipe name provided"
msgstr ""
#: ./cli/app/upgrade.go:242
#: ./cli/app/upgrade.go:241
#, c-format
msgid "no release notes for upgrading from %s to %s"
msgstr ""
@@ -3810,7 +3810,7 @@ msgstr ""
msgid "no secrets to remove?"
msgstr ""
#: ./cli/app/new.go:355 ./cli/internal/validate.go:167
#: ./cli/app/new.go:351 ./cli/internal/validate.go:167
msgid "no server provided"
msgstr ""
@@ -3868,11 +3868,11 @@ msgstr ""
msgid "no volumes to remove"
msgstr ""
#: ./cli/app/deploy.go:438 ./cli/app/rollback.go:369 ./cli/app/upgrade.go:478
#: ./cli/app/deploy.go:437 ./cli/app/rollback.go:368 ./cli/app/upgrade.go:477
msgid "no-converge-checks"
msgstr ""
#: ./cli/app/deploy.go:430 ./cli/app/rollback.go:361 ./cli/app/upgrade.go:470
#: ./cli/app/deploy.go:429 ./cli/app/rollback.go:360 ./cli/app/upgrade.go:469
msgid "no-domain-checks"
msgstr ""
@@ -3940,7 +3940,7 @@ msgstr ""
msgid "only show errors"
msgstr ""
#: ./cli/app/upgrade.go:489
#: ./cli/app/upgrade.go:488
msgid "only show release notes"
msgstr ""
@@ -3952,7 +3952,7 @@ msgstr ""
#. with no spaces in between
#. translators: `abra server prune` aliases. use a comma separated list of
#. aliases with no spaces in between
#: ./cli/app/backup.go:295 ./cli/app/new.go:395 ./cli/app/ps.go:29 ./cli/app/secret.go:561 ./cli/app/secret.go:585 ./cli/app/secret.go:625 ./cli/app/undeploy.go:169 ./cli/catalogue/catalogue.go:294 ./cli/recipe/list.go:112 ./cli/server/prune.go:18
#: ./cli/app/backup.go:295 ./cli/app/new.go:391 ./cli/app/ps.go:29 ./cli/app/secret.go:561 ./cli/app/secret.go:585 ./cli/app/secret.go:625 ./cli/app/undeploy.go:169 ./cli/catalogue/catalogue.go:294 ./cli/recipe/list.go:112 ./cli/server/prune.go:18
msgid "p"
msgstr ""
@@ -3971,27 +3971,27 @@ msgstr ""
msgid "parsed following command arguments: %s"
msgstr ""
#: ./cli/app/upgrade.go:346
#: ./cli/app/upgrade.go:345
#, c-format
msgid "parsing chosen upgrade version failed: %s"
msgstr ""
#: ./cli/app/upgrade.go:390
#: ./cli/app/upgrade.go:389
#, c-format
msgid "parsing deployed version failed: %s"
msgstr ""
#: ./cli/app/upgrade.go:351
#: ./cli/app/upgrade.go:350
#, c-format
msgid "parsing deployment version failed: %s"
msgstr ""
#: ./cli/app/upgrade.go:357 ./cli/app/upgrade.go:396
#: ./cli/app/upgrade.go:356 ./cli/app/upgrade.go:395
#, c-format
msgid "parsing recipe version failed: %s"
msgstr ""
#: ./cli/app/new.go:394 ./cli/app/secret.go:560 ./cli/app/secret.go:584 ./cli/app/secret.go:624
#: ./cli/app/new.go:390 ./cli/app/secret.go:560 ./cli/app/secret.go:584 ./cli/app/secret.go:624
msgid "pass"
msgstr ""
@@ -4011,7 +4011,7 @@ msgstr ""
msgid "pattern"
msgstr ""
#: ./cli/app/deploy.go:425 ./cli/app/env.go:327 ./cli/app/remove.go:165 ./cli/app/rollback.go:356 ./cli/app/upgrade.go:465 ./cli/app/volume.go:219
#: ./cli/app/deploy.go:424 ./cli/app/env.go:327 ./cli/app/remove.go:165 ./cli/app/rollback.go:355 ./cli/app/upgrade.go:464 ./cli/app/volume.go:219
msgid "perform action without further prompt"
msgstr ""
@@ -4021,22 +4021,22 @@ msgstr ""
msgid "pl,p"
msgstr ""
#: ./cli/app/rollback.go:268
#: ./cli/app/rollback.go:267
#, c-format
msgid "please select a downgrade (version: %s):"
msgstr ""
#: ./cli/app/rollback.go:273
#: ./cli/app/rollback.go:272
#, c-format
msgid "please select a downgrade (version: %s, chaos: %s):"
msgstr ""
#: ./cli/app/upgrade.go:313
#: ./cli/app/upgrade.go:312
#, c-format
msgid "please select an upgrade (version: %s):"
msgstr ""
#: ./cli/app/upgrade.go:318
#: ./cli/app/upgrade.go:317
#, c-format
msgid "please select an upgrade (version: %s, chaos: %s):"
msgstr ""
@@ -4119,7 +4119,7 @@ msgstr ""
#. with no spaces in between
#. translators: `abra recipe` aliases. use a comma separated list of aliases
#. with no spaces in between
#: ./cli/app/backup.go:327 ./cli/app/list.go:304 ./cli/app/move.go:350 ./cli/app/run.go:23 ./cli/app/upgrade.go:487 ./cli/catalogue/catalogue.go:302 ./cli/recipe/recipe.go:12 ./cli/recipe/release.go:624
#: ./cli/app/backup.go:327 ./cli/app/list.go:304 ./cli/app/move.go:350 ./cli/app/run.go:23 ./cli/app/upgrade.go:486 ./cli/catalogue/catalogue.go:302 ./cli/recipe/recipe.go:12 ./cli/recipe/release.go:624
msgid "r"
msgstr ""
@@ -4239,7 +4239,7 @@ msgstr ""
msgid "release failed. any changes made have been reverted"
msgstr ""
#: ./cli/app/upgrade.go:486
#: ./cli/app/upgrade.go:485
msgid "releasenotes"
msgstr ""
@@ -4543,7 +4543,7 @@ msgstr ""
msgid "run command locally"
msgstr ""
#: ./cli/app/deploy.go:282 ./cli/app/upgrade.go:294
#: ./cli/app/deploy.go:281 ./cli/app/upgrade.go:293
#, c-format
msgid "run the following post-deploy commands: %s"
msgstr ""
@@ -4584,7 +4584,7 @@ msgstr ""
#. no spaces in between
#. translators: `abra server` aliases. use a comma separated list of aliases
#. with no spaces in between
#: ./cli/app/backup.go:198 ./cli/app/backup.go:263 ./cli/app/backup.go:287 ./cli/app/env.go:333 ./cli/app/list.go:327 ./cli/app/logs.go:101 ./cli/app/new.go:372 ./cli/app/restore.go:114 ./cli/app/secret.go:535 ./cli/catalogue/catalogue.go:27 ./cli/catalogue/catalogue.go:310 ./cli/recipe/fetch.go:130 ./cli/server/server.go:12
#: ./cli/app/backup.go:198 ./cli/app/backup.go:263 ./cli/app/backup.go:287 ./cli/app/env.go:333 ./cli/app/list.go:327 ./cli/app/logs.go:101 ./cli/app/new.go:368 ./cli/app/restore.go:114 ./cli/app/secret.go:535 ./cli/catalogue/catalogue.go:27 ./cli/catalogue/catalogue.go:310 ./cli/recipe/fetch.go:130 ./cli/server/server.go:12
msgid "s"
msgstr ""
@@ -4626,12 +4626,12 @@ msgstr ""
msgid "secret not found: %s"
msgstr ""
#: ./cli/app/deploy.go:359
#: ./cli/app/deploy.go:358
#, c-format
msgid "secret not generated: %s"
msgstr ""
#: ./cli/app/deploy.go:357
#: ./cli/app/deploy.go:356
#, c-format
msgid "secret not inserted (#generate=false): %s"
msgstr ""
@@ -4641,27 +4641,27 @@ msgstr ""
msgid "secret: %s removed"
msgstr ""
#: ./cli/app/backup.go:302 ./cli/app/new.go:402
#: ./cli/app/backup.go:302 ./cli/app/new.go:398
msgid "secrets"
msgstr ""
#: ./cli/app/new.go:242
#: ./cli/app/new.go:238
#, c-format
msgid "secrets are %s shown again, please save them %s"
msgstr ""
#: ./cli/app/deploy.go:307
#: ./cli/app/deploy.go:306
#, c-format
msgid "selected latest recipe version: %s (from %d available versions)"
msgstr ""
#: ./cli/app/new.go:125
#: ./cli/app/new.go:121
#, c-format
msgid "selected recipe version: %s (from %d available versions)"
msgstr ""
#. translators: `abra server` command for autocompletion
#: ./cli/app/env.go:332 ./cli/app/env.go:339 ./cli/app/list.go:326 ./cli/app/list.go:341 ./cli/app/new.go:371 ./cli/app/new.go:378 ./cli/run.go:101
#: ./cli/app/env.go:332 ./cli/app/env.go:339 ./cli/app/list.go:326 ./cli/app/list.go:341 ./cli/app/new.go:367 ./cli/app/new.go:374 ./cli/run.go:101
msgid "server"
msgstr ""
@@ -4775,7 +4775,7 @@ msgstr ""
msgid "severity"
msgstr ""
#: ./cli/app/deploy.go:457 ./cli/app/rollback.go:380 ./cli/app/upgrade.go:497
#: ./cli/app/deploy.go:456 ./cli/app/rollback.go:379 ./cli/app/upgrade.go:496
msgid "show all configs & images, including unchanged ones"
msgstr ""
@@ -4799,7 +4799,7 @@ msgstr ""
msgid "show debug messages"
msgstr ""
#: ./cli/app/deploy.go:454 ./cli/app/rollback.go:377 ./cli/app/upgrade.go:494
#: ./cli/app/deploy.go:453 ./cli/app/rollback.go:376 ./cli/app/upgrade.go:493
msgid "show-unchanged"
msgstr ""
@@ -4807,7 +4807,7 @@ msgstr ""
msgid "since"
msgstr ""
#: ./cli/app/new.go:340
#: ./cli/app/new.go:336
#, c-format
msgid "single server detected, choosing %s automatically"
msgstr ""
@@ -4847,11 +4847,11 @@ msgstr ""
msgid "skipping converge logic checks"
msgstr ""
#: ./cli/app/deploy.go:209
#: ./cli/app/deploy.go:208
msgid "skipping domain checks"
msgstr ""
#: ./cli/app/deploy.go:206
#: ./cli/app/deploy.go:205
msgid "skipping domain checks, no DOMAIN=... configured"
msgstr ""
@@ -4907,7 +4907,7 @@ msgstr ""
msgid "specify secret value"
msgstr ""
#: ./cli/app/new.go:374
#: ./cli/app/new.go:370
msgid "specify server for new app"
msgstr ""
@@ -4965,7 +4965,7 @@ msgstr ""
msgid "store generated secrets in a local pass store"
msgstr ""
#: ./cli/app/new.go:397
#: ./cli/app/new.go:393
msgid "store secrets in a local pass store"
msgstr ""
@@ -5115,7 +5115,7 @@ msgstr ""
msgid "trim input"
msgstr ""
#: ./cli/app/new.go:267 ./pkg/app/app.go:141
#: ./cli/app/new.go:263 ./pkg/app/app.go:141
#, c-format
msgid "trimming %s to %s to avoid runtime limits"
msgstr ""
@@ -5653,27 +5653,27 @@ msgstr ""
msgid "version wiped from %s.env"
msgstr ""
#: ./cli/app/deploy.go:373
#: ./cli/app/deploy.go:372
#, c-format
msgid "version: taking chaos version: %s"
msgstr ""
#: ./cli/app/deploy.go:399
#: ./cli/app/deploy.go:398
#, c-format
msgid "version: taking deployed version: %s"
msgstr ""
#: ./cli/app/deploy.go:404
#: ./cli/app/deploy.go:403
#, c-format
msgid "version: taking new recipe version: %s"
msgstr ""
#: ./cli/app/deploy.go:393
#: ./cli/app/deploy.go:392
#, c-format
msgid "version: taking version from .env file: %s"
msgstr ""
#: ./cli/app/deploy.go:379
#: ./cli/app/deploy.go:378
#, c-format
msgid "version: taking version from cli arg: %s"
msgstr ""
@@ -5801,7 +5801,7 @@ msgstr ""
msgid "writer: %v, "
msgstr ""
#: ./cli/app/deploy.go:289 ./cli/app/new.go:255 ./cli/app/rollback.go:257 ./cli/app/undeploy.go:120 ./cli/app/upgrade.go:302
#: ./cli/app/deploy.go:288 ./cli/app/new.go:251 ./cli/app/rollback.go:256 ./cli/app/undeploy.go:120 ./cli/app/upgrade.go:301
#, c-format
msgid "writing recipe version failed: %s"
msgstr ""
+92 -92
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-06-14 17:56+0200\n"
"POT-Creation-Date: 2026-04-11 11:34+0200\n"
"PO-Revision-Date: 2026-02-28 13:52+0000\n"
"Last-Translator: chasqui <chasqui@cryptolab.net>\n"
"Language-Team: Spanish <https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/>\n"
@@ -318,7 +318,7 @@ msgstr "%s ya existe"
msgid "%s already exists?"
msgstr "%s ¿ya existe?"
#: cli/app/new.go:206
#: cli/app/new.go:202
#, c-format
msgid "%s created (version: %s)"
msgstr "%s creado (versión: %s)"
@@ -403,7 +403,7 @@ msgstr "¿%s no tiene una aplicación principal?"
msgid "%s has no published versions?"
msgstr "¿%s no tiene versiones publicadas?"
#: cli/app/new.go:315
#: cli/app/new.go:311
#, c-format
msgid "%s has no secrets to generate, skipping..."
msgstr "%s no tiene secretos para generar, omitiendo..."
@@ -443,19 +443,19 @@ msgstr "¿%s no tiene un archivo compose.yml o compose.*.yml?"
msgid "%s is missing the TYPE env var?"
msgstr "¿A %s le falta la variable de entorno TYPE?"
#: cli/app/rollback.go:310 cli/app/rollback.go:314
#: cli/app/rollback.go:309 cli/app/rollback.go:313
#, c-format
msgid "%s is not a downgrade for %s?"
msgstr "¿%s no es un downgrade para %s?"
#: cli/app/upgrade.go:430 cli/app/upgrade.go:434
#: cli/app/upgrade.go:429 cli/app/upgrade.go:433
#, c-format
msgid "%s is not an upgrade for %s?"
msgstr "¿%s no es una actualización para %s?"
#: cli/app/env.go:146 cli/app/logs.go:65 cli/app/ps.go:62
#: cli/app/restart.go:100 cli/app/services.go:55 cli/app/undeploy.go:66
#: cli/app/upgrade.go:451
#: cli/app/upgrade.go:450
#, c-format
msgid "%s is not deployed?"
msgstr "¿%s no está desplegado?"
@@ -475,7 +475,7 @@ msgstr "%s todavía está desplegado. Ejecuta \"abra aplicacion plegar %s\""
msgid "%s missing context, run \"abra server add %s\"?"
msgstr "falta contexto %s, ¿ejecutar \"abra servidor agregar %s\"?"
#: cli/app/deploy.go:195 cli/app/upgrade.go:211
#: cli/app/deploy.go:194 cli/app/upgrade.go:210
#, c-format
msgid "%s missing from %s.env"
msgstr "falta %s de %s.env"
@@ -510,17 +510,17 @@ msgstr "notas de la versión %s:"
msgid "%s removed from pass store"
msgstr "%s eliminado del almacén de contraseñas"
#: cli/app/new.go:224
#: cli/app/new.go:220
#, c-format
msgid "%s requires secret generation before deploy, run \"abra app secret generate %s --all\""
msgstr "%s requiere generación de secretos antes del despliegue, ejecuta \"abra aplicacion secreto generar %s --all\""
#: cli/app/new.go:228
#: cli/app/new.go:224
#, c-format
msgid "%s requires secret insertion before deploy (#generate=false)"
msgstr "%s requiere inserción de secretos antes del despliegue (#generate=false)"
#: cli/app/new.go:155
#: cli/app/new.go:151
#, c-format
msgid "%s sanitised as %s for new app"
msgstr "%s sanitisado como %s para nueva aplicación"
@@ -655,12 +655,12 @@ msgstr "%s: no se puede resolver la dirección IPv4: %s"
msgid "%s: waiting %d seconds before next retry"
msgstr "%s: esperando %d segundos antes del siguiente reintento"
#: cli/app/upgrade.go:425
#: cli/app/upgrade.go:424
#, c-format
msgid "'%s' is not a known version"
msgstr "'%s' no es una versión conocida"
#: cli/app/rollback.go:305 cli/app/upgrade.go:420
#: cli/app/rollback.go:304 cli/app/upgrade.go:419
#, c-format
msgid "'%s' is not a known version for %s"
msgstr "'%s' no es una versión conocida para %s"
@@ -759,8 +759,8 @@ msgid "Both local recipe and live deployment labels are shown."
msgstr "Se muestran tanto la receta local como las etiquetas del despliegue en vivo."
#: cli/app/backup.go:319 cli/app/backup.go:335 cli/app/check.go:95
#: cli/app/cmd.go:285 cli/app/cp.go:385 cli/app/deploy.go:415
#: cli/app/labels.go:143 cli/app/list.go:335 cli/app/new.go:411
#: cli/app/cmd.go:285 cli/app/cp.go:385 cli/app/deploy.go:414
#: cli/app/labels.go:143 cli/app/list.go:335 cli/app/new.go:407
#: cli/app/ps.go:213 cli/app/restart.go:163 cli/app/restore.go:138
#: cli/app/secret.go:569 cli/app/secret.go:609 cli/app/secret.go:633
#: cli/app/secret.go:641 cli/catalogue/catalogue.go:318 cli/recipe/lint.go:137
@@ -959,8 +959,8 @@ msgstr ""
"en un almacén pass (ver passwordstore.org). El comando pass debe estar \n"
"disponible en tu $PATH."
#: cli/app/deploy.go:431 cli/app/new.go:387 cli/app/rollback.go:362
#: cli/app/upgrade.go:471
#: cli/app/deploy.go:430 cli/app/new.go:383 cli/app/rollback.go:361
#: cli/app/upgrade.go:470
msgid "D"
msgstr ""
@@ -1112,7 +1112,7 @@ msgstr ""
"\n"
"Usa la opción \"--estado/-S\" para consultar en todos los servidores sobre el estado de despliegue."
#: cli/app/new.go:321
#: cli/app/new.go:317
msgid "Generate app secrets?"
msgstr "¿Generar secretos de la aplicación?"
@@ -1579,7 +1579,7 @@ msgid "Run app commands"
msgstr "Ejecutar 💻 comando dentro una 🚀aplicación"
#: cli/app/backup.go:303 cli/app/list.go:296 cli/app/logs.go:109
#: cli/app/new.go:403
#: cli/app/new.go:399
msgid "S"
msgstr ""
@@ -1587,7 +1587,7 @@ msgstr ""
msgid "SECRETS"
msgstr "🥷 SECRETOS"
#: cli/app/new.go:238
#: cli/app/new.go:234
msgid "SECRETS OVERVIEW"
msgstr "RESUMEN DE LOS 🥷 SECRETOS"
@@ -1621,7 +1621,7 @@ msgstr ""
msgid "STATUS"
msgstr "🩻 ESTADO"
#: cli/app/new.go:346
#: cli/app/new.go:342
msgid "Select app server:"
msgstr "Seleccionar servidor para la aplicación:"
@@ -1659,7 +1659,7 @@ msgstr "Especifica un 🌐 nombre de dominio (DNS)"
msgid "Specify a server name"
msgstr "Especifica un nombre de servidor"
#: cli/app/new.go:297
#: cli/app/new.go:293
msgid "Specify app domain"
msgstr "Especifica un 🌐 nombre de dominio (DNS) para la aplicación"
@@ -1881,7 +1881,7 @@ msgstr ""
" PS> abra autocomplete powershell > abra.ps1\n"
" # y fuente (source) ese archivo desde tu perfil de PowerShell."
#: cli/app/deploy.go:455 cli/app/rollback.go:378 cli/app/upgrade.go:495
#: cli/app/deploy.go:454 cli/app/rollback.go:377 cli/app/upgrade.go:494
msgid "U"
msgstr ""
@@ -2368,7 +2368,7 @@ msgstr "Intentando generar y almacenar %s en %s"
msgid "attempting to run %s"
msgstr "Intentando ejecutar %s"
#: cli/app/deploy.go:284 cli/app/upgrade.go:297
#: cli/app/deploy.go:283 cli/app/upgrade.go:296
#, c-format
msgid "attempting to run post deploy commands, saw: %s"
msgstr "Intentando ejecutar los comandos posteriores al despliegue: %s"
@@ -2404,7 +2404,7 @@ msgstr "autocompletar [bash|zsh|fish|powershell]"
msgid "autocomplete failed: %s"
msgstr "autocompletar falló: %s"
#: cli/app/new.go:405
#: cli/app/new.go:401
msgid "automatically generate secrets"
msgstr "generar secretos automáticamente"
@@ -2454,8 +2454,8 @@ msgstr "enlace symlink roto en tus carpetas de configuración de abra: %s"
#. no spaces in between
#. translators: `abra app cp` aliases. use a comma separated list of aliases with
#. no spaces in between
#: cli/app/backup.go:148 cli/app/cp.go:30 cli/app/deploy.go:439
#: cli/app/rollback.go:370 cli/app/upgrade.go:479
#: cli/app/backup.go:148 cli/app/cp.go:30 cli/app/deploy.go:438
#: cli/app/rollback.go:369 cli/app/upgrade.go:478
msgid "c"
msgstr ""
@@ -2511,7 +2511,7 @@ msgstr "no se puede obtener la etiqueta %s para %s"
msgid "cannot redeploy previous chaos version (%s), did you mean to use \"--chaos\"?"
msgstr "no se puede redeplegar la versión anterior de caos (%s), ¿Era tu intención usar \"--caos\"?"
#: cli/app/deploy.go:389
#: cli/app/deploy.go:388
#, c-format
msgid ""
"cannot redeploy previous chaos version (%s), did you mean to use \"--chaos\"?\n"
@@ -2533,7 +2533,7 @@ msgstr "no se puede especificar una etiqueta y un tipo de incremento al mismo ti
msgid "cannot use '[secret] [version]' and '--all' together"
msgstr "no se puede usar '[secreto] [versión]' y '--todos' juntos"
#: cli/app/deploy.go:331
#: cli/app/deploy.go:330
msgid "cannot use --chaos and --latest together"
msgstr "no se puede usar --caos y --latest juntos"
@@ -2557,11 +2557,11 @@ msgstr "no se puede usar [server] y --local al mismo tiempo"
msgid "cannot use [service] and --all-services/-a together"
msgstr "no se puede usar [servicio] y --todos-los-servicios/-a juntos"
#: cli/app/deploy.go:323 cli/app/new.go:80
#: cli/app/deploy.go:322 cli/app/new.go:80
msgid "cannot use [version] and --chaos together"
msgstr "no se puede usar [versión] y --caos juntos"
#: cli/app/deploy.go:327
#: cli/app/deploy.go:326
msgid "cannot use [version] and --latest together"
msgstr "no se puede usar [versión] y --latest juntos"
@@ -2598,8 +2598,8 @@ msgid "cfg"
msgstr ""
#: cli/app/backup.go:318 cli/app/backup.go:334 cli/app/check.go:94
#: cli/app/cmd.go:284 cli/app/cp.go:384 cli/app/deploy.go:414
#: cli/app/labels.go:142 cli/app/list.go:334 cli/app/new.go:410
#: cli/app/cmd.go:284 cli/app/cp.go:384 cli/app/deploy.go:413
#: cli/app/labels.go:142 cli/app/list.go:334 cli/app/new.go:406
#: cli/app/ps.go:212 cli/app/restart.go:162 cli/app/restore.go:137
#: cli/app/secret.go:568 cli/app/secret.go:608 cli/app/secret.go:632
#: cli/app/secret.go:640 cli/catalogue/catalogue.go:317 cli/recipe/lint.go:136
@@ -2616,7 +2616,7 @@ msgstr ""
msgid "check <domain> [flags]"
msgstr "verificar <aplicacion> [opciones]"
#: cli/app/deploy.go:95 cli/app/undeploy.go:58 cli/app/upgrade.go:443
#: cli/app/deploy.go:95 cli/app/undeploy.go:58 cli/app/upgrade.go:442
#, c-format
msgid "checking whether %s is already deployed"
msgstr "verificando si %s ya está desplegado"
@@ -2919,7 +2919,7 @@ msgstr "crítico"
msgid "critical errors present in %s config"
msgstr "errores críticos presentes en la configuración de %s"
#: cli/app/rollback.go:300
#: cli/app/rollback.go:299
#, c-format
msgid "current deployment '%s' is not a known version for %s"
msgstr "el despliegue actual '%s' no es una versión conocida para %s"
@@ -2971,7 +2971,7 @@ msgstr "despliegue en proceso 🟠"
msgid "deploy labels stanza present"
msgstr "stanza de etiquetas de despliegue presente"
#: cli/app/deploy.go:449
#: cli/app/deploy.go:448
msgid "deploy latest recipe version"
msgstr "desplegar la última versión de la receta"
@@ -3073,11 +3073,11 @@ msgstr "el directorio está vacío: %s"
msgid "dirty: %v, "
msgstr ""
#: cli/app/deploy.go:441 cli/app/rollback.go:372 cli/app/upgrade.go:481
#: cli/app/deploy.go:440 cli/app/rollback.go:371 cli/app/upgrade.go:480
msgid "disable converge logic checks"
msgstr "desactivar comprobaciones de lógica de convergencia"
#: cli/app/deploy.go:433 cli/app/rollback.go:364 cli/app/upgrade.go:473
#: cli/app/deploy.go:432 cli/app/rollback.go:363 cli/app/upgrade.go:472
msgid "disable public DNS checks"
msgstr "desactivar comprobaciones de DNS público"
@@ -3093,11 +3093,11 @@ msgstr "no solicitar un TTY"
msgid "docker: is the daemon running / your user has docker permissions?"
msgstr "docker: ¿está corriendo el daemon / tu usuario tiene permisos de Docker?"
#: cli/app/new.go:386
#: cli/app/new.go:382
msgid "domain"
msgstr "dominio"
#: cli/app/new.go:389
#: cli/app/new.go:385
msgid "domain name for app"
msgstr "nombre de dominio para la aplicación"
@@ -3288,8 +3288,8 @@ msgstr "extrayendo secreto %s en %s"
#. translators: `abra recipe fetch` aliases. use a comma separated list of aliases
#. with no spaces in between
#: cli/app/deploy.go:423 cli/app/env.go:325 cli/app/remove.go:163
#: cli/app/rollback.go:354 cli/app/secret.go:593 cli/app/upgrade.go:463
#: cli/app/deploy.go:422 cli/app/env.go:325 cli/app/remove.go:163
#: cli/app/rollback.go:353 cli/app/secret.go:593 cli/app/upgrade.go:462
#: cli/app/volume.go:217 cli/recipe/fetch.go:20 cli/recipe/fetch.go:138
msgid "f"
msgstr ""
@@ -3451,7 +3451,7 @@ msgstr "🛑 No se pudo eliminar algunos recursos del stock: %s"
msgid "failed to resize tty, using default size"
msgstr "🛑 No se pudo redimensionar el TTY; se usará el tamaño predeterminado"
#: cli/app/new.go:138
#: cli/app/new.go:134
#, c-format
msgid "failed to retrieve latest commit for %s: %s"
msgstr "🛑 No se pudo obtener el último commit de %s: %s"
@@ -3541,8 +3541,8 @@ msgstr "Filtrar por receta"
msgid "final merged env values for %s are: %s"
msgstr "Los valores finales combinados de entorno para %s son: %s"
#: cli/app/deploy.go:422 cli/app/env.go:324 cli/app/remove.go:162
#: cli/app/rollback.go:353 cli/app/upgrade.go:462 cli/app/volume.go:216
#: cli/app/deploy.go:421 cli/app/env.go:324 cli/app/remove.go:162
#: cli/app/rollback.go:352 cli/app/upgrade.go:461 cli/app/volume.go:216
#: cli/recipe/fetch.go:137
msgid "force"
msgstr "forzar"
@@ -3758,8 +3758,8 @@ msgid "id: %s, "
msgstr ""
#: cli/app/backup.go:321 cli/app/backup.go:337 cli/app/check.go:97
#: cli/app/cmd.go:287 cli/app/cp.go:387 cli/app/deploy.go:417
#: cli/app/labels.go:145 cli/app/list.go:337 cli/app/new.go:413
#: cli/app/cmd.go:287 cli/app/cp.go:387 cli/app/deploy.go:416
#: cli/app/labels.go:145 cli/app/list.go:337 cli/app/new.go:409
#: cli/app/ps.go:215 cli/app/restart.go:165 cli/app/restore.go:140
#: cli/app/secret.go:571 cli/app/secret.go:611 cli/app/secret.go:635
#: cli/app/secret.go:643 cli/catalogue/catalogue.go:320 cli/recipe/lint.go:139
@@ -3960,7 +3960,7 @@ msgstr "versión %s especificada inválida"
#. no spaces in between
#. translators: `abra recipe lint` aliases. use a comma separated list of
#. aliases with no spaces in between
#: cli/app/cmd.go:261 cli/app/deploy.go:447 cli/app/logs.go:20
#: cli/app/cmd.go:261 cli/app/deploy.go:446 cli/app/logs.go:20
#: cli/recipe/lint.go:17 cli/server/add.go:207
msgid "l"
msgstr ""
@@ -3976,7 +3976,7 @@ msgstr ""
msgid "labels <domain> [flags]"
msgstr "etiquetas <aplicacion> [opciones]"
#: cli/app/deploy.go:446 cli/app/list.go:186
#: cli/app/deploy.go:445 cli/app/list.go:186
msgid "latest"
msgstr "ultima"
@@ -4325,7 +4325,7 @@ msgstr "no hay configuraciones para eliminar"
msgid "no containers matching the %v filter found?"
msgstr "no se encontraron contenedores que coincidan con el filtro %v?"
#: cli/app/new.go:306 cli/internal/validate.go:129
#: cli/app/new.go:302 cli/internal/validate.go:129
msgid "no domain provided"
msgstr "no se proporcionó dominio"
@@ -4352,7 +4352,7 @@ msgstr "¿no existe la receta '%s'?"
msgid "no recipe name provided"
msgstr "no se proporcionó el nombre de la receta"
#: cli/app/upgrade.go:242
#: cli/app/upgrade.go:241
#, fuzzy, c-format
msgid "no release notes for upgrading from %s to %s"
msgstr "ejecución de prueba: mover la nota de la versión de 'next' a %s"
@@ -4383,7 +4383,7 @@ msgstr "no hay secretos para eliminar"
msgid "no secrets to remove?"
msgstr "¿No hay secretos para eliminar?"
#: cli/app/new.go:355 cli/internal/validate.go:167
#: cli/app/new.go:351 cli/internal/validate.go:167
msgid "no server provided"
msgstr "no se proporcionó servidor"
@@ -4441,11 +4441,11 @@ msgstr "no se eliminaron volúmenes"
msgid "no volumes to remove"
msgstr "no hay volúmenes para eliminar"
#: cli/app/deploy.go:438 cli/app/rollback.go:369 cli/app/upgrade.go:478
#: cli/app/deploy.go:437 cli/app/rollback.go:368 cli/app/upgrade.go:477
msgid "no-converge-checks"
msgstr "sin-verificaciones-de-convergencia"
#: cli/app/deploy.go:430 cli/app/rollback.go:361 cli/app/upgrade.go:470
#: cli/app/deploy.go:429 cli/app/rollback.go:360 cli/app/upgrade.go:469
msgid "no-domain-checks"
msgstr "sin-verificaciones-de-dominio"
@@ -4513,7 +4513,7 @@ msgstr "solo se usaron etiquetas anotadas para la versión de la receta"
msgid "only show errors"
msgstr "mostrar solo errores"
#: cli/app/upgrade.go:489
#: cli/app/upgrade.go:488
msgid "only show release notes"
msgstr "solo mostrar notas de la versión"
@@ -4525,7 +4525,7 @@ msgstr "solo cola de stderr"
#. with no spaces in between
#. translators: `abra server prune` aliases. use a comma separated list of
#. aliases with no spaces in between
#: cli/app/backup.go:295 cli/app/new.go:395 cli/app/ps.go:29
#: cli/app/backup.go:295 cli/app/new.go:391 cli/app/ps.go:29
#: cli/app/secret.go:561 cli/app/secret.go:585 cli/app/secret.go:625
#: cli/app/undeploy.go:169 cli/catalogue/catalogue.go:294
#: cli/recipe/list.go:112 cli/server/prune.go:18
@@ -4547,27 +4547,27 @@ msgstr "parseado %s de %s"
msgid "parsed following command arguments: %s"
msgstr "parseados los siguientes argumentos del comando: %s"
#: cli/app/upgrade.go:346
#: cli/app/upgrade.go:345
#, c-format
msgid "parsing chosen upgrade version failed: %s"
msgstr "falló la actualización de versión elegida para el parsing: %s"
#: cli/app/upgrade.go:390
#: cli/app/upgrade.go:389
#, c-format
msgid "parsing deployed version failed: %s"
msgstr "parsing fallido de la versión desplegada: %s"
#: cli/app/upgrade.go:351
#: cli/app/upgrade.go:350
#, c-format
msgid "parsing deployment version failed: %s"
msgstr "parsing fallido de la versión de despliegue: %s"
#: cli/app/upgrade.go:357 cli/app/upgrade.go:396
#: cli/app/upgrade.go:356 cli/app/upgrade.go:395
#, c-format
msgid "parsing recipe version failed: %s"
msgstr "parsing fallido de la versión de la receta: %s"
#: cli/app/new.go:394 cli/app/secret.go:560 cli/app/secret.go:584
#: cli/app/new.go:390 cli/app/secret.go:560 cli/app/secret.go:584
#: cli/app/secret.go:624
msgid "pass"
msgstr ""
@@ -4590,8 +4590,8 @@ msgstr "ruta"
msgid "pattern"
msgstr "patrón"
#: cli/app/deploy.go:425 cli/app/env.go:327 cli/app/remove.go:165
#: cli/app/rollback.go:356 cli/app/upgrade.go:465 cli/app/volume.go:219
#: cli/app/deploy.go:424 cli/app/env.go:327 cli/app/remove.go:165
#: cli/app/rollback.go:355 cli/app/upgrade.go:464 cli/app/volume.go:219
msgid "perform action without further prompt"
msgstr "realizar acción sin más avisos"
@@ -4601,22 +4601,22 @@ msgstr "realizar acción sin más avisos"
msgid "pl,p"
msgstr ""
#: cli/app/rollback.go:268
#: cli/app/rollback.go:267
#, c-format
msgid "please select a downgrade (version: %s):"
msgstr "por favor, selecciona una versión anterior (downgrade) (versión: %s):"
#: cli/app/rollback.go:273
#: cli/app/rollback.go:272
#, c-format
msgid "please select a downgrade (version: %s, chaos: %s):"
msgstr "por favor, selecciona una versión anterior (downgrade) (versión: %s, caos: %s):"
#: cli/app/upgrade.go:313
#: cli/app/upgrade.go:312
#, c-format
msgid "please select an upgrade (version: %s):"
msgstr "por favor, selecciona una actualización (versión: %s):"
#: cli/app/upgrade.go:318
#: cli/app/upgrade.go:317
#, c-format
msgid "please select an upgrade (version: %s, chaos: %s):"
msgstr "por favor, selecciona una actualización (versión: %s, caos: %s):"
@@ -4702,7 +4702,7 @@ msgstr "consultando servidores remotos..."
#. translators: `abra recipe` aliases. use a comma separated list of aliases
#. with no spaces in between
#: cli/app/backup.go:327 cli/app/list.go:304 cli/app/move.go:350
#: cli/app/run.go:23 cli/app/upgrade.go:487 cli/catalogue/catalogue.go:302
#: cli/app/run.go:23 cli/app/upgrade.go:486 cli/catalogue/catalogue.go:302
#: cli/recipe/recipe.go:12 cli/recipe/release.go:624
msgid "r"
msgstr ""
@@ -4827,7 +4827,7 @@ msgstr "publicar <receta> [version] [opciones]"
msgid "release failed. any changes made have been reverted"
msgstr ""
#: cli/app/upgrade.go:486
#: cli/app/upgrade.go:485
msgid "releasenotes"
msgstr "notas de la versión"
@@ -5132,7 +5132,7 @@ msgstr "ejecutar comando como usuario"
msgid "run command locally"
msgstr "ejecutar comando localmente"
#: cli/app/deploy.go:282 cli/app/upgrade.go:294
#: cli/app/deploy.go:281 cli/app/upgrade.go:293
#, c-format
msgid "run the following post-deploy commands: %s"
msgstr "Ejecutar los siguientes comandos después del despliegue: %s"
@@ -5175,7 +5175,7 @@ msgstr "ejecutando el comando posterior '%s %s' en el contenedor %s"
#. with no spaces in between
#: cli/app/backup.go:198 cli/app/backup.go:263 cli/app/backup.go:287
#: cli/app/env.go:333 cli/app/list.go:327 cli/app/logs.go:101
#: cli/app/new.go:372 cli/app/restore.go:114 cli/app/secret.go:535
#: cli/app/new.go:368 cli/app/restore.go:114 cli/app/secret.go:535
#: cli/catalogue/catalogue.go:27 cli/catalogue/catalogue.go:310
#: cli/recipe/fetch.go:130 cli/server/server.go:12
msgid "s"
@@ -5219,12 +5219,12 @@ msgstr "datos del secreto no proporcionados en la línea de comandos o stdin, so
msgid "secret not found: %s"
msgstr "secreto no encontrado: %s"
#: cli/app/deploy.go:359
#: cli/app/deploy.go:358
#, c-format
msgid "secret not generated: %s"
msgstr "secreto no generado: %s"
#: cli/app/deploy.go:357
#: cli/app/deploy.go:356
#, c-format
msgid "secret not inserted (#generate=false): %s"
msgstr "secreto no insertado (#generate=false): %s"
@@ -5234,28 +5234,28 @@ msgstr "secreto no insertado (#generate=false): %s"
msgid "secret: %s removed"
msgstr "secreto: %s eliminado"
#: cli/app/backup.go:302 cli/app/new.go:402
#: cli/app/backup.go:302 cli/app/new.go:398
msgid "secrets"
msgstr "secretos"
#: cli/app/new.go:242
#: cli/app/new.go:238
#, c-format
msgid "secrets are %s shown again, please save them %s"
msgstr "los secretos se muestran %s nuevamente, por favor guárdalos %s"
#: cli/app/deploy.go:307
#: cli/app/deploy.go:306
#, c-format
msgid "selected latest recipe version: %s (from %d available versions)"
msgstr "versión de receta más reciente seleccionada: %s (de %d versiones disponibles)"
#: cli/app/new.go:125
#: cli/app/new.go:121
#, c-format
msgid "selected recipe version: %s (from %d available versions)"
msgstr "versión de receta seleccionada: %s (de %d versiones disponibles)"
#. translators: `abra server` command for autocompletion
#: cli/app/env.go:332 cli/app/env.go:339 cli/app/list.go:326
#: cli/app/list.go:341 cli/app/new.go:371 cli/app/new.go:378 cli/run.go:101
#: cli/app/list.go:341 cli/app/new.go:367 cli/app/new.go:374 cli/run.go:101
msgid "server"
msgstr "servidor"
@@ -5369,7 +5369,7 @@ msgstr "establecer referencia: %s"
msgid "severity"
msgstr "gravedad"
#: cli/app/deploy.go:457 cli/app/rollback.go:380 cli/app/upgrade.go:497
#: cli/app/deploy.go:456 cli/app/rollback.go:379 cli/app/upgrade.go:496
msgid "show all configs & images, including unchanged ones"
msgstr "mostrar todas las configuraciones e imágenes, incluidas las no cambiadas"
@@ -5393,7 +5393,7 @@ msgstr "mostrar aplicaciones de un servidor específico"
msgid "show debug messages"
msgstr "mostrar mensajes para el debugeo"
#: cli/app/deploy.go:454 cli/app/rollback.go:377 cli/app/upgrade.go:494
#: cli/app/deploy.go:453 cli/app/rollback.go:376 cli/app/upgrade.go:493
msgid "show-unchanged"
msgstr "mostrar-sin-cambios"
@@ -5401,7 +5401,7 @@ msgstr "mostrar-sin-cambios"
msgid "since"
msgstr "desde"
#: cli/app/new.go:340
#: cli/app/new.go:336
#, c-format
msgid "single server detected, choosing %s automatically"
msgstr "se ha detectado un único servidor, eligiendo %s automáticamente"
@@ -5441,11 +5441,11 @@ msgstr "omitiendo según lo solicitado, plegado aún está en progreso 🟠"
msgid "skipping converge logic checks"
msgstr "omitiendo comprobaciones de lógica de convergencia"
#: cli/app/deploy.go:209
#: cli/app/deploy.go:208
msgid "skipping domain checks"
msgstr "omitiendo comprobaciones de dominio"
#: cli/app/deploy.go:206
#: cli/app/deploy.go:205
msgid "skipping domain checks, no DOMAIN=... configured"
msgstr "omitiendo comprobaciones de dominio, no DOMAIN=... configurado"
@@ -5501,7 +5501,7 @@ msgstr "especificar archivo de secreto"
msgid "specify secret value"
msgstr "especificar valor de secreto"
#: cli/app/new.go:374
#: cli/app/new.go:370
msgid "specify server for new app"
msgstr "especificar servidor para la nueva aplicación"
@@ -5559,7 +5559,7 @@ msgstr ""
msgid "store generated secrets in a local pass store"
msgstr "almacenar secretos generados en un almacén de contraseñas local"
#: cli/app/new.go:397
#: cli/app/new.go:393
msgid "store secrets in a local pass store"
msgstr "almacenar secretos en un almacén de contraseñas local"
@@ -5710,7 +5710,7 @@ msgstr "recortar"
msgid "trim input"
msgstr "recortar entrada"
#: cli/app/new.go:267 pkg/app/app.go:141
#: cli/app/new.go:263 pkg/app/app.go:141
#, c-format
msgid "trimming %s to %s to avoid runtime limits"
msgstr "recortando %s a %s para evitar límites de tiempo de ejecución"
@@ -6255,27 +6255,27 @@ msgstr "la versión parece inválida: %s"
msgid "version wiped from %s.env"
msgstr "versión eliminada de %s.env"
#: cli/app/deploy.go:373
#: cli/app/deploy.go:372
#, c-format
msgid "version: taking chaos version: %s"
msgstr "versión: tomando la versión de caos: %s"
#: cli/app/deploy.go:399
#: cli/app/deploy.go:398
#, c-format
msgid "version: taking deployed version: %s"
msgstr "versión: tomando la versión desplegada: %s"
#: cli/app/deploy.go:404
#: cli/app/deploy.go:403
#, c-format
msgid "version: taking new recipe version: %s"
msgstr "versión: tomando la nueva versión de la receta: %s"
#: cli/app/deploy.go:393
#: cli/app/deploy.go:392
#, c-format
msgid "version: taking version from .env file: %s"
msgstr "versión: tomando la versión desde el archivo .env: %s"
#: cli/app/deploy.go:379
#: cli/app/deploy.go:378
#, c-format
msgid "version: taking version from cli arg: %s"
msgstr "versión: tomando la versión desde el argumento de la línea de comandos: %s"
@@ -6406,8 +6406,8 @@ msgstr "el directorio de trabajo no está limpio en %s, abortando"
msgid "writer: %v, "
msgstr ""
#: cli/app/deploy.go:289 cli/app/new.go:255 cli/app/rollback.go:257
#: cli/app/undeploy.go:120 cli/app/upgrade.go:302
#: cli/app/deploy.go:288 cli/app/new.go:251 cli/app/rollback.go:256
#: cli/app/undeploy.go:120 cli/app/upgrade.go:301
#, c-format
msgid "writing recipe version failed: %s"
msgstr "escritura de la versión de la receta fallida: %s"
+3 -4
View File
@@ -7,7 +7,6 @@ import (
"os"
"path"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/i18n"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/recipe"
@@ -322,7 +321,7 @@ func LintAllImagesTagged(recipe recipe.Recipe) (bool, error) {
return false, err
}
for _, service := range config.Services {
img, err := reference.ParseNormalizedNamed(formatter.StripDigest(service.Image))
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return false, err
}
@@ -340,7 +339,7 @@ func LintNoUnstableTags(recipe recipe.Recipe) (bool, error) {
return false, err
}
for _, service := range config.Services {
img, err := reference.ParseNormalizedNamed(formatter.StripDigest(service.Image))
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return false, err
}
@@ -367,7 +366,7 @@ func LintSemverLikeTags(recipe recipe.Recipe) (bool, error) {
return false, err
}
for _, service := range config.Services {
img, err := reference.ParseNormalizedNamed(formatter.StripDigest(service.Image))
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return false, err
}
+2 -68
View File
@@ -122,7 +122,6 @@ func (r Recipe) UpdateTag(image, tag string) (bool, error) {
log.Debug(i18n.G("considering %s config(s) for tag update", strings.Join(composeFiles, ", ")))
updated := false
for _, composeFile := range composeFiles {
opts := stack.Deploy{Composefiles: []string{composeFile}}
@@ -141,7 +140,7 @@ func (r Recipe) UpdateTag(image, tag string) (bool, error) {
continue // may be a compose.$optional.yml file
}
img, _ := reference.ParseNormalizedNamed(formatter.StripDigest(service.Image))
img, _ := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return false, err
}
@@ -167,10 +166,6 @@ func (r Recipe) UpdateTag(image, tag string) (bool, error) {
old := fmt.Sprintf("%s:%s", composeImage, composeTag)
new := fmt.Sprintf("%s:%s", composeImage, tag)
if old == new {
continue
}
replacedBytes := strings.Replace(string(bytes), old, new, -1)
log.Debug(i18n.G("updating %s to %s in %s", old, new, compose.Filename))
@@ -178,72 +173,11 @@ func (r Recipe) UpdateTag(image, tag string) (bool, error) {
if err := os.WriteFile(compose.Filename, []byte(replacedBytes), 0o764); err != nil {
return false, err
}
updated = true
}
}
}
return updated, nil
}
// UpdatePinnedTag updates an image reference to a pinned version (image:tag@sha256:...) in-place on local compose files.
func (r Recipe) UpdatePinnedTag(image, tag, digest string) (bool, error) {
glob := fmt.Sprintf("%s/compose**yml", r.Dir)
image = formatter.StripTagMeta(image)
composeFiles, err := filepath.Glob(glob)
if err != nil {
return false, err
}
updated := false
for _, composeFile := range composeFiles {
opts := stack.Deploy{Composefiles: []string{composeFile}}
sampleEnv, err := r.SampleEnv()
if err != nil {
return false, err
}
compose, err := loader.LoadComposefile(opts, sampleEnv)
if err != nil {
return false, err
}
for _, service := range compose.Services {
if service.Image == "" {
continue
}
img, _ := reference.ParseNormalizedNamed(formatter.StripDigest(service.Image))
composeImage := formatter.StripTagMeta(reference.Path(img))
if image == composeImage {
bytes, err := ioutil.ReadFile(composeFile)
if err != nil {
return false, err
}
old := service.Image
new := fmt.Sprintf("%s:%s@%s", composeImage, tag, digest)
if old == new {
continue
}
replacedBytes := strings.Replace(string(bytes), old, new, -1)
log.Debug(i18n.G("updating pinned reference %s to %s in %s", old, new, compose.Filename))
if err := os.WriteFile(compose.Filename, []byte(replacedBytes), 0o764); err != nil {
return false, err
}
updated = true
}
}
}
return updated, nil
return false, nil
}
// UpdateLabel updates a label in-place on file system local compose files.
+1 -1
View File
@@ -426,7 +426,7 @@ func (r Recipe) GetRecipeVersions() (RecipeVersions, []string, error) {
versionMeta := make(map[string]ServiceMeta)
for _, service := range config.Services {
img, err := reference.ParseNormalizedNamed(formatter.StripDigest(service.Image))
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
log.Debug(i18n.G("failed to parse image for %s in %s: %s", service.Name, tag, err))
warnMsg = append(warnMsg, i18n.G("skipping tag %s: invalid image reference in service %s: %s", tag, service.Name, err))
+1 -1
View File
@@ -3,7 +3,7 @@ version: "3.8"
services:
app:
image: nginx:1.31.3
image: nginx:1.31.1
secrets:
- test_pass_one
- test_pass_two
-20
View File
@@ -61,16 +61,6 @@ teardown(){
run grep -q "TYPE=$TEST_RECIPE:0.3.0+1.21.0" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
# 0.3.0-only
run grep -q "NETWORK_WITH_COMMENT=BAZ" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
# latest-only
run grep -q "TIMEOUT=120" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_failure
}
@test "ensure recipe is up-to-date" {
@@ -110,16 +100,6 @@ teardown(){
run grep -q "TYPE=$TEST_RECIPE:${tagHash}" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
# 0.3.0-only
run grep -q "NETWORK_WITH_COMMENT=BAZ" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
# latest-only
run grep -q "TIMEOUT=120" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_failure
}
@test "does not overwrite existing env files" {
+1 -1
View File
@@ -3,7 +3,7 @@ version: "3.8"
services:
app:
image: nginx:1.31.3
image: nginx:1.31.1
networks:
- proxy
deploy:
-1
View File
@@ -1,3 +1,2 @@
* -text
*.bin -text -diff
*.md text eol=lf
+700 -700
View File
File diff suppressed because it is too large Load Diff
+78 -78
View File
@@ -1,79 +1,79 @@
# Finite State Entropy
This package provides Finite State Entropy encoding and decoding.
Finite State Entropy (also referenced as [tANS](https://en.wikipedia.org/wiki/Asymmetric_numeral_systems#tANS))
encoding provides a fast near-optimal symbol encoding/decoding
for byte blocks as implemented in [zstandard](https://github.com/facebook/zstd).
This can be used for compressing input with a lot of similar input values to the smallest number of bytes.
This does not perform any multi-byte [dictionary coding](https://en.wikipedia.org/wiki/Dictionary_coder) as LZ coders,
but it can be used as a secondary step to compressors (like Snappy) that does not do entropy encoding.
* [Godoc documentation](https://godoc.org/github.com/klauspost/compress/fse)
## News
* Feb 2018: First implementation released. Consider this beta software for now.
# Usage
This package provides a low level interface that allows to compress single independent blocks.
Each block is separate, and there is no built in integrity checks.
This means that the caller should keep track of block sizes and also do checksums if needed.
Compressing a block is done via the [`Compress`](https://godoc.org/github.com/klauspost/compress/fse#Compress) function.
You must provide input and will receive the output and maybe an error.
These error values can be returned:
| Error | Description |
|---------------------|-----------------------------------------------------------------------------|
| `<nil>` | Everything ok, output is returned |
| `ErrIncompressible` | Returned when input is judged to be too hard to compress |
| `ErrUseRLE` | Returned from the compressor when the input is a single byte value repeated |
| `(error)` | An internal error occurred. |
As can be seen above there are errors that will be returned even under normal operation so it is important to handle these.
To reduce allocations you can provide a [`Scratch`](https://godoc.org/github.com/klauspost/compress/fse#Scratch) object
that can be re-used for successive calls. Both compression and decompression accepts a `Scratch` object, and the same
object can be used for both.
Be aware, that when re-using a `Scratch` object that the *output* buffer is also re-used, so if you are still using this
you must set the `Out` field in the scratch to nil. The same buffer is used for compression and decompression output.
Decompressing is done by calling the [`Decompress`](https://godoc.org/github.com/klauspost/compress/fse#Decompress) function.
You must provide the output from the compression stage, at exactly the size you got back. If you receive an error back
your input was likely corrupted.
It is important to note that a successful decoding does *not* mean your output matches your original input.
There are no integrity checks, so relying on errors from the decompressor does not assure your data is valid.
For more detailed usage, see examples in the [godoc documentation](https://godoc.org/github.com/klauspost/compress/fse#pkg-examples).
# Performance
A lot of factors are affecting speed. Block sizes and compressibility of the material are primary factors.
All compression functions are currently only running on the calling goroutine so only one core will be used per block.
The compressor is significantly faster if symbols are kept as small as possible. The highest byte value of the input
is used to reduce some of the processing, so if all your input is above byte value 64 for instance, it may be
beneficial to transpose all your input values down by 64.
With moderate block sizes around 64k speed are typically 200MB/s per core for compression and
around 300MB/s decompression speed.
The same hardware typically does Huffman (deflate) encoding at 125MB/s and decompression at 100MB/s.
# Plans
At one point, more internals will be exposed to facilitate more "expert" usage of the components.
A streaming interface is also likely to be implemented. Likely compatible with [FSE stream format](https://github.com/Cyan4973/FiniteStateEntropy/blob/dev/programs/fileio.c#L261).
# Contributing
Contributions are always welcome. Be aware that adding public functions will require good justification and breaking
# Finite State Entropy
This package provides Finite State Entropy encoding and decoding.
Finite State Entropy (also referenced as [tANS](https://en.wikipedia.org/wiki/Asymmetric_numeral_systems#tANS))
encoding provides a fast near-optimal symbol encoding/decoding
for byte blocks as implemented in [zstandard](https://github.com/facebook/zstd).
This can be used for compressing input with a lot of similar input values to the smallest number of bytes.
This does not perform any multi-byte [dictionary coding](https://en.wikipedia.org/wiki/Dictionary_coder) as LZ coders,
but it can be used as a secondary step to compressors (like Snappy) that does not do entropy encoding.
* [Godoc documentation](https://godoc.org/github.com/klauspost/compress/fse)
## News
* Feb 2018: First implementation released. Consider this beta software for now.
# Usage
This package provides a low level interface that allows to compress single independent blocks.
Each block is separate, and there is no built in integrity checks.
This means that the caller should keep track of block sizes and also do checksums if needed.
Compressing a block is done via the [`Compress`](https://godoc.org/github.com/klauspost/compress/fse#Compress) function.
You must provide input and will receive the output and maybe an error.
These error values can be returned:
| Error | Description |
|---------------------|-----------------------------------------------------------------------------|
| `<nil>` | Everything ok, output is returned |
| `ErrIncompressible` | Returned when input is judged to be too hard to compress |
| `ErrUseRLE` | Returned from the compressor when the input is a single byte value repeated |
| `(error)` | An internal error occurred. |
As can be seen above there are errors that will be returned even under normal operation so it is important to handle these.
To reduce allocations you can provide a [`Scratch`](https://godoc.org/github.com/klauspost/compress/fse#Scratch) object
that can be re-used for successive calls. Both compression and decompression accepts a `Scratch` object, and the same
object can be used for both.
Be aware, that when re-using a `Scratch` object that the *output* buffer is also re-used, so if you are still using this
you must set the `Out` field in the scratch to nil. The same buffer is used for compression and decompression output.
Decompressing is done by calling the [`Decompress`](https://godoc.org/github.com/klauspost/compress/fse#Decompress) function.
You must provide the output from the compression stage, at exactly the size you got back. If you receive an error back
your input was likely corrupted.
It is important to note that a successful decoding does *not* mean your output matches your original input.
There are no integrity checks, so relying on errors from the decompressor does not assure your data is valid.
For more detailed usage, see examples in the [godoc documentation](https://godoc.org/github.com/klauspost/compress/fse#pkg-examples).
# Performance
A lot of factors are affecting speed. Block sizes and compressibility of the material are primary factors.
All compression functions are currently only running on the calling goroutine so only one core will be used per block.
The compressor is significantly faster if symbols are kept as small as possible. The highest byte value of the input
is used to reduce some of the processing, so if all your input is above byte value 64 for instance, it may be
beneficial to transpose all your input values down by 64.
With moderate block sizes around 64k speed are typically 200MB/s per core for compression and
around 300MB/s decompression speed.
The same hardware typically does Huffman (deflate) encoding at 125MB/s and decompression at 100MB/s.
# Plans
At one point, more internals will be exposed to facilitate more "expert" usage of the components.
A streaming interface is also likely to be implemented. Likely compatible with [FSE stream format](https://github.com/Cyan4973/FiniteStateEntropy/blob/dev/programs/fileio.c#L261).
# Contributing
Contributions are always welcome. Be aware that adding public functions will require good justification and breaking
changes will likely not be accepted. If in doubt open an issue before writing the PR.
+89 -89
View File
@@ -1,89 +1,89 @@
# Huff0 entropy compression
This package provides Huff0 encoding and decoding as used in zstd.
[Huff0](https://github.com/Cyan4973/FiniteStateEntropy#new-generation-entropy-coders),
a Huffman codec designed for modern CPU, featuring OoO (Out of Order) operations on multiple ALU
(Arithmetic Logic Unit), achieving extremely fast compression and decompression speeds.
This can be used for compressing input with a lot of similar input values to the smallest number of bytes.
This does not perform any multi-byte [dictionary coding](https://en.wikipedia.org/wiki/Dictionary_coder) as LZ coders,
but it can be used as a secondary step to compressors (like Snappy) that does not do entropy encoding.
* [Godoc documentation](https://godoc.org/github.com/klauspost/compress/huff0)
## News
This is used as part of the [zstandard](https://github.com/klauspost/compress/tree/master/zstd#zstd) compression and decompression package.
This ensures that most functionality is well tested.
# Usage
This package provides a low level interface that allows to compress single independent blocks.
Each block is separate, and there is no built in integrity checks.
This means that the caller should keep track of block sizes and also do checksums if needed.
Compressing a block is done via the [`Compress1X`](https://godoc.org/github.com/klauspost/compress/huff0#Compress1X) and
[`Compress4X`](https://godoc.org/github.com/klauspost/compress/huff0#Compress4X) functions.
You must provide input and will receive the output and maybe an error.
These error values can be returned:
| Error | Description |
|---------------------|-----------------------------------------------------------------------------|
| `<nil>` | Everything ok, output is returned |
| `ErrIncompressible` | Returned when input is judged to be too hard to compress |
| `ErrUseRLE` | Returned from the compressor when the input is a single byte value repeated |
| `ErrTooBig` | Returned if the input block exceeds the maximum allowed size (128 Kib) |
| `(error)` | An internal error occurred. |
As can be seen above some of there are errors that will be returned even under normal operation so it is important to handle these.
To reduce allocations you can provide a [`Scratch`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch) object
that can be re-used for successive calls. Both compression and decompression accepts a `Scratch` object, and the same
object can be used for both.
Be aware, that when re-using a `Scratch` object that the *output* buffer is also re-used, so if you are still using this
you must set the `Out` field in the scratch to nil. The same buffer is used for compression and decompression output.
The `Scratch` object will retain state that allows to re-use previous tables for encoding and decoding.
## Tables and re-use
Huff0 allows for reusing tables from the previous block to save space if that is expected to give better/faster results.
The Scratch object allows you to set a [`ReusePolicy`](https://godoc.org/github.com/klauspost/compress/huff0#ReusePolicy)
that controls this behaviour. See the documentation for details. This can be altered between each block.
Do however note that this information is *not* stored in the output block and it is up to the users of the package to
record whether [`ReadTable`](https://godoc.org/github.com/klauspost/compress/huff0#ReadTable) should be called,
based on the boolean reported back from the CompressXX call.
If you want to store the table separate from the data, you can access them as `OutData` and `OutTable` on the
[`Scratch`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch) object.
## Decompressing
The first part of decoding is to initialize the decoding table through [`ReadTable`](https://godoc.org/github.com/klauspost/compress/huff0#ReadTable).
This will initialize the decoding tables.
You can supply the complete block to `ReadTable` and it will return the data part of the block
which can be given to the decompressor.
Decompressing is done by calling the [`Decompress1X`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch.Decompress1X)
or [`Decompress4X`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch.Decompress4X) function.
For concurrently decompressing content with a fixed table a stateless [`Decoder`](https://godoc.org/github.com/klauspost/compress/huff0#Decoder) can be requested which will remain correct as long as the scratch is unchanged. The capacity of the provided slice indicates the expected output size.
You must provide the output from the compression stage, at exactly the size you got back. If you receive an error back
your input was likely corrupted.
It is important to note that a successful decoding does *not* mean your output matches your original input.
There are no integrity checks, so relying on errors from the decompressor does not assure your data is valid.
# Contributing
Contributions are always welcome. Be aware that adding public functions will require good justification and breaking
changes will likely not be accepted. If in doubt open an issue before writing the PR.
# Huff0 entropy compression
This package provides Huff0 encoding and decoding as used in zstd.
[Huff0](https://github.com/Cyan4973/FiniteStateEntropy#new-generation-entropy-coders),
a Huffman codec designed for modern CPU, featuring OoO (Out of Order) operations on multiple ALU
(Arithmetic Logic Unit), achieving extremely fast compression and decompression speeds.
This can be used for compressing input with a lot of similar input values to the smallest number of bytes.
This does not perform any multi-byte [dictionary coding](https://en.wikipedia.org/wiki/Dictionary_coder) as LZ coders,
but it can be used as a secondary step to compressors (like Snappy) that does not do entropy encoding.
* [Godoc documentation](https://godoc.org/github.com/klauspost/compress/huff0)
## News
This is used as part of the [zstandard](https://github.com/klauspost/compress/tree/master/zstd#zstd) compression and decompression package.
This ensures that most functionality is well tested.
# Usage
This package provides a low level interface that allows to compress single independent blocks.
Each block is separate, and there is no built in integrity checks.
This means that the caller should keep track of block sizes and also do checksums if needed.
Compressing a block is done via the [`Compress1X`](https://godoc.org/github.com/klauspost/compress/huff0#Compress1X) and
[`Compress4X`](https://godoc.org/github.com/klauspost/compress/huff0#Compress4X) functions.
You must provide input and will receive the output and maybe an error.
These error values can be returned:
| Error | Description |
|---------------------|-----------------------------------------------------------------------------|
| `<nil>` | Everything ok, output is returned |
| `ErrIncompressible` | Returned when input is judged to be too hard to compress |
| `ErrUseRLE` | Returned from the compressor when the input is a single byte value repeated |
| `ErrTooBig` | Returned if the input block exceeds the maximum allowed size (128 Kib) |
| `(error)` | An internal error occurred. |
As can be seen above some of there are errors that will be returned even under normal operation so it is important to handle these.
To reduce allocations you can provide a [`Scratch`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch) object
that can be re-used for successive calls. Both compression and decompression accepts a `Scratch` object, and the same
object can be used for both.
Be aware, that when re-using a `Scratch` object that the *output* buffer is also re-used, so if you are still using this
you must set the `Out` field in the scratch to nil. The same buffer is used for compression and decompression output.
The `Scratch` object will retain state that allows to re-use previous tables for encoding and decoding.
## Tables and re-use
Huff0 allows for reusing tables from the previous block to save space if that is expected to give better/faster results.
The Scratch object allows you to set a [`ReusePolicy`](https://godoc.org/github.com/klauspost/compress/huff0#ReusePolicy)
that controls this behaviour. See the documentation for details. This can be altered between each block.
Do however note that this information is *not* stored in the output block and it is up to the users of the package to
record whether [`ReadTable`](https://godoc.org/github.com/klauspost/compress/huff0#ReadTable) should be called,
based on the boolean reported back from the CompressXX call.
If you want to store the table separate from the data, you can access them as `OutData` and `OutTable` on the
[`Scratch`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch) object.
## Decompressing
The first part of decoding is to initialize the decoding table through [`ReadTable`](https://godoc.org/github.com/klauspost/compress/huff0#ReadTable).
This will initialize the decoding tables.
You can supply the complete block to `ReadTable` and it will return the data part of the block
which can be given to the decompressor.
Decompressing is done by calling the [`Decompress1X`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch.Decompress1X)
or [`Decompress4X`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch.Decompress4X) function.
For concurrently decompressing content with a fixed table a stateless [`Decoder`](https://godoc.org/github.com/klauspost/compress/huff0#Decoder) can be requested which will remain correct as long as the scratch is unchanged. The capacity of the provided slice indicates the expected output size.
You must provide the output from the compression stage, at exactly the size you got back. If you receive an error back
your input was likely corrupted.
It is important to note that a successful decoding does *not* mean your output matches your original input.
There are no integrity checks, so relying on errors from the decompressor does not assure your data is valid.
# Contributing
Contributions are always welcome. Be aware that adding public functions will require good justification and breaking
changes will likely not be accepted. If in doubt open an issue before writing the PR.
+2 -2
View File
@@ -1,5 +1,5 @@
//go:build (appengine || js || nacl || tinygo || wasm || wasip1 || wasip2) && !windows
// +build appengine js nacl tinygo wasm wasip1 wasip2
//go:build (appengine || js || nacl || tinygo || wasm) && !windows
// +build appengine js nacl tinygo wasm
// +build !windows
package isatty
+2 -13
View File
@@ -31,10 +31,6 @@ func init() {
if procGetFileInformationByHandleEx.Find() != nil {
procGetFileInformationByHandleEx = nil
}
// Check if NtQueryObject is available.
if procNtQueryObject.Find() != nil {
procNtQueryObject = nil
}
}
// IsTerminal return true if the file descriptor is terminal.
@@ -47,7 +43,6 @@ func IsTerminal(fd uintptr) bool {
// Check pipe name is used for cygwin/msys2 pty.
// Cygwin/MSYS2 PTY has a name like:
// \{cygwin,msys}-XXXXXXXXXXXXXXXX-ptyN-{from,to}-master
// On Windows 7 a trailing suffix (e.g. "-nat") may be appended.
func isCygwinPipeName(name string) bool {
token := strings.Split(name, "-")
if len(token) < 5 {
@@ -77,19 +72,13 @@ func isCygwinPipeName(name string) bool {
return false
}
for _, t := range token[5:] {
if t == "" {
return false
}
}
return true
}
// getFileNameByHandle use the undocumented ntdll NtQueryObject to get file full name from file handler
// getFileNameByHandle use the undocomented ntdll NtQueryObject to get file full name from file handler
// since GetFileInformationByHandleEx is not available under windows Vista and still some old fashion
// guys are using Windows XP, this is a workaround for those guys, it will also work on system from
// Windows Vista to 10
// Windows vista to 10
// see https://stackoverflow.com/a/18792477 for details
func getFileNameByHandle(fd uintptr) (string, error) {
if procNtQueryObject == nil {
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.25@sha256:31c1e53dfc1cc2d269deec9c83f58729fa3c53dc9a576f6426109d1e319e9e9a
FROM golang:1.26@sha256:6df14f4a4bc9d979a3721f488981e0d1b318006377e473ed23d026796f5f4c0a
ENV GOOS=linux
ENV GOARCH=arm
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.25@sha256:31c1e53dfc1cc2d269deec9c83f58729fa3c53dc9a576f6426109d1e319e9e9a
FROM golang:1.26@sha256:6df14f4a4bc9d979a3721f488981e0d1b318006377e473ed23d026796f5f4c0a
ENV GOOS=linux
ENV GOARCH=arm64
-15
View File
@@ -1,15 +0,0 @@
*
!.git/
!build/root.tgz
!cmd/
!config/
!internal/
!mod/
!pkg/
!regclient/
!scheme/
!types/
!vendor/
!go.*
!*.go
!Makefile
-5
View File
@@ -1,5 +0,0 @@
artifacts/
bin/
output/
vendor/
.regctl_conf_ci.json
-19
View File
@@ -1,19 +0,0 @@
# all lists use a `-`
MD004:
style: dash
# allow tabs in code blocks (for Go)
MD010:
code_blocks: false
# disable line length, prefer one sentence per line for PRs
MD013: false
# emphasis with underscore (`_emphasis_`)
MD049:
style: "underscore"
# bold with asterisk (`**bold**`)
MD050:
style: "asterisk"
-1
View File
@@ -1 +0,0 @@
GoVersionOverride = "1.26.3"
-53
View File
@@ -1,53 +0,0 @@
{"name":"docker-arg-alpine-digest","key":"docker.io/library/alpine:3.23.4","version":"sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11"}
{"name":"docker-arg-alpine-tag","key":"docker.io/library/alpine","version":"3.23.4"}
{"name":"docker-arg-ecr","key":"https://github.com/awslabs/amazon-ecr-credential-helper.git","version":"v0.12.0"}
{"name":"docker-arg-gcr","key":"https://github.com/GoogleCloudPlatform/docker-credential-gcr.git","version":"v2.1.32"}
{"name":"docker-arg-go-digest","key":"docker.io/library/golang:1.26.3-alpine","version":"sha256:91eda9776261207ea25fd06b5b7fed8d397dd2c0a283e77f2ab6e91bfa71079d"}
{"name":"docker-arg-go-tag","key":"docker.io/library/golang","version":"1.26.3"}
{"name":"docker-arg-lunajson","key":"https://github.com/grafi-tt/lunajson.git:master","version":"e3a9666eb1275741e887e29926b144f8daee3bef"}
{"name":"docker-arg-semver","key":"https://github.com/kikito/semver.lua.git:master","version":"a4b708ba243208d46e575da870af969dca46a94d"}
{"name":"gha-alpine-digest","key":"docker.io/library/alpine:3.23.4","version":"sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11"}
{"name":"gha-alpine-tag-base","key":"docker.io/library/alpine","version":"3"}
{"name":"gha-alpine-tag-comment","key":"docker.io/library/alpine","version":"3.23.4"}
{"name":"gha-cosign-version","key":"https://github.com/sigstore/cosign.git","version":"v3.0.6"}
{"name":"gha-golang-matrix","key":"golang-matrix","version":"[\"1.25\", \"1.26\"]"}
{"name":"gha-golang-release","key":"golang-latest","version":"1.26"}
{"name":"gha-syft-version","key":"docker.io/anchore/syft","version":"v1.44.0"}
{"name":"gha-uses-commit","key":"https://github.com/actions/checkout.git:v6.0.2","version":"de0fac2e4500dabe0009e67214ff5f5447ce83dd"}
{"name":"gha-uses-commit","key":"https://github.com/actions/setup-go.git:v6.4.0","version":"4a3601121dd01d1626a1e23e37211e3254c1c06c"}
{"name":"gha-uses-commit","key":"https://github.com/actions/stale.git:v10.3.0","version":"eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899"}
{"name":"gha-uses-commit","key":"https://github.com/actions/upload-artifact.git:v7.0.1","version":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"}
{"name":"gha-uses-commit","key":"https://github.com/anchore/sbom-action.git:v0.24.0","version":"e22c389904149dbc22b58101806040fa8d37a610"}
{"name":"gha-uses-commit","key":"https://github.com/docker/build-push-action.git:v7.2.0","version":"f9f3042f7e2789586610d6e8b85c8f03e5195baf"}
{"name":"gha-uses-commit","key":"https://github.com/docker/login-action.git:v4.2.0","version":"650006c6eb7dba73a995cc03b0b2d7f5ca915bee"}
{"name":"gha-uses-commit","key":"https://github.com/docker/setup-buildx-action.git:v4.1.0","version":"d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5"}
{"name":"gha-uses-commit","key":"https://github.com/regclient/actions.git:main","version":"c70ad64367908075211b10dcd2ab9fad4bfa1816"}
{"name":"gha-uses-commit","key":"https://github.com/sigstore/cosign-installer.git:v4.1.2","version":"6f9f17788090df1f26f669e9d70d6ae9567deba6"}
{"name":"gha-uses-commit","key":"https://github.com/softprops/action-gh-release.git:v3.0.0","version":"b4309332981a82ec1c5618f44dd2e27cc8bfbfda"}
{"name":"gha-uses-semver","key":"https://github.com/actions/checkout.git","version":"v6.0.2"}
{"name":"gha-uses-semver","key":"https://github.com/actions/setup-go.git","version":"v6.4.0"}
{"name":"gha-uses-semver","key":"https://github.com/actions/stale.git","version":"v10.3.0"}
{"name":"gha-uses-semver","key":"https://github.com/actions/upload-artifact.git","version":"v7.0.1"}
{"name":"gha-uses-semver","key":"https://github.com/anchore/sbom-action.git","version":"v0.24.0"}
{"name":"gha-uses-semver","key":"https://github.com/docker/build-push-action.git","version":"v7.2.0"}
{"name":"gha-uses-semver","key":"https://github.com/docker/login-action.git","version":"v4.2.0"}
{"name":"gha-uses-semver","key":"https://github.com/docker/setup-buildx-action.git","version":"v4.1.0"}
{"name":"gha-uses-semver","key":"https://github.com/sigstore/cosign-installer.git","version":"v4.1.2"}
{"name":"gha-uses-semver","key":"https://github.com/softprops/action-gh-release.git","version":"v3.0.0"}
{"name":"go-mod-golang-release","key":"golang-oldest","version":"1.25.0"}
{"name":"makefile-ci-distribution","key":"docker.io/library/registry","version":"3.1.1"}
{"name":"makefile-ci-zot","key":"ghcr.io/project-zot/zot-linux-amd64","version":"v2.1.17"}
{"name":"makefile-go-vulncheck","key":"https://go.googlesource.com/vuln.git","version":"v1.3.0"}
{"name":"makefile-gofumpt","key":"https://github.com/mvdan/gofumpt.git","version":"v0.10.0"}
{"name":"makefile-gomajor","key":"https://github.com/icholy/gomajor.git","version":"v0.15.0"}
{"name":"makefile-gosec","key":"https://github.com/securego/gosec.git","version":"v2.26.1"}
{"name":"makefile-markdown-lint","key":"docker.io/davidanson/markdownlint-cli2","version":"v0.22.1"}
{"name":"makefile-osv-scanner","key":"https://github.com/google/osv-scanner.git","version":"v2.3.8"}
{"name":"makefile-staticcheck","key":"https://github.com/dominikh/go-tools.git","version":"v0.7.0"}
{"name":"makefile-syft-container-digest","key":"anchore/syft:v1.44.0","version":"sha256:86fde6445b483d902fe011dd9f68c4987dd94e07da1e9edc004e3c2422650de6"}
{"name":"makefile-syft-container-tag","key":"anchore/syft","version":"v1.44.0"}
{"name":"makefile-syft-version","key":"docker.io/anchore/syft","version":"v1.44.0"}
{"name":"osv-golang-release","key":"docker.io/library/golang","version":"1.26.3"}
{"name":"shell-alpine-digest","key":"docker.io/library/alpine:3.23.4","version":"sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11"}
{"name":"shell-alpine-tag-base","key":"docker.io/library/alpine","version":"3"}
{"name":"shell-alpine-tag-comment","key":"docker.io/library/alpine","version":"3.23.4"}
-346
View File
@@ -1,346 +0,0 @@
files:
"build/Dockerfile*":
processors:
- docker-arg-alpine-tag
- docker-arg-alpine-digest
- docker-arg-go-tag
- docker-arg-go-digest
- docker-arg-ecr
- docker-arg-gcr
- docker-arg-lunajson
- docker-arg-semver
"build/oci-image.sh":
processors:
- shell-alpine-tag-base
- shell-alpine-tag-comment
- shell-alpine-digest
".github/workflows/*.yml":
processors:
- gha-golang-matrix
- gha-golang-release
- gha-uses-vx
- gha-uses-semver
- gha-uses-commit
- gha-syft-version
- gha-cosign-version
- gha-alpine-tag-base
- gha-alpine-tag-comment
- gha-alpine-digest
"Makefile":
processors:
- makefile-gofumpt
- makefile-gomajor
- makefile-go-vulncheck
- makefile-markdown-lint
- makefile-gosec
- makefile-osv-scanner
- makefile-staticcheck
- makefile-syft-version
- makefile-syft-container-tag
- makefile-syft-container-digest
- makefile-ci-distribution
- makefile-ci-zot
"go.mod":
processors:
- go-mod-golang-release
".osv-scanner.toml":
processors:
- osv-golang-release
x-processor-tmpl:
git-commit: &git-commit
key: "{{ .SourceArgs.url }}:{{ .SourceArgs.ref }}"
scan: "regexp"
source: "git-commit"
filter:
expr: "^{{ .SourceArgs.ref }}$"
git-tag-semver: &git-tag-semver
key: "{{ .SourceArgs.url }}"
scan: "regexp"
source: "git-tag"
filter:
expr: '^v?\d+\.\d+\.\d+$'
sort:
method: "semver"
registry-digest: &registry-digest
key: "{{ .SourceArgs.image }}"
scan: "regexp"
source: "registry-digest"
registry-tag-semver: &registry-tag-semver
key: "{{ .SourceArgs.repo }}"
scan: "regexp"
source: "registry-tag"
filter:
expr: '^v?\d+\.\d+\.\d+$'
sort:
method: "semver"
processors:
docker-arg-alpine-tag:
<<: *registry-tag-semver
scanArgs:
regexp: '^ARG ALPINE_VER=(?P<Version>v?\d+\.\d+\.\d+)@(?P<SHA>sha256:[0-9a-f]+)\s*$'
sourceArgs:
repo: "docker.io/library/alpine"
docker-arg-alpine-digest:
<<: *registry-digest
scanArgs:
regexp: '^ARG ALPINE_VER=(?P<Tag>v?\d+\.\d+\.\d+)@(?P<Version>sha256:[0-9a-f]+)\s*$'
sourceArgs:
image: "docker.io/library/alpine:{{.ScanMatch.Tag}}"
docker-arg-go-tag:
<<: *registry-tag-semver
scanArgs:
regexp: '^ARG GO_VER=(?P<Version>[a-z0-9\-\.]+)-alpine@(?P<SHA>sha256:[0-9a-f]+)\s*$'
sourceArgs:
repo: "docker.io/library/golang"
docker-arg-go-digest:
<<: *registry-digest
scanArgs:
regexp: '^ARG GO_VER=(?P<Tag>[a-z0-9\-\.]+)@(?P<Version>sha256:[0-9a-f]+)\s*$'
sourceArgs:
image: "docker.io/library/golang:{{.ScanMatch.Tag}}"
docker-arg-ecr:
<<: *git-tag-semver
scanArgs:
regexp: '^ARG ECR_HELPER_VER=(?P<Version>v?\d+\.\d+\.\d+)\s*$'
sourceArgs:
url: "https://github.com/awslabs/amazon-ecr-credential-helper.git"
# get the version for the ecr-login nested package in the repo
filter:
expr: '^ecr-login/v?\d+\.\d+\.\d+$'
# sort and output only the version number without the package name prefix
sort:
method: "semver"
template: '{{ index (split . "/") 1 }}'
template: '{{ index (split .Version "/") 1 }}'
docker-arg-gcr:
<<: *git-tag-semver
scanArgs:
regexp: '^ARG GCR_HELPER_VER=(?P<Version>v?\d+\.\d+\.\d+)\s*$'
sourceArgs:
url: "https://github.com/GoogleCloudPlatform/docker-credential-gcr.git"
docker-arg-lunajson:
<<: *git-commit
scanArgs:
regexp: '^ARG LUNAJSON_COMMIT=(?P<Version>[0-9a-f]+)\s*$'
sourceArgs:
url: "https://github.com/grafi-tt/lunajson.git"
ref: master
docker-arg-semver:
<<: *git-commit
scanArgs:
regexp: '^ARG SEMVER_COMMIT=(?P<Version>[0-9a-f]+)\s*$'
sourceArgs:
url: "https://github.com/kikito/semver.lua.git"
ref: master
gha-alpine-digest:
<<: *registry-digest
scanArgs:
regexp: '^\s*ALPINE_DIGEST: "(?P<Version>sha256:[0-9a-f]+)"\s*#\s*(?P<Tag>\d+\.\d+\.\d+)\s*$'
sourceArgs:
image: "docker.io/library/alpine:{{ .ScanMatch.Tag }}"
gha-alpine-tag-base:
<<: *registry-tag-semver
scanArgs:
regexp: '^\s*ALPINE_NAME: "alpine:(?P<Version>v?\d+)"\s*$'
sourceArgs:
repo: "docker.io/library/alpine"
# only return the major version number in the tag to support detecting a change in the base image
template: '{{ index ( split .Version "." ) 0 }}'
gha-alpine-tag-comment:
<<: *registry-tag-semver
scanArgs:
regexp: '^\s*ALPINE_DIGEST: "(?P<Digest>sha256:[0-9a-f]+)"\s*#\s*(?P<Version>v?\d+\.\d+\.\d+)\s*$'
sourceArgs:
repo: "docker.io/library/alpine"
gha-cosign-version:
<<: *git-tag-semver
scanArgs:
regexp: '^\s*cosign-release: "(?P<Version>v?[0-9\.]+)"\s*$'
sourceArgs:
url: "https://github.com/sigstore/cosign.git"
filter:
expr: '^v?3\.\d+\.\d+$' # pin to v3, v4 will remove support for older clients
gha-golang-matrix:
<<: *registry-tag-semver
key: "golang-matrix"
scanArgs:
regexp: '^\s*gover: (?P<Version>\[["0-9, \.]+\])\s*$'
sourceArgs:
repo: "docker.io/library/golang"
filter:
expr: '^v?\d+\.\d+$'
template: '["{{ index .VerMap ( index .VerList 1 ) }}", "{{ index .VerMap ( index .VerList 0 ) }}"]'
gha-golang-release:
<<: *registry-tag-semver
key: "golang-latest"
scanArgs:
regexp: '^\s*RELEASE_GO_VER: "(?P<Version>v?[0-9\.]+)"\s*$'
sourceArgs:
repo: "docker.io/library/golang"
filter:
expr: '^v?\d+\.\d+$'
gha-syft-version:
<<: *registry-tag-semver
scanArgs:
regexp: '^\s*syft-version: "(?P<Version>v?[0-9\.]+)"\s*$'
sourceArgs:
repo: "docker.io/anchore/syft"
gha-uses-vx:
<<: *git-tag-semver
scanArgs:
regexp: '^\s+-?\s+uses: (?P<Repo>[^@/]+/[^@/]+)[^@]*@(?P<Commit>[0-9a-f]+)\s+#\s+(?P<Version>v?\d+)\s*$'
sourceArgs:
url: "https://github.com/{{ .ScanMatch.Repo }}.git"
filter:
expr: '^v?\d+$'
gha-uses-semver:
<<: *git-tag-semver
scanArgs:
regexp: '^\s+-?\s+uses: (?P<Repo>[^@/]+/[^@/]+)[^@]*@(?P<Commit>[0-9a-f]+)\s+#\s+(?P<Version>v?\d+\.\d+\.\d+)\s*$'
sourceArgs:
url: "https://github.com/{{ .ScanMatch.Repo }}.git"
gha-uses-commit:
<<: *git-commit
scanArgs:
regexp: '^\s+-?\s+uses: (?P<Repo>[^@/]+/[^@/]+)[^@]*@(?P<Version>[0-9a-f]+)\s+#\s+(?P<Ref>[\w\d\.]+)\s*$'
sourceArgs:
url: "https://github.com/{{ .ScanMatch.Repo }}.git"
ref: "{{ .ScanMatch.Ref }}"
go-mod-golang-release:
<<: *registry-tag-semver
key: "golang-oldest"
scanArgs:
regexp: '^go (?P<Version>[0-9\.]+)\s*$'
sourceArgs:
repo: "docker.io/library/golang"
filter:
expr: '^\d+\.\d+$'
template: '{{ index .VerMap ( index .VerList 1 ) }}.0'
makefile-ci-distribution:
<<: *registry-tag-semver
scanArgs:
regexp: '^CI_DISTRIBUTION_VER\?=(?P<Version>v?[0-9\.]+)\s*$'
sourceArgs:
repo: "docker.io/library/registry"
makefile-ci-zot:
<<: *registry-tag-semver
scanArgs:
regexp: '^CI_ZOT_VER\?=(?P<Version>v?[0-9\.]+)\s*$'
sourceArgs:
repo: "ghcr.io/project-zot/zot-linux-amd64"
makefile-gofumpt:
<<: *git-tag-semver
scanArgs:
regexp: '^GOFUMPT_VER\?=(?P<Version>v?[0-9\.]+)\s*$'
sourceArgs:
url: "https://github.com/mvdan/gofumpt.git"
makefile-gomajor:
<<: *git-tag-semver
scanArgs:
regexp: '^GOMAJOR_VER\?=(?P<Version>v?[0-9\.]+)\s*$'
sourceArgs:
url: "https://github.com/icholy/gomajor.git"
makefile-gosec:
<<: *git-tag-semver
scanArgs:
regexp: '^GOSEC_VER\?=(?P<Version>v?[0-9\.]+)\s*$'
sourceArgs:
url: "https://github.com/securego/gosec.git"
makefile-go-vulncheck:
<<: *git-tag-semver
scanArgs:
regexp: '^GO_VULNCHECK_VER\?=(?P<Version>v?[0-9\.]+)\s*$'
sourceArgs:
url: "https://go.googlesource.com/vuln.git"
makefile-markdown-lint:
<<: *registry-tag-semver
scanArgs:
regexp: '^MARKDOWN_LINT_VER\?=(?P<Version>v?[0-9\.]+)\s*$'
sourceArgs:
repo: "docker.io/davidanson/markdownlint-cli2"
makefile-osv-scanner:
<<: *git-tag-semver
scanArgs:
regexp: '^OSV_SCANNER_VER\?=(?P<Version>v?[0-9\.]+)\s*$'
sourceArgs:
url: "https://github.com/google/osv-scanner.git"
makefile-staticcheck:
<<: *git-tag-semver
scanArgs:
regexp: '^STATICCHECK_VER\?=(?P<Version>v?[0-9\.]+)\s*$'
sourceArgs:
url: "https://github.com/dominikh/go-tools.git"
filter:
# repo also has dated tags, ignore versions without a preceding "v"
expr: '^v\d+\.\d+\.\d+$'
makefile-syft-container-tag:
<<: *registry-tag-semver
scanArgs:
regexp: '^SYFT_CONTAINER\?=(?P<Repo>[^:]*):(?P<Version>v?[0-9\.]+)@(?P<Digest>sha256:[0-9a-f]+)\s*$'
sourceArgs:
repo: "{{ .ScanMatch.Repo }}"
makefile-syft-container-digest:
<<: *registry-digest
scanArgs:
regexp: '^SYFT_CONTAINER\?=(?P<Image>[^:]*):(?P<Tag>v?[0-9\.]+)@(?P<Version>sha256:[0-9a-f]+)\s*$'
sourceArgs:
image: "{{ .ScanMatch.Image }}:{{.ScanMatch.Tag}}"
makefile-syft-version:
<<: *registry-tag-semver
scanArgs:
regexp: '^SYFT_VERSION\?=(?P<Version>v[0-9\.]+)\s*$'
sourceArgs:
repo: "docker.io/anchore/syft"
osv-golang-release:
<<: *registry-tag-semver
scanArgs:
regexp: '^GoVersionOverride = "(?P<Version>v?[0-9\.]+)"\s*$'
sourceArgs:
repo: "docker.io/library/golang"
shell-alpine-tag-base:
<<: *registry-tag-semver
scanArgs:
regexp: '^\s*ALPINE_NAME="alpine:(?P<Version>v?\d+)"\s*$'
sourceArgs:
repo: "docker.io/library/alpine"
# only return the major version number in the tag to support detecting a change in the base image
template: '{{ index ( split .Version "." ) 0 }}'
shell-alpine-tag-comment:
<<: *registry-tag-semver
scanArgs:
regexp: '^\s*ALPINE_DIGEST="(?P<Digest>sha256:[0-9a-f]+)"\s*#\s*(?P<Version>v?\d+\.\d+\.\d+)\s*$'
sourceArgs:
repo: "docker.io/library/alpine"
shell-alpine-digest:
<<: *registry-digest
scanArgs:
regexp: '^\s*ALPINE_DIGEST="(?P<Version>sha256:[0-9a-f]+)"\s*#\s*(?P<Tag>\d+\.\d+\.\d+)\s*$'
sourceArgs:
image: "docker.io/library/alpine:{{ .ScanMatch.Tag }}"
scans:
regexp:
type: "regexp"
sources:
git-commit:
type: "git"
args:
type: "commit"
git-tag:
type: "git"
args:
type: "tag"
registry-digest:
type: "registry"
registry-tag:
type: "registry"
args:
type: "tag"
-134
View File
@@ -1,134 +0,0 @@
# Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or advances of
any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email address,
without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
<git@bmitch.net> or slack (I'm found on the CNCF, Docker, OCI, and OpenSSF
slacks).
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
-80
View File
@@ -1,80 +0,0 @@
# Contributing
## Reporting security issues
Please see [SECURITY.md](security.md) for the process to report security issues.
## Reporting other issues
Please search for similar issues and if none are seen, report an issue at [github.com/regclient/regclient/issues](https://github.com/regclient/regclient/issues).
## Code style
This project attempts to follow these principles:
- Code is canonical Go, following styles and patterns commonly used by the Go community.
- Dependencies outside of the Go standard library should be minimized.
- Dependencies should be pinned to a specific digest and tracked by Go or version-check.
- Unit tests are strongly encouraged with a focus on test coverage of the successful path and common errors.
- Linters and other style formatting tools are used, please run `make all` before committing any changes.
## LLM Policy
This project expects all contributions to be developed by a human or created with a reproducible tool.
Developers using an AI/LLM tool to generate their contribution are expected to fully understand the entire contribution and the logic behind its design.
Contributions that appear to have been generated by an AI/LLM without a human review may result in a ban from future contributions to the project.
## Pull requests
PRs are welcome following the below guides:
- For anything beyond a minor fix, opening an issue is suggested to discuss possible solutions.
- Changes should be rebased on the main branch.
- Changes should be squashed to a single commit per logical change.
All changes must be signed (`git commit -s`) to indicate you agree to the [Developer Certificate or Origin](https://developercertificate.org/):
```text
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
```
The sign-off will include the following message in your commit:
```text
Signed-off-by: Your Name <your-email@example.org>
```
This needs to be your real name, no aliases please.
-191
View File
@@ -1,191 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2020 The regclient Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-285
View File
@@ -1,285 +0,0 @@
COMMANDS?=regctl regsync regbot
BINARIES?=$(addprefix bin/,$(COMMANDS))
IMAGES?=$(addprefix docker-,$(COMMANDS))
ARTIFACT_PLATFORMS?=linux-amd64 linux-arm64 linux-ppc64le linux-s390x linux-riscv64 darwin-amd64 darwin-arm64 windows-amd64.exe freebsd-amd64
ARTIFACTS?=$(foreach cmd,$(addprefix artifacts/,$(COMMANDS)),$(addprefix $(cmd)-,$(ARTIFACT_PLATFORMS)))
IMAGE_PLATFORMS?=linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/riscv64
VCS_REPO?="https://github.com/regclient/regclient.git"
VCS_REF?=$(shell git rev-list -1 HEAD)
ifneq ($(shell git status --porcelain 2>/dev/null),)
VCS_REF := $(VCS_REF)-dirty
endif
VCS_VERSION?=$(shell vcs_describe="$$(git describe --all)"; \
vcs_version="(devel)"; \
if [ "$${vcs_describe}" != "$${vcs_describe#tags/}" ]; then \
vcs_version="$${vcs_describe#tags/}"; \
elif [ "$${vcs_describe}" != "$${vcs_describe#heads/}" ]; then \
vcs_version="$${vcs_describe#heads/}"; \
if [ "main" = "$${vcs_version}" ]; then vcs_version=edge; fi; \
fi; \
echo "$${vcs_version}" | sed -r 's#/+#-#g')
VCS_TAG?=$(shell git describe --tags --abbrev=0 2>/dev/null || true)
VCS_SEC?=$(shell git log -1 --format=%ct)
VCS_DATE?=$(shell date -d "@$(VCS_SEC)" +%Y-%m-%dT%H:%M:%SZ --utc)
LD_FLAGS?=-s -w -extldflags -static -buildid= -X \"github.com/regclient/regclient/internal/version.vcsTag=$(VCS_TAG)\"
GO_BUILD_FLAGS?=-trimpath -ldflags "$(LD_FLAGS)"
DOCKERFILE_EXT?=$(shell if docker build --help 2>/dev/null | grep -q -- '--progress'; then echo ".buildkit"; fi)
DOCKER_ARGS?=--build-arg "VCS_REF=$(VCS_REF)" --build-arg "VCS_VERSION=$(VCS_VERSION)" --build-arg "SOURCE_DATE_EPOCH=$(VCS_SEC)" --build-arg "BUILD_DATE=$(VCS_DATE)"
GOPATH?=$(shell go env GOPATH)
PWD:=$(shell pwd)
VER_BUMP?=$(shell command -v version-bump 2>/dev/null)
VER_BUMP_CONTAINER?=sudobmitch/version-bump:edge
ifeq "$(strip $(VER_BUMP))" ''
VER_BUMP=docker run --rm \
-v "$(shell pwd)/:$(shell pwd)/" -w "$(shell pwd)" \
-u "$(shell id -u):$(shell id -g)" \
$(VER_BUMP_CONTAINER)
endif
MARKDOWN_LINT_VER?=v0.22.1
GOFUMPT_VER?=v0.10.0
GOMAJOR_VER?=v0.15.0
GOSEC_VER?=v2.26.1
GO_VULNCHECK_VER?=v1.3.0
OSV_SCANNER_VER?=v2.3.8
SYFT?=$(shell command -v syft 2>/dev/null)
SYFT_CMD_VER:=$(shell [ -x "$(SYFT)" ] && echo "v$$($(SYFT) version | awk '/^Version: / {print $$2}')" || echo "0")
SYFT_VERSION?=v1.44.0
SYFT_CONTAINER?=anchore/syft:v1.44.0@sha256:86fde6445b483d902fe011dd9f68c4987dd94e07da1e9edc004e3c2422650de6
ifneq "$(SYFT_CMD_VER)" "$(SYFT_VERSION)"
SYFT=docker run --rm \
-v "$(shell pwd)/:$(shell pwd)/" -w "$(shell pwd)" \
-u "$(shell id -u):$(shell id -g)" \
$(SYFT_CONTAINER)
endif
STATICCHECK_VER?=v0.7.0
CI_DISTRIBUTION_VER?=3.1.1
CI_ZOT_VER?=v2.1.17
.PHONY: .FORCE
.FORCE:
.PHONY: all
all: fmt gofumpt gofix goimports vet test lint binaries ## Full build of Go binaries (including fmt, vet, test, and lint)
.PHONY: fmt
fmt: ## go fmt
go fmt ./...
.PHONY: gofumpt
gofumpt: $(GOPATH)/bin/gofumpt ## gofumpt is a stricter alternative to go fmt
gofumpt -l -w .
.PHONY: gofix
gofix: ## go fix
go fix ./...
goimports: $(GOPATH)/bin/goimports
$(GOPATH)/bin/goimports -w -format-only -local github.com/regclient .
.PHONY: vet
vet: ## go vet
go vet ./...
.PHONY: test
test: ## go test
go test -cover -race ./...
.PHONY: lint
lint: lint-go lint-goimports lint-md lint-gosec ## Run all linting
.PHONY: lint-go
lint-go: $(GOPATH)/bin/gofumpt $(GOPATH)/bin/staticcheck .FORCE ## Run linting for Go
$(GOPATH)/bin/staticcheck -checks all ./...
$(GOPATH)/bin/gofumpt -l -d .
errors=$$(go fix -diff ./...); if [ "$${errors}" != "" ]; then echo "$${errors}"; exit 1; fi
lint-goimports: $(GOPATH)/bin/goimports
@if [ -n "$$($(GOPATH)/bin/goimports -l -format-only -local github.com/regclient .)" ]; then \
echo $(GOPATH)/bin/goimports -d -format-only -local github.com/regclient .; \
$(GOPATH)/bin/goimports -d -format-only -local github.com/regclient .; \
exit 1; \
fi
# excluding types/platform pending resultion to https://github.com/securego/gosec/issues/1116
.PHONY: lint-gosec
lint-gosec: $(GOPATH)/bin/gosec .FORCE ## Run gosec
$(GOPATH)/bin/gosec -terse -exclude-dir types/platform ./...
.PHONY: lint-md
lint-md: .FORCE ## Run linting for markdown
docker run --rm -v "$(PWD):/workdir:ro" davidanson/markdownlint-cli2:$(MARKDOWN_LINT_VER) \
"**/*.md" "#vendor"
.PHONY: vulnerability-scan
vulnerability-scan: osv-scanner vulncheck-go ## Run all vulnerability scanners
.PHONY: osv-scanner
osv-scanner: $(GOPATH)/bin/osv-scanner .FORCE ## Run OSV Scanner
$(GOPATH)/bin/osv-scanner scan --config .osv-scanner.toml -r --licenses="Apache-2.0,BSD-3-Clause,MIT,CC-BY-SA-4.0,UNKNOWN" .
.PHONY: vulncheck-go
vulncheck-go: $(GOPATH)/bin/govulncheck .FORCE ## Run govulncheck
$(GOPATH)/bin/govulncheck ./...
.PHONY: vendor
vendor: ## Vendor Go modules
go mod vendor
.PHONY: binaries
binaries: $(BINARIES) ## Build Go binaries
bin/%: .FORCE
CGO_ENABLED=0 go build ${GO_BUILD_FLAGS} -o bin/$* ./cmd/$*
.PHONY: docker
docker: $(IMAGES) ## Build Docker images
docker-%: .FORCE
docker build -t regclient/$* -f build/Dockerfile.$*$(DOCKERFILE_EXT) $(DOCKER_ARGS) .
docker build -t regclient/$*:alpine -f build/Dockerfile.$*$(DOCKERFILE_EXT) --target release-alpine $(DOCKER_ARGS) .
.PHONY: oci-image
oci-image: $(addprefix oci-image-,$(COMMANDS)) ## Build reproducible images to an OCI Layout
oci-image-%: bin/regctl .FORCE
PATH="$(PWD)/bin:$(PATH)" build/oci-image.sh -r scratch -i "$*" -p "$(IMAGE_PLATFORMS)"
PATH="$(PWD)/bin:$(PATH)" build/oci-image.sh -r alpine -i "$*" -p "$(IMAGE_PLATFORMS)" -b "alpine:3"
.PHONY: test-docker
test-docker: $(addprefix test-docker-,$(COMMANDS)) ## Build multi-platform docker images (but do not tag)
test-docker-%:
docker buildx build --platform="$(IMAGE_PLATFORMS)" -f build/Dockerfile.$*.buildkit .
docker buildx build --platform="$(IMAGE_PLATFORMS)" -f build/Dockerfile.$*.buildkit --target release-alpine .
.PHONY: ci
ci: ci-distribution ci-zot ## Run CI tests against self hosted registries
.PHONY: ci-distribution
ci-distribution:
docker run --rm -d -p 5000 \
--label regclient-ci=true --name regclient-ci-distribution \
-e "REGISTRY_STORAGE_DELETE_ENABLED=true" \
docker.io/library/registry:$(CI_DISTRIBUTION_VER)
./build/ci-test.sh -t localhost:$$(docker port regclient-ci-distribution 5000 | head -1 | cut -f2 -d:)/test-ci
docker stop regclient-ci-distribution
.PHONY: ci-zot
ci-zot:
docker run --rm -d -p 5000 \
--label regclient-ci=true --name regclient-ci-zot \
-v "$$(pwd)/build/zot-config.json:/etc/zot/config.json:ro" \
ghcr.io/project-zot/zot-linux-amd64:$(CI_ZOT_VER)
./build/ci-test.sh -t localhost:$$(docker port regclient-ci-zot 5000 | head -1 | cut -f2 -d:)/test-ci
docker stop regclient-ci-zot
.PHONY: artifacts
artifacts: $(ARTIFACTS) ## Generate artifacts
.PHONY: artifact-pre
artifact-pre:
mkdir -p artifacts
artifacts/%: artifact-pre .FORCE
@set -e; \
target="$*"; \
command="$${target%%-*}"; \
platform_ext="$${target#*-}"; \
platform="$${platform_ext%.*}"; \
export GOOS="$${platform%%-*}"; \
export GOARCH="$${platform#*-}"; \
echo export GOOS=$${GOOS}; \
echo export GOARCH=$${GOARCH}; \
echo go build ${GO_BUILD_FLAGS} -o "$@" ./cmd/$${command}/; \
CGO_ENABLED=0 go build ${GO_BUILD_FLAGS} -o "$@" ./cmd/$${command}/; \
$(SYFT) scan -q "file:$@" --source-name "$${command}" -o cyclonedx-json >"artifacts/$${command}-$${platform}.cyclonedx.json"; \
$(SYFT) scan -q "file:$@" --source-name "$${command}" -o spdx-json >"artifacts/$${command}-$${platform}.spdx.json"
.PHONY: clean
clean: ## delete generated content
[ ! -d artifacts ] || rm -r artifacts
[ ! -d bin ] || rm -r bin
[ ! -d output ] || rm -r output
[ ! -d vendor ] || rm -r vendor
.PHONY: plugin-user
plugin-user:
mkdir -p ${HOME}/.docker/cli-plugins/
cp docker-plugin/docker-regclient ${HOME}/.docker/cli-plugins/docker-regctl
.PHONY: plugin-host
plugin-host:
sudo cp docker-plugin/docker-regclient /usr/libexec/docker/cli-plugins/docker-regctl
.PHONY: util-golang-major
util-golang-major: $(GOPATH)/bin/gomajor ## check for major dependency updates
$(GOPATH)/bin/gomajor list
.PHONY: util-golang-update
util-golang-update: ## update go module versions
go get -u -t ./...
go mod tidy
[ ! -d vendor ] || go mod vendor
.PHONY: util-release-preview
util-release-preview: $(GOPATH)/bin/gorelease ## preview changes for next release
git checkout main
./.github/release.sh -d
gorelease
.PHONY: util-release-run
util-release-run: ## generate a new release
git checkout main
./.github/release.sh
.PHONY: util-version-check
util-version-check: ## check all dependencies for updates
$(VER_BUMP) check
.PHONY: util-version-update
util-version-update: ## update versions on all dependencies
$(VER_BUMP) update
$(GOPATH)/bin/gofumpt: .FORCE
@[ -f "$(GOPATH)/bin/gofumpt" ] \
&& [ "$$($(GOPATH)/bin/gofumpt -version | cut -f 1 -d ' ')" = "$(GOFUMPT_VER)" ] \
|| go install mvdan.cc/gofumpt@$(GOFUMPT_VER)
$(GOPATH)/bin/gomajor: .FORCE
@[ -f "$(GOPATH)/bin/gomajor" ] \
&& [ "$$($(GOPATH)/bin/gomajor version | grep '^version' | cut -f 2 -d ' ')" = "$(GOMAJOR_VER)" ] \
|| go install github.com/icholy/gomajor@$(GOMAJOR_VER)
$(GOPATH)/bin/goimports: .FORCE
@[ -f "$(GOPATH)/bin/goimports" ] && [ "$$(go version | cut -f3 -d' ')" = "$$(go version $(GOPATH)/bin/goimports | cut -f2 -d' ')" ] \
|| go install golang.org/x/tools/cmd/goimports@latest
$(GOPATH)/bin/gorelease: .FORCE
@[ -f "$(GOPATH)/bin/gorelease" ] && [ "$$(go version | cut -f3 -d' ')" = "$$(go version $(GOPATH)/bin/gorelease | cut -f2 -d' ')" ] \
|| go install golang.org/x/exp/cmd/gorelease@latest
$(GOPATH)/bin/gosec: .FORCE
@[ -f $(GOPATH)/bin/gosec ] \
&& [ "$$($(GOPATH)/bin/gosec -version | grep '^Version' | cut -f 2 -d ' ')" = "$(GOSEC_VER)" ] \
|| go install -ldflags '-X main.Version=$(GOSEC_VER) -X main.GitTag=$(GOSEC_VER)' \
github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VER)
$(GOPATH)/bin/staticcheck: .FORCE
@[ -f $(GOPATH)/bin/staticcheck ] \
&& [ "$$($(GOPATH)/bin/staticcheck -version | cut -f 3 -d ' ' | tr -d '()')" = "$(STATICCHECK_VER)" ] \
|| go install "honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VER)"
$(GOPATH)/bin/govulncheck: .FORCE
@[ -f $(GOPATH)/bin/govulncheck ] \
&& [ $$(go version -m $(GOPATH)/bin/govulncheck | \
awk -F ' ' '{ if ($$1 == "mod" && $$2 == "golang.org/x/vuln") { printf "%s\n", $$3 } }') = "$(GO_VULNCHECK_VER)" ] \
|| CGO_ENABLED=0 go install "golang.org/x/vuln/cmd/govulncheck@$(GO_VULNCHECK_VER)"
$(GOPATH)/bin/osv-scanner: .FORCE
@[ -f $(GOPATH)/bin/osv-scanner ] \
&& [ "$$(osv-scanner --version | awk -F ': ' '{ if ($$1 == "osv-scanner version") { printf "%s\n", $$2 } }')" = "$(OSV_SCANNER_VER)" ] \
|| CGO_ENABLED=0 go install "github.com/google/osv-scanner/v2/cmd/osv-scanner@$(OSV_SCANNER_VER)"
.PHONY: help
help: # Display help
@awk -F ':|##' '/^[^\t].+?:.*?##/ { printf "\033[36m%-30s\033[0m %s\n", $$1, $$NF }' $(MAKEFILE_LIST)
-116
View File
@@ -1,116 +0,0 @@
# regclient
[![Go Workflow Status](https://img.shields.io/github/actions/workflow/status/regclient/regclient/go.yml?branch=main&label=Go%20build)](https://github.com/regclient/regclient/actions/workflows/go.yml)
[![Docker Workflow Status](https://img.shields.io/github/actions/workflow/status/regclient/regclient/docker.yml?branch=main&label=Docker%20build)](https://github.com/regclient/regclient/actions/workflows/docker.yml)
[![Dependency Workflow Status](https://img.shields.io/github/actions/workflow/status/regclient/regclient/version-check.yml?branch=main&label=Dependency%20check)](https://github.com/regclient/regclient/actions/workflows/version-check.yml)
[![Vulnerability Workflow Status](https://img.shields.io/github/actions/workflow/status/regclient/regclient/vulnscans.yml?branch=main&label=Vulnerability%20check)](https://github.com/regclient/regclient/actions/workflows/vulnscans.yml)
[![Go Reference](https://pkg.go.dev/badge/github.com/regclient/regclient.svg)](https://pkg.go.dev/github.com/regclient/regclient)
![License](https://img.shields.io/github/license/regclient/regclient)
[![Go Report Card](https://goreportcard.com/badge/github.com/regclient/regclient)](https://goreportcard.com/report/github.com/regclient/regclient)
[![GitHub Downloads](https://img.shields.io/github/downloads/regclient/regclient/total?label=GitHub%20downloads)](https://github.com/regclient/regclient/releases)
regclient is a client interface to OCI conformant registries and content shipped with the OCI Image Layout.
It includes a Go library and several CLI commands.
## regclient Go Library Features
- Runs without a container runtime and without privileged access to the local host.
- Querying for a tag listing, repository listing, and remotely inspecting the contents of images.
- Efficiently copying and retagging images, only pulling layers when required, and without changing the image digest.
- Support for multi-platform images.
- Support for querying, creating, and copying OCI Artifacts, allowing arbitrary data to be stored in an OCI registry.
- Support for packaging OCI Artifacts with an Index of multiple artifacts, which can be used for platform specific artifacts.
- Support for querying OCI referrers, copying referrers, and pushing content with an OCI subject field, associating artifacts with other content on the registry.
- Support for the “digest tags” used by projects like sigstore/cosign, allowing the content to be included when copying images.
- Efficiently query for an image digest.
- Efficiently query for pull rate limits used by Docker Hub.
- Import and export content into OCI Layouts and Docker formatted tar files.
- Support OCI Layouts in all commands as a local disk equivalent of a repository.
- Support for deleting tags, manifests, and blobs.
- Ability to mutate existing images, including:
- Settings annotations or labels
- Deleting content from layers
- Changing timestamps for reproducibility
- Converting between Docker and OCI media types
- Replacing the base image layers
- Add or remove volumes and exposed ports
- Change digest algorithms
- Support for registry warning headers, which may be used to notify users of issues with the server or content they are using.
- Automatically import logins from the docker CLI, and registry certificates from the docker engine.
- Automatic retry, and fallback to a chunked blob push, when network issues are encountered.
The full Go references is available on [pkg.go.dev](https://pkg.go.dev/github.com/regclient/regclient).
## regctl Features
`regctl` is a CLI interface to the `regclient` library.
In addition to the features listed for `regclient`, `regctl` adds the following abilities:
- Generating multi-platform manifests from multiple images that may have been separately built.
- Repackage a multi-platform image with only the requested platforms.
- Push and pull arbitrary OCI artifacts.
- Recursively list all content associated with an image.
- Extract files from a layer or image.
- Compare images, showing the differences between manifests, the config, and layers.
- Formatted output using Go templates.
The project website includes [usage instructions](https://regclient.org/usage/regctl/) and a [CLI reference](https://regclient.org/cli/regctl/).
## regsync features
`regsync` is an image mirroring tool.
It will copy images between two locations with the following additional features:
- Ability to run on a cron schedule, one time synchronization, or only report stale images.
- Uses a yaml configuration.
- Each source may be an entire registry (not recommended), a repository, or a single image, with the ability to filter repositories and tags.
- Support for multi-platform images, OCI referrers, “digest tags”, and copying to or from an OCI Layout (for maintaining a mirror over an air-gap).
- Ability to mirror multiple images concurrently.
- Support for copying a single platform from multi-platform images.
- Ability to backup an existing image before overwriting the tag.
- Ability to postpone mirror step when rate limit (used by Docker Hub) is below a threshold.
- Can use users docker configuration for user credentials and registry certificates.
The project website includes [usage instructions](https://regclient.org/usage/regsync/) and a [CLI reference](https://regclient.org/cli/regsync/).
## regbot features
`regbot` is a scripting tool on top of the `regclient` API with the following features:
- Ability to run on a cron schedule, one time execution, or test with a dry-run mode.
- Uses a yaml configuration.
- Scripts are written in Lua and executed directly in Go.
- Built-in functions include:
- Repository list
- Tag list
- Image manifest (either head or get, and optional resolving multi-platform reference)
- Image config (this includes the creation time, labels, and other details shown in a docker image inspect)
- Image rate limit and a wait function to delay the script when rate limit remaining is below a threshold
- Image copy
- Manifest delete
- Tag delete
The project website includes [usage instructions](https://regclient.org/usage/regbot/) and a [CLI reference](https://regclient.org/cli/regbot/).
## Development Status
This project is using v0 version numbers due to Go's backwards compatibility requirements of a v1 release.
The library and commands are stable for external use.
Minor version updates may contain breaking changes, however effort is made to first deprecate and provide warnings to give users time to move off of older APIs and commands.
## Installing
See the [installation instructions](https://regclient.org/install/) on the project website for the various ways to download or build CLI binaries.
## Usage
See the [project documentation](https://regclient.org/usage/).
## Contributors
<a href="https://github.com/regclient/regclient/graphs/contributors">
<img src="https://contrib.rocks/image?repo=regclient/regclient" alt="contributor list"/>
</a>
<!-- markdownlint-disable-file MD033 -->
-5
View File
@@ -1,5 +0,0 @@
# Reporting security issues
Please report security issues directly in GitHub at <https://github.com/regclient/regclient/security/advisories/new> or alternatively email <git@bmitch.net>.
We will typically respond within 7 working days of your report. If the issue is confirmed as a vulnerability, we will open a Security Advisory and acknowledge your contributions as part of it. This project follows a 90 day disclosure timeline.
-283
View File
@@ -1,283 +0,0 @@
package regclient
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"time"
"github.com/regclient/regclient/internal/pqueue"
"github.com/regclient/regclient/internal/reqmeta"
"github.com/regclient/regclient/scheme"
"github.com/regclient/regclient/types"
"github.com/regclient/regclient/types/blob"
"github.com/regclient/regclient/types/descriptor"
"github.com/regclient/regclient/types/errs"
"github.com/regclient/regclient/types/ref"
"github.com/regclient/regclient/types/warning"
)
const blobCBFreq = time.Millisecond * 100
type blobOpt struct {
callback func(kind types.CallbackKind, instance string, state types.CallbackState, cur, total int64)
readerHook func(*blob.BReader) (*blob.BReader, error)
}
// BlobOpts define options for the Image* commands.
type BlobOpts func(*blobOpt)
// BlobWithCallback provides progress data to a callback function.
func BlobWithCallback(callback func(kind types.CallbackKind, instance string, state types.CallbackState, cur, total int64)) BlobOpts {
return func(opts *blobOpt) {
opts.callback = callback
}
}
// BlobWithReaderHook is called in [RegClient.BlobCopy] with the blob source.
// The returned [blob.BReader] is pushed to the target.
// If the hook returns an error, the copy will fail.
func BlobWithReaderHook(hook func(*blob.BReader) (*blob.BReader, error)) BlobOpts {
return func(opts *blobOpt) {
opts.readerHook = hook
}
}
// BlobCopy copies a blob between two locations.
// If the blob already exists in the target, the copy is skipped.
// A server side cross repository blob mount is attempted.
func (rc *RegClient) BlobCopy(ctx context.Context, refSrc ref.Ref, refTgt ref.Ref, d descriptor.Descriptor, opts ...BlobOpts) error {
if !refSrc.IsSetRepo() {
return fmt.Errorf("refSrc is not set: %s%.0w", refSrc.CommonName(), errs.ErrInvalidReference)
}
if !refTgt.IsSetRepo() {
return fmt.Errorf("refTgt is not set: %s%.0w", refTgt.CommonName(), errs.ErrInvalidReference)
}
var opt blobOpt
for _, optFn := range opts {
optFn(&opt)
}
// dedup warnings
if w := warning.FromContext(ctx); w == nil {
ctx = warning.NewContext(ctx, &warning.Warning{Hook: warning.DefaultHook()})
}
tDesc := d
tDesc.URLs = []string{} // ignore URLs when pushing to target
if opt.callback != nil {
opt.callback(types.CallbackBlob, d.Digest.String(), types.CallbackStarted, 0, d.Size)
}
// for the same repository, there's nothing to copy
if ref.EqualRepository(refSrc, refTgt) {
if opt.callback != nil {
opt.callback(types.CallbackBlob, d.Digest.String(), types.CallbackSkipped, 0, d.Size)
}
rc.slog.Debug("Blob copy skipped, same repo",
slog.String("src", refSrc.Reference),
slog.String("tgt", refTgt.Reference),
slog.String("digest", string(d.Digest)))
return nil
}
// check if layer already exists
if _, err := rc.BlobHead(ctx, refTgt, tDesc); err == nil {
if opt.callback != nil {
opt.callback(types.CallbackBlob, d.Digest.String(), types.CallbackSkipped, 0, d.Size)
}
rc.slog.Debug("Blob copy skipped, already exists",
slog.String("src", refSrc.Reference),
slog.String("tgt", refTgt.Reference),
slog.String("digest", string(d.Digest)))
return nil
}
// acquire throttle for both src and tgt to avoid deadlocks
tList := []*pqueue.Queue[reqmeta.Data]{}
schemeSrcAPI, err := rc.schemeGet(refSrc.Scheme)
if err != nil {
return err
}
schemeTgtAPI, err := rc.schemeGet(refTgt.Scheme)
if err != nil {
return err
}
if tSrc, ok := schemeSrcAPI.(scheme.Throttler); ok {
tList = append(tList, tSrc.Throttle(refSrc, false)...)
}
if tTgt, ok := schemeTgtAPI.(scheme.Throttler); ok {
tList = append(tList, tTgt.Throttle(refTgt, true)...)
}
if len(tList) > 0 {
ctxMulti, done, err := pqueue.AcquireMulti[reqmeta.Data](ctx, reqmeta.Data{Kind: reqmeta.Blob, Size: d.Size}, tList...)
if err != nil {
return err
}
if done != nil {
defer done()
}
ctx = ctxMulti
}
// try mounting blob from the source repo is the registry is the same
if ref.EqualRegistry(refSrc, refTgt) {
err := rc.BlobMount(ctx, refSrc, refTgt, d)
if err == nil {
if opt.callback != nil {
opt.callback(types.CallbackBlob, d.Digest.String(), types.CallbackSkipped, 0, d.Size)
}
rc.slog.Debug("Blob copy performed server side with registry mount",
slog.String("src", refSrc.Reference),
slog.String("tgt", refTgt.Reference),
slog.String("digest", string(d.Digest)))
return nil
}
rc.slog.Warn("Failed to mount blob",
slog.String("src", refSrc.Reference),
slog.String("tgt", refTgt.Reference),
slog.String("err", err.Error()))
}
// fast options failed, download layer from source and push to target
blobIO, err := rc.BlobGet(ctx, refSrc, d)
if err != nil {
if !errors.Is(err, context.Canceled) {
rc.slog.Warn("Failed to retrieve blob",
slog.String("src", refSrc.Reference),
slog.String("digest", string(d.Digest)),
slog.String("err", err.Error()))
}
return err
}
if opt.callback != nil {
opt.callback(types.CallbackBlob, d.Digest.String(), types.CallbackStarted, 0, d.Size)
ticker := time.NewTicker(blobCBFreq)
done := make(chan bool)
defer func() {
close(done)
ticker.Stop()
if ctx.Err() == nil {
opt.callback(types.CallbackBlob, d.Digest.String(), types.CallbackFinished, d.Size, d.Size)
}
}()
go func() {
for {
select {
case <-done:
return
case <-ticker.C:
offset, err := blobIO.Seek(0, io.SeekCurrent)
if err == nil && offset > 0 {
opt.callback(types.CallbackBlob, d.Digest.String(), types.CallbackActive, offset, d.Size)
}
}
}
}()
}
if opt.readerHook != nil {
blobIO, err = opt.readerHook(blobIO)
if err != nil {
rc.slog.Warn("Failed to apply reader hook to blob",
slog.String("src", refSrc.Reference),
slog.String("err", err.Error()))
return err
}
}
defer blobIO.Close()
if _, err := rc.BlobPut(ctx, refTgt, blobIO.GetDescriptor(), blobIO); err != nil {
if !errors.Is(err, context.Canceled) {
rc.slog.Warn("Failed to push blob",
slog.String("src", refSrc.Reference),
slog.String("tgt", refTgt.Reference),
slog.String("err", err.Error()))
}
return err
}
return nil
}
// BlobDelete removes a blob from the registry.
// This method should only be used to repair a damaged registry.
// Typically a server side garbage collection should be used to purge unused blobs.
func (rc *RegClient) BlobDelete(ctx context.Context, r ref.Ref, d descriptor.Descriptor) error {
if !r.IsSetRepo() {
return fmt.Errorf("ref is not set: %s%.0w", r.CommonName(), errs.ErrInvalidReference)
}
schemeAPI, err := rc.schemeGet(r.Scheme)
if err != nil {
return err
}
return schemeAPI.BlobDelete(ctx, r, d)
}
// BlobGet retrieves a blob, returning a reader.
// This reader must be closed to free up resources that limit concurrent pulls.
func (rc *RegClient) BlobGet(ctx context.Context, r ref.Ref, d descriptor.Descriptor) (blob.Reader, error) {
data, err := d.GetData()
if err == nil {
return blob.NewReader(blob.WithDesc(d), blob.WithRef(r), blob.WithReader(bytes.NewReader(data))), nil
}
if !r.IsSetRepo() {
return nil, fmt.Errorf("ref is not set: %s%.0w", r.CommonName(), errs.ErrInvalidReference)
}
schemeAPI, err := rc.schemeGet(r.Scheme)
if err != nil {
return nil, err
}
return schemeAPI.BlobGet(ctx, r, d)
}
// BlobGetOCIConfig retrieves an OCI config from a blob, automatically extracting the JSON.
func (rc *RegClient) BlobGetOCIConfig(ctx context.Context, r ref.Ref, d descriptor.Descriptor) (blob.OCIConfig, error) {
if !r.IsSetRepo() {
return nil, fmt.Errorf("ref is not set: %s%.0w", r.CommonName(), errs.ErrInvalidReference)
}
b, err := rc.BlobGet(ctx, r, d)
if err != nil {
return nil, err
}
return b.ToOCIConfig()
}
// BlobHead is used to verify if a blob exists and is accessible.
func (rc *RegClient) BlobHead(ctx context.Context, r ref.Ref, d descriptor.Descriptor) (blob.Reader, error) {
if !r.IsSetRepo() {
return nil, fmt.Errorf("ref is not set: %s%.0w", r.CommonName(), errs.ErrInvalidReference)
}
schemeAPI, err := rc.schemeGet(r.Scheme)
if err != nil {
return nil, err
}
return schemeAPI.BlobHead(ctx, r, d)
}
// BlobMount attempts to perform a server side copy/mount of the blob between repositories.
func (rc *RegClient) BlobMount(ctx context.Context, refSrc ref.Ref, refTgt ref.Ref, d descriptor.Descriptor) error {
if !refSrc.IsSetRepo() {
return fmt.Errorf("ref is not set: %s%.0w", refSrc.CommonName(), errs.ErrInvalidReference)
}
if !refTgt.IsSetRepo() {
return fmt.Errorf("ref is not set: %s%.0w", refTgt.CommonName(), errs.ErrInvalidReference)
}
schemeAPI, err := rc.schemeGet(refSrc.Scheme)
if err != nil {
return err
}
return schemeAPI.BlobMount(ctx, refSrc, refTgt, d)
}
// BlobPut uploads a blob to a repository.
// Descriptor is optional, leave size and digest to zero value if unknown.
// Reader must also be an [io.Seeker] to support chunked upload fallback.
//
// This will attempt an anonymous blob mount first which some registries may support.
// It will then try doing a full put of the blob without chunking (most widely supported).
// If the full put fails, it will fall back to a chunked upload (useful for flaky networks).
func (rc *RegClient) BlobPut(ctx context.Context, r ref.Ref, d descriptor.Descriptor, rdr io.Reader) (descriptor.Descriptor, error) {
if !r.IsSetRepo() {
return descriptor.Descriptor{}, fmt.Errorf("ref is not set: %s%.0w", r.CommonName(), errs.ErrInvalidReference)
}
schemeAPI, err := rc.schemeGet(r.Scheme)
if err != nil {
return descriptor.Descriptor{}, err
}
return schemeAPI.BlobPut(ctx, r, d, rdr)
}
-100
View File
@@ -1,100 +0,0 @@
package config
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"strings"
)
// credHelper wraps a command that manages user credentials.
type credHelper struct {
prog string
env map[string]string
}
func newCredHelper(prog string, env map[string]string) *credHelper {
return &credHelper{prog: prog, env: env}
}
func (ch *credHelper) run(arg string, input io.Reader) ([]byte, error) {
//#nosec G204 only untrusted arg is a hostname which the executed command should not trust
cmd := exec.Command(ch.prog, arg)
cmd.Env = os.Environ()
if ch.env != nil {
for k, v := range ch.env {
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
}
}
cmd.Stderr = os.Stderr
cmd.Stdin = input
return cmd.Output()
}
type credStore struct {
ServerURL string `json:"ServerURL"`
Username string `json:"Username"`
Secret string `json:"Secret"` //#nosec G117 exported struct intentionally holds secrets
}
// get requests a credential from the helper for a given host.
func (ch *credHelper) get(host *Host) error {
hostname := host.Hostname
if host.CredHost != "" {
hostname = host.CredHost
}
hostIn := strings.NewReader(hostname)
credOut := credStore{
Username: host.User,
Secret: host.Pass,
}
outB, err := ch.run("get", hostIn)
if err != nil {
outS := strings.TrimSpace(string(outB))
return fmt.Errorf("error getting credentials, output: %s, error: %w", outS, err)
}
err = json.NewDecoder(bytes.NewReader(outB)).Decode(&credOut)
if err != nil {
return fmt.Errorf("error reading credentials: %w", err)
}
if credOut.Username == tokenUser {
host.User = ""
host.Pass = ""
host.Token = credOut.Secret
} else {
host.User = credOut.Username
host.Pass = credOut.Secret
host.Token = ""
}
return nil
}
// list returns a list of hosts supported by the credential helper.
func (ch *credHelper) list() ([]Host, error) {
credList := map[string]string{}
outB, err := ch.run("list", bytes.NewReader([]byte{}))
if err != nil {
outS := strings.TrimSpace(string(outB))
return nil, fmt.Errorf("error getting credential list, output: %s, error: %w", outS, err)
}
err = json.NewDecoder(bytes.NewReader(outB)).Decode(&credList)
if err != nil {
return nil, fmt.Errorf("error reading credential list: %w", err)
}
hostList := []Host{}
for host, user := range credList {
if !HostValidate(host) {
continue
}
h := HostNewName(host)
h.User = user
h.CredHelper = ch.prog
hostList = append(hostList, *h)
}
return hostList, nil
}
// TODO: store method not implemented
-210
View File
@@ -1,210 +0,0 @@
package config
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"strings"
"github.com/regclient/regclient/internal/conffile"
"github.com/regclient/regclient/types/errs"
)
const (
// dockerEnv is the environment variable used to look for Docker's config.json.
dockerEnv = "DOCKER_CONFIG"
// dockerEnvConfig is used to inject the config as an environment variable.
dockerEnvConfig = "DOCKER_AUTH_CONFIG"
// dockerDir is the directory name for Docker's config (inside the users home directory).
dockerDir = ".docker"
// dockerConfFile is the name of Docker's config file.
dockerConfFile = "config.json"
// dockerHelperPre is the prefix of docker credential helpers.
dockerHelperPre = "docker-credential-"
)
// dockerConfig is used to parse the ~/.docker/config.json
type dockerConfig struct {
AuthConfigs map[string]dockerAuthConfig `json:"auths"`
HTTPHeaders map[string]string `json:"HttpHeaders,omitempty"`
DetachKeys string `json:"detachKeys,omitempty"`
CredentialsStore string `json:"credsStore,omitempty"`
CredentialHelpers map[string]string `json:"credHelpers,omitempty"`
Proxies map[string]dockerProxyConfig `json:"proxies,omitempty"`
}
// dockerProxyConfig contains proxy configuration settings
type dockerProxyConfig struct {
HTTPProxy string `json:"httpProxy,omitempty"`
HTTPSProxy string `json:"httpsProxy,omitempty"`
NoProxy string `json:"noProxy,omitempty"`
FTPProxy string `json:"ftpProxy,omitempty"`
AllProxy string `json:"allProxy,omitempty"`
}
// dockerAuthConfig contains the auths
type dockerAuthConfig struct {
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"` //#nosec G117 exported struct intentionally holds secrets
Auth string `json:"auth,omitempty"`
ServerAddress string `json:"serveraddress,omitempty"`
// IdentityToken is used to authenticate the user and get
// an access token for the registry.
IdentityToken string `json:"identitytoken,omitempty"`
// RegistryToken is a bearer token to be sent to a registry
RegistryToken string `json:"registrytoken,omitempty"`
}
// DockerLoad returns a slice of hosts from the users docker config.
// This will search for the config.json in either the DOCKER_CONFIG identified directory or the default .docker directory.
// It also includes hosts extracted from the DOCKER_AUTH_CONFIG variable.
// If the config file is missing and no value is injected using an environment variable, an empty list is returned.
func DockerLoad() ([]Host, error) {
hosts := []Host{}
errList := []error{}
// load from a file
cf := conffile.New(
conffile.WithHomeDir(dockerDir, dockerConfFile, true),
conffile.WithEnvDir(dockerEnv, dockerConfFile),
)
rdr, err := cf.Open()
if err != nil && !errors.Is(err, fs.ErrNotExist) {
errList = append(errList, err)
} else if err == nil {
defer rdr.Close()
hostsFile, err := dockerParse(rdr)
if err != nil {
errList = append(errList, err)
} else {
hosts = append(hosts, hostsFile...)
}
}
// load from an env var
hostsEnv, err := DockerLoadEnv(dockerEnvConfig)
if err != nil && !errors.Is(err, errs.ErrNotFound) {
errList = append(errList, err)
} else if err == nil {
hosts = append(hosts, hostsEnv...)
}
// return the concatenated result, only wrapping an error list if necessary
if len(errList) == 1 {
return hosts, errList[0]
} else {
return hosts, errors.Join(errList...)
}
}
// DockerLoadFile returns a slice of hosts from a named docker config file.
func DockerLoadFile(fname string) ([]Host, error) {
//#nosec G304 scoping file operations to a directory is not yet a feature of regclient.
rdr, err := os.Open(fname)
if err != nil && errors.Is(err, fs.ErrNotExist) {
return []Host{}, nil
} else if err != nil {
return nil, err
}
defer rdr.Close()
return dockerParse(rdr)
}
// DockerLoadEnv returns a slice of hosts extracted from the config injected in an environment variable.
func DockerLoadEnv(envName string) ([]Host, error) {
envVal := os.Getenv(envName)
if envVal == "" {
return []Host{}, errs.ErrNotFound
}
return dockerParse(strings.NewReader(envVal))
}
// dockerParse parses a docker config into a slice of Hosts.
func dockerParse(rdr io.Reader) ([]Host, error) {
dc := dockerConfig{}
if err := json.NewDecoder(rdr).Decode(&dc); err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
hosts := []Host{}
for name, auth := range dc.AuthConfigs {
if !HostValidate(name) {
continue
}
h, err := dockerAuthToHost(name, dc, auth)
if err != nil {
continue
}
hosts = append(hosts, h)
}
// also include default entries for credential helpers
for name, helper := range dc.CredentialHelpers {
if !HostValidate(name) {
continue
}
h := HostNewName(name)
h.CredHelper = dockerHelperPre + helper
if _, ok := dc.AuthConfigs[name]; ok {
continue // skip fields with auth config
}
hosts = append(hosts, *h)
}
// add credStore entries
if dc.CredentialsStore != "" {
ch := newCredHelper(dockerHelperPre+dc.CredentialsStore, map[string]string{})
csHosts, err := ch.list()
if err == nil {
hosts = append(hosts, csHosts...)
}
}
return hosts, nil
}
// dockerAuthToHost parses an auth entry from a docker config into a Host.
func dockerAuthToHost(name string, conf dockerConfig, auth dockerAuthConfig) (Host, error) {
helper := ""
if conf.CredentialHelpers != nil && conf.CredentialHelpers[name] != "" {
helper = dockerHelperPre + conf.CredentialHelpers[name]
}
// parse base64 auth into user/pass
if auth.Auth != "" {
var err error
auth.Username, auth.Password, err = decodeAuth(auth.Auth)
if err != nil {
return Host{}, err
}
}
if (auth.Username == "" || auth.Password == "") && auth.IdentityToken == "" && helper == "" {
return Host{}, fmt.Errorf("no credentials found for %s", name)
}
h := HostNewName(name)
// ignore unknown names
if h.Name != DockerRegistry && !strings.HasSuffix(strings.TrimSuffix(name, "/"), h.Name) {
return Host{}, fmt.Errorf("rejecting entry with repository: %s", name)
}
h.User = auth.Username
h.Pass = auth.Password
h.Token = auth.IdentityToken
h.CredHelper = helper
return *h, nil
}
// decodeAuth extracts a base64 encoded user:pass into the username and password.
func decodeAuth(authStr string) (string, string, error) {
if authStr == "" {
return "", "", nil
}
decoded, err := base64.StdEncoding.DecodeString(authStr)
if err != nil {
return "", "", err
}
userPass := strings.SplitN(string(decoded), ":", 2)
if len(userPass) != 2 {
return "", "", fmt.Errorf("invalid auth configuration file")
}
return userPass[0], strings.Trim(userPass[1], "\x00"), nil
}
-521
View File
@@ -1,521 +0,0 @@
// Package config is used for all regclient configuration settings.
package config
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"maps"
"slices"
"strings"
"time"
"github.com/regclient/regclient/internal/timejson"
)
// TLSConf specifies whether TLS is enabled and verified for a host.
type TLSConf int
const (
// TLSUndefined indicates TLS is not passed, defaults to Enabled.
TLSUndefined TLSConf = iota
// TLSEnabled uses TLS (https) for the connection.
TLSEnabled
// TLSInsecure uses TLS but does not verify CA.
TLSInsecure
// TLSDisabled does not use TLS (http).
TLSDisabled
)
const (
// DockerRegistry is the name resolved in docker images on Hub.
DockerRegistry = "docker.io"
// DockerRegistryAuth is the name provided in docker's config for Hub.
DockerRegistryAuth = "https://index.docker.io/v1/"
// DockerRegistryDNS is the host to connect to for Hub.
DockerRegistryDNS = "registry-1.docker.io"
// defaultExpire is the default time to expire a credential and force re-authentication.
defaultExpire = time.Hour * 1
// defaultCredHelperRetry is the time to refresh a credential from a failed credential helper command.
defaultCredHelperRetry = time.Second * 5
// defaultConcurrent is the default number of concurrent registry connections.
defaultConcurrent = 3
// defaultReqPerSec is the default maximum frequency to send requests to a registry.
defaultReqPerSec = 0
// tokenUser is the username returned by credential helpers that indicates the password is an identity token.
tokenUser = "<token>"
)
// MarshalJSON converts TLSConf to a json string using MarshalText.
func (t TLSConf) MarshalJSON() ([]byte, error) {
s, err := t.MarshalText()
if err != nil {
return []byte(""), err
}
return json.Marshal(string(s))
}
// MarshalText converts TLSConf to a string.
func (t TLSConf) MarshalText() ([]byte, error) {
var s string
switch t {
default:
s = ""
case TLSEnabled:
s = "enabled"
case TLSInsecure:
s = "insecure"
case TLSDisabled:
s = "disabled"
}
return []byte(s), nil
}
// UnmarshalJSON converts TLSConf from a json string.
func (t *TLSConf) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
return t.UnmarshalText([]byte(s))
}
// UnmarshalText converts TLSConf from a string.
func (t *TLSConf) UnmarshalText(b []byte) error {
switch strings.ToLower(string(b)) {
default:
return fmt.Errorf("unknown TLS value \"%s\"", b)
case "":
*t = TLSUndefined
case "enabled":
*t = TLSEnabled
case "insecure":
*t = TLSInsecure
case "disabled":
*t = TLSDisabled
}
return nil
}
// Host defines settings for connecting to a registry.
type Host struct {
Name string `json:"-" yaml:"registry,omitempty"` // Name of the registry (required) (yaml configs pass this as a field, json provides this from the object key)
TLS TLSConf `json:"tls,omitempty" yaml:"tls"` // TLS setting: enabled (default), disabled, insecure
RegCert string `json:"regcert,omitempty" yaml:"regcert"` // public pem cert of registry
ClientCert string `json:"clientCert,omitempty" yaml:"clientCert"` // public pem cert for client (mTLS)
ClientKey string `json:"clientKey,omitempty" yaml:"clientKey"` //#nosec G117 private pem cert for client (mTLS)
Hostname string `json:"hostname,omitempty" yaml:"hostname"` // hostname of registry, default is the registry name
User string `json:"user,omitempty" yaml:"user"` // username, not used with credHelper
Pass string `json:"pass,omitempty" yaml:"pass"` //#nosec G117 password, not used with credHelper
Token string `json:"token,omitempty" yaml:"token"` // token, experimental for specific APIs
CredHelper string `json:"credHelper,omitempty" yaml:"credHelper"` // credential helper command for requesting logins
CredExpire timejson.Duration `json:"credExpire,omitempty" yaml:"credExpire"` // time until credential expires
CredHost string `json:"credHost,omitempty" yaml:"credHost"` // used when a helper hostname doesn't match Hostname
PathPrefix string `json:"pathPrefix,omitempty" yaml:"pathPrefix"` // used for mirrors defined within a repository namespace
Mirrors []string `json:"mirrors,omitempty" yaml:"mirrors"` // list of other Host Names to use as mirrors
Priority uint `json:"priority,omitempty" yaml:"priority"` // priority when sorting mirrors, higher priority attempted first
RepoAuth bool `json:"repoAuth,omitempty" yaml:"repoAuth"` // tracks a separate auth per repo
API string `json:"api,omitempty" yaml:"api"` // Deprecated: registry API to use
APIOpts map[string]string `json:"apiOpts,omitempty" yaml:"apiOpts"` // options for APIs
BlobChunk int64 `json:"blobChunk,omitempty" yaml:"blobChunk"` // size of each blob chunk
BlobMax int64 `json:"blobMax,omitempty" yaml:"blobMax"` // threshold to switch to chunked upload, -1 to disable, 0 for regclient.blobMaxPut
ReqPerSec float64 `json:"reqPerSec,omitempty" yaml:"reqPerSec"` // requests per second
ReqConcurrent int64 `json:"reqConcurrent,omitempty" yaml:"reqConcurrent"` // concurrent requests, default is defaultConcurrent(3)
Scheme string `json:"scheme,omitempty" yaml:"scheme"` // Deprecated: use TLS instead
credRefresh time.Time `json:"-" yaml:"-"` // internal use, when to refresh credentials
}
// Cred defines a user credential for accessing a registry.
type Cred struct {
User, Password, Token string //#nosec G117 exported struct intentionally holds secrets
}
// HostNew creates a default Host entry.
func HostNew() *Host {
h := Host{
TLS: TLSEnabled,
APIOpts: map[string]string{},
ReqConcurrent: int64(defaultConcurrent),
ReqPerSec: float64(defaultReqPerSec),
}
return &h
}
// HostNewName creates a default Host with a hostname.
func HostNewName(name string) *Host {
return HostNewDefName(nil, name)
}
// HostNewDefName creates a host using provided defaults and hostname.
func HostNewDefName(def *Host, name string) *Host {
var h Host
if def == nil {
h = *HostNew()
} else {
h = *def
// configure required defaults
if h.TLS == TLSUndefined {
h.TLS = TLSEnabled
}
if h.APIOpts == nil {
h.APIOpts = map[string]string{}
}
if h.ReqConcurrent == 0 {
h.ReqConcurrent = int64(defaultConcurrent)
}
if h.ReqPerSec == 0 {
h.ReqPerSec = float64(defaultReqPerSec)
}
// copy any fields that are not passed by value
if len(h.APIOpts) > 0 {
orig := h.APIOpts
h.APIOpts = map[string]string{}
maps.Copy(h.APIOpts, orig)
}
if h.Mirrors != nil {
orig := h.Mirrors
h.Mirrors = make([]string, len(orig))
copy(h.Mirrors, orig)
}
}
// configure host
scheme, registry, _ := parseName(name)
if scheme == "http" {
h.TLS = TLSDisabled
}
// Docker Hub is a special case
if registry == DockerRegistry {
h.Name = DockerRegistry
h.Hostname = DockerRegistryDNS
h.CredHost = DockerRegistryAuth
return &h
}
h.Name = registry
h.Hostname = registry
if name != registry {
h.CredHost = name
}
return &h
}
// HostValidate returns true if the scheme is missing or a known value, and the path is not set.
func HostValidate(name string) bool {
scheme, _, path := parseName(name)
return path == "" && (scheme == "https" || scheme == "http")
}
// GetCred returns the credential, fetching from a credential helper if needed.
func (host *Host) GetCred() Cred {
// refresh from credHelper if needed
if host.CredHelper != "" && (host.credRefresh.IsZero() || time.Now().After(host.credRefresh)) {
host.refreshHelper()
}
return Cred{User: host.User, Password: host.Pass, Token: host.Token}
}
func (host *Host) refreshHelper() {
if host.CredHelper == "" {
return
}
if host.CredExpire <= 0 {
host.CredExpire = timejson.Duration(defaultExpire)
}
// run a cred helper, calling get method
ch := newCredHelper(host.CredHelper, map[string]string{})
err := ch.get(host)
if err != nil {
host.credRefresh = time.Now().Add(defaultCredHelperRetry)
} else {
host.credRefresh = time.Now().Add(time.Duration(host.CredExpire))
}
}
// IsZero returns true if the struct is set to the zero value or the result of [HostNew].
func (host Host) IsZero() bool {
if (host.TLS != TLSUndefined && host.TLS != TLSEnabled) ||
host.RegCert != "" ||
host.ClientCert != "" ||
host.ClientKey != "" ||
(host.Hostname != "" && host.Hostname != host.Name) ||
host.User != "" ||
host.Pass != "" ||
host.Token != "" ||
host.CredHelper != "" ||
host.CredExpire != 0 ||
host.CredHost != "" ||
host.PathPrefix != "" ||
len(host.Mirrors) != 0 ||
host.Priority != 0 ||
host.RepoAuth ||
len(host.APIOpts) != 0 ||
host.BlobChunk != 0 ||
host.BlobMax != 0 ||
(host.ReqPerSec != 0 && host.ReqPerSec != float64(defaultReqPerSec)) ||
(host.ReqConcurrent != 0 && host.ReqConcurrent != int64(defaultConcurrent)) ||
!host.credRefresh.IsZero() {
return false
}
return true
}
// Merge adds fields from a new config host entry.
func (host *Host) Merge(newHost Host, log *slog.Logger) error {
name := newHost.Name
if name == "" {
name = host.Name
}
if log == nil {
log = slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{}))
}
// merge the existing and new config host
if host.Name == "" {
// only set the name if it's not initialized, this shouldn't normally change
host.Name = newHost.Name
}
if newHost.CredHelper == "" && (newHost.Pass != "" || host.Token != "") {
// unset existing cred helper for user/pass or token
host.CredHelper = ""
host.CredExpire = 0
}
if newHost.CredHelper != "" && newHost.User == "" && newHost.Pass == "" && newHost.Token == "" {
// unset existing user/pass/token for cred helper
host.User = ""
host.Pass = ""
host.Token = ""
}
if newHost.User != "" {
if host.User != "" && host.User != newHost.User {
log.Warn("Changing login user for registry",
slog.String("orig", host.User),
slog.String("new", newHost.User),
slog.String("host", name))
}
host.User = newHost.User
}
if newHost.Pass != "" {
if host.Pass != "" && host.Pass != newHost.Pass {
log.Warn("Changing login password for registry",
slog.String("host", name))
}
host.Pass = newHost.Pass
}
if newHost.Token != "" {
if host.Token != "" && host.Token != newHost.Token {
log.Warn("Changing login token for registry",
slog.String("host", name))
}
host.Token = newHost.Token
}
if newHost.CredHelper != "" {
if host.CredHelper != "" && host.CredHelper != newHost.CredHelper {
log.Warn("Changing credential helper for registry",
slog.String("host", name),
slog.String("orig", host.CredHelper),
slog.String("new", newHost.CredHelper))
}
host.CredHelper = newHost.CredHelper
}
if newHost.CredExpire != 0 {
if host.CredExpire != 0 && host.CredExpire != newHost.CredExpire {
log.Warn("Changing credential expire for registry",
slog.String("host", name),
slog.Any("orig", host.CredExpire),
slog.Any("new", newHost.CredExpire))
}
host.CredExpire = newHost.CredExpire
}
if newHost.CredHost != "" {
if host.CredHost != "" && host.CredHost != newHost.CredHost {
log.Warn("Changing credential host for registry",
slog.String("host", name),
slog.String("orig", host.CredHost),
slog.String("new", newHost.CredHost))
}
host.CredHost = newHost.CredHost
}
if newHost.TLS != TLSUndefined {
if host.TLS != TLSUndefined && host.TLS != newHost.TLS {
tlsOrig, _ := host.TLS.MarshalText()
tlsNew, _ := newHost.TLS.MarshalText()
log.Warn("Changing TLS settings for registry",
slog.String("orig", string(tlsOrig)),
slog.String("new", string(tlsNew)),
slog.String("host", name))
}
host.TLS = newHost.TLS
}
if newHost.RegCert != "" {
if host.RegCert != "" && host.RegCert != newHost.RegCert {
log.Warn("Changing certificate settings for registry",
slog.String("orig", host.RegCert),
slog.String("new", newHost.RegCert),
slog.String("host", name))
}
host.RegCert = newHost.RegCert
}
if newHost.ClientCert != "" {
if host.ClientCert != "" && host.ClientCert != newHost.ClientCert {
log.Warn("Changing client certificate settings for registry",
slog.String("orig", host.ClientCert),
slog.String("new", newHost.ClientCert),
slog.String("host", name))
}
host.ClientCert = newHost.ClientCert
}
if newHost.ClientKey != "" {
if host.ClientKey != "" && host.ClientKey != newHost.ClientKey {
log.Warn("Changing client certificate key settings for registry",
slog.String("host", name))
}
host.ClientKey = newHost.ClientKey
}
if newHost.Hostname != "" {
if host.Hostname != "" && host.Hostname != newHost.Hostname {
log.Warn("Changing hostname settings for registry",
slog.String("orig", host.Hostname),
slog.String("new", newHost.Hostname),
slog.String("host", name))
}
host.Hostname = newHost.Hostname
}
if newHost.PathPrefix != "" {
newHost.PathPrefix = strings.Trim(newHost.PathPrefix, "/") // leading and trailing / are not needed
if host.PathPrefix != "" && host.PathPrefix != newHost.PathPrefix {
log.Warn("Changing path prefix settings for registry",
slog.String("orig", host.PathPrefix),
slog.String("new", newHost.PathPrefix),
slog.String("host", name))
}
host.PathPrefix = newHost.PathPrefix
}
if len(newHost.Mirrors) > 0 {
if len(host.Mirrors) > 0 && !slices.Equal(host.Mirrors, newHost.Mirrors) {
log.Warn("Changing mirror settings for registry",
slog.Any("orig", host.Mirrors),
slog.Any("new", newHost.Mirrors),
slog.String("host", name))
}
host.Mirrors = newHost.Mirrors
}
if newHost.Priority != 0 {
if host.Priority != 0 && host.Priority != newHost.Priority {
log.Warn("Changing priority settings for registry",
slog.Uint64("orig", uint64(host.Priority)),
slog.Uint64("new", uint64(newHost.Priority)),
slog.String("host", name))
}
host.Priority = newHost.Priority
}
if newHost.RepoAuth {
host.RepoAuth = newHost.RepoAuth
}
// TODO: eventually delete
if newHost.API != "" {
log.Warn("API field has been deprecated",
slog.String("api", newHost.API),
slog.String("host", name))
}
if len(newHost.APIOpts) > 0 {
if len(host.APIOpts) > 0 {
merged := maps.Clone(host.APIOpts)
for k, v := range newHost.APIOpts {
if host.APIOpts[k] != "" && host.APIOpts[k] != v {
log.Warn("Changing APIOpts setting for registry",
slog.String("orig", host.APIOpts[k]),
slog.String("new", newHost.APIOpts[k]),
slog.String("opt", k),
slog.String("host", name))
}
merged[k] = v
}
host.APIOpts = merged
} else {
host.APIOpts = newHost.APIOpts
}
}
if newHost.BlobChunk > 0 {
if host.BlobChunk != 0 && host.BlobChunk != newHost.BlobChunk {
log.Warn("Changing blobChunk settings for registry",
slog.Int64("orig", host.BlobChunk),
slog.Int64("new", newHost.BlobChunk),
slog.String("host", name))
}
host.BlobChunk = newHost.BlobChunk
}
if newHost.BlobMax != 0 {
if host.BlobMax != 0 && host.BlobMax != newHost.BlobMax {
log.Warn("Changing blobMax settings for registry",
slog.Int64("orig", host.BlobMax),
slog.Int64("new", newHost.BlobMax),
slog.String("host", name))
}
host.BlobMax = newHost.BlobMax
}
if newHost.ReqPerSec != 0 {
if host.ReqPerSec != 0 && host.ReqPerSec != newHost.ReqPerSec {
log.Warn("Changing reqPerSec settings for registry",
slog.Float64("orig", host.ReqPerSec),
slog.Float64("new", newHost.ReqPerSec),
slog.String("host", name))
}
host.ReqPerSec = newHost.ReqPerSec
}
if newHost.ReqConcurrent > 0 {
if host.ReqConcurrent != 0 && host.ReqConcurrent != newHost.ReqConcurrent {
log.Warn("Changing reqPerSec settings for registry",
slog.Int64("orig", host.ReqConcurrent),
slog.Int64("new", newHost.ReqConcurrent),
slog.String("host", name))
}
host.ReqConcurrent = newHost.ReqConcurrent
}
return nil
}
// parseName splits a registry into the scheme, hostname, and repository/path.
func parseName(name string) (string, string, string) {
scheme := "https"
path := ""
// Docker Hub is a special case
if name == DockerRegistryAuth || name == DockerRegistryDNS || name == DockerRegistry {
return scheme, DockerRegistry, ""
}
// handle http/https prefix
i := strings.Index(name, "://")
if i > 0 {
scheme = name[:i]
name = name[i+3:]
}
// trim any repository path
i = strings.Index(name, "/")
if i > 0 {
path = name[i+1:]
name = name[:i]
}
return scheme, name, path
}
-1905
View File
File diff suppressed because it is too large Load Diff
-923
View File
@@ -1,923 +0,0 @@
// Package auth is used for HTTP authentication
package auth
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"slices"
"strings"
"sync"
"time"
"github.com/regclient/regclient/internal/regnet"
"github.com/regclient/regclient/types/errs"
)
type charLU byte
var charLUs [256]charLU
var defaultClientID = "regclient"
// minTokenLife tokens are required to last at least 60 seconds to support older docker clients
var minTokenLife = 60
// tokenBuffer is used to renew a token before it expires to account for time to process requests on the server
var tokenBuffer = time.Second * 5
const (
isSpace charLU = 1 << iota
isToken
)
func init() {
for c := range 256 {
charLUs[c] = 0
if strings.ContainsRune(" \t\r\n", rune(c)) {
charLUs[c] |= isSpace
}
if (rune('a') <= rune(c) && rune(c) <= rune('z')) || (rune('A') <= rune(c) && rune(c) <= rune('Z') || (rune('0') <= rune(c) && rune(c) <= rune('9')) || strings.ContainsRune("-._~+/", rune(c))) {
charLUs[c] |= isToken
}
}
}
// CredsFn is passed to lookup credentials for a given hostname, response is a username and password or empty strings
type CredsFn func(host string) Cred
// Cred is returned by the CredsFn.
// If Token is provided and auth method is bearer, it will attempt to use it as a refresh token.
// Else if user and password are provided, they are attempted with all auth methods.
// Else if neither are provided and auth method is bearer, an anonymous login is attempted.
type Cred struct {
//#nosec G117 exported struct intentionally holds secrets
User, Password string // clear text username and password
Token string // refresh token only used for bearer auth
}
// challenge is the extracted contents of the WWW-Authenticate header.
type challenge struct {
authType string
params map[string]string
}
// handler handles a challenge for a host to return an auth header
type handler interface {
AddScope(scope string) error
ProcessChallenge(challenge) error
UpdateRequest(*http.Request) error
}
// handlerBuild is used to make a new handler for a specific authType and URL
type handlerBuild func(client *http.Client, clientID, host string, credFn CredsFn, slog *slog.Logger) handler
// Opts configures options for NewAuth
type Opts func(*Auth)
// Auth is used to handle authentication requests.
type Auth struct {
httpClient *http.Client
clientID string
credsFn CredsFn
hbs map[string]handlerBuild // handler builders based on authType
hs map[string]map[string]handler // handlers based on url and authType
authTypes []string
slog *slog.Logger
mu sync.Mutex
}
// NewAuth creates a new Auth
func NewAuth(opts ...Opts) *Auth {
a := &Auth{
httpClient: &http.Client{},
clientID: defaultClientID,
credsFn: DefaultCredsFn,
hbs: map[string]handlerBuild{},
hs: map[string]map[string]handler{},
authTypes: []string{},
slog: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})),
}
for _, opt := range opts {
opt(a)
}
if len(a.authTypes) == 0 {
a.addDefaultHandlers()
}
return a
}
// WithCreds provides a user/pass lookup for a url
func WithCreds(f CredsFn) Opts {
return func(a *Auth) {
if f != nil {
a.credsFn = f
}
}
}
// WithHTTPClient uses a specific http client with requests
func WithHTTPClient(h *http.Client) Opts {
return func(a *Auth) {
if h != nil {
a.httpClient = h
}
}
}
// WithClientID uses a client ID with request headers
func WithClientID(clientID string) Opts {
return func(a *Auth) {
a.clientID = clientID
}
}
// WithHandler includes a handler for a specific auth type
func WithHandler(authType string, hb handlerBuild) Opts {
return func(a *Auth) {
lcat := strings.ToLower(authType)
a.hbs[lcat] = hb
a.authTypes = append(a.authTypes, lcat)
}
}
// WithDefaultHandlers includes a Basic and Bearer handler, this is automatically added with "WithHandler" is not called
func WithDefaultHandlers() Opts {
return func(a *Auth) {
a.addDefaultHandlers()
}
}
// WithLog injects a Logger
func WithLog(slog *slog.Logger) Opts {
return func(a *Auth) {
a.slog = slog
}
}
// AddScope extends an existing auth with additional scopes.
// This is used to pre-populate scopes with the Docker convention rather than
// depend on the registry to respond with the correct http status and headers.
func (a *Auth) AddScope(host, scope string) error {
a.mu.Lock()
defer a.mu.Unlock()
success := false
if a.hs[host] == nil {
return errs.ErrNoNewChallenge
}
for _, at := range a.authTypes {
if a.hs[host][at] != nil {
err := a.hs[host][at].AddScope(scope)
if err == nil {
success = true
} else if err != errs.ErrNoNewChallenge {
return err
}
}
}
if !success {
return errs.ErrNoNewChallenge
}
a.slog.Debug("Auth scope added",
slog.String("host", host),
slog.String("scope", scope))
return nil
}
// HandleResponse parses the 401 response, extracting the WWW-Authenticate
// header and verifying the requirement is different from what was included in
// the last request
func (a *Auth) HandleResponse(resp *http.Response) error {
a.mu.Lock()
defer a.mu.Unlock()
// verify response is an access denied
if resp.StatusCode != http.StatusUnauthorized {
return errs.ErrUnsupported
}
// extract host and auth header
host := resp.Request.URL.Host
cl, err := ParseAuthHeaders(resp.Header.Values("WWW-Authenticate"))
if err != nil {
return err
}
a.slog.Debug("Auth request parsed",
slog.Any("challenge", cl))
if len(cl) < 1 {
return errs.ErrEmptyChallenge
}
goodChallenge := false
// loop over the received challenge(s)
for _, c := range cl {
if _, ok := a.hbs[c.authType]; !ok {
a.slog.Warn("Unsupported auth type",
slog.String("authtype", c.authType))
continue
}
// setup a handler for the host and auth type
if _, ok := a.hs[host]; !ok {
a.hs[host] = map[string]handler{}
}
if _, ok := a.hs[host][c.authType]; !ok {
h := a.hbs[c.authType](a.httpClient, a.clientID, host, a.credsFn, a.slog)
if h == nil {
continue
}
a.hs[host][c.authType] = h
}
// process the challenge with that handler
err := a.hs[host][c.authType].ProcessChallenge(c)
if err == nil {
goodChallenge = true
} else if err == errs.ErrNoNewChallenge {
// handle race condition when another request updates the challenge
// detect that by seeing the current auth header is different
prevAH := resp.Request.Header.Get("Authorization")
err := a.hs[host][c.authType].UpdateRequest(resp.Request)
if err == nil && prevAH != resp.Request.Header.Get("Authorization") {
goodChallenge = true
}
} else {
return err
}
}
if !goodChallenge {
return errs.ErrHTTPUnauthorized
}
return nil
}
// UpdateRequest adds Authorization headers to a request
func (a *Auth) UpdateRequest(req *http.Request) error {
a.mu.Lock()
defer a.mu.Unlock()
host := req.URL.Host
if a.hs[host] == nil {
return nil
}
var err error
for _, at := range a.authTypes {
if a.hs[host][at] != nil {
err = a.hs[host][at].UpdateRequest(req)
if err != nil {
a.slog.Debug("Failed to generate auth",
slog.String("err", err.Error()),
slog.String("host", host),
slog.String("authtype", at))
continue
}
break
}
}
if err != nil {
return err
}
return nil
}
func (a *Auth) addDefaultHandlers() {
if _, ok := a.hbs["basic"]; !ok {
a.hbs["basic"] = NewBasicHandler
a.authTypes = append(a.authTypes, "basic")
}
if _, ok := a.hbs["bearer"]; !ok {
a.hbs["bearer"] = NewBearerHandler
a.authTypes = append(a.authTypes, "bearer")
}
}
// DefaultCredsFn is used to return no credentials when auth is not configured with a CredsFn
// This avoids the need to check for nil pointers
func DefaultCredsFn(h string) Cred {
return Cred{}
}
// ParseAuthHeaders extracts the scheme and realm from WWW-Authenticate headers
func ParseAuthHeaders(ahl []string) ([]challenge, error) {
var cl []challenge
for _, ah := range ahl {
c, err := parseAuthHeader(ah)
if err != nil {
return nil, fmt.Errorf("failed to parse challenge header: %s, %w", ah, err)
}
cl = append(cl, c...)
}
return cl, nil
}
// parseAuthHeader parses a single header line for WWW-Authenticate
// Example values:
// Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:samalba/my-app:pull,push"
// Basic realm="GitHub Package Registry"
func parseAuthHeader(ah string) ([]challenge, error) {
var cl []challenge
var c *challenge
curElement := []byte{}
curKey := ""
stateElement := "string"
stateSyntax := "start"
for _, b := range []byte(ah) {
switch stateElement {
case "string":
// string: ignore leading space, enter quote only if first character, handle escapes, handle valid tokens, else end
if charLUs[b]&isToken != 0 {
// add valid tokens to element
curElement = append(curElement, b)
} else if charLUs[b]&isSpace != 0 && len(curElement) == 0 {
// ignore leading spaces
} else if b == '"' && len(curElement) == 0 {
stateElement = "quote"
} else if b == '\\' {
stateElement = "escape_string"
} else {
stateElement = "end"
}
case "quote":
// quote: handle escapes, handle closing quote (quote_end to read next character), all other tokens are valid
if b == '\\' {
stateElement = "escape_quote"
} else if b == '"' {
stateElement = "quote_end"
} else {
curElement = append(curElement, b)
}
case "quote_end":
// any character after the close quote is the end of the element
stateElement = "end"
case "escape_string":
// escape_string: handle any character and return to string state
curElement = append(curElement, b)
stateElement = "string"
case "escape_quote":
// escape_quote: handle any character and return to quote state
curElement = append(curElement, b)
stateElement = "quote"
case "end":
// finished parsing element, continue to processing element according to the current state
default:
return nil, fmt.Errorf("unhandled element case: %w", errs.ErrParsingFailed)
}
if stateElement != "end" {
// continue parsing the element until it ends
continue
}
// syntax looks at each string within the overall challenge syntax
switch stateSyntax {
case "start":
// start: (start of auth_type) read auth_type and space (end_auth_type) or auth_type and comma (start)
if charLUs[b]&isSpace != 0 && len(curElement) > 0 {
stateSyntax = "end_auth_type"
} else if b == ',' && len(curElement) > 0 {
// state remains at start
} else {
return nil, fmt.Errorf("start element did not end with a space or comma: %w", errs.ErrParsingFailed)
}
c = &challenge{authType: strings.ToLower(string(curElement)), params: map[string]string{}}
cl = append(cl, *c)
case "start_or_param":
// start_or_param: (after param_value) read auth_type and space (end_auth_type) or param_key and equals (param_value)
if charLUs[b]&isSpace != 0 && len(curElement) > 0 {
c = &challenge{authType: strings.ToLower(string(curElement)), params: map[string]string{}}
cl = append(cl, *c)
stateSyntax = "end_auth_type"
} else if b == '=' && len(curElement) > 0 {
curKey = strings.ToLower(string(curElement))
stateSyntax = "param_value"
} else {
return nil, fmt.Errorf("expected auth type or param: %w", errs.ErrParsingFailed)
}
case "end_auth_type":
// end_auth_type: (after reading auth_type) read param_key and equals (param_value) or just a comma (start)
if b == '=' && len(curElement) > 0 {
curKey = strings.ToLower(string(curElement))
stateSyntax = "param_value"
} else if b == ',' && len(curElement) == 0 {
// ignore white space between end of auth_type and comma
stateSyntax = "start"
} else {
return nil, fmt.Errorf("expected param or comma: %w", errs.ErrParsingFailed)
}
case "param_value":
// param_value: (after param_key) read param_value and comma (start_or_param)
if b == ',' {
c.params[curKey] = string(curElement)
stateSyntax = "start_or_param"
curKey = ""
} else {
return nil, fmt.Errorf("expected param value: %w", errs.ErrParsingFailed)
}
default:
return nil, fmt.Errorf("unhandled syntax case: %w", errs.ErrParsingFailed)
}
// reset element state
stateElement = "string"
curElement = []byte{}
}
// at end of parsing, if the element is not empty, process according to syntax state:
if len(curElement) > 0 {
// ensure this is not within an unclosed quote or partial escape
if stateElement != "string" && stateElement != "quote_end" {
return nil, fmt.Errorf("eol element in state %s: %w", stateElement, errs.ErrParsingFailed)
}
switch stateSyntax {
case "start", "start_or_param":
// add a new auth type if a string is seen at the start, before any equals
c = &challenge{authType: strings.ToLower(string(curElement)), params: map[string]string{}}
cl = append(cl, *c)
case "param_value":
// add the last param key=val
c.params[curKey] = string(curElement)
case "end_auth_type":
// missing equals for param
return nil, fmt.Errorf("eol at param without value: %w", errs.ErrParsingFailed)
}
}
return cl, nil
}
// basicHandler supports Basic auth type requests
type basicHandler struct {
realm string
host string
credsFn CredsFn
}
// NewBasicHandler creates a new BasicHandler
func NewBasicHandler(client *http.Client, clientID, host string, credsFn CredsFn, slog *slog.Logger) handler {
return &basicHandler{
realm: "",
host: host,
credsFn: credsFn,
}
}
// AddScope is not valid for BasicHandler
func (b *basicHandler) AddScope(scope string) error {
return errs.ErrNoNewChallenge
}
// ProcessChallenge for BasicHandler is a noop
func (b *basicHandler) ProcessChallenge(c challenge) error {
if _, ok := c.params["realm"]; !ok {
return errs.ErrInvalidChallenge
}
if b.realm != c.params["realm"] {
b.realm = c.params["realm"]
return nil
}
return errs.ErrNoNewChallenge
}
// UpdateRequest for BasicHandler generates base64 encoded user/pass for a host
func (b *basicHandler) UpdateRequest(req *http.Request) error {
cred := b.credsFn(b.host)
if cred.User == "" || cred.Password == "" {
return fmt.Errorf("no credentials available: %w", errs.ErrHTTPUnauthorized)
}
req.Header.Set("Authorization", fmt.Sprintf("Basic %s",
base64.StdEncoding.EncodeToString([]byte(cred.User+":"+cred.Password))))
return nil
}
// bearerHandler supports Bearer auth type requests
type bearerHandler struct {
client *http.Client
clientID string
realm, service string
host string
credsFn CredsFn
scopes []string
tokenURL *url.URL
token bearerToken
slog *slog.Logger
}
// bearerToken is the json response to the Bearer request
type bearerToken struct {
Token string `json:"token"`
AccessToken string `json:"access_token"` //#nosec G117 exported struct intentionally holds secrets
ExpiresIn int `json:"expires_in"`
IssuedAt time.Time `json:"issued_at"`
RefreshToken string `json:"refresh_token"` //#nosec G117 exported struct intentionally holds secrets
Scope string `json:"scope"`
}
// NewBearerHandler creates a new BearerHandler
func NewBearerHandler(client *http.Client, clientID, host string, credsFn CredsFn, slog *slog.Logger) handler {
return &bearerHandler{
client: client,
clientID: clientID,
host: host,
credsFn: credsFn,
realm: "",
service: "",
scopes: []string{},
slog: slog,
}
}
// AddScope appends a new scope if it doesn't already exist
func (b *bearerHandler) AddScope(scope string) error {
if b.scopeExists(scope) {
if b.token.Token == "" || !b.isExpired() {
return errs.ErrNoNewChallenge
}
return nil
}
b.addScope(scope)
return nil
}
func (b *bearerHandler) addScope(scope string) {
if !b.tryExtendExistingScope(scope) {
b.scopes = append(b.scopes, scope)
}
// delete old token
b.token.Token = ""
}
var knownActions = []string{"pull", "push", "delete"}
// tryExtendExistingScope extends an existing scope if both the new scope and the current scope contain only knownActions.
// It returns true if actions are added or are already present. Otherwise, it returns false,
// indicating that the new scope should be appended to b.scopes instead.
func (b *bearerHandler) tryExtendExistingScope(scope string) bool {
repo, actions, ok := parseScope(scope)
if !ok {
return false
}
scopePrefix := "repository:" + repo + ":"
for i, cur := range b.scopes {
if !strings.HasPrefix(cur, scopePrefix) {
continue
}
_, curActions, curOk := parseScope(cur)
if !curOk {
continue
}
for _, a := range actions {
if !slices.Contains(curActions, a) {
curActions = append(curActions, a)
}
}
b.scopes[i] = scopePrefix + strings.Join(curActions, ",")
return true
}
return false
}
// parseScope splits a scope into the repo and slice of actions.
// Unknown actions in the scope will set bool to false.
func parseScope(scope string) (string, []string, bool) {
scopeSplit := strings.SplitN(scope, ":", 3)
if scopeSplit[0] != "repository" || len(scopeSplit) < 3 {
return "", nil, false
}
actionSplit := strings.Split(scopeSplit[2], ",")
for _, a := range actionSplit {
if !slices.Contains(knownActions, a) {
return "", nil, false
}
}
return scopeSplit[1], actionSplit, true
}
// ProcessChallenge handles WWW-Authenticate header for bearer tokens
// Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:samalba/my-app:pull,push"
func (b *bearerHandler) ProcessChallenge(c challenge) error {
if _, ok := c.params["realm"]; !ok {
return errs.ErrInvalidChallenge
}
if _, ok := c.params["service"]; !ok {
c.params["service"] = ""
}
if _, ok := c.params["scope"]; !ok {
c.params["scope"] = ""
}
existingScope := b.scopeExists(c.params["scope"])
if b.realm == c.params["realm"] && b.service == c.params["service"] && existingScope && (b.token.Token == "" || !b.isExpired()) {
return errs.ErrNoNewChallenge
}
if b.realm == "" {
b.realm = c.params["realm"]
} else if b.realm != c.params["realm"] {
return errs.ErrInvalidChallenge
}
if b.service == "" {
b.service = c.params["service"]
} else if b.service != c.params["service"] {
return errs.ErrInvalidChallenge
}
if !existingScope {
b.addScope(c.params["scope"])
}
return nil
}
// UpdateRequest for BearerHandler adds a bearer token to the request.
func (b *bearerHandler) UpdateRequest(req *http.Request) error {
// handle relative realm values
if b.tokenURL == nil {
u, err := req.URL.Parse(b.realm)
if err != nil {
return err
}
b.tokenURL = u
}
// verify tokenURL is allowed for request URL
if err := regnet.AllowRedirect(*req.URL, *b.tokenURL); err != nil {
return err
}
// if unexpired token already exists, return it
if b.token.Token != "" && !b.isExpired() {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", b.token.Token))
return nil
}
// attempt to post if a refresh token is available or token auth is being used
cred := b.credsFn(b.host)
if b.token.RefreshToken != "" || cred.Token != "" {
if err := b.tryPost(cred); err == nil {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", b.token.Token))
return nil
} else if err != errs.ErrHTTPUnauthorized {
return fmt.Errorf("failed to request auth token (post): %w%.0w", err, errs.ErrHTTPUnauthorized)
}
}
// attempt a get (with basic auth if user/pass available)
if err := b.tryGet(cred); err == nil {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", b.token.Token))
return nil
} else if err != errs.ErrHTTPUnauthorized {
return fmt.Errorf("failed to request auth token (get): %w%.0w", err, errs.ErrHTTPUnauthorized)
}
return errs.ErrHTTPUnauthorized
}
// isExpired returns true when token issue date is either 0, token has expired,
// or will expire within buffer time
func (b *bearerHandler) isExpired() bool {
if b.token.IssuedAt.IsZero() {
return true
}
expireSec := b.token.IssuedAt.Add(time.Duration(b.token.ExpiresIn) * time.Second)
expireSec = expireSec.Add(tokenBuffer * -1)
return time.Now().After(expireSec)
}
// tryGet requests a new token with a GET request
func (b *bearerHandler) tryGet(cred Cred) error {
//#nosec G704 inputs follow specification
req, err := http.NewRequest("GET", b.tokenURL.String(), nil)
if err != nil {
return err
}
reqParams := req.URL.Query()
reqParams.Add("client_id", b.clientID)
// Note, an offline_token should not be requested by default due to broken OAuth2 implementations returning an invalid token
if b.service != "" {
reqParams.Add("service", b.service)
}
for _, s := range b.scopes {
reqParams.Add("scope", s)
}
if cred.User != "" && cred.Password != "" {
reqParams.Add("account", cred.User)
req.SetBasicAuth(cred.User, cred.Password)
}
req.Header.Add("User-Agent", b.clientID)
req.URL.RawQuery = reqParams.Encode()
//#nosec G704 inputs follow specification
resp, err := b.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return b.validateResponse(resp)
}
// tryPost requests a new token via a POST request
func (b *bearerHandler) tryPost(cred Cred) error {
form := url.Values{}
if len(b.scopes) > 0 {
form.Set("scope", strings.Join(b.scopes, " "))
}
if b.service != "" {
form.Set("service", b.service)
}
form.Set("client_id", b.clientID)
if b.token.RefreshToken != "" {
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", b.token.RefreshToken)
} else if cred.Token != "" {
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", cred.Token)
} else if cred.User != "" && cred.Password != "" {
form.Set("grant_type", "password")
form.Set("username", cred.User)
form.Set("password", cred.Password)
}
//#nosec G704 inputs are user controlled or follow specification
req, err := http.NewRequest("POST", b.tokenURL.String(), strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
req.Header.Add("User-Agent", b.clientID)
//#nosec G704 inputs are user controlled or follow specification
resp, err := b.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return b.validateResponse(resp)
}
// scopeExists check if the scope already exists within the list of scopes
func (b *bearerHandler) scopeExists(search string) bool {
if search == "" {
return true
}
searchRepo, searchActions, searchOk := parseScope(search)
if !searchOk {
return slices.Contains(b.scopes, search)
}
scopePrefix := "repository:" + searchRepo + ":"
for _, scope := range b.scopes {
if scope == search {
return true
}
if !strings.HasPrefix(scope, scopePrefix) {
continue
}
_, actions, ok := parseScope(scope)
if !ok {
continue
}
for _, sa := range searchActions {
if !slices.Contains(actions, sa) {
return false
}
}
return true
}
return false
}
// validateResponse extracts the returned token
func (b *bearerHandler) validateResponse(resp *http.Response) error {
if resp.StatusCode != 200 {
return errs.ErrHTTPUnauthorized
}
// decode response and if successful, update token
decoder := json.NewDecoder(resp.Body)
decoded := bearerToken{}
if err := decoder.Decode(&decoded); err != nil {
return err
}
b.token = decoded
if b.token.ExpiresIn < minTokenLife {
b.token.ExpiresIn = minTokenLife
}
// If token is already expired, it was sent with a zero value or
// there may be a clock skew between the client and auth server.
// Also handle cases of remote time in the future.
// But if remote time is slightly in the past, leave as is so token
// expires here before the server.
if b.isExpired() || b.token.IssuedAt.After(time.Now()) {
b.token.IssuedAt = time.Now().UTC()
}
// AccessToken and Token should be the same and we use Token elsewhere
if b.token.AccessToken != "" {
b.token.Token = b.token.AccessToken
}
return nil
}
// jwtHubHandler supports JWT auth type requests.
type jwtHubHandler struct {
client *http.Client
clientID string
realm string
host string
credsFn CredsFn
jwt string
}
type jwtHubPost struct {
User string `json:"username"`
Pass string `json:"password"` //#nosec G117 exported struct intentionally holds secrets
}
type jwtHubResp struct {
Detail string `json:"detail"`
Token string `json:"token"`
RefreshToken string `json:"refresh_token"` //#nosec G117 exported struct intentionally holds secrets
}
// NewJWTHubHandler creates a new JWTHandler for Docker Hub.
func NewJWTHubHandler(client *http.Client, clientID, host string, credsFn CredsFn, slog *slog.Logger) handler {
// JWT handler is only tested against Hub, and the API is Hub specific
if host == "hub.docker.com" {
return &jwtHubHandler{
client: client,
clientID: clientID,
host: host,
credsFn: credsFn,
realm: "https://hub.docker.com/v2/users/login",
}
}
return nil
}
// AddScope is not valid for JWTHubHandler
func (j *jwtHubHandler) AddScope(scope string) error {
return errs.ErrNoNewChallenge
}
// ProcessChallenge handles WWW-Authenticate header for JWT auth on Docker Hub
func (j *jwtHubHandler) ProcessChallenge(c challenge) error {
cred := j.credsFn(j.host)
// use token if provided
if cred.Token != "" {
j.jwt = cred.Token
return nil
}
// send a login request to hub
bodyBytes, err := json.Marshal(jwtHubPost{
User: cred.User,
Pass: cred.Password, //#nosec G117 field name follows spec
})
if err != nil {
return err
}
req, err := http.NewRequest("POST", j.realm, bytes.NewReader(bodyBytes))
if err != nil {
return err
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Accept", "application/json")
req.Header.Add("User-Agent", j.clientID)
//#nosec G704 inputs are user controlled or follow specification requirements
resp, err := j.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 || resp.StatusCode >= 300 {
return errs.ErrHTTPUnauthorized
}
var bodyParsed jwtHubResp
err = json.Unmarshal(body, &bodyParsed)
if err != nil {
return err
}
j.jwt = bodyParsed.Token
return nil
}
// UpdateRequest for JWTHubHandler adds JWT header
func (j *jwtHubHandler) UpdateRequest(req *http.Request) error {
if len(j.jwt) > 0 {
req.Header.Set("Authorization", fmt.Sprintf("JWT %s", j.jwt))
return nil
}
return errs.ErrHTTPUnauthorized
}
-48
View File
@@ -1,48 +0,0 @@
package auth
import (
"github.com/regclient/regclient/types/errs"
)
var (
// ErrEmptyChallenge indicates an issue with the received challenge in the WWW-Authenticate header
//
// Deprecated: replace with [errs.ErrEmptyChallenge].
//go:fix inline
ErrEmptyChallenge = errs.ErrEmptyChallenge
// ErrInvalidChallenge indicates an issue with the received challenge in the WWW-Authenticate header
//
// Deprecated: replace with [errs.ErrInvalidChallenge].
//go:fix inline
ErrInvalidChallenge = errs.ErrInvalidChallenge
// ErrNoNewChallenge indicates a challenge update did not result in any change
//
// Deprecated: replace with [errs.ErrNoNewChallenge].
//go:fix inline
ErrNoNewChallenge = errs.ErrNoNewChallenge
// ErrNotFound indicates no credentials found for basic auth
//
// Deprecated: replace with [errs.ErrNotFound].
//go:fix inline
ErrNotFound = errs.ErrNotFound
// ErrNotImplemented returned when method has not been implemented yet
//
// Deprecated: replace with [errs.ErrNotImplemented].
//go:fix inline
ErrNotImplemented = errs.ErrNotImplemented
// ErrParseFailure indicates the WWW-Authenticate header could not be parsed
//
// Deprecated: replace with [errs.ErrParseFailure].
//go:fix inline
ErrParseFailure = errs.ErrParsingFailed
// ErrUnauthorized request was not authorized
//
// Deprecated: replace with [errs.ErrUnauthorized].
//go:fix inline
ErrUnauthorized = errs.ErrHTTPUnauthorized
// ErrUnsupported indicates the request was unsupported
//
// Deprecated: replace with [errs.ErrUnsupported].
//go:fix inline
ErrUnsupported = errs.ErrUnsupported
)
-181
View File
@@ -1,181 +0,0 @@
//go:build go1.18
// Package cache is used to store values with limits.
// Items are automatically pruned when too many entries are stored, or values become stale.
package cache
import (
"sort"
"sync"
"time"
"github.com/regclient/regclient/types/errs"
)
type Cache[k comparable, v any] struct {
mu sync.Mutex
minAge time.Duration
maxAge time.Duration
minCount int
maxCount int
timer *time.Timer
entries map[k]*Entry[v]
}
type Entry[v any] struct {
used time.Time
value v
}
type sortKeys[k comparable] struct {
keys []k
lessFn func(a, b k) bool
}
type conf struct {
minAge time.Duration
maxCount int
}
type cacheOpts func(*conf)
func WithAge(age time.Duration) cacheOpts {
return func(c *conf) {
c.minAge = age
}
}
func WithCount(count int) cacheOpts {
return func(c *conf) {
c.maxCount = count
}
}
func New[k comparable, v any](opts ...cacheOpts) Cache[k, v] {
c := conf{}
for _, opt := range opts {
opt(&c)
}
maxAge := c.minAge + (c.minAge / 10)
minCount := 0
if c.maxCount > 0 {
minCount = int(float64(c.maxCount) * 0.9)
}
return Cache[k, v]{
minAge: c.minAge,
maxAge: maxAge,
minCount: minCount,
maxCount: c.maxCount,
entries: map[k]*Entry[v]{},
}
}
func (c *Cache[k, v]) Delete(key k) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
delete(c.entries, key)
if len(c.entries) == 0 && c.timer != nil {
c.timer.Stop()
c.timer = nil
}
}
func (c *Cache[k, v]) Set(key k, val v) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.entries[key] = &Entry[v]{
used: time.Now(),
value: val,
}
if len(c.entries) > c.maxCount {
c.pruneLocked()
} else if c.timer == nil {
// prune resets the timer, so this is only needed if the prune wasn't triggered
c.timer = time.AfterFunc(c.maxAge, c.prune)
}
}
func (c *Cache[k, v]) Get(key k) (v, error) {
if c == nil {
var val v
return val, errs.ErrNotFound
}
c.mu.Lock()
defer c.mu.Unlock()
if e, ok := c.entries[key]; ok {
if e.used.Add(c.minAge).Before(time.Now()) {
// entry expired
go c.prune()
} else {
c.entries[key].used = time.Now()
return e.value, nil
}
}
var val v
return val, errs.ErrNotFound
}
func (c *Cache[k, v]) prune() {
c.mu.Lock()
defer c.mu.Unlock()
c.pruneLocked()
}
func (c *Cache[k, v]) pruneLocked() {
// sort key list by last used date
keyList := make([]k, 0, len(c.entries))
for key := range c.entries {
keyList = append(keyList, key)
}
sk := sortKeys[k]{
keys: keyList,
lessFn: func(a, b k) bool {
return c.entries[a].used.Before(c.entries[b].used)
},
}
sort.Sort(&sk)
// prune entries
now := time.Now()
cutoff := now.Add(c.minAge * -1)
nextTime := now
delCount := len(keyList) - c.minCount
for i, key := range keyList {
if i < delCount || c.entries[key].used.Before(cutoff) {
delete(c.entries, key)
} else {
nextTime = c.entries[key].used
break
}
}
// set next timer
if len(c.entries) > 0 {
dur := nextTime.Sub(now) + c.maxAge
if c.timer == nil {
// this shouldn't be possible
c.timer = time.AfterFunc(dur, c.prune)
} else {
c.timer.Reset(dur)
}
} else if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
}
func (sk *sortKeys[k]) Len() int {
return len(sk.keys)
}
func (sk *sortKeys[k]) Less(i, j int) bool {
return sk.lessFn(sk.keys[i], sk.keys[j])
}
func (sk *sortKeys[k]) Swap(i, j int) {
sk.keys[i], sk.keys[j] = sk.keys[j], sk.keys[i]
}
-188
View File
@@ -1,188 +0,0 @@
// Package conffile wraps the read and write of configuration files
package conffile
import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/user"
"path/filepath"
)
type File struct {
perms int
fullname string
}
type Opt func(*File)
// New returns a new File.
// The last successful option determines the filename.
func New(opts ...Opt) *File {
f := File{perms: 0o600}
for _, fn := range opts {
fn(&f)
}
if f.fullname == "" {
return nil
}
return &f
}
// WithAppDir determines the filename from the XDG or Windows specification.
// By default, this is based in $HOME/.config on Linux and %APPDATA% on Windows.
// If the file does not exist, this will set the filename only if "force" is true.
func WithAppDir(unixDir, winDir, name string, force bool) Opt {
var dir string
if winDir == "" {
dir = unixDir
} else {
dir = osString(unixDir, winDir)
}
return func(f *File) {
fullname := filepath.Join(appDir(), dir, name)
if force || exists(fullname) {
f.fullname = fullname
}
}
}
// WithDirName determines the filename from a subdirectory in the user's HOME.
//
// Deprecated: Replace with [WithHomeDir]
//
//go:fix inline
func WithDirName(dir, name string) Opt {
return WithHomeDir(dir, name, true)
}
// WithEnvFile sets the fullname to the environment value if defined.
func WithEnvFile(envVar string) Opt {
return func(f *File) {
val := os.Getenv(envVar)
if val != "" {
f.fullname = val
}
}
}
// WithEnvDir sets the fullname to the environment value + filename if the environment variable is defined.
func WithEnvDir(envVar, name string) Opt {
return func(f *File) {
val := os.Getenv(envVar)
if val != "" {
f.fullname = filepath.Join(val, name)
}
}
}
// WithFullname specifies the filename.
// This will always set the filename even if the file does not exist.
func WithFullname(fullname string) Opt {
return func(f *File) {
f.fullname = fullname
}
}
// WithHomeDir determines the filename from a subdirectory in the user's HOME
// e.g. dir=".app", name="config.json", sets the fullname to "$HOME/.app/config.json".
// If the file does not exist, this will set the filename only if "force" is true.
func WithHomeDir(dir, name string, force bool) Opt {
return func(f *File) {
filename := filepath.Join(homeDir(), dir, name)
if force || exists(filename) {
f.fullname = filename
}
}
}
// WithPerms specifies the permissions to create a file with (default 0600).
func WithPerms(perms int) Opt {
return func(f *File) {
f.perms = perms
}
}
func (f *File) Name() string {
return f.fullname
}
func (f *File) Open() (io.ReadCloser, error) {
return os.Open(f.fullname)
}
func (f *File) Write(rdr io.Reader) error {
// create temp file/open
dir := filepath.Dir(f.fullname)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, filepath.Base(f.fullname))
if err != nil {
return err
}
tmpStat, err := tmp.Stat()
if err != nil {
return err
}
tmpName := tmpStat.Name()
tmpFullname := filepath.Join(dir, tmpName)
defer os.Remove(tmpFullname)
// copy from rdr to temp file
_, err = io.Copy(tmp, rdr)
errC := tmp.Close()
if err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
if errC != nil {
return fmt.Errorf("failed to close config: %w", errC)
}
// adjust file ownership/permissions
mode := os.FileMode(0o600)
uid := os.Getuid()
gid := os.Getgid()
// adjust defaults based on existing file if available
stat, err := os.Stat(f.fullname)
if err == nil {
// adjust mode to existing file
if stat.Mode().IsRegular() {
mode = stat.Mode()
}
uid, gid, _ = getFileOwner(stat)
} else if !errors.Is(err, fs.ErrNotExist) {
return err
}
// update mode and owner of temp file
//#nosec G703 tempfile location is user controlled
if err := os.Chmod(tmpFullname, mode); err != nil {
return err
}
if uid > 0 && gid > 0 {
//#nosec G703 tempfile location is user controlled
_ = os.Chown(tmpFullname, uid, gid)
}
// move temp file to target filename
//#nosec G703 tempfile location is user controlled
return os.Rename(tmpFullname, f.fullname)
}
func exists(name string) bool {
_, err := os.Stat(name)
return err == nil
}
func homeDir() string {
home := os.Getenv(homeEnv)
if home == "" {
u, err := user.Current()
if err == nil {
home = u.HomeDir
}
}
return home
}
@@ -1,37 +0,0 @@
//go:build !windows
package conffile
import (
"io/fs"
"os"
"path/filepath"
"syscall"
)
const (
appDirEnv = "XDG_CONFIG_HOME"
homeEnv = "HOME"
)
func appDir() string {
appDir := os.Getenv(appDirEnv)
if appDir == "" {
home := homeDir()
appDir = filepath.Join(home, ".config")
}
return appDir
}
func getFileOwner(stat fs.FileInfo) (int, int, error) {
var uid, gid int
if sysstat, ok := stat.Sys().(*syscall.Stat_t); ok {
uid = int(sysstat.Uid)
gid = int(sysstat.Gid)
}
return uid, gid, nil
}
func osString(unix, _ string) string {
return unix
}
@@ -1,31 +0,0 @@
//go:build windows
package conffile
import (
"io/fs"
"os"
"path/filepath"
)
const (
appDirEnv = "APPDATA"
homeEnv = "USERPROFILE"
)
func appDir() string {
appDir := os.Getenv(appDirEnv)
if appDir == "" {
home := homeDir()
appDir = filepath.Join(home, "AppData")
}
return appDir
}
func getFileOwner(_ fs.FileInfo) (int, int, error) {
return 0, 0, nil
}
func osString(_, win string) string {
return win
}
-198
View File
@@ -1,198 +0,0 @@
// Package httplink parses the Link header from HTTP responses according to RFC5988
package httplink
import (
"fmt"
"strings"
"github.com/regclient/regclient/types/errs"
)
type (
Links []Link
Link struct {
URI string
Param map[string]string
}
)
type charLU byte
var charLUs [256]charLU
const (
isSpace charLU = 1 << iota
isToken
isAlphaNum
)
func init() {
for c := range 256 {
charLUs[c] = 0
if strings.ContainsRune(" \t\r\n", rune(c)) {
charLUs[c] |= isSpace
}
if (rune('a') <= rune(c) && rune(c) <= rune('z')) || (rune('A') <= rune(c) && rune(c) <= rune('Z') || (rune('0') <= rune(c) && rune(c) <= rune('9'))) {
charLUs[c] |= isAlphaNum | isToken
}
if strings.ContainsRune("!#$%&'()*+-./:<=>?@[]^_`{|}~", rune(c)) {
charLUs[c] |= isToken
}
}
}
// Parse reads "Link" http headers into an array of Link structs.
// Header array should be the output of resp.Header.Values("link").
func Parse(headers []string) (Links, error) {
links := []Link{}
for _, h := range headers {
state := "init"
var ub, pnb, pvb []byte
parms := map[string]string{}
endLink := func() {
links = append(links, Link{
URI: string(ub),
Param: parms,
})
// reset state
ub, pnb, pvb = []byte{}, []byte{}, []byte{}
parms = map[string]string{}
}
endParm := func() {
if _, ok := parms[string(pnb)]; !ok {
parms[string(pnb)] = string(pvb)
}
// reset parm
pnb, pvb = []byte{}, []byte{}
}
for i, b := range []byte(h) {
switch state {
case "init":
if b == '<' {
state = "uriQuoted"
} else if charLUs[b]&isToken != 0 {
state = "uri"
ub = append(ub, b)
} else if charLUs[b]&isSpace != 0 || b == ',' {
// noop
} else {
// unknown character
return nil, fmt.Errorf("unknown character in position %d of %s: %w", i, h, errs.ErrParsingFailed)
}
case "uri":
// parse tokens until space or comma
if charLUs[b]&isToken != 0 {
ub = append(ub, b)
} else if charLUs[b]&isSpace != 0 {
state = "fieldSep"
} else if b == ';' {
state = "parmName"
} else if b == ',' {
state = "init"
endLink()
} else {
// unknown character
return nil, fmt.Errorf("unknown character in position %d of %s: %w", i, h, errs.ErrParsingFailed)
}
case "uriQuoted":
// parse tokens until quote
if b == '>' {
state = "fieldSep"
} else {
ub = append(ub, b)
}
case "fieldSep":
if b == ';' {
state = "parmName"
} else if b == ',' {
state = "init"
endLink()
} else if charLUs[b]&isSpace != 0 {
// noop
} else {
// unknown character
return nil, fmt.Errorf("unknown character in position %d of %s: %w", i, h, errs.ErrParsingFailed)
}
case "parmName":
if len(pnb) > 0 && b == '=' {
state = "parmValue"
} else if len(pnb) > 0 && b == '*' {
state = "parmNameStar"
} else if charLUs[b]&isAlphaNum != 0 {
pnb = append(pnb, b)
} else if len(pnb) == 0 && charLUs[b]&isSpace != 0 {
// noop
} else {
// unknown character
return nil, fmt.Errorf("unknown character in position %d of %s: %w", i, h, errs.ErrParsingFailed)
}
case "parmNameStar":
if b == '=' {
state = "parmValue"
} else {
// unknown character
return nil, fmt.Errorf("unknown character in position %d of %s: %w", i, h, errs.ErrParsingFailed)
}
case "parmValue":
if len(pvb) == 0 {
if charLUs[b]&isToken != 0 {
pvb = append(pvb, b)
} else if b == '"' {
state = "parmValueQuoted"
} else {
// unknown character
return nil, fmt.Errorf("unknown character in position %d of %s: %w", i, h, errs.ErrParsingFailed)
}
} else {
if charLUs[b]&isToken != 0 {
pvb = append(pvb, b)
} else if charLUs[b]&isSpace != 0 {
state = "fieldSep"
endParm()
} else if b == ';' {
state = "parmName"
endParm()
} else if b == ',' {
state = "init"
endParm()
endLink()
} else {
// unknown character
return nil, fmt.Errorf("unknown character in position %d of %s: %w", i, h, errs.ErrParsingFailed)
}
}
case "parmValueQuoted":
if b == '"' {
state = "fieldSep"
endParm()
} else {
pvb = append(pvb, b)
}
}
}
// check for valid state at end of header
switch state {
case "parmValue":
endParm()
endLink()
case "uri", "fieldSep":
endLink()
case "init":
// noop
default:
return nil, fmt.Errorf("unexpected end state %s for header %s: %w", state, h, errs.ErrParsingFailed)
}
}
return links, nil
}
// Get returns a link with a specific parm value, e.g. rel="next"
func (links Links) Get(parm, val string) (Link, error) {
for _, link := range links {
if link.Param != nil && link.Param[parm] == val {
return link, nil
}
}
return Link{}, errs.ErrNotFound
}
-29
View File
@@ -1,29 +0,0 @@
// Package limitread provides a reader that will error if the limit is ever exceeded
package limitread
import (
"fmt"
"io"
"github.com/regclient/regclient/types/errs"
)
type LimitRead struct {
Reader io.Reader
Limit int64
}
func (lr *LimitRead) Read(p []byte) (int, error) {
if lr.Limit < 0 {
return 0, fmt.Errorf("read limit exceeded%.0w", errs.ErrSizeLimitExceeded)
}
if int64(len(p)) > lr.Limit+1 {
p = p[0 : lr.Limit+1]
}
n, err := lr.Reader.Read(p)
lr.Limit -= int64(n)
if lr.Limit < 0 {
return n, fmt.Errorf("read limit exceeded%.0w", errs.ErrSizeLimitExceeded)
}
return n, err
}
-257
View File
@@ -1,257 +0,0 @@
// Package pqueue implements a priority queue.
package pqueue
import (
"context"
"fmt"
"slices"
"sync"
)
type Queue[T any] struct {
mu sync.Mutex
max int
next func(queued, active []*T) int
active []*T
queued []*T
wait []*chan struct{}
}
// Opts is used to configure a new priority queue.
type Opts[T any] struct {
Max int // maximum concurrent entries, defaults to 1.
Next func(queued, active []*T) int // function to lookup index of next queued entry to release, defaults to oldest entry.
}
// New creates a new priority queue.
func New[T any](opts Opts[T]) *Queue[T] {
if opts.Max <= 0 {
opts.Max = 1
}
return &Queue[T]{
max: opts.Max,
next: opts.Next,
}
}
// Acquire adds a new entry to the queue and returns once it is ready.
// The returned function must be called when the queued job completes to release the next entry.
// If there is any error, the returned function will be nil.
func (q *Queue[T]) Acquire(ctx context.Context, e T) (func(), error) {
if q == nil {
return func() {}, nil
}
found, err := q.checkContext(ctx)
if err != nil {
return nil, err
}
if found {
return func() {}, nil
}
q.mu.Lock()
if len(q.active)+len(q.queued) < q.max {
q.active = append(q.active, &e)
q.mu.Unlock()
return q.releaseFn(&e), nil
}
// limit reached, add to queue and wait
w := make(chan struct{}, 1)
q.queued = append(q.queued, &e)
q.wait = append(q.wait, &w)
q.mu.Unlock()
// wait on both context and queue
select {
case <-ctx.Done():
// context abort, remove queued entry
q.mu.Lock()
if i := slices.Index(q.queued, &e); i >= 0 {
q.queued = slices.Delete(q.queued, i, i+1)
q.wait = slices.Delete(q.wait, i, i+1)
q.mu.Unlock()
return nil, ctx.Err()
}
q.mu.Unlock()
// queued entry found, assume race condition with context and entry being released, release next entry
q.release(&e)
return nil, ctx.Err()
case <-w:
return q.releaseFn(&e), nil
}
}
// TryAcquire attempts to add an entry on to the list of active entries.
// If the returned function is nil, the queue was not available.
// If the returned function is not nil, it must be called when the job is complete to release the next entry.
func (q *Queue[T]) TryAcquire(ctx context.Context, e T) (func(), error) {
if q == nil {
return func() {}, nil
}
found, err := q.checkContext(ctx)
if err != nil {
return nil, err
}
if found {
return func() {}, nil
}
q.mu.Lock()
defer q.mu.Unlock()
if len(q.active)+len(q.queued) < q.max {
q.active = append(q.active, &e)
return q.releaseFn(&e), nil
}
return nil, nil
}
// release next entry or noop.
func (q *Queue[T]) release(prev *T) {
q.mu.Lock()
defer q.mu.Unlock()
// remove prev entry from active list
if i := slices.Index(q.active, prev); i >= 0 {
q.active = slices.Delete(q.active, i, i+1)
}
// skip checks when at limit or nothing queued
if len(q.queued) == 0 {
if len(q.active) == 0 {
// free up slices if this was the last active entry
q.active = nil
q.queued = nil
q.wait = nil
}
return
}
if len(q.active) >= q.max {
return
}
i := 0
if q.next != nil && len(q.queued) > 1 {
i = q.next(q.queued, q.active)
// validate response
i = max(min(i, len(q.queued)-1), 0)
}
// release queued entry, move to active list, and remove from queued/wait lists
close(*q.wait[i])
q.active = append(q.active, q.queued[i])
q.queued = slices.Delete(q.queued, i, i+1)
q.wait = slices.Delete(q.wait, i, i+1)
}
// releaseFn is a convenience wrapper around [release].
func (q *Queue[T]) releaseFn(prev *T) func() {
return func() {
q.release(prev)
}
}
// TODO: is there a way to make a different context key for each generic type?
type ctxType int
var ctxKey ctxType
type valMulti[T any] struct {
qList []*Queue[T]
}
// AcquireMulti is used to simultaneously lock multiple queues without the risk of deadlock.
// The returned context needs to be used on calls to [Acquire] or [TryAcquire] which will immediately succeed since the resource is already acquired.
// Attempting to acquire other resources with [Acquire], [TryAcquire], or [AcquireMulti] using the returned context and will fail for being outside of the transaction.
// The returned function must be called to release the resources.
// The returned function is not thread safe, ensure no other simultaneous calls to [Acquire] or [TryAcquire] using the returned context have finished before it is called.
func AcquireMulti[T any](ctx context.Context, e T, qList ...*Queue[T]) (context.Context, func(), error) {
// verify context not already holding locks
qCtx := ctx.Value(ctxKey)
if qCtx != nil {
if qCtxVal, ok := qCtx.(*valMulti[T]); !ok || qCtxVal.qList != nil {
return ctx, nil, fmt.Errorf("context already used by another AcquireMulti request")
}
}
// delete nil entries
for i := len(qList) - 1; i >= 0; i-- {
if qList[i] == nil {
qList = slices.Delete(qList, i, i+1)
}
}
// empty/nil list is a noop
if len(qList) == 0 {
return ctx, func() {}, nil
}
// dedup entries from the list
for i := len(qList) - 2; i >= 0; i-- {
for j := len(qList) - 1; j > i; j-- {
if qList[i] == qList[j] {
qList[j] = qList[len(qList)-1]
qList = qList[:len(qList)-1]
}
}
}
// Loop through queues to acquire, waiting on the first, and attempting the remaining.
// If any of the remaining entries cannot be immediately acquired, reset and make it the new queue to wait on.
lockI := 0
doneList := make([]func(), len(qList))
for {
acquired := true
i := 0
done, err := qList[lockI].Acquire(ctx, e)
if err != nil {
return ctx, nil, err
}
doneList[lockI] = done
for i < len(qList) {
if i != lockI {
doneList[i], err = qList[i].TryAcquire(ctx, e)
if doneList[i] == nil || err != nil {
acquired = false
break
}
}
i++
}
if err == nil && acquired {
break
}
// cleanup on failed attempt
if lockI > i {
doneList[lockI]()
}
// track blocking index for a retry
lockI = i
for i > 0 {
i--
doneList[i]()
}
// abort on errors
if err != nil {
return ctx, nil, err
}
}
// success, update context
ctxVal := valMulti[T]{qList: qList}
newCtx := context.WithValue(ctx, ctxKey, &ctxVal)
cleanup := func() {
ctxVal.qList = nil
// dequeue in reverse order to minimize chance of another AcquireMulti being freed and immediately blocking on the next queue
for i := len(doneList) - 1; i >= 0; i-- {
doneList[i]()
}
}
return newCtx, cleanup, nil
}
func (q *Queue[T]) checkContext(ctx context.Context) (bool, error) {
qCtx := ctx.Value(ctxKey)
if qCtx == nil {
return false, nil
}
qCtxVal, ok := qCtx.(*valMulti[T])
if !ok {
return false, nil // another type is using the context, treat it as unset
}
if qCtxVal.qList == nil {
return false, nil
}
if slices.Contains(qCtxVal.qList, q) {
// instance already locked
return true, nil
}
return true, fmt.Errorf("cannot acquire new locks during a transaction")
}
-987
View File
@@ -1,987 +0,0 @@
// Package reghttp is used for HTTP requests to a registry
package reghttp
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strconv"
"strings"
"sync"
"time"
// crypto libraries included for go-digest
_ "crypto/sha256"
_ "crypto/sha512"
"github.com/regclient/regclient/config"
"github.com/regclient/regclient/internal/auth"
"github.com/regclient/regclient/internal/pqueue"
"github.com/regclient/regclient/internal/regnet"
"github.com/regclient/regclient/internal/reqmeta"
"github.com/regclient/regclient/types"
"github.com/regclient/regclient/types/errs"
"github.com/regclient/regclient/types/warning"
)
var (
defaultDelayInit, _ = time.ParseDuration("0.1s")
defaultDelayMax, _ = time.ParseDuration("30s")
warnRegexp = regexp.MustCompile(`^299\s+-\s+"([^"]+)"`)
)
const (
DefaultRetryLimit = 5 // number of times a request will be retried
backoffResetCount = 5 // number of successful requests needed to reduce the backoff
)
// Client is an HTTP client wrapper.
// It handles features like authentication, retries, backoff delays, TLS settings.
type Client struct {
httpClient *http.Client // upstream [http.Client], this is wrapped per repository for an auth handler on redirects
getConfigHost func(string) *config.Host // call-back to get the [config.Host] for a specific registry
host map[string]*clientHost // host specific settings, wrap access with a mutex lock
rootCAPool [][]byte // list of root CAs for configuring the http.Client transport
rootCADirs []string // list of directories for additional root CAs
retryLimit int // number of retries before failing a request, this applies to each host, and each request
delayInit time.Duration // how long to initially delay requests on a failure
delayMax time.Duration // maximum time to delay a request
slog *slog.Logger // logging for tracing and failures
userAgent string // user agent to specify in http request headers
mu sync.Mutex // mutex to prevent data races
}
type clientHost struct {
config *config.Host // config entry
httpClient *http.Client // modified http client for registry specific settings
userAgent string // user agent to specify in http request headers
slog *slog.Logger // logging for tracing and failures
auth map[string]*auth.Auth // map of auth handlers by repository
backoffCur int // current count of backoffs for this host
backoffLast time.Time // time the last request was released, this may be in the future if there is a queue, or zero if no delay is needed
backoffReset int // count of successful requests when a backoff is experienced, once [backoffResetCount] is reached, [backoffCur] is reduced by one and this is reset to 0
reqFreq time.Duration // how long between submitting requests for this host
reqNext time.Time // time to release the next request
throttle *pqueue.Queue[reqmeta.Data] // limit concurrent requests to the host
mu sync.Mutex // mutex to prevent data races
}
// Req is a request to send to a registry.
type Req struct {
MetaKind reqmeta.Kind // kind of request for the priority queue
Host string // registry name, hostname and mirrors will be looked up from host configuration
Method string // http method to call
DirectURL *url.URL // url to query, overrides repository, path, and query
Repository string // repository to scope the request
Path string // path of the request within a repository
Query url.Values // url query parameters
BodyLen int64 // length of body to send
BodyBytes []byte // bytes of the body, overridden by BodyFunc
BodyFunc func() (io.ReadCloser, error) // function to return a new body
Headers http.Header // headers to send in the request
NoPrefix bool // do not include the repository prefix
NoMirrors bool // do not send request to a mirror
ExpectLen int64 // expected size of the returned body
TransactLen int64 // size of an overall transaction for the priority queue
IgnoreErr bool // ignore http errors and do not trigger backoffs
}
// Resp is used to handle the result of a request.
type Resp struct {
ctx context.Context
client *Client
req *Req
resp *http.Response
mirror string
done bool
reader io.Reader
readCur, readMax int64
retryCount int
throttleDone func()
}
// Opts is used to configure client options.
type Opts func(*Client)
// NewClient returns a client for handling requests.
func NewClient(opts ...Opts) *Client {
c := Client{
httpClient: &http.Client{},
host: map[string]*clientHost{},
retryLimit: DefaultRetryLimit,
delayInit: defaultDelayInit,
delayMax: defaultDelayMax,
slog: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})),
rootCAPool: [][]byte{},
rootCADirs: []string{},
}
for _, opt := range opts {
opt(&c)
}
return &c
}
// WithCerts adds certificates.
func WithCerts(certs [][]byte) Opts {
return func(c *Client) {
c.rootCAPool = append(c.rootCAPool, certs...)
}
}
// WithCertDirs adds directories to check for host specific certs.
func WithCertDirs(dirs []string) Opts {
return func(c *Client) {
c.rootCADirs = append(c.rootCADirs, dirs...)
}
}
// WithCertFiles adds certificates by filename.
func WithCertFiles(files []string) Opts {
return func(c *Client) {
for _, f := range files {
//#nosec G304 command is run by a user accessing their own files
cert, err := os.ReadFile(f)
if err != nil {
c.slog.Warn("Failed to read certificate",
slog.String("err", err.Error()),
slog.String("file", f))
} else {
c.rootCAPool = append(c.rootCAPool, cert)
}
}
}
}
// WithConfigHostFn adds the callback to request a [config.Host] struct.
// The function must normalize the hostname for Docker Hub support.
func WithConfigHostFn(gch func(string) *config.Host) Opts {
return func(c *Client) {
c.getConfigHost = gch
}
}
// WithDelay initial time to wait between retries (increased with exponential backoff).
func WithDelay(delayInit time.Duration, delayMax time.Duration) Opts {
return func(c *Client) {
if delayInit > 0 {
c.delayInit = delayInit
}
// delayMax must be at least delayInit, if 0 initialize to 30x delayInit
if delayMax > c.delayInit {
c.delayMax = delayMax
} else if delayMax > 0 {
c.delayMax = c.delayInit
} else {
c.delayMax = c.delayInit * 30
}
}
}
// WithHTTPClient uses a specific http client with retryable requests.
func WithHTTPClient(hc *http.Client) Opts {
return func(c *Client) {
c.httpClient = hc
}
}
// WithRetryLimit restricts the number of retries (defaults to 5).
func WithRetryLimit(rl int) Opts {
return func(c *Client) {
if rl > 0 {
c.retryLimit = rl
}
}
}
// WithLog injects a slog Logger configuration.
func WithLog(slog *slog.Logger) Opts {
return func(c *Client) {
c.slog = slog
}
}
// WithTransport uses a specific http transport with retryable requests.
func WithTransport(t *http.Transport) Opts {
return func(c *Client) {
c.httpClient = &http.Client{Transport: t}
}
}
// WithUserAgent sets a user agent header.
func WithUserAgent(ua string) Opts {
return func(c *Client) {
c.userAgent = ua
}
}
// Do runs a request, returning the response result.
func (c *Client) Do(ctx context.Context, req *Req) (*Resp, error) {
resp := &Resp{
ctx: ctx,
client: c,
req: req,
readCur: 0,
readMax: req.ExpectLen,
}
err := resp.next()
return resp, err
}
// next sends requests until a mirror responds or all requests fail.
func (resp *Resp) next() error {
var err error
c := resp.client
req := resp.req
// lookup reqHost entry
reqHost := c.getHost(req.Host)
// create sorted list of mirrors, based on backoffs, upstream, and priority
hosts := make([]*clientHost, 0, 1+len(reqHost.config.Mirrors))
if !req.NoMirrors {
for _, m := range reqHost.config.Mirrors {
hosts = append(hosts, c.getHost(m))
}
}
hosts = append(hosts, reqHost)
sort.Slice(hosts, sortHostsCmp(hosts, reqHost.config.Name))
// loop over requests to mirrors and retries
curHost := 0
for {
backoff := false
dropHost := false
retryHost := false
if len(hosts) == 0 {
if err != nil {
return err
}
return errs.ErrAllRequestsFailed
}
if curHost >= len(hosts) {
curHost = 0
}
h := hosts[curHost]
resp.mirror = h.config.Name
// there is an intentional extra retry in this check to allow for auth requests
if resp.retryCount > c.retryLimit {
return errs.ErrRetryLimitExceeded
}
resp.retryCount++
// check that context isn't canceled/done
ctxErr := resp.ctx.Err()
if ctxErr != nil {
return ctxErr
}
// wait for other concurrent requests to this host
throttleDone, throttleErr := h.throttle.Acquire(resp.ctx, reqmeta.Data{
Kind: req.MetaKind,
Size: req.BodyLen + req.ExpectLen + req.TransactLen,
})
if throttleErr != nil {
return throttleErr
}
// try each host in a closure to handle all the backoff/dropHost from one place
loopErr := func() error {
var err error
if req.Method == "HEAD" && h.config.APIOpts != nil {
var disableHead bool
disableHead, err = strconv.ParseBool(h.config.APIOpts["disableHead"])
if err == nil && disableHead {
dropHost = true
return fmt.Errorf("head requests disabled for host \"%s\": %w", h.config.Name, errs.ErrUnsupportedAPI)
}
}
// build the url
var u url.URL
if req.DirectURL != nil {
u = *req.DirectURL
} else {
u = url.URL{
Host: h.config.Hostname,
Scheme: "https",
}
path := strings.Builder{}
path.WriteString("/v2")
if h.config.PathPrefix != "" && !req.NoPrefix {
path.WriteString("/" + h.config.PathPrefix)
}
if req.Repository != "" {
path.WriteString("/" + req.Repository)
}
path.WriteString("/" + req.Path)
u.Path = path.String()
if h.config.TLS == config.TLSDisabled {
u.Scheme = "http"
}
query := url.Values{}
if req.Query != nil {
query = req.Query
}
if h.config.Hostname != reqHost.config.Hostname {
query.Set("ns", reqHost.config.Hostname)
}
u.RawQuery = query.Encode()
}
// close previous response
if resp.resp != nil && resp.resp.Body != nil {
_ = resp.resp.Body.Close()
}
// delay for backoff if needed
bu := resp.backoffGet()
if !bu.IsZero() && bu.After(time.Now()) {
sleepTime := time.Until(bu)
c.slog.Debug("Sleeping for backoff",
slog.String("Host", h.config.Name),
slog.Duration("Duration", sleepTime))
select {
case <-resp.ctx.Done():
return errs.ErrCanceled
case <-time.After(sleepTime):
}
}
var httpReq *http.Request
httpReq, err = http.NewRequestWithContext(resp.ctx, req.Method, u.String(), nil)
if err != nil {
dropHost = true
return err
}
if req.BodyFunc != nil {
body, err := req.BodyFunc()
if err != nil {
dropHost = true
return err
}
httpReq.Body = body
httpReq.GetBody = req.BodyFunc
httpReq.ContentLength = req.BodyLen
} else if len(req.BodyBytes) > 0 {
body := io.NopCloser(bytes.NewReader(req.BodyBytes))
httpReq.Body = body
httpReq.GetBody = func() (io.ReadCloser, error) { return body, nil }
httpReq.ContentLength = req.BodyLen
}
if len(req.Headers) > 0 {
httpReq.Header = req.Headers.Clone()
}
if c.userAgent != "" && httpReq.Header.Get("User-Agent") == "" {
httpReq.Header.Add("User-Agent", c.userAgent)
}
if resp.readCur > 0 && resp.readMax > 0 {
if req.Headers.Get("Range") == "" {
httpReq.Header.Add("Range", fmt.Sprintf("bytes=%d-%d", resp.readCur, resp.readMax))
} else {
// TODO: support Seek within a range request
dropHost = true
return fmt.Errorf("unable to resume a connection within a range request")
}
}
hAuth := h.getAuth(req.Repository)
if hAuth != nil {
// include docker generated scope to emulate docker clients
if req.Repository != "" {
scope := "repository:" + req.Repository + ":pull"
if req.Method != "HEAD" && req.Method != "GET" {
scope = scope + ",push"
}
_ = hAuth.AddScope(h.config.Hostname, scope)
}
// add auth headers
err = hAuth.UpdateRequest(httpReq)
// abort on auth errors, but only after the first request has been attempted
if err != nil && resp.resp != nil {
if errors.Is(err, errs.ErrHTTPUnauthorized) {
dropHost = true
} else {
backoff = true
}
return err
}
}
// delay for the rate limit
if h.reqFreq > 0 {
sleep := time.Duration(0)
h.mu.Lock()
if time.Now().Before(h.reqNext) {
sleep = time.Until(h.reqNext)
h.reqNext = h.reqNext.Add(h.reqFreq)
} else {
h.reqNext = time.Now().Add(h.reqFreq)
}
h.mu.Unlock()
if sleep > 0 {
time.Sleep(sleep)
}
}
// send request
hc := h.getHTTPClient(req.Repository)
//#nosec G704 inputs are user controlled and sanitized
resp.resp, err = hc.Do(httpReq)
if err != nil {
c.slog.Debug("Request failed",
slog.String("URL", u.String()),
slog.String("err", err.Error()))
backoff = true
return err
}
statusCode := resp.resp.StatusCode
if statusCode < 200 || statusCode >= 300 {
switch statusCode {
case http.StatusUnauthorized:
// if auth can be done, retry same host without delay, otherwise drop/backoff
if hAuth != nil {
err = hAuth.HandleResponse(resp.resp)
} else {
err = fmt.Errorf("authentication handler unavailable")
}
if err != nil {
if errors.Is(err, errs.ErrEmptyChallenge) || errors.Is(err, errs.ErrNoNewChallenge) || errors.Is(err, errs.ErrHTTPUnauthorized) {
c.slog.Debug("Failed to handle auth request",
slog.String("URL", u.String()),
slog.String("Err", err.Error()))
} else {
c.slog.Warn("Failed to handle auth request",
slog.String("URL", u.String()),
slog.String("Err", err.Error()))
}
dropHost = true
} else {
err = fmt.Errorf("authentication required")
retryHost = true
}
return err
case http.StatusNotFound:
// if not found, drop mirror for this req, but other requests don't need backoff
dropHost = true
case http.StatusRequestedRangeNotSatisfiable:
// if range request error (blob push), drop mirror for this req, but other requests don't need backoff
dropHost = true
case http.StatusTooManyRequests, http.StatusRequestTimeout, http.StatusGatewayTimeout, http.StatusBadGateway, http.StatusInternalServerError:
// server is likely overloaded, backoff but still retry
backoff = true
default:
// all other errors indicate a bigger issue, don't retry and set backoff
backoff = true
dropHost = true
}
errHTTP := HTTPError(resp.resp.StatusCode)
errBody, _ := io.ReadAll(resp.resp.Body)
_ = resp.resp.Body.Close()
return fmt.Errorf("request failed: %w: %s", errHTTP, errBody)
}
resp.reader = resp.resp.Body
resp.done = false
// set variables from headers if found
clHeader := resp.resp.Header.Get("Content-Length")
if resp.readCur == 0 && clHeader != "" {
cl, parseErr := strconv.ParseInt(clHeader, 10, 64)
if parseErr != nil {
c.slog.Debug("failed to parse content-length header",
slog.String("err", parseErr.Error()),
slog.String("header", clHeader))
} else if resp.readMax > 0 {
if resp.readMax != cl {
return fmt.Errorf("unexpected content-length, expected %d, received %d", resp.readMax, cl)
}
} else {
resp.readMax = cl
}
}
// verify Content-Range header when range request used, fail if missing
if httpReq.Header.Get("Range") != "" && resp.resp.Header.Get("Content-Range") == "" {
dropHost = true
_ = resp.resp.Body.Close()
return fmt.Errorf("range request not supported by server")
}
return nil
}()
// return on success
if loopErr == nil {
resp.throttleDone = throttleDone
return nil
}
// backoff, dropHost, and/or go to next host in the list
if backoff {
if req.IgnoreErr {
// don't set a backoff, immediately drop the host when errors ignored
dropHost = true
} else {
boErr := resp.backoffSet()
if boErr != nil {
// reached backoff limit
dropHost = true
}
}
}
throttleDone()
// when error does not allow retries, abort with the last known err value
if err != nil && errors.Is(loopErr, errs.ErrNotRetryable) {
return err
}
err = loopErr
if dropHost {
hosts = slices.Delete(hosts, curHost, curHost+1)
} else if !retryHost {
curHost++
}
}
}
// GetThrottle returns the current [pqueue.Queue] for a host used to throttle connections.
// This can be used to acquire multiple throttles before performing a request across multiple hosts.
func (c *Client) GetThrottle(host string) *pqueue.Queue[reqmeta.Data] {
ch := c.getHost(host)
return ch.throttle
}
// HTTPResponse returns the [http.Response] from the last request.
func (resp *Resp) HTTPResponse() *http.Response {
return resp.resp
}
// Read provides a retryable read from the body of the response.
func (resp *Resp) Read(b []byte) (int, error) {
if resp.done || resp.reader == nil {
return 0, io.EOF
}
if resp.resp == nil {
return 0, errs.ErrNotFound
}
// perform the read
i, err := resp.reader.Read(b)
resp.readCur += int64(i)
if err == io.EOF || err == io.ErrUnexpectedEOF {
if resp.resp.Request.Method == "HEAD" || resp.readCur >= resp.readMax {
resp.backoffReset()
resp.done = true
} else {
// short read, retry?
resp.client.slog.Debug("EOF before reading all content, retrying",
slog.Int64("curRead", resp.readCur),
slog.Int64("contentLen", resp.readMax))
// retry
respErr := resp.backoffSet()
if respErr == nil {
respErr = resp.next()
}
// unrecoverable EOF
if respErr != nil {
resp.client.slog.Warn("Failed to recover from short read",
slog.String("err", respErr.Error()))
resp.done = true
return i, err
}
// retry successful, no EOF
return i, nil
}
}
if err == nil {
return i, nil
}
return i, err
}
// Close frees up resources from the request.
func (resp *Resp) Close() error {
if resp.throttleDone != nil {
resp.throttleDone()
resp.throttleDone = nil
}
if resp.resp == nil {
return errs.ErrNotFound
}
if !resp.done {
resp.backoffReset()
}
resp.done = true
return resp.resp.Body.Close()
}
// Seek provides a limited ability seek within the request response.
func (resp *Resp) Seek(offset int64, whence int) (int64, error) {
newOffset := resp.readCur
switch whence {
case io.SeekStart:
newOffset = offset
case io.SeekCurrent:
newOffset += offset
case io.SeekEnd:
if resp.readMax <= 0 {
return resp.readCur, fmt.Errorf("seek from end is not supported")
} else if resp.readMax+offset < 0 {
return resp.readCur, fmt.Errorf("seek past beginning of the file is not supported")
}
newOffset = resp.readMax + offset
default:
return resp.readCur, fmt.Errorf("unknown value of whence: %d", whence)
}
if newOffset != resp.readCur {
resp.readCur = newOffset
// rerun the request to restart
resp.retryCount-- // do not count a seek as a retry
err := resp.next()
if err != nil {
return resp.readCur, err
}
}
return resp.readCur, nil
}
func (resp *Resp) backoffGet() time.Time {
c := resp.client
ch := c.getHost(resp.mirror)
ch.mu.Lock()
defer ch.mu.Unlock()
if ch.backoffCur > 0 {
delay := c.delayInit << ch.backoffCur
delay = min(delay, c.delayMax)
next := ch.backoffLast.Add(delay)
now := time.Now()
if now.After(next) {
next = now
}
ch.backoffLast = next
return next
}
// reset a stale "retry-after" time
if !ch.backoffLast.IsZero() && ch.backoffLast.Before(time.Now()) {
ch.backoffLast = time.Time{}
}
return ch.backoffLast
}
func (resp *Resp) backoffSet() error {
c := resp.client
ch := c.getHost(resp.mirror)
ch.mu.Lock()
defer ch.mu.Unlock()
// check rate limit header and use that directly if possible
if resp.resp != nil && resp.resp.Header.Get("Retry-After") != "" {
ras := resp.resp.Header.Get("Retry-After")
ra, _ := time.ParseDuration(ras + "s")
if ra > 0 {
next := time.Now().Add(ra)
if ch.backoffLast.Before(next) {
ch.backoffLast = next
}
return nil
}
}
// Else track the number of backoffs and fail when the limit is exceeded.
// New requests always get at least one try, but fail fast if the server has been throwing errors.
ch.backoffCur++
if ch.backoffLast.IsZero() {
ch.backoffLast = time.Now()
}
if ch.backoffCur >= c.retryLimit {
return fmt.Errorf("%w: backoffs %d", errs.ErrBackoffLimit, ch.backoffCur)
}
return nil
}
func (resp *Resp) backoffReset() {
c := resp.client
ch := c.getHost(resp.mirror)
ch.mu.Lock()
defer ch.mu.Unlock()
if ch.backoffCur > 0 {
ch.backoffReset++
// If enough successful requests are seen, lower the backoffCur count.
// This requires multiple successful requests of a flaky server, but quickly drops when above the retry limit.
if ch.backoffReset > backoffResetCount || ch.backoffCur > c.retryLimit {
ch.backoffReset = 0
ch.backoffCur--
if ch.backoffCur == 0 {
// reset the last time to the zero value
ch.backoffLast = time.Time{}
}
}
}
}
// getHost looks up or creates a clientHost for a given registry.
func (c *Client) getHost(host string) *clientHost {
c.mu.Lock()
defer c.mu.Unlock()
if h, ok := c.host[host]; ok {
return h
}
var conf *config.Host
if c.getConfigHost != nil {
conf = c.getConfigHost(host)
} else {
conf = config.HostNewName(host)
}
if conf.Name != host {
if h, ok := c.host[conf.Name]; ok {
return h
}
}
h := &clientHost{
config: conf,
userAgent: c.userAgent,
slog: c.slog,
auth: map[string]*auth.Auth{},
}
if h.config.ReqPerSec > 0 {
h.reqFreq = time.Duration(float64(time.Second) / h.config.ReqPerSec)
}
if h.config.ReqConcurrent > 0 {
h.throttle = pqueue.New(pqueue.Opts[reqmeta.Data]{Max: int(h.config.ReqConcurrent), Next: reqmeta.DataNext})
}
// copy the http client and configure registry specific settings
hc := *c.httpClient
h.httpClient = &hc
if h.httpClient.Transport == nil {
h.httpClient.Transport = http.DefaultTransport.(*http.Transport).Clone()
}
// configure transport for insecure requests and root certs
if h.config.TLS == config.TLSInsecure || len(c.rootCAPool) > 0 || len(c.rootCADirs) > 0 || h.config.RegCert != "" || (h.config.ClientCert != "" && h.config.ClientKey != "") {
t, ok := h.httpClient.Transport.(*http.Transport)
if ok {
var tlsc *tls.Config
if t.TLSClientConfig != nil {
tlsc = t.TLSClientConfig.Clone()
} else {
//#nosec G402 the default TLS 1.2 minimum version is allowed to support older registries
tlsc = &tls.Config{}
}
if h.config.TLS == config.TLSInsecure {
tlsc.InsecureSkipVerify = true
} else {
rootPool, err := makeRootPool(c.rootCAPool, c.rootCADirs, h.config.Hostname, h.config.RegCert)
if err != nil {
c.slog.Warn("failed to setup CA pool",
slog.String("err", err.Error()))
} else {
tlsc.RootCAs = rootPool
}
}
if h.config.ClientCert != "" && h.config.ClientKey != "" {
cert, err := tls.X509KeyPair([]byte(h.config.ClientCert), []byte(h.config.ClientKey))
if err != nil {
c.slog.Warn("failed to configure client certs",
slog.String("err", err.Error()))
} else {
tlsc.Certificates = []tls.Certificate{cert}
}
}
t.TLSClientConfig = tlsc
h.httpClient.Transport = t
}
}
// wrap the transport for logging and to handle warning headers
h.httpClient.Transport = &wrapTransport{c: c, orig: h.httpClient.Transport}
c.host[conf.Name] = h
if conf.Name != host {
// save another reference for faster lookups
c.host[host] = h
}
return h
}
// getHTTPClient returns a client specific to the repo being queried.
// Repository specific authentication needs a dedicated CheckRedirect handler.
func (ch *clientHost) getHTTPClient(repo string) *http.Client {
hc := *ch.httpClient
hc.CheckRedirect = ch.checkRedirect(repo, hc.CheckRedirect)
return &hc
}
// checkRedirect wraps http.CheckRedirect to inject auth headers to specific hosts in the redirect chain
func (ch *clientHost) checkRedirect(repo string, orig func(req *http.Request, via []*http.Request) error) func(req *http.Request, via []*http.Request) error {
return func(req *http.Request, via []*http.Request) error {
// fail on too many redirects
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
// verify redirect is allowed
last := via[len(via)-1]
if err := regnet.AllowRedirect(*last.URL, *req.URL); err != nil {
return err
}
// add auth headers if appropriate for the target host
hAuth := ch.getAuth(repo)
err := hAuth.UpdateRequest(req)
if err != nil {
return err
}
// wrap original redirect check
if orig != nil {
return orig(req, via)
}
return nil
}
}
// getAuth returns an auth, which may be repository specific.
func (ch *clientHost) getAuth(repo string) *auth.Auth {
ch.mu.Lock()
defer ch.mu.Unlock()
if !ch.config.RepoAuth {
repo = "" // without RepoAuth, unset the provided repo
}
if _, ok := ch.auth[repo]; !ok {
ch.auth[repo] = auth.NewAuth(
auth.WithLog(ch.slog),
auth.WithHTTPClient(ch.httpClient),
auth.WithCreds(ch.AuthCreds()),
auth.WithClientID(ch.userAgent),
)
}
return ch.auth[repo]
}
func (ch *clientHost) AuthCreds() func(h string) auth.Cred {
if ch == nil || ch.config == nil {
return auth.DefaultCredsFn
}
return func(h string) auth.Cred {
// only return credentials to challenges from the registry server, not to any redirects
if h == ch.config.Hostname {
hCred := ch.config.GetCred()
return auth.Cred{User: hCred.User, Password: hCred.Password, Token: hCred.Token}
} else {
return auth.Cred{}
}
}
}
type wrapTransport struct {
c *Client
orig http.RoundTripper
}
func (wt *wrapTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := wt.orig.RoundTrip(req)
// copy headers to censor auth field
reqHead := req.Header.Clone()
if reqHead.Get("Authorization") != "" {
reqHead.Set("Authorization", "[censored]")
}
if err != nil {
wt.c.slog.Debug("reg http request",
slog.String("req-method", req.Method),
slog.String("req-url", req.URL.String()),
slog.Any("req-headers", reqHead),
slog.String("err", err.Error()))
} else {
// extract any warnings
for _, wh := range resp.Header.Values("Warning") {
if match := warnRegexp.FindStringSubmatch(wh); len(match) == 2 {
// TODO(bmitch): pass other fields (registry hostname) with structured logging
warning.Handle(req.Context(), wt.c.slog, match[1])
}
}
wt.c.slog.Log(req.Context(), types.LevelTrace, "reg http request",
slog.String("req-method", req.Method),
slog.String("req-url", req.URL.String()),
slog.Any("req-headers", reqHead),
slog.String("resp-status", resp.Status),
slog.Any("resp-headers", resp.Header))
}
return resp, err
}
// HTTPError returns an error based on the status code.
func HTTPError(statusCode int) error {
switch statusCode {
case 401:
return fmt.Errorf("%w [http %d]", errs.ErrHTTPUnauthorized, statusCode)
case 403:
return fmt.Errorf("%w [http %d]", errs.ErrHTTPUnauthorized, statusCode)
case 404:
return fmt.Errorf("%w [http %d]", errs.ErrNotFound, statusCode)
case 429:
return fmt.Errorf("%w [http %d]", errs.ErrHTTPRateLimit, statusCode)
default:
return fmt.Errorf("%w: %s [http %d]", errs.ErrHTTPStatus, http.StatusText(statusCode), statusCode)
}
}
func makeRootPool(rootCAPool [][]byte, rootCADirs []string, hostname string, hostcert string) (*x509.CertPool, error) {
pool, err := x509.SystemCertPool()
if err != nil {
return nil, err
}
for _, ca := range rootCAPool {
if ok := pool.AppendCertsFromPEM(ca); !ok {
return nil, fmt.Errorf("failed to load ca: %s", ca)
}
}
for _, dir := range rootCADirs {
hostDir := filepath.Join(dir, hostname)
files, err := os.ReadDir(hostDir)
if err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to read directory %s: %w", hostDir, err)
}
continue
}
for _, f := range files {
if f.IsDir() {
continue
}
if strings.HasSuffix(f.Name(), ".crt") {
f := filepath.Join(hostDir, f.Name())
//#nosec G304 file from a known directory and extension read by the user running the command on their own host
cert, err := os.ReadFile(f)
if err != nil {
return nil, fmt.Errorf("failed to read %s: %w", f, err)
}
if ok := pool.AppendCertsFromPEM(cert); !ok {
return nil, fmt.Errorf("failed to import cert from %s", f)
}
}
}
}
if hostcert != "" {
if ok := pool.AppendCertsFromPEM([]byte(hostcert)); !ok {
// try to parse the certificate and generate a useful error
block, _ := pem.Decode([]byte(hostcert))
if block == nil {
err = fmt.Errorf("pem.Decode is nil")
} else {
_, err = x509.ParseCertificate(block.Bytes)
}
return nil, fmt.Errorf("failed to load host specific ca (registry: %s): %w: %s", hostname, err, hostcert)
}
}
return pool, nil
}
// sortHostCmp to sort host list of mirrors.
func sortHostsCmp(hosts []*clientHost, upstream string) func(i, j int) bool {
now := time.Now()
// sort by backoff first, then priority decending, then upstream name last
return func(i, j int) bool {
if now.Before(hosts[i].backoffLast) || now.Before(hosts[j].backoffLast) {
return hosts[i].backoffLast.Before(hosts[j].backoffLast)
}
if hosts[i].config.Priority != hosts[j].config.Priority {
return hosts[i].config.Priority < hosts[j].config.Priority
}
return hosts[i].config.Name != upstream
}
}
-48
View File
@@ -1,48 +0,0 @@
// Package regnet contains networking helper functions for interacting with registries.
package regnet
import (
"fmt"
"net"
"net/url"
"github.com/regclient/regclient/types/errs"
)
func AllowRedirect(src, dest url.URL) error {
if src.Scheme == "https" && dest.Scheme != "https" {
return fmt.Errorf("redirect from an https to non-https server is not allowed (%s)%.0w", dest.String(), errs.ErrHTTPRedirectRefused)
}
if !IsLocal(src.Host) && IsLocal(dest.Host) {
return fmt.Errorf("redirect to a local domain is not allowed (%s)%.0w", dest.String(), errs.ErrHTTPRedirectRefused)
}
return nil
}
func IsLocal(hostPort string) bool {
// strip trailing port
host, _, err := net.SplitHostPort(hostPort)
if err != nil {
host = hostPort
}
// parse IP
ip := net.ParseIP(host)
if ip != nil {
return isIPLocal(ip)
}
// else resolve the hostname and then check each IP
ips, err := net.LookupIP(host)
if err != nil {
return false
}
for _, ip := range ips {
if ip != nil && isIPLocal(ip) {
return true
}
}
return false
}
func isIPLocal(ip net.IP) bool {
return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified()
}
@@ -1,3 +0,0 @@
// Package reproducible is used for reproducibility.
// See <https://reproducible-builds.org/docs/> for more information of the issues being solved.
package reproducible
-35
View File
@@ -1,35 +0,0 @@
package reproducible
import (
"errors"
"os"
"strconv"
"time"
)
const EpocEnv = "SOURCE_DATE_EPOC"
var errInvalidEpoc = errors.New("invalid epoc var")
// TimeNow returns the current time or SOURCE_DATE_EPOC if that is set.
func TimeNow() time.Time {
now, err := TimeEpocEnv()
if err == nil {
return now
}
return time.Now().UTC()
}
// TimeEpocEnv returns the time parsed by SOURCE_DATE_EPOC.
// This should be used to override any timestamps that should be reproducible.
func TimeEpocEnv() (time.Time, error) {
sec := os.Getenv(EpocEnv)
if sec == "" {
return time.Time{}, errInvalidEpoc
}
secI, err := strconv.ParseInt(sec, 10, 64)
if err != nil {
return time.Time{}, err
}
return time.Unix(secI, 0).UTC(), nil
}
-88
View File
@@ -1,88 +0,0 @@
// Package reqmeta provides metadata on requests for prioritizing with a pqueue.
package reqmeta
type Data struct {
Kind Kind
Size int64
}
type Kind int
const (
Unknown Kind = iota
Head
Manifest
Query
Blob
)
const (
smallLimit = 4194304 // 4MiB
largePct = 0.9 // anything above 90% of largest queued entry size is large
)
func DataNext(queued, active []*Data) int {
if len(queued) == 0 {
return -1
}
// After removing one small entry, split remaining requests 50/50 between large and old (truncated int division always rounds down).
// If len active = 2, this function returns the 3rd entry (+1), minus 1 for the small, divide by 2 to split with old = goal of 1.
largeGoal := len(active) / 2
largeI := 0
var largeSize int64
if largeGoal > 0 {
// find the largest queued blob requests
for i, cur := range queued {
if cur.Kind == Blob && cur.Size > largeSize {
largeI = i
largeSize = cur.Size
}
}
}
largeCutoff := int64(float64(largeSize) * 0.9)
// count active requests by type
small := 0
large := 0
old := 0
for _, cur := range active {
if cur.Kind != Blob && cur.Size <= smallLimit {
small++
} else if cur.Kind == Blob && largeSize > 0 && cur.Size >= largeCutoff {
large++
} else {
old++
}
}
// if there is at least one active, and none are small, return the best small entry if available.
if len(active) > 0 && small == 0 {
var sizeI int64
bestI := -1
kindI := Unknown
for i, cur := range queued {
// the small search skips blobs and large requests
if cur.Kind == Blob || cur.Size > smallLimit {
continue
}
// the best small entry is the:
// - first one found if no other matches
// - one with a better Kind (Head > Manifest > Query)
// - one with the same kind but smaller request
if bestI < 0 ||
(cur.Kind != Unknown && (kindI == Unknown || cur.Kind < kindI)) ||
(cur.Kind == kindI && cur.Size > 0 && (cur.Size < sizeI || sizeI <= 0)) {
bestI = i
kindI = cur.Kind
sizeI = cur.Size
}
}
if bestI >= 0 {
return bestI
}
}
// Prefer the biggest of these blobs to minimize the size of the last running blob.
if largeGoal > 0 && large < largeGoal && largeSize > 0 {
return largeI
}
// enough small and large, or none available, so return the oldest queued entry to avoid starvation.
return 0
}
-125
View File
@@ -1,125 +0,0 @@
//go:build !wasm
// Package sloghandle provides a transition handler for migrating from logrus to slog.
package sloghandle
import (
"context"
"log/slog"
"strings"
"github.com/sirupsen/logrus"
"github.com/regclient/regclient/types"
)
func Logrus(logger *logrus.Logger) *logrusHandler {
return &logrusHandler{
logger: logger,
}
}
type logrusHandler struct {
logger *logrus.Logger
attrs []slog.Attr
groups []string
}
func (h *logrusHandler) Enabled(_ context.Context, level slog.Level) bool {
ll := h.logger.GetLevel()
if curLevel, ok := logrusToSlog[ll]; ok {
return level >= curLevel
}
return true
}
func (h *logrusHandler) Handle(ctx context.Context, r slog.Record) error {
log := logrus.NewEntry(h.logger).WithContext(ctx)
if !r.Time.IsZero() {
log = log.WithTime(r.Time)
}
fields := logrus.Fields{}
for _, a := range h.attrs {
if a.Key != "" {
fields[a.Key] = a.Value
}
}
r.Attrs(func(a slog.Attr) bool {
if a.Key != "" {
fields[a.Key] = a.Value
}
return true
})
if len(fields) > 0 {
log = log.WithFields(fields)
}
log.Log(slogToLogrus(r.Level), r.Message)
return nil
}
func (h *logrusHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
ret := h.clone()
prefix := ""
if len(h.groups) > 0 {
prefix = strings.Join(h.groups, ":") + ":"
}
for _, a := range attrs {
if a.Key == "" {
continue
}
ret.attrs = append(ret.attrs, slog.Attr{
Key: prefix + a.Key,
Value: a.Value,
})
}
return ret
}
func (h *logrusHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
ret := h.clone()
ret.groups = append(ret.groups, name)
return ret
}
func (h *logrusHandler) clone() *logrusHandler {
attrs := make([]slog.Attr, len(h.attrs))
copy(attrs, h.attrs)
groups := make([]string, len(h.groups))
copy(groups, h.groups)
return &logrusHandler{
logger: h.logger,
attrs: attrs,
groups: groups,
}
}
var logrusToSlog = map[logrus.Level]slog.Level{
logrus.TraceLevel: types.LevelTrace,
logrus.DebugLevel: slog.LevelDebug,
logrus.InfoLevel: slog.LevelInfo,
logrus.WarnLevel: slog.LevelWarn,
logrus.ErrorLevel: slog.LevelError,
logrus.FatalLevel: slog.LevelError + 4,
logrus.PanicLevel: slog.LevelError + 8,
}
func slogToLogrus(level slog.Level) logrus.Level {
if level <= types.LevelTrace {
return logrus.TraceLevel
} else if level <= slog.LevelDebug {
return logrus.DebugLevel
} else if level <= slog.LevelInfo {
return logrus.InfoLevel
} else if level <= slog.LevelWarn {
return logrus.WarnLevel
} else if level <= slog.LevelError {
return logrus.ErrorLevel
} else if level <= slog.LevelError+4 {
return logrus.FatalLevel
} else {
return logrus.PanicLevel
}
}
-91
View File
@@ -1,91 +0,0 @@
// Package strparse is used to parse strings
package strparse
import (
"fmt"
"github.com/regclient/regclient/types/errs"
)
// SplitCSKV splits a comma separated key=value list into a map
func SplitCSKV(s string) (map[string]string, error) {
state := "key"
key := ""
val := ""
result := map[string]string{}
procKV := func() {
if key != "" {
result[key] = val
}
state = "key"
key = ""
val = ""
}
for _, c := range s {
switch state {
case "key":
switch c {
case '"':
state = "keyQuote"
case '\\':
state = "keyEscape"
case '=':
state = "val"
case ',':
procKV()
default:
key = key + string(c)
}
case "keyQuote":
switch c {
case '"':
state = "key"
case '\\':
state = "keyEscapeQuote"
default:
key = key + string(c)
}
case "keyEscape":
key = key + string(c)
state = "key"
case "keyEscapeQuote":
key = key + string(c)
state = "keyQuote"
case "val":
switch c {
case '"':
state = "valQuote"
case ',':
procKV()
case '\\':
state = "valEscape"
default:
val = val + string(c)
}
case "valQuote":
switch c {
case '"':
state = "val"
case '\\':
state = "valEscapeQuote"
default:
val = val + string(c)
}
case "valEscape":
val = val + string(c)
state = "val"
case "valEscapeQuote":
val = val + string(c)
state = "valQuote"
default:
return nil, fmt.Errorf("unhandled state: %s", state)
}
}
switch state {
case "val", "key":
procKV()
default:
return nil, fmt.Errorf("string parsing failed, end state: %s%.0w", state, errs.ErrParsingFailed)
}
return result, nil
}
-41
View File
@@ -1,41 +0,0 @@
// Package timejson extends time methods with marshal/unmarshal for json
package timejson
import (
"encoding/json"
"errors"
"time"
)
var errInvalid = errors.New("invalid duration")
// Duration is an alias to time.Duration
// Implementation taken from https://stackoverflow.com/questions/48050945/how-to-unmarshal-json-into-durations
type Duration time.Duration
// MarshalJSON converts a duration to json
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(d).String())
}
// UnmarshalJSON converts json to a duration
func (d *Duration) UnmarshalJSON(b []byte) error {
var v any
if err := json.Unmarshal(b, &v); err != nil {
return err
}
switch value := v.(type) {
case float64:
*d = Duration(time.Duration(value))
return nil
case string:
timeDur, err := time.ParseDuration(value)
if err != nil {
return err
}
*d = Duration(timeDur)
return nil
default:
return errInvalid
}
}
-59
View File
@@ -1,59 +0,0 @@
// Package units is taken from https://github.com/docker/go-units
package units
// Copyright 2015 Docker, Inc.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// https://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import (
"fmt"
)
var (
decimapAbbrs = []string{"B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}
binaryAbbrs = []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"}
)
func getSizeAndUnit(size float64, base float64, unitList []string) (float64, string) {
i := 0
unitsLimit := len(unitList) - 1
for size >= base && i < unitsLimit {
size = size / base
i++
}
return size, unitList[i]
}
// CustomSize returns a human-readable approximation of a size using custom format.
func CustomSize(format string, size float64, base float64, unitList []string) string {
size, unit := getSizeAndUnit(size, base, unitList)
return fmt.Sprintf(format, size, unit)
}
// HumanSizeWithPrecision allows the size to be in any precision.
func HumanSizeWithPrecision(size float64, width, precision int) string {
size, unit := getSizeAndUnit(size, 1000.0, decimapAbbrs)
return fmt.Sprintf("%*.*f%s", width, precision, size, unit)
}
// HumanSize returns a human-readable approximation of a size
// with a width of 5 (eg. "2.746MB", "796.0KB").
func HumanSize(size float64) string {
return HumanSizeWithPrecision(size, 5, 3)
}
// BytesSize returns a human-readable size in bytes, kibibytes,
// mebibytes, gibibytes, or tebibytes (eg. "44.2kiB", "17.6MiB").
func BytesSize(size float64) string {
return CustomSize("%5.3f%s", size, 1024.0, binaryAbbrs)
}
-32
View File
@@ -1,32 +0,0 @@
// Package version returns details on the Go and Git repo used in the build
package version
import (
"bytes"
"fmt"
"text/tabwriter"
)
const (
stateClean = "clean"
stateDirty = "dirty"
unknown = "unknown"
biVCSDate = "vcs.time"
biVCSCommit = "vcs.revision"
biVCSModified = "vcs.modified"
)
func (i Info) MarshalPretty() ([]byte, error) {
buf := &bytes.Buffer{}
tw := tabwriter.NewWriter(buf, 0, 0, 1, ' ', 0)
fmt.Fprintf(tw, "VCSTag:\t%s\n", i.VCSTag)
fmt.Fprintf(tw, "VCSRef:\t%s\n", i.VCSRef)
fmt.Fprintf(tw, "VCSCommit:\t%s\n", i.VCSCommit)
fmt.Fprintf(tw, "VCSState:\t%s\n", i.VCSState)
fmt.Fprintf(tw, "VCSDate:\t%s\n", i.VCSDate)
fmt.Fprintf(tw, "Platform:\t%s\n", i.Platform)
fmt.Fprintf(tw, "GoVer:\t%s\n", i.GoVer)
fmt.Fprintf(tw, "GoCompiler:\t%s\n", i.GoCompiler)
err := tw.Flush()
return buf.Bytes(), err
}
@@ -1,74 +0,0 @@
//go:build go1.18
package version
import (
"fmt"
"runtime"
"runtime/debug"
"time"
)
var vcsTag = ""
type Info struct {
GoVer string `json:"goVersion"` // go version
GoCompiler string `json:"goCompiler"` // go compiler
Platform string `json:"platform"` // os/arch
VCSCommit string `json:"vcsCommit"` // commit sha
VCSDate string `json:"vcsDate"` // commit date in RFC3339 format
VCSRef string `json:"vcsRef"` // commit sha + dirty if state is not clean
VCSState string `json:"vcsState"` // clean or dirty
VCSTag string `json:"vcsTag"` // tag is not available from Go
Debug *debug.BuildInfo `json:"debug,omitempty"` // build info debugging data
}
func GetInfo() Info {
i := Info{
GoVer: unknown,
Platform: unknown,
VCSCommit: unknown,
VCSDate: unknown,
VCSRef: unknown,
VCSState: unknown,
VCSTag: vcsTag,
}
i.GoVer = runtime.Version()
i.GoCompiler = runtime.Compiler
i.Platform = fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)
if bi, ok := debug.ReadBuildInfo(); ok && bi != nil {
i.Debug = bi
if i.VCSTag == "" {
i.VCSTag = bi.Main.Version
}
date := biSetting(bi, biVCSDate)
if t, err := time.Parse(time.RFC3339, date); err == nil {
i.VCSDate = t.UTC().Format(time.RFC3339)
}
i.VCSCommit = biSetting(bi, biVCSCommit)
i.VCSRef = i.VCSCommit
modified := biSetting(bi, biVCSModified)
if modified == "true" {
i.VCSState = stateDirty
i.VCSRef += "-" + stateDirty
} else if modified == "false" {
i.VCSState = stateClean
}
}
return i
}
func biSetting(bi *debug.BuildInfo, key string) string {
if bi == nil {
return unknown
}
for _, setting := range bi.Settings {
if setting.Key == key {
return setting.Value
}
}
return unknown
}
-37
View File
@@ -1,37 +0,0 @@
//go:build !go1.18
package version
import (
"fmt"
"runtime"
)
type Info struct {
GoVer string `json:"goVersion"` // go version
GoCompiler string `json:"goCompiler"` // go compiler
Platform string `json:"platform"` // os/arch
VCSCommit string `json:"vcsCommit"` // commit sha
VCSDate string `json:"vcsDate"` // commit date in RFC3339 format
VCSRef string `json:"vcsRef"` // commit sha + dirty if state is not clean
VCSState string `json:"vcsState"` // clean or dirty
VCSTag string `json:"vcsTag"` // tag
}
func GetInfo() Info {
i := Info{
GoVer: unknown,
Platform: unknown,
VCSCommit: unknown,
VCSDate: unknown,
VCSRef: unknown,
VCSState: unknown,
VCSTag: "",
}
i.GoVer = runtime.Version()
i.GoCompiler = runtime.Compiler
i.Platform = fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)
return i
}
-206
View File
@@ -1,206 +0,0 @@
package regclient
import (
"context"
"fmt"
"log/slog"
"github.com/regclient/regclient/scheme"
"github.com/regclient/regclient/types/descriptor"
"github.com/regclient/regclient/types/errs"
"github.com/regclient/regclient/types/manifest"
"github.com/regclient/regclient/types/platform"
"github.com/regclient/regclient/types/ref"
"github.com/regclient/regclient/types/warning"
)
type manifestOpt struct {
d descriptor.Descriptor
platform *platform.Platform
schemeOpts []scheme.ManifestOpts
requireDigest bool
}
// ManifestOpts define options for the Manifest* commands.
type ManifestOpts func(*manifestOpt)
// WithManifest passes a manifest to ManifestDelete.
func WithManifest(m manifest.Manifest) ManifestOpts {
return func(opts *manifestOpt) {
opts.schemeOpts = append(opts.schemeOpts, scheme.WithManifest(m))
}
}
// WithManifestCheckReferrers checks for referrers field on ManifestDelete.
// This will update the client managed referrer listing.
func WithManifestCheckReferrers() ManifestOpts {
return func(opts *manifestOpt) {
opts.schemeOpts = append(opts.schemeOpts, scheme.WithManifestCheckReferrers())
}
}
// WithManifestChild for ManifestPut indicates the manifest is not the top level manifest being copied.
// This is used by the ocidir scheme to determine what entries to include in the index.json.
func WithManifestChild() ManifestOpts {
return func(opts *manifestOpt) {
opts.schemeOpts = append(opts.schemeOpts, scheme.WithManifestChild())
}
}
// WithManifestDesc includes the descriptor for ManifestGet.
// This is used to automatically extract a Data field if available.
func WithManifestDesc(d descriptor.Descriptor) ManifestOpts {
return func(opts *manifestOpt) {
opts.d = d
}
}
// WithManifestPlatform resolves the platform specific manifest on Get and Head requests.
// This causes an additional GET query to a registry when an Index or Manifest List is encountered.
// This option is ignored if the retrieved manifest is not an Index or Manifest List.
func WithManifestPlatform(p platform.Platform) ManifestOpts {
return func(opts *manifestOpt) {
opts.platform = &p
}
}
// WithManifestRequireDigest falls back from a HEAD to a GET request when digest headers aren't received.
func WithManifestRequireDigest() ManifestOpts {
return func(opts *manifestOpt) {
opts.requireDigest = true
}
}
// ManifestDelete removes a manifest, including all tags pointing to that registry.
// The reference must include the digest to delete (see TagDelete for deleting a tag).
// All tags pointing to the manifest will be deleted.
func (rc *RegClient) ManifestDelete(ctx context.Context, r ref.Ref, opts ...ManifestOpts) error {
if !r.IsSet() {
return fmt.Errorf("ref is not set: %s%.0w", r.CommonName(), errs.ErrInvalidReference)
}
opt := manifestOpt{schemeOpts: []scheme.ManifestOpts{}}
for _, fn := range opts {
fn(&opt)
}
schemeAPI, err := rc.schemeGet(r.Scheme)
if err != nil {
return err
}
return schemeAPI.ManifestDelete(ctx, r, opt.schemeOpts...)
}
// ManifestGet retrieves a manifest.
func (rc *RegClient) ManifestGet(ctx context.Context, r ref.Ref, opts ...ManifestOpts) (manifest.Manifest, error) {
if !r.IsSet() {
return nil, fmt.Errorf("ref is not set: %s%.0w", r.CommonName(), errs.ErrInvalidReference)
}
opt := manifestOpt{schemeOpts: []scheme.ManifestOpts{}}
for _, fn := range opts {
fn(&opt)
}
if opt.d.Digest != "" {
r = r.AddDigest(opt.d.Digest.String())
data, err := opt.d.GetData()
if err == nil {
return manifest.New(
manifest.WithDesc(opt.d),
manifest.WithRaw(data),
manifest.WithRef(r),
)
}
}
// dedup warnings
if w := warning.FromContext(ctx); w == nil {
ctx = warning.NewContext(ctx, &warning.Warning{Hook: warning.DefaultHook()})
}
schemeAPI, err := rc.schemeGet(r.Scheme)
if err != nil {
return nil, err
}
m, err := schemeAPI.ManifestGet(ctx, r)
if err != nil {
return m, err
}
if opt.platform != nil && !m.IsList() {
rc.slog.Debug("ignoring platform option, image is not an index",
slog.String("platform", opt.platform.String()),
slog.String("ref", r.CommonName()))
}
// this will loop to handle a nested index
for opt.platform != nil && m.IsList() {
d, err := manifest.GetPlatformDesc(m, opt.platform)
if err != nil {
return m, err
}
r = r.SetDigest(d.Digest.String())
m, err = schemeAPI.ManifestGet(ctx, r)
if err != nil {
return m, err
}
}
return m, err
}
// ManifestHead queries for the existence of a manifest and returns metadata (digest, media-type, size).
func (rc *RegClient) ManifestHead(ctx context.Context, r ref.Ref, opts ...ManifestOpts) (manifest.Manifest, error) {
if !r.IsSet() {
return nil, fmt.Errorf("ref is not set: %s%.0w", r.CommonName(), errs.ErrInvalidReference)
}
opt := manifestOpt{schemeOpts: []scheme.ManifestOpts{}}
for _, fn := range opts {
fn(&opt)
}
// dedup warnings
if w := warning.FromContext(ctx); w == nil {
ctx = warning.NewContext(ctx, &warning.Warning{Hook: warning.DefaultHook()})
}
schemeAPI, err := rc.schemeGet(r.Scheme)
if err != nil {
return nil, err
}
m, err := schemeAPI.ManifestHead(ctx, r)
if err != nil {
return m, err
}
if opt.platform != nil && !m.IsList() {
rc.slog.Debug("ignoring platform option, image is not an index",
slog.String("platform", opt.platform.String()),
slog.String("ref", r.CommonName()))
}
// this will loop to handle a nested index
for opt.platform != nil && m.IsList() {
if !m.IsSet() {
m, err = schemeAPI.ManifestGet(ctx, r)
}
d, err := manifest.GetPlatformDesc(m, opt.platform)
if err != nil {
return m, err
}
r = r.SetDigest(d.Digest.String())
m, err = schemeAPI.ManifestHead(ctx, r)
if err != nil {
return m, err
}
}
if opt.requireDigest && m.GetDescriptor().Digest.String() == "" {
m, err = schemeAPI.ManifestGet(ctx, r)
}
return m, err
}
// ManifestPut pushes a manifest.
// Any descriptors referenced by the manifest typically need to be pushed first.
func (rc *RegClient) ManifestPut(ctx context.Context, r ref.Ref, m manifest.Manifest, opts ...ManifestOpts) error {
if !r.IsSetRepo() {
return fmt.Errorf("ref is not set: %s%.0w", r.CommonName(), errs.ErrInvalidReference)
}
opt := manifestOpt{schemeOpts: []scheme.ManifestOpts{}}
for _, fn := range opts {
fn(&opt)
}
schemeAPI, err := rc.schemeGet(r.Scheme)
if err != nil {
return err
}
return schemeAPI.ManifestPut(ctx, r, m, opt.schemeOpts...)
}
-18
View File
@@ -1,18 +0,0 @@
package regclient
import (
"context"
"github.com/regclient/regclient/types/ping"
"github.com/regclient/regclient/types/ref"
)
// Ping verifies access to a registry or equivalent.
func (rc *RegClient) Ping(ctx context.Context, r ref.Ref) (ping.Result, error) {
schemeAPI, err := rc.schemeGet(r.Scheme)
if err != nil {
return ping.Result{}, err
}
return schemeAPI.Ping(ctx, r)
}
-2
View File
@@ -1,2 +0,0 @@
// Package archive is used to read and write tar files
package archive
-160
View File
@@ -1,160 +0,0 @@
package archive
import (
"bufio"
"bytes"
"compress/bzip2"
"compress/gzip"
"errors"
"fmt"
"io"
"github.com/klauspost/compress/zstd"
"github.com/ulikunitz/xz"
)
// CompressType identifies the detected compression type
type CompressType int
const (
CompressNone CompressType = iota // uncompressed or unable to detect compression
CompressBzip2 // bzip2
CompressGzip // gzip
CompressXz // xz
CompressZstd // zstd
)
// compressHeaders are used to detect the compression type
var compressHeaders = map[CompressType][]byte{
CompressBzip2: []byte("\x42\x5A\x68"),
CompressGzip: []byte("\x1F\x8B\x08"),
CompressXz: []byte("\xFD\x37\x7A\x58\x5A\x00"),
CompressZstd: []byte("\x28\xB5\x2F\xFD"),
}
func Compress(r io.Reader, oComp CompressType) (io.ReadCloser, error) {
switch oComp {
// note, bzip2 compression is not supported
case CompressGzip:
return writeToRead(r, newGzipWriter)
case CompressXz:
return writeToRead(r, xz.NewWriter)
case CompressZstd:
return writeToRead(r, newZstdWriter)
case CompressNone:
return io.NopCloser(r), nil
default:
return nil, ErrUnknownType
}
}
// newGzipWriter generates a writer and an always nil error.
func newGzipWriter(w io.Writer) (io.WriteCloser, error) {
return gzip.NewWriter(w), nil
}
// newZstdWriter generates a writer with the default options.
func newZstdWriter(w io.Writer) (io.WriteCloser, error) {
return zstd.NewWriter(w)
}
// writeToRead uses a pipe + goroutine + copy to switch from a writer to a reader.
func writeToRead[wc io.WriteCloser](src io.Reader, newWriterFn func(io.Writer) (wc, error)) (io.ReadCloser, error) {
pr, pw := io.Pipe()
go func() {
// buffer output to avoid lots of small reads
bw := bufio.NewWriterSize(pw, 2<<16)
dest, err := newWriterFn(bw)
if err != nil {
_ = pw.CloseWithError(err)
return
}
if _, err := io.Copy(dest, src); err != nil {
_ = pw.CloseWithError(err)
}
if err := dest.Close(); err != nil {
_ = pw.CloseWithError(err)
}
if err := bw.Flush(); err != nil {
_ = pw.CloseWithError(err)
}
_ = pw.Close()
}()
return pr, nil
}
// Decompress extracts gzip and bzip streams
func Decompress(r io.Reader) (io.Reader, error) {
// create bufio to peak on first few bytes
br := bufio.NewReader(r)
head, err := br.Peek(10)
if err != nil && !errors.Is(err, io.EOF) {
return br, fmt.Errorf("failed to detect compression: %w", err)
}
// compare peaked data against known compression types
switch DetectCompression(head) {
case CompressBzip2:
return bzip2.NewReader(br), nil
case CompressGzip:
return gzip.NewReader(br)
case CompressXz:
return xz.NewReader(br)
case CompressZstd:
return zstd.NewReader(br)
default:
return br, nil
}
}
// DetectCompression identifies the compression type based on the first few bytes
func DetectCompression(head []byte) CompressType {
for c, b := range compressHeaders {
if bytes.HasPrefix(head, b) {
return c
}
}
return CompressNone
}
func (ct CompressType) String() string {
mt, err := ct.MarshalText()
if err != nil {
return "unknown"
}
return string(mt)
}
func (ct CompressType) MarshalText() ([]byte, error) {
switch ct {
case CompressNone:
return []byte("none"), nil
case CompressBzip2:
return []byte("bzip2"), nil
case CompressGzip:
return []byte("gzip"), nil
case CompressXz:
return []byte("xz"), nil
case CompressZstd:
return []byte("zstd"), nil
}
return nil, fmt.Errorf("unknown compression type")
}
func (ct *CompressType) UnmarshalText(text []byte) error {
switch string(text) {
case "none":
*ct = CompressNone
case "bzip2":
*ct = CompressBzip2
case "gzip":
*ct = CompressGzip
case "xz":
*ct = CompressXz
case "zstd":
*ct = CompressZstd
default:
return fmt.Errorf("unknown compression type %s", string(text))
}
return nil
}
-13
View File
@@ -1,13 +0,0 @@
package archive
import "errors"
var (
// ErrNotImplemented used for routines that need to be developed still
ErrNotImplemented = errors.New("this archive routine is not implemented yet")
// ErrUnknownType used for unknown compression types
ErrUnknownType = errors.New("unknown compression type")
// ErrXzUnsupported because there isn't a Go package for this and I'm
// avoiding dependencies on external binaries
ErrXzUnsupported = errors.New("xz compression is currently unsupported")
)
-170
View File
@@ -1,170 +0,0 @@
package archive
import (
"archive/tar"
"compress/gzip"
"context"
"fmt"
"io"
"io/fs"
"math"
"os"
"path/filepath"
"time"
)
// TarOpts configures options for Create/Extract tar
type TarOpts func(*tarOpts)
// TODO: add support for compressed files with bzip
type tarOpts struct {
// allowRelative bool // allow relative paths outside of target folder
compress string
}
// TarCompressGzip option to use gzip compression on tar files
func TarCompressGzip(to *tarOpts) {
to.compress = "gzip"
}
// TarUncompressed option to tar (noop)
func TarUncompressed(to *tarOpts) {
}
// TODO: add option for full path or to adjust the relative path
// Tar creation
func Tar(ctx context.Context, path string, w io.Writer, opts ...TarOpts) error {
to := tarOpts{}
for _, opt := range opts {
opt(&to)
}
twOut := w
if to.compress == "gzip" {
gw := gzip.NewWriter(w)
defer gw.Close()
twOut = gw
}
tw := tar.NewWriter(twOut)
defer tw.Close()
// walk the path performing a recursive tar
err := filepath.Walk(path, func(file string, fi os.FileInfo, err error) error {
// return any errors filepath encounters accessing the file
if err != nil {
return err
}
// TODO: handle symlinks, security attributes, hard links
// TODO: add options for file owner and timestamps
// TODO: add options to override time, or disable access/change stamps
// adjust for relative path
relPath, err := filepath.Rel(path, file)
if err != nil || relPath == "." {
return nil
}
header, err := tar.FileInfoHeader(fi, relPath)
if err != nil {
return err
}
header.Format = tar.FormatPAX
header.Name = filepath.ToSlash(relPath)
header.AccessTime = time.Time{}
header.ChangeTime = time.Time{}
header.ModTime = header.ModTime.Truncate(time.Second)
if err = tw.WriteHeader(header); err != nil {
return err
}
// open file and copy contents into tar writer
if header.Typeflag == tar.TypeReg && header.Size > 0 {
//#nosec G122 G304 filename is limited to provided path directory
f, err := os.Open(file)
if err != nil {
return err
}
if _, err = io.Copy(tw, f); err != nil {
return err
}
err = f.Close()
if err != nil {
return fmt.Errorf("failed to close file: %w", err)
}
}
return nil
})
return err
}
// Extract Tar
func Extract(ctx context.Context, path string, r io.Reader, opts ...TarOpts) error {
to := tarOpts{}
for _, opt := range opts {
opt(&to)
}
// verify path exists
fi, err := os.Stat(path)
if err != nil {
return err
}
if !fi.IsDir() {
return fmt.Errorf("extract path must be a directory: \"%s\"", path)
}
// decompress
rd, err := Decompress(r)
if err != nil {
return err
}
rt := tar.NewReader(rd)
for {
hdr, err := rt.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
// join a cleaned version of the filename with the path
fn := filepath.Join(path, filepath.Clean("/"+hdr.Name))
switch hdr.Typeflag {
case tar.TypeDir:
if hdr.Mode < 0 || hdr.Mode > math.MaxUint32 {
return fmt.Errorf("integer conversion overflow/underflow (file mode = %d)", hdr.Mode)
}
err = os.MkdirAll(fn, fs.FileMode(hdr.Mode))
if err != nil {
return err
}
case tar.TypeReg:
// TODO: configure file mode, creation timestamp, etc
//#nosec G304 filename is limited to provided path directory
fh, err := os.Create(fn)
if err != nil {
return err
}
n, err := io.CopyN(fh, rt, hdr.Size)
errC := fh.Close()
if err != nil {
return err
}
if errC != nil {
return fmt.Errorf("failed to close file: %w", errC)
}
if n != hdr.Size {
return fmt.Errorf("size mismatch extracting \"%s\", expected %d, extracted %d", hdr.Name, hdr.Size, n)
}
// TODO: handle other tar types (symlinks, etc)
}
}
return nil
}
-57
View File
@@ -1,57 +0,0 @@
package regclient
import (
"context"
"fmt"
"github.com/regclient/regclient/scheme"
"github.com/regclient/regclient/types/errs"
"github.com/regclient/regclient/types/platform"
"github.com/regclient/regclient/types/ref"
"github.com/regclient/regclient/types/referrer"
"github.com/regclient/regclient/types/warning"
)
// ReferrerList retrieves a list of referrers to a manifest.
// The descriptor list should contain manifests that each have a subject field matching the requested ref.
func (rc *RegClient) ReferrerList(ctx context.Context, rSubject ref.Ref, opts ...scheme.ReferrerOpts) (referrer.ReferrerList, error) {
if !rSubject.IsSet() {
return referrer.ReferrerList{}, fmt.Errorf("ref is not set: %s%.0w", rSubject.CommonName(), errs.ErrInvalidReference)
}
// dedup warnings
if w := warning.FromContext(ctx); w == nil {
ctx = warning.NewContext(ctx, &warning.Warning{Hook: warning.DefaultHook()})
}
// set the digest on the subject reference
config := scheme.ReferrerConfig{}
for _, opt := range opts {
opt(&config)
}
if rSubject.Digest == "" || config.Platform != "" {
mo := []ManifestOpts{WithManifestRequireDigest()}
if config.Platform != "" {
p, err := platform.Parse(config.Platform)
if err != nil {
return referrer.ReferrerList{}, fmt.Errorf("failed to lookup referrer platform: %w", err)
}
mo = append(mo, WithManifestPlatform(p))
}
m, err := rc.ManifestHead(ctx, rSubject, mo...)
if err != nil {
return referrer.ReferrerList{}, fmt.Errorf("failed to get digest for subject: %w", err)
}
rSubject = rSubject.SetDigest(m.GetDescriptor().Digest.String())
}
// lookup the scheme for the appropriate ref
var r ref.Ref
if config.SrcRepo.IsSet() {
r = config.SrcRepo
} else {
r = rSubject
}
schemeAPI, err := rc.schemeGet(r.Scheme)
if err != nil {
return referrer.ReferrerList{}, err
}
return schemeAPI.ReferrerList(ctx, rSubject, opts...)
}
-275
View File
@@ -1,275 +0,0 @@
// Package regclient is used to access OCI registries.
package regclient
import (
"fmt"
"io"
"log/slog"
"time"
"github.com/regclient/regclient/config"
"github.com/regclient/regclient/internal/version"
"github.com/regclient/regclient/scheme"
"github.com/regclient/regclient/scheme/ocidir"
"github.com/regclient/regclient/scheme/reg"
)
const (
// DefaultUserAgent sets the header on http requests.
DefaultUserAgent = "regclient/regclient"
// DockerCertDir default location for docker certs.
DockerCertDir = "/etc/docker/certs.d"
// DockerRegistry is the well known name of Docker Hub, "docker.io".
DockerRegistry = config.DockerRegistry
// DockerRegistryAuth is the name of Docker Hub seen in docker's config.json.
DockerRegistryAuth = config.DockerRegistryAuth
// DockerRegistryDNS is the actual registry DNS name for Docker Hub.
DockerRegistryDNS = config.DockerRegistryDNS
)
// RegClient is used to access OCI distribution-spec registries.
type RegClient struct {
hosts map[string]*config.Host
hostDefault *config.Host
regOpts []reg.Opts
schemes map[string]scheme.API
slog *slog.Logger
userAgent string
}
// Opt functions are used by [New] to create a [*RegClient].
type Opt func(*RegClient)
// New returns a registry client.
func New(opts ...Opt) *RegClient {
rc := RegClient{
hosts: map[string]*config.Host{},
userAgent: DefaultUserAgent,
regOpts: []reg.Opts{},
schemes: map[string]scheme.API{},
slog: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})),
}
info := version.GetInfo()
if info.VCSTag != "" {
rc.userAgent = fmt.Sprintf("%s (%s)", rc.userAgent, info.VCSTag)
} else {
rc.userAgent = fmt.Sprintf("%s (%s)", rc.userAgent, info.VCSRef)
}
// inject Docker Hub settings
_ = rc.hostSet(*config.HostNewName(config.DockerRegistryAuth))
for _, opt := range opts {
opt(&rc)
}
// configure regOpts
hostList := []*config.Host{}
for _, h := range rc.hosts {
hostList = append(hostList, h)
}
rc.regOpts = append(
rc.regOpts,
reg.WithConfigHosts(hostList),
reg.WithConfigHostDefault(rc.hostDefault),
reg.WithSlog(rc.slog),
reg.WithUserAgent(rc.userAgent),
)
// setup scheme's
rc.schemes["reg"] = reg.New(rc.regOpts...)
rc.schemes["ocidir"] = ocidir.New(
ocidir.WithSlog(rc.slog),
)
rc.slog.Debug("regclient initialized",
slog.String("VCSRef", info.VCSRef),
slog.String("VCSTag", info.VCSTag))
return &rc
}
// WithBlobLimit sets the max size for chunked blob uploads which get stored in memory.
//
// Deprecated: replace with WithRegOpts(reg.WithBlobLimit(limit)), see [WithRegOpts] and [reg.WithBlobLimit].
//
//go:fix inline
func WithBlobLimit(limit int64) Opt {
return WithRegOpts(reg.WithBlobLimit(limit))
}
// WithBlobSize overrides default blob sizes.
//
// Deprecated: replace with WithRegOpts(reg.WithBlobSize(chunk, max)), see [WithRegOpts] and [reg.WithBlobSize].
//
//go:fix inline
func WithBlobSize(chunk, max int64) Opt {
return WithRegOpts(reg.WithBlobSize(chunk, max))
}
// WithCertDir adds a path of certificates to trust similar to Docker's /etc/docker/certs.d.
//
// Deprecated: replace with WithRegOpts(reg.WithCertDirs(path)), see [WithRegOpts] and [reg.WithCertDirs].
//
//go:fix inline
func WithCertDir(path ...string) Opt {
return WithRegOpts(reg.WithCertDirs(path))
}
// WithConfigHost adds a list of config host settings.
func WithConfigHost(configHost ...config.Host) Opt {
return func(rc *RegClient) {
rc.hostLoad("host", configHost)
}
}
// WithConfigHostDefault adds default settings for new hosts.
func WithConfigHostDefault(configHost config.Host) Opt {
return func(rc *RegClient) {
rc.hostDefault = &configHost
}
}
// WithConfigHosts adds a list of config host settings.
//
// Deprecated: replace with [WithConfigHost].
//
//go:fix inline
func WithConfigHosts(configHosts []config.Host) Opt {
return WithConfigHost(configHosts...)
}
// WithDockerCerts adds certificates trusted by docker in /etc/docker/certs.d.
func WithDockerCerts() Opt {
return WithRegOpts(reg.WithCertDirs([]string{DockerCertDir}))
}
// WithDockerCreds adds configuration from users docker config with registry logins.
// This changes the default value from the config file, and should be added after the config file is loaded.
func WithDockerCreds() Opt {
return func(rc *RegClient) {
configHosts, err := config.DockerLoad()
if err != nil {
rc.slog.Warn("Failed to load docker creds",
slog.String("err", err.Error()))
return
}
rc.hostLoad("docker", configHosts)
}
}
// WithDockerCredsFile adds configuration from a named docker config file with registry logins.
// This changes the default value from the config file, and should be added after the config file is loaded.
func WithDockerCredsFile(fname string) Opt {
return func(rc *RegClient) {
configHosts, err := config.DockerLoadFile(fname)
if err != nil {
rc.slog.Warn("Failed to load docker creds",
slog.String("err", err.Error()))
return
}
rc.hostLoad("docker-file", configHosts)
}
}
// WithRegOpts passes through opts to the reg scheme.
func WithRegOpts(opts ...reg.Opts) Opt {
return func(rc *RegClient) {
if len(opts) == 0 {
return
}
rc.regOpts = append(rc.regOpts, opts...)
}
}
// WithRetryDelay specifies the time permitted for retry delays.
//
// Deprecated: replace with WithRegOpts(reg.WithDelay(delayInit, delayMax)), see [WithRegOpts] and [reg.WithDelay].
//
//go:fix inline
func WithRetryDelay(delayInit, delayMax time.Duration) Opt {
return WithRegOpts(reg.WithDelay(delayInit, delayMax))
}
// WithRetryLimit specifies the number of retries for non-fatal errors.
//
// Deprecated: replace with WithRegOpts(reg.WithRetryLimit(retryLimit)), see [WithRegOpts] and [reg.WithRetryLimit].
//
//go:fix inline
func WithRetryLimit(retryLimit int) Opt {
return WithRegOpts(reg.WithRetryLimit(retryLimit))
}
// WithSlog configures the slog Logger.
func WithSlog(slog *slog.Logger) Opt {
return func(rc *RegClient) {
rc.slog = slog
}
}
// WithUserAgent specifies the User-Agent http header.
func WithUserAgent(ua string) Opt {
return func(rc *RegClient) {
rc.userAgent = ua
}
}
func (rc *RegClient) hostLoad(src string, hosts []config.Host) {
for _, configHost := range hosts {
if configHost.Name == "" {
if configHost.Pass != "" {
configHost.Pass = "***"
}
if configHost.Token != "" {
configHost.Token = "***"
}
rc.slog.Warn("Ignoring registry config without a name",
slog.Any("entry", configHost))
continue
}
if configHost.Name == DockerRegistry || configHost.Name == DockerRegistryDNS || configHost.Name == DockerRegistryAuth {
configHost.Name = DockerRegistry
if configHost.Hostname == "" || configHost.Hostname == DockerRegistry || configHost.Hostname == DockerRegistryAuth {
configHost.Hostname = DockerRegistryDNS
}
}
tls, _ := configHost.TLS.MarshalText()
rc.slog.Debug("Loading config",
slog.Int64("blobChunk", configHost.BlobChunk),
slog.Int64("blobMax", configHost.BlobMax),
slog.String("helper", configHost.CredHelper),
slog.String("hostname", configHost.Hostname),
slog.Any("mirrors", configHost.Mirrors),
slog.String("name", configHost.Name),
slog.String("pathPrefix", configHost.PathPrefix),
slog.Bool("repoAuth", configHost.RepoAuth),
slog.String("source", src),
slog.String("tls", string(tls)),
slog.String("user", configHost.User))
err := rc.hostSet(configHost)
if err != nil {
rc.slog.Warn("Failed to update host config",
slog.String("host", configHost.Name),
slog.String("user", configHost.User),
slog.String("error", err.Error()))
}
}
}
func (rc *RegClient) hostSet(newHost config.Host) error {
name := newHost.Name
var err error
if _, ok := rc.hosts[name]; !ok {
// merge newHost with default host settings
rc.hosts[name] = config.HostNewDefName(rc.hostDefault, name)
err = rc.hosts[name].Merge(newHost, nil)
} else {
// merge newHost with existing settings
err = rc.hosts[name].Merge(newHost, rc.slog)
}
if err != nil {
return err
}
return nil
}
-19
View File
@@ -1,19 +0,0 @@
//go:build !wasm
package regclient
import (
"log/slog"
"github.com/sirupsen/logrus"
"github.com/regclient/regclient/internal/sloghandle"
)
// WithLog configuring logging with a logrus Logger.
// Note that regclient has switched to log/slog for logging and my eventually deprecate logrus support.
func WithLog(log *logrus.Logger) Opt {
return func(rc *RegClient) {
rc.slog = slog.New(sloghandle.Logrus(log))
}
}
-19
View File
@@ -1,19 +0,0 @@
# Release v0.11.5
Security:
- Prevent https to non-https downgrades and localhost redirects. ([PR 1093][pr-1093])
- Forbid sending auth on redirects. ([PR 1095][pr-1095])
Features:
- Add regbot `manifest.descriptor` to the sandbox. ([PR 1091][pr-1091])
Contributors:
- @GimmyDatBeeR
- @sudo-bmitch
[pr-1091]: https://github.com/regclient/regclient/pull/1091
[pr-1093]: https://github.com/regclient/regclient/pull/1093
[pr-1095]: https://github.com/regclient/regclient/pull/1095
-33
View File
@@ -1,33 +0,0 @@
package regclient
import (
"context"
"fmt"
"strings"
"github.com/regclient/regclient/scheme"
"github.com/regclient/regclient/types/errs"
"github.com/regclient/regclient/types/repo"
)
type repoLister interface {
RepoList(ctx context.Context, hostname string, opts ...scheme.RepoOpts) (*repo.RepoList, error)
}
// RepoList returns a list of repositories on a registry.
// Note the underlying "_catalog" API is not supported on many cloud registries.
func (rc *RegClient) RepoList(ctx context.Context, hostname string, opts ...scheme.RepoOpts) (*repo.RepoList, error) {
i := strings.Index(hostname, "/")
if i > 0 {
return nil, fmt.Errorf("invalid hostname: %s%.0w", hostname, errs.ErrParsingFailed)
}
schemeAPI, err := rc.schemeGet("reg")
if err != nil {
return nil, err
}
rl, ok := schemeAPI.(repoLister)
if !ok {
return nil, errs.ErrNotImplemented
}
return rl.RepoList(ctx, hostname, opts...)
}
-33
View File
@@ -1,33 +0,0 @@
package regclient
import (
"context"
"fmt"
"github.com/regclient/regclient/scheme"
"github.com/regclient/regclient/types/errs"
"github.com/regclient/regclient/types/ref"
)
func (rc *RegClient) schemeGet(scheme string) (scheme.API, error) {
s, ok := rc.schemes[scheme]
if !ok {
return nil, fmt.Errorf("%w: unknown scheme \"%s\"", errs.ErrNotImplemented, scheme)
}
return s, nil
}
// Close is used to free resources associated with a reference.
// With ocidir, this may trigger a garbage collection process.
func (rc *RegClient) Close(ctx context.Context, r ref.Ref) error {
schemeAPI, err := rc.schemeGet(r.Scheme)
if err != nil {
return err
}
// verify Closer api is defined, noop if missing
sc, ok := schemeAPI.(scheme.Closer)
if !ok {
return nil
}
return sc.Close(ctx, r)
}
-160
View File
@@ -1,160 +0,0 @@
package ocidir
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path"
"github.com/regclient/regclient/internal/reqmeta"
"github.com/regclient/regclient/types/blob"
"github.com/regclient/regclient/types/descriptor"
"github.com/regclient/regclient/types/errs"
"github.com/regclient/regclient/types/ref"
)
// BlobDelete removes a blob from the repository.
// This method does not verify that blobs are unused.
// Calling the [OCIDir.Close] method to trigger the garbage collection is preferred.
func (o *OCIDir) BlobDelete(ctx context.Context, r ref.Ref, d descriptor.Descriptor) error {
err := d.Digest.Validate()
if err != nil {
return fmt.Errorf("failed to validate digest %s: %w", d.Digest.String(), err)
}
file := path.Join(r.Path, "blobs", d.Digest.Algorithm().String(), d.Digest.Encoded())
return os.Remove(file)
}
// BlobGet retrieves a blob, returning a reader
func (o *OCIDir) BlobGet(ctx context.Context, r ref.Ref, d descriptor.Descriptor) (blob.Reader, error) {
err := d.Digest.Validate()
if err != nil {
return nil, fmt.Errorf("failed to validate digest %s: %w", d.Digest.String(), err)
}
file := path.Join(r.Path, "blobs", d.Digest.Algorithm().String(), d.Digest.Encoded())
//#nosec G304 users should validate references they attempt to open
fd, err := os.Open(file)
if err != nil {
return nil, err
}
if d.Size <= 0 {
fi, err := fd.Stat()
if err != nil {
_ = fd.Close()
return nil, err
}
d.Size = fi.Size()
}
br := blob.NewReader(
blob.WithRef(r),
blob.WithReader(fd),
blob.WithDesc(d),
)
o.slog.Debug("retrieved blob",
slog.String("ref", r.CommonName()),
slog.String("file", file))
return br, nil
}
// BlobHead verifies the existence of a blob, the reader contains the headers but no body to read
func (o *OCIDir) BlobHead(ctx context.Context, r ref.Ref, d descriptor.Descriptor) (blob.Reader, error) {
err := d.Digest.Validate()
if err != nil {
return nil, fmt.Errorf("failed to validate digest %s: %w", d.Digest.String(), err)
}
file := path.Join(r.Path, "blobs", d.Digest.Algorithm().String(), d.Digest.Encoded())
//#nosec G304 users should validate references they attempt to open
fd, err := os.Open(file)
if err != nil {
return nil, err
}
defer fd.Close()
if d.Size <= 0 {
fi, err := fd.Stat()
if err != nil {
return nil, err
}
d.Size = fi.Size()
}
br := blob.NewReader(
blob.WithRef(r),
blob.WithDesc(d),
)
return br, nil
}
// BlobMount attempts to perform a server side copy of the blob
func (o *OCIDir) BlobMount(ctx context.Context, refSrc ref.Ref, refTgt ref.Ref, d descriptor.Descriptor) error {
return errs.ErrUnsupported
}
// BlobPut sends a blob to the repository, returns the digest and size when successful
func (o *OCIDir) BlobPut(ctx context.Context, r ref.Ref, d descriptor.Descriptor, rdr io.Reader) (descriptor.Descriptor, error) {
t := o.throttleGet(r, false)
done, err := t.Acquire(ctx, reqmeta.Data{Kind: reqmeta.Blob, Size: d.Size})
if err != nil {
return d, err
}
defer done()
err = o.initIndex(r, false)
if err != nil {
return d, err
}
digester := d.DigestAlgo().Digester()
rdr = io.TeeReader(rdr, digester.Hash())
// write the blob to a tmp file
dir := path.Join(r.Path, "blobs", d.DigestAlgo().String())
tmpPattern := "*.tmp"
//#nosec G301 defer to user umask settings
err = os.MkdirAll(dir, 0o777)
if err != nil && !errors.Is(err, fs.ErrExist) {
return d, fmt.Errorf("failed creating %s: %w", dir, err)
}
tmpFile, err := os.CreateTemp(dir, tmpPattern)
if err != nil {
return d, fmt.Errorf("failed creating blob tmp file: %w", err)
}
fi, err := tmpFile.Stat()
if err != nil {
return d, fmt.Errorf("failed to stat blob tmpfile: %w", err)
}
tmpName := fi.Name()
i, err := io.Copy(tmpFile, rdr)
errC := tmpFile.Close()
if err != nil {
return d, err
}
if errC != nil {
return d, errC
}
// validate result matches descriptor, or update descriptor if it wasn't defined
if d.Digest.Validate() != nil {
d.Digest = digester.Digest()
} else if d.Digest != digester.Digest() {
return d, fmt.Errorf("unexpected digest, expected %s, computed %s", d.Digest, digester.Digest())
}
if d.Size <= 0 {
d.Size = i
} else if i != d.Size {
return d, fmt.Errorf("unexpected blob length, expected %d, received %d", d.Size, i)
}
file := path.Join(r.Path, "blobs", d.Digest.Algorithm().String(), d.Digest.Encoded())
//#nosec G703 inputs are user controlled
err = os.Rename(path.Join(dir, tmpName), file)
if err != nil {
return d, fmt.Errorf("failed to write blob (rename tmp file %s to %s): %w", path.Join(dir, tmpName), file, err)
}
o.slog.Debug("pushed blob",
slog.String("ref", r.CommonName()),
slog.String("file", file))
o.mu.Lock()
o.refMod(r)
o.mu.Unlock()
return d, nil
}
-117
View File
@@ -1,117 +0,0 @@
package ocidir
import (
"context"
"fmt"
"log/slog"
"os"
"path"
"github.com/regclient/regclient/types/manifest"
"github.com/regclient/regclient/types/ref"
)
// Close triggers a garbage collection if the underlying path has been modified
func (o *OCIDir) Close(ctx context.Context, r ref.Ref) error {
if !o.gc {
return nil
}
o.mu.Lock()
defer o.mu.Unlock()
if gc, ok := o.modRefs[r.Path]; !ok || !gc.mod || gc.locks > 0 {
// unmodified or locked, skip gc
return nil
}
// perform GC
o.slog.Debug("running GC",
slog.String("ref", r.CommonName()))
dl := map[string]bool{}
// recurse through index, manifests, and blob lists, generating a digest list
index, err := o.readIndex(r, true)
if err != nil {
return err
}
im, err := manifest.New(manifest.WithOrig(index))
if err != nil {
return err
}
err = o.closeProcManifest(ctx, r, im, &dl)
if err != nil {
return err
}
// go through filesystem digest list, removing entries not seen in recursive pass
blobsPath := path.Join(r.Path, "blobs")
blobDirs, err := os.ReadDir(blobsPath)
if err != nil {
return err
}
for _, blobDir := range blobDirs {
if !blobDir.IsDir() {
// should this warn or delete unexpected files in the blobs folder?
continue
}
digestFiles, err := os.ReadDir(path.Join(blobsPath, blobDir.Name()))
if err != nil {
return err
}
for _, digestFile := range digestFiles {
digest := fmt.Sprintf("%s:%s", blobDir.Name(), digestFile.Name())
if !dl[digest] {
o.slog.Debug("ocidir garbage collect",
slog.String("digest", digest))
// delete
err = os.Remove(path.Join(blobsPath, blobDir.Name(), digestFile.Name()))
if err != nil {
return fmt.Errorf("failed to delete %s: %w", path.Join(blobsPath, blobDir.Name(), digestFile.Name()), err)
}
}
}
}
delete(o.modRefs, r.Path)
return nil
}
func (o *OCIDir) closeProcManifest(ctx context.Context, r ref.Ref, m manifest.Manifest, dl *map[string]bool) error {
if mi, ok := m.(manifest.Indexer); ok {
// go through manifest list, updating dl, and recursively processing nested manifests
ml, err := mi.GetManifestList()
if err != nil {
return err
}
for _, cur := range ml {
cr := r.SetDigest(cur.Digest.String())
(*dl)[cr.Digest] = true
cm, err := o.manifestGet(ctx, cr)
if err != nil {
// ignore errors in case a manifest has been deleted or sparse copy
o.slog.Debug("could not retrieve manifest",
slog.String("ref", cr.CommonName()),
slog.String("err", err.Error()))
continue
}
err = o.closeProcManifest(ctx, cr, cm, dl)
if err != nil {
return err
}
}
}
if mi, ok := m.(manifest.Imager); ok {
// get config from manifest if it exists
cd, err := mi.GetConfig()
if err == nil {
(*dl)[cd.Digest.String()] = true
}
// finally add all layers to digest list
layers, err := mi.GetLayers()
if err != nil {
return err
}
for _, layer := range layers {
(*dl)[layer.Digest.String()] = true
}
}
return nil
}
-306
View File
@@ -1,306 +0,0 @@
package ocidir
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path"
"slices"
// crypto libraries included for go-digest
_ "crypto/sha256"
_ "crypto/sha512"
"github.com/opencontainers/go-digest"
"github.com/regclient/regclient/scheme"
"github.com/regclient/regclient/types"
"github.com/regclient/regclient/types/errs"
"github.com/regclient/regclient/types/manifest"
"github.com/regclient/regclient/types/mediatype"
"github.com/regclient/regclient/types/ref"
)
// ManifestDelete removes a manifest, including all tags that point to that manifest
func (o *OCIDir) ManifestDelete(ctx context.Context, r ref.Ref, opts ...scheme.ManifestOpts) error {
o.mu.Lock()
defer o.mu.Unlock()
if r.Digest == "" {
return fmt.Errorf("digest required to delete manifest, reference %s%.0w", r.CommonName(), errs.ErrMissingDigest)
}
mc := scheme.ManifestConfig{}
for _, opt := range opts {
opt(&mc)
}
// always check for refers with ocidir
if mc.Manifest == nil {
m, err := o.manifestGet(ctx, r)
if err != nil {
return fmt.Errorf("failed to pull manifest for refers: %w", err)
}
mc.Manifest = m
}
if mc.Manifest != nil {
if ms, ok := mc.Manifest.(manifest.Subjecter); ok {
sDesc, err := ms.GetSubject()
if err == nil && sDesc != nil && sDesc.Digest != "" {
// attempt to delete the referrer, but ignore if the referrer entry wasn't found
err = o.referrerDelete(ctx, r, mc.Manifest)
if err != nil && !errors.Is(err, errs.ErrNotFound) && !errors.Is(err, fs.ErrNotExist) {
return err
}
}
}
}
// get index
changed := false
index, err := o.readIndex(r, true)
if err != nil {
return fmt.Errorf("failed to read index: %w", err)
}
for i := len(index.Manifests) - 1; i >= 0; i-- {
// remove matching entry from index
if r.Digest != "" && index.Manifests[i].Digest.String() == r.Digest {
changed = true
index.Manifests = slices.Delete(index.Manifests, i, i+1)
}
}
// push manifest back out
if changed {
err = o.writeIndex(r, index, true)
if err != nil {
return fmt.Errorf("failed to write index: %w", err)
}
}
// delete from filesystem like a registry would do
d := digest.Digest(r.Digest)
file := path.Join(r.Path, "blobs", d.Algorithm().String(), d.Encoded())
err = os.Remove(file)
if err != nil {
return fmt.Errorf("failed to delete manifest: %w", err)
}
o.refMod(r)
return nil
}
// ManifestGet retrieves a manifest from a repository
func (o *OCIDir) ManifestGet(ctx context.Context, r ref.Ref) (manifest.Manifest, error) {
o.mu.Lock()
defer o.mu.Unlock()
return o.manifestGet(ctx, r)
}
func (o *OCIDir) manifestGet(_ context.Context, r ref.Ref) (manifest.Manifest, error) {
index, err := o.readIndex(r, true)
if err != nil {
return nil, fmt.Errorf("unable to read oci index: %w", err)
}
if r.Digest == "" && r.Tag == "" {
r = r.SetTag("latest")
}
desc, err := indexGet(index, r)
if err != nil {
if r.Digest != "" {
desc.Digest = digest.Digest(r.Digest)
} else {
return nil, err
}
}
if desc.Digest == "" {
return nil, errs.ErrNotFound
}
if err = desc.Digest.Validate(); err != nil {
return nil, fmt.Errorf("invalid digest in index: %s: %w", string(desc.Digest), err)
}
file := path.Join(r.Path, "blobs", desc.Digest.Algorithm().String(), desc.Digest.Encoded())
//#nosec G304 users should validate references they attempt to open
fd, err := os.Open(file)
if err != nil {
return nil, fmt.Errorf("failed to open manifest: %w", err)
}
defer fd.Close()
mb, err := io.ReadAll(fd)
if err != nil {
return nil, fmt.Errorf("failed to read manifest: %w", err)
}
if desc.Size == 0 {
desc.Size = int64(len(mb))
}
o.slog.Debug("retrieved manifest",
slog.String("ref", r.CommonName()),
slog.String("file", file))
return manifest.New(
manifest.WithRef(r),
manifest.WithDesc(desc),
manifest.WithRaw(mb),
)
}
// ManifestHead gets metadata about the manifest (existence, digest, mediatype, size)
func (o *OCIDir) ManifestHead(ctx context.Context, r ref.Ref) (manifest.Manifest, error) {
index, err := o.readIndex(r, false)
if err != nil {
return nil, fmt.Errorf("unable to read oci index: %w", err)
}
if r.Digest == "" && r.Tag == "" {
r = r.SetTag("latest")
}
desc, err := indexGet(index, r)
if err != nil {
if r.Digest != "" {
desc.Digest = digest.Digest(r.Digest)
} else {
return nil, err
}
}
if desc.Digest == "" {
return nil, errs.ErrNotFound
}
if err = desc.Digest.Validate(); err != nil {
return nil, fmt.Errorf("invalid digest in index: %s: %w", string(desc.Digest), err)
}
// verify underlying file exists
file := path.Join(r.Path, "blobs", desc.Digest.Algorithm().String(), desc.Digest.Encoded())
fi, err := os.Stat(file)
if err != nil || fi.IsDir() {
return nil, errs.ErrNotFound
}
// if missing, set media type on desc
if desc.MediaType == "" {
//#nosec G304 users should validate references they attempt to open
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
mt := struct {
MediaType string `json:"mediaType,omitempty"`
SchemaVersion int `json:"schemaVersion,omitempty"`
Signatures []any `json:"signatures,omitempty"`
}{}
err = json.Unmarshal(raw, &mt)
if err != nil {
return nil, err
}
if mt.MediaType != "" {
desc.MediaType = mt.MediaType
desc.Size = int64(len(raw))
} else if mt.SchemaVersion == 1 && len(mt.Signatures) > 0 {
desc.MediaType = mediatype.Docker1ManifestSigned
} else if mt.SchemaVersion == 1 {
desc.MediaType = mediatype.Docker1Manifest
desc.Size = int64(len(raw))
}
}
return manifest.New(
manifest.WithRef(r),
manifest.WithDesc(desc),
)
}
// ManifestPut sends a manifest to the repository
func (o *OCIDir) ManifestPut(ctx context.Context, r ref.Ref, m manifest.Manifest, opts ...scheme.ManifestOpts) error {
o.mu.Lock()
defer o.mu.Unlock()
return o.manifestPut(ctx, r, m, opts...)
}
func (o *OCIDir) manifestPut(ctx context.Context, r ref.Ref, m manifest.Manifest, opts ...scheme.ManifestOpts) error {
config := scheme.ManifestConfig{}
for _, opt := range opts {
opt(&config)
}
if !config.Child && r.Digest == "" && r.Tag == "" {
r = r.SetTag("latest")
}
err := o.initIndex(r, true)
if err != nil {
return err
}
desc := m.GetDescriptor()
if err = desc.Digest.Validate(); err != nil {
return fmt.Errorf("invalid digest for manifest: %s: %w", string(desc.Digest), err)
}
b, err := m.RawBody()
if err != nil {
return fmt.Errorf("could not serialize manifest: %w", err)
}
if r.Digest != "" && desc.Digest.String() != r.Digest {
// Digest algorithm may have changed, try recreating the manifest with the provided ref.
// This will fail if the ref digest does not match the manifest.
m, err = manifest.New(manifest.WithRef(r), manifest.WithRaw(b))
if err != nil {
return fmt.Errorf("failed to rebuilding manifest with ref \"%s\": %w", r.CommonName(), err)
}
}
desc.Annotations = map[string]string{}
if r.Tag != "" {
desc.Annotations[types.AnnotationRefName] = r.Tag
}
// TODO: if the manifest contains a subject, option to add descriptor details and include entry in the index.json, config.child=false
// create manifest CAS file
dir := path.Join(r.Path, "blobs", desc.Digest.Algorithm().String())
//#nosec G301 defer to user umask settings
err = os.MkdirAll(dir, 0o777)
if err != nil && !errors.Is(err, fs.ErrExist) {
return fmt.Errorf("failed creating %s: %w", dir, err)
}
// write to a tmp file, rename after validating
tmpFile, err := os.CreateTemp(dir, desc.Digest.Encoded()+".*.tmp")
if err != nil {
return fmt.Errorf("failed to create manifest tmpfile: %w", err)
}
fi, err := tmpFile.Stat()
if err != nil {
return fmt.Errorf("failed to stat manifest tmpfile: %w", err)
}
tmpName := fi.Name()
_, err = tmpFile.Write(b)
errC := tmpFile.Close()
if err != nil {
return fmt.Errorf("failed to write manifest tmpfile: %w", err)
}
if errC != nil {
return fmt.Errorf("failed to close manifest tmpfile: %w", errC)
}
file := path.Join(dir, desc.Digest.Encoded())
//#nosec G703 inputs are user controlled
err = os.Rename(path.Join(dir, tmpName), file)
if err != nil {
return fmt.Errorf("failed to write manifest (rename tmpfile): %w", err)
}
// verify/update index
err = o.updateIndex(r, desc, config.Child, true)
if err != nil {
return err
}
o.refMod(r)
o.slog.Debug("pushed manifest",
slog.String("ref", r.CommonName()),
slog.String("file", file))
// update referrers if defined on this manifest
if ms, ok := m.(manifest.Subjecter); ok {
mDesc, err := ms.GetSubject()
if err != nil {
return err
}
if mDesc != nil && mDesc.Digest != "" {
err = o.referrerPut(ctx, r, m)
if err != nil {
return err
}
}
}
return nil
}
-419
View File
@@ -1,419 +0,0 @@
// Package ocidir implements the OCI Image Layout scheme with a directory (not packed in a tar)
package ocidir
import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path"
"slices"
"strings"
"sync"
"time"
"github.com/regclient/regclient/internal/pqueue"
"github.com/regclient/regclient/internal/reproducible"
"github.com/regclient/regclient/internal/reqmeta"
"github.com/regclient/regclient/types"
"github.com/regclient/regclient/types/descriptor"
"github.com/regclient/regclient/types/errs"
"github.com/regclient/regclient/types/mediatype"
v1 "github.com/regclient/regclient/types/oci/v1"
"github.com/regclient/regclient/types/ref"
)
const (
imageLayoutFile = "oci-layout"
defThrottle = 3
)
// OCIDir is used for accessing OCI Image Layouts defined as a directory
type OCIDir struct {
slog *slog.Logger
gc bool
modRefs map[string]*ociGC
throttle map[string]*pqueue.Queue[reqmeta.Data]
throttleDef int
mu sync.Mutex
}
type ociGC struct {
mod bool
locks int
}
type ociConf struct {
gc bool
slog *slog.Logger
throttle int
}
// Opts are used for passing options to ocidir
type Opts func(*ociConf)
// New creates a new OCIDir with options
func New(opts ...Opts) *OCIDir {
conf := ociConf{
slog: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})),
gc: true,
throttle: defThrottle,
}
for _, opt := range opts {
opt(&conf)
}
return &OCIDir{
slog: conf.slog,
gc: conf.gc,
modRefs: map[string]*ociGC{},
throttle: map[string]*pqueue.Queue[reqmeta.Data]{},
throttleDef: conf.throttle,
}
}
// WithGC configures the garbage collection setting
// This defaults to enabled
func WithGC(gc bool) Opts {
return func(c *ociConf) {
c.gc = gc
}
}
// WithSlog provides a slog logger.
// By default logging is disabled.
func WithSlog(slog *slog.Logger) Opts {
return func(c *ociConf) {
c.slog = slog
}
}
// WithThrottle provides a number of concurrent write actions (blob/manifest put)
func WithThrottle(count int) Opts {
return func(c *ociConf) {
c.throttle = count
}
}
// GCLock is used to prevent GC on a ref
func (o *OCIDir) GCLock(r ref.Ref) {
o.mu.Lock()
defer o.mu.Unlock()
if gc, ok := o.modRefs[r.Path]; ok && gc != nil {
gc.locks++
} else {
o.modRefs[r.Path] = &ociGC{locks: 1}
}
}
// GCUnlock removes a hold on GC of a ref, this must be done before the ref is closed
func (o *OCIDir) GCUnlock(r ref.Ref) {
o.mu.Lock()
defer o.mu.Unlock()
if gc, ok := o.modRefs[r.Path]; ok && gc != nil && gc.locks > 0 {
gc.locks--
}
}
// Throttle is used to limit concurrency
func (o *OCIDir) Throttle(r ref.Ref, put bool) []*pqueue.Queue[reqmeta.Data] {
tList := []*pqueue.Queue[reqmeta.Data]{}
// throttle only applies to put requests
if !put || o.throttleDef <= 0 {
return tList
}
return []*pqueue.Queue[reqmeta.Data]{o.throttleGet(r, false)}
}
func (o *OCIDir) throttleGet(r ref.Ref, locked bool) *pqueue.Queue[reqmeta.Data] {
if !locked {
o.mu.Lock()
defer o.mu.Unlock()
}
if t, ok := o.throttle[r.Path]; ok {
return t
}
// init a new throttle
o.throttle[r.Path] = pqueue.New(pqueue.Opts[reqmeta.Data]{Max: o.throttleDef})
return o.throttle[r.Path]
}
func (o *OCIDir) initIndex(r ref.Ref, locked bool) error {
if !locked {
o.mu.Lock()
defer o.mu.Unlock()
}
layoutFile := path.Join(r.Path, imageLayoutFile)
_, err := os.Stat(layoutFile)
if err == nil {
return nil
}
//#nosec G301 defer to user umask settings
err = os.MkdirAll(r.Path, 0o777)
if err != nil && !errors.Is(err, fs.ErrExist) {
return fmt.Errorf("failed creating %s: %w", r.Path, err)
}
// create/replace oci-layout file
layout := v1.ImageLayout{
Version: "1.0.0",
}
lb, err := json.Marshal(layout)
if err != nil {
return fmt.Errorf("cannot marshal layout: %w", err)
}
//#nosec G304 users should validate references they attempt to open
lfh, err := os.Create(layoutFile)
if err != nil {
return fmt.Errorf("cannot create %s: %w", imageLayoutFile, err)
}
defer lfh.Close()
_, err = lfh.Write(lb)
if err != nil {
return fmt.Errorf("cannot write %s: %w", imageLayoutFile, err)
}
return nil
}
func (o *OCIDir) readIndex(r ref.Ref, locked bool) (v1.Index, error) {
if !locked {
o.mu.Lock()
defer o.mu.Unlock()
}
// validate dir
index := v1.Index{}
err := o.valid(r.Path, true)
if err != nil {
return index, err
}
indexFile := path.Join(r.Path, "index.json")
//#nosec G304 users should validate references they attempt to open
fh, err := os.Open(indexFile)
if err != nil {
return index, fmt.Errorf("%s cannot be open: %w", indexFile, err)
}
defer fh.Close()
ib, err := io.ReadAll(fh)
if err != nil {
return index, fmt.Errorf("%s cannot be read: %w", indexFile, err)
}
err = json.Unmarshal(ib, &index)
if err != nil {
return index, fmt.Errorf("%s cannot be parsed: %w", indexFile, err)
}
return index, nil
}
func (o *OCIDir) updateIndex(r ref.Ref, d descriptor.Descriptor, child bool, locked bool) error {
if !locked {
o.mu.Lock()
defer o.mu.Unlock()
}
indexChanged := false
index, err := o.readIndex(r, true)
if err != nil {
index = indexCreate()
indexChanged = true
}
if !child {
err := indexSet(&index, r, d)
if err != nil {
return fmt.Errorf("failed to update index: %w", err)
}
indexChanged = true
}
if indexChanged {
err = o.writeIndex(r, index, true)
if err != nil {
return fmt.Errorf("failed to write index: %w", err)
}
}
return nil
}
func (o *OCIDir) writeIndex(r ref.Ref, i v1.Index, locked bool) error {
if !locked {
o.mu.Lock()
defer o.mu.Unlock()
}
//#nosec G301 defer to user umask settings
err := os.MkdirAll(r.Path, 0o777)
if err != nil && !errors.Is(err, fs.ErrExist) {
return fmt.Errorf("failed creating %s: %w", r.Path, err)
}
// create/replace oci-layout file
layout := v1.ImageLayout{
Version: "1.0.0",
}
lb, err := json.Marshal(layout)
if err != nil {
return fmt.Errorf("cannot marshal layout: %w", err)
}
lfh, err := os.Create(path.Join(r.Path, imageLayoutFile))
if err != nil {
return fmt.Errorf("cannot create %s: %w", imageLayoutFile, err)
}
defer lfh.Close()
_, err = lfh.Write(lb)
if err != nil {
return fmt.Errorf("cannot write %s: %w", imageLayoutFile, err)
}
// create/replace index.json file
tmpFile, err := os.CreateTemp(r.Path, "index.json.*.tmp")
if err != nil {
return fmt.Errorf("cannot create index tmpfile: %w", err)
}
fi, err := tmpFile.Stat()
if err != nil {
return fmt.Errorf("failed to stat index tmpfile: %w", err)
}
tmpName := fi.Name()
b, err := json.Marshal(i)
if err != nil {
return fmt.Errorf("cannot marshal index: %w", err)
}
_, err = tmpFile.Write(b)
errC := tmpFile.Close()
if err != nil {
return fmt.Errorf("cannot write index: %w", err)
}
if errC != nil {
return fmt.Errorf("cannot close index: %w", errC)
}
indexFile := path.Join(r.Path, "index.json")
//#nosec G703 inputs are user controlled
err = os.Rename(path.Join(r.Path, tmpName), indexFile)
if err != nil {
return fmt.Errorf("cannot rename tmpfile to index: %w", err)
}
return nil
}
// func valid (dir) (error) // check for `oci-layout` file and `index.json` for read
func (o *OCIDir) valid(dir string, locked bool) error {
if !locked {
o.mu.Lock()
defer o.mu.Unlock()
}
layout := v1.ImageLayout{}
reqVer := "1.0.0"
//#nosec G304 users should validate references they attempt to open
fh, err := os.Open(path.Join(dir, imageLayoutFile))
if err != nil {
return fmt.Errorf("%s cannot be open: %w", imageLayoutFile, err)
}
defer fh.Close()
lb, err := io.ReadAll(fh)
if err != nil {
return fmt.Errorf("%s cannot be read: %w", imageLayoutFile, err)
}
err = json.Unmarshal(lb, &layout)
if err != nil {
return fmt.Errorf("%s cannot be parsed: %w", imageLayoutFile, err)
}
if layout.Version != reqVer {
return fmt.Errorf("unsupported oci layout version, expected %s, received %s", reqVer, layout.Version)
}
return nil
}
func (o *OCIDir) refMod(r ref.Ref) {
if gc, ok := o.modRefs[r.Path]; ok && gc != nil {
gc.mod = true
} else {
o.modRefs[r.Path] = &ociGC{mod: true}
}
}
func indexCreate() v1.Index {
i := v1.Index{
Versioned: v1.IndexSchemaVersion,
MediaType: mediatype.OCI1ManifestList,
Manifests: []descriptor.Descriptor{},
Annotations: map[string]string{},
}
return i
}
func indexGet(index v1.Index, r ref.Ref) (descriptor.Descriptor, error) {
if r.Digest == "" && r.Tag == "" {
r = r.SetTag("latest")
}
if r.Digest != "" {
for _, im := range index.Manifests {
if im.Digest.String() == r.Digest {
return im, nil
}
}
} else if r.Tag != "" {
for _, im := range index.Manifests {
if name, ok := im.Annotations[types.AnnotationRefName]; ok && name == r.Tag {
return im, nil
}
}
// fall back to support full image name in annotation
for _, im := range index.Manifests {
if name, ok := im.Annotations[types.AnnotationRefName]; ok && strings.HasSuffix(name, ":"+r.Tag) {
return im, nil
}
}
}
return descriptor.Descriptor{}, errs.ErrNotFound
}
func indexSet(index *v1.Index, r ref.Ref, d descriptor.Descriptor) error {
if index == nil {
return fmt.Errorf("index is nil")
}
if d.Annotations == nil {
d.Annotations = map[string]string{}
}
if r.Tag != "" {
d.Annotations[types.AnnotationRefName] = r.Tag
}
if _, ok := d.Annotations[types.AnnotationCreated]; !ok {
d.Annotations[types.AnnotationCreated] = reproducible.TimeNow().Format(time.RFC3339)
}
if index.Manifests == nil {
index.Manifests = []descriptor.Descriptor{}
}
pos := -1
// search for existing
for i := range index.Manifests {
var name string
if index.Manifests[i].Annotations != nil {
name = index.Manifests[i].Annotations[types.AnnotationRefName]
}
if r.Tag == "" && name == "" && index.Manifests[i].Digest == d.Digest {
// untagged entry already exists, no need to replace it
pos = i
break
} else if r.Tag != "" && name == r.Tag {
if index.Manifests[i].Digest != d.Digest {
// replace the tag with a new digest/descriptor
index.Manifests[i] = d
}
pos = i
break
}
}
if pos >= 0 {
// existing entry was replaced, remove any dup entries
for i := len(index.Manifests) - 1; i > pos; i-- {
var name string
if index.Manifests[i].Annotations != nil {
name = index.Manifests[i].Annotations[types.AnnotationRefName]
}
// prune untagged entries with a matching digest if we didn't push a tag
// or entries with a matching tag
if (r.Tag == "" && name == "" && index.Manifests[i].Digest == d.Digest) || (r.Tag != "" && name == r.Tag) {
index.Manifests = slices.Delete(index.Manifests, i, i+1)
}
}
} else {
// existing entry to replace was not found, add the descriptor
index.Manifests = append(index.Manifests, d)
}
return nil
}
-19
View File
@@ -1,19 +0,0 @@
//go:build !wasm
package ocidir
import (
"log/slog"
"github.com/sirupsen/logrus"
"github.com/regclient/regclient/internal/sloghandle"
)
// WithLog provides a logrus logger.
// By default logging is disabled.
func WithLog(log *logrus.Logger) Opts {
return func(c *ociConf) {
c.slog = slog.New(sloghandle.Logrus(log))
}
}
-29
View File
@@ -1,29 +0,0 @@
package ocidir
import (
"context"
"fmt"
"os"
"github.com/regclient/regclient/types/ping"
"github.com/regclient/regclient/types/ref"
)
// Ping for an ocidir verifies access to read the path.
func (o *OCIDir) Ping(ctx context.Context, r ref.Ref) (ping.Result, error) {
ret := ping.Result{}
fd, err := os.Open(r.Path)
if err != nil {
return ret, err
}
defer fd.Close()
fi, err := fd.Stat()
if err != nil {
return ret, err
}
ret.Stat = fi
if !fi.IsDir() {
return ret, fmt.Errorf("failed to access %s: not a directory", r.Path)
}
return ret, nil
}
-218
View File
@@ -1,218 +0,0 @@
package ocidir
import (
"context"
"errors"
"fmt"
"strings"
"github.com/regclient/regclient/scheme"
"github.com/regclient/regclient/types"
"github.com/regclient/regclient/types/errs"
"github.com/regclient/regclient/types/manifest"
"github.com/regclient/regclient/types/mediatype"
v1 "github.com/regclient/regclient/types/oci/v1"
"github.com/regclient/regclient/types/ref"
"github.com/regclient/regclient/types/referrer"
)
// ReferrerList returns a list of referrers to a given reference.
// The reference must include the digest. Use [regclient.ReferrerList] to resolve the platform or tag.
func (o *OCIDir) ReferrerList(ctx context.Context, r ref.Ref, opts ...scheme.ReferrerOpts) (referrer.ReferrerList, error) {
o.mu.Lock()
defer o.mu.Unlock()
return o.referrerList(ctx, r, opts...)
}
func (o *OCIDir) referrerList(ctx context.Context, rSubject ref.Ref, opts ...scheme.ReferrerOpts) (referrer.ReferrerList, error) {
config := scheme.ReferrerConfig{}
for _, opt := range opts {
opt(&config)
}
var r ref.Ref
if config.SrcRepo.IsSet() {
r = config.SrcRepo.SetDigest(rSubject.Digest)
} else {
r = rSubject.SetDigest(rSubject.Digest)
}
rl := referrer.New(rSubject)
if rSubject.Digest == "" {
return rl, fmt.Errorf("digest required to query referrers %s", rSubject.CommonName())
}
// TODO: Add fast path with private field in index.json indicating referrers are tracked using an annotation.
// This can be implemented with backward compatible support by also pushing individual manifests or the fallback tag.
// TODO: Upstream OCI specification of how to track subject/referrers in an OCI Layout is still pending.
// optional slow query of all manifests for ORAS support
if config.SlowSearch {
idx, err := o.readIndex(r, true)
if err != nil {
return rl, err
}
for _, desc := range idx.Manifests {
// pull the manifest
rCur := r.SetDigest(desc.Digest.String())
m, err := o.manifestGet(ctx, rCur)
if err != nil {
// skip all errors
continue
}
// check for the subject
mSubj, ok := m.(manifest.Subjecter)
if !ok {
continue
}
subj, err := mSubj.GetSubject()
if err != nil || subj == nil || subj.Digest.String() != rSubject.Digest {
continue
}
// extract/track tag
if name, ok := desc.Annotations[types.AnnotationRefName]; ok && !strings.ContainsAny(name, ":/") {
rl.Tags = append(rl.Tags, name)
}
// if matching, add to rl
err = rl.Add(m)
if err != nil {
return rl, err
}
}
}
// try to request the fallback tag
rlFallback, err := o.referrerListFallback(ctx, r)
if err == nil {
err = rl.Merge(rlFallback)
if err != nil {
return rl, fmt.Errorf("failed to merge referrers from fallback tag: %w", err)
}
}
// update referrer list
rl.Subject = rSubject
if config.SrcRepo.IsSet() {
rl.Source = config.SrcRepo
}
rl = scheme.ReferrerFilter(config, rl)
return rl, nil
}
func (o *OCIDir) referrerListFallback(ctx context.Context, r ref.Ref) (referrer.ReferrerList, error) {
rl := referrer.New(r)
rlTag, err := referrer.FallbackTag(r)
if err != nil {
return rl, err
}
m, err := o.manifestGet(ctx, rlTag)
if err != nil {
if errors.Is(err, errs.ErrNotFound) {
// empty list, initialize a new manifest
rl.Manifest, err = manifest.New(manifest.WithOrig(v1.Index{
Versioned: v1.IndexSchemaVersion,
MediaType: mediatype.OCI1ManifestList,
}))
if err != nil {
return rl, err
}
return rl, nil
}
return rl, err
}
ociML, ok := m.GetOrig().(v1.Index)
if !ok {
return rl, fmt.Errorf("manifest is not an OCI index: %s", rlTag.CommonName())
}
// update referrer list
rl.Manifest = m
rl.Descriptors = ociML.Manifests
rl.Annotations = ociML.Annotations
rl.Tags = append(rl.Tags, rlTag.Tag)
return rl, nil
}
// referrerDelete deletes a referrer associated with a manifest
func (o *OCIDir) referrerDelete(ctx context.Context, r ref.Ref, m manifest.Manifest) error {
// get refers field
mSubject, ok := m.(manifest.Subjecter)
if !ok {
return fmt.Errorf("manifest does not support subject: %w", errs.ErrUnsupportedMediaType)
}
subject, err := mSubject.GetSubject()
if err != nil {
return err
}
// validate/set subject descriptor
if subject == nil || subject.Digest == "" {
return fmt.Errorf("subject is not set%.0w", errs.ErrNotFound)
}
// get descriptor for subject
rSubject := r.SetDigest(subject.Digest.String())
// pull existing referrer list
rl, err := o.referrerList(ctx, rSubject)
if err != nil {
return err
}
err = rl.Delete(m)
if err != nil {
return err
}
// push updated referrer list by tag
rlTag, err := referrer.FallbackTag(rSubject)
if err != nil {
return err
}
if rl.IsEmpty() {
err = o.tagDelete(ctx, rlTag)
if err == nil {
return nil
}
// if delete is not supported, fall back to pushing empty list
}
return o.manifestPut(ctx, rlTag, rl.Manifest)
}
// referrerPut pushes a new referrer associated with a given reference
func (o *OCIDir) referrerPut(ctx context.Context, r ref.Ref, m manifest.Manifest) error {
// get subject field
mSubject, ok := m.(manifest.Subjecter)
if !ok {
return fmt.Errorf("manifest does not support subject: %w", errs.ErrUnsupportedMediaType)
}
subject, err := mSubject.GetSubject()
if err != nil {
return err
}
// validate/set subject descriptor
if subject == nil || subject.Digest == "" {
return fmt.Errorf("subject is not set%.0w", errs.ErrNotFound)
}
// get descriptor for subject
rSubject := r.SetDigest(subject.Digest.String())
// pull existing referrer list
rl, err := o.referrerList(ctx, rSubject)
if err != nil {
return err
}
err = rl.Add(m)
if err != nil {
return err
}
// TODO: add support for pushing a fast path generated response
// TODO: allow fallback tag creation to be skipped, potentially via a config in the manifestPut
// push updated referrer list by tag
rlTag, err := referrer.FallbackTag(rSubject)
if err != nil {
return err
}
return o.manifestPut(ctx, rlTag, rl.Manifest)
}
-90
View File
@@ -1,90 +0,0 @@
package ocidir
import (
"context"
"encoding/json"
"fmt"
"slices"
"sort"
"strings"
"github.com/regclient/regclient/scheme"
"github.com/regclient/regclient/types"
"github.com/regclient/regclient/types/errs"
"github.com/regclient/regclient/types/mediatype"
"github.com/regclient/regclient/types/ref"
"github.com/regclient/regclient/types/tag"
)
// TagDelete removes a tag from the repository
func (o *OCIDir) TagDelete(ctx context.Context, r ref.Ref) error {
o.mu.Lock()
defer o.mu.Unlock()
return o.tagDelete(ctx, r)
}
func (o *OCIDir) tagDelete(_ context.Context, r ref.Ref) error {
if r.Tag == "" {
return errs.ErrMissingTag
}
// get index
index, err := o.readIndex(r, true)
if err != nil {
return fmt.Errorf("failed to read index: %w", err)
}
changed := false
for i, desc := range index.Manifests {
if t, ok := desc.Annotations[types.AnnotationRefName]; ok && t == r.Tag {
// remove matching entry from index
index.Manifests = slices.Delete(index.Manifests, i, i+1)
changed = true
}
}
if !changed {
return fmt.Errorf("failed deleting %s: %w", r.CommonName(), errs.ErrNotFound)
}
// push manifest back out
err = o.writeIndex(r, index, true)
if err != nil {
return fmt.Errorf("failed to write index: %w", err)
}
o.refMod(r)
return nil
}
// TagList returns a list of tags from the repository
func (o *OCIDir) TagList(ctx context.Context, r ref.Ref, opts ...scheme.TagOpts) (*tag.List, error) {
// get index
index, err := o.readIndex(r, false)
if err != nil {
return nil, err
}
tl := []string{}
for _, desc := range index.Manifests {
if t, ok := desc.Annotations[types.AnnotationRefName]; ok {
if i := strings.LastIndex(t, ":"); i >= 0 {
t = t[i+1:]
}
if !slices.Contains(tl, t) {
tl = append(tl, t)
}
}
}
sort.Strings(tl)
ib, err := json.Marshal(index)
if err != nil {
return nil, err
}
// return listing from index
t, err := tag.New(
tag.WithRaw(ib),
tag.WithRef(r),
tag.WithMT(mediatype.OCI1ManifestList),
tag.WithLayoutIndex(index),
tag.WithTags(tl),
)
if err != nil {
return nil, err
}
return t, nil
}
-680
View File
@@ -1,680 +0,0 @@
package reg
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
// crypto libraries included for go-digest
_ "crypto/sha256"
_ "crypto/sha512"
"github.com/opencontainers/go-digest"
"github.com/regclient/regclient/internal/reghttp"
"github.com/regclient/regclient/internal/regnet"
"github.com/regclient/regclient/internal/reqmeta"
"github.com/regclient/regclient/types/blob"
"github.com/regclient/regclient/types/descriptor"
"github.com/regclient/regclient/types/errs"
"github.com/regclient/regclient/types/ref"
"github.com/regclient/regclient/types/warning"
)
var zeroDig = digest.SHA256.FromBytes([]byte{})
// BlobDelete removes a blob from the repository
func (reg *Reg) BlobDelete(ctx context.Context, r ref.Ref, d descriptor.Descriptor) error {
req := &reghttp.Req{
MetaKind: reqmeta.Query,
Host: r.Registry,
Method: "DELETE",
Repository: r.Repository,
Path: "blobs/" + d.Digest.String(),
}
resp, err := reg.reghttp.Do(ctx, req)
if err != nil {
return fmt.Errorf("failed to delete blob, digest %s, ref %s: %w", d.Digest.String(), r.CommonName(), err)
}
if resp.HTTPResponse().StatusCode != 202 {
return fmt.Errorf("failed to delete blob, digest %s, ref %s: %w", d.Digest.String(), r.CommonName(), reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
return nil
}
// BlobGet retrieves a blob from the repository, returning a blob reader
func (reg *Reg) BlobGet(ctx context.Context, r ref.Ref, d descriptor.Descriptor) (blob.Reader, error) {
// build/send request
req := &reghttp.Req{
MetaKind: reqmeta.Blob,
Host: r.Registry,
Method: "GET",
Repository: r.Repository,
Path: "blobs/" + d.Digest.String(),
ExpectLen: d.Size,
}
resp, err := reg.reghttp.Do(ctx, req)
if err != nil && len(d.URLs) > 0 {
for _, curURL := range d.URLs {
// fallback for external blobs
var u *url.URL
u, err = url.Parse(curURL)
if err != nil {
return nil, fmt.Errorf("failed to parse external url \"%s\": %w", curURL, err)
}
// refuse requests to local URLs unless registry is also local
// Note: the AllowRedirect check is not used because blobs could be hosted on an http CDN and data is content addressable
if regnet.IsLocal(u.Host) && !regnet.IsLocal(req.Host) {
return nil, fmt.Errorf("refusing to redirect blob request to a local URL: %s%.0w", curURL, errs.ErrHTTPRedirectRefused)
}
req = &reghttp.Req{
MetaKind: reqmeta.Blob,
Host: r.Registry,
Method: "GET",
Repository: r.Repository,
DirectURL: u,
NoMirrors: true,
ExpectLen: d.Size,
}
resp, err = reg.reghttp.Do(ctx, req)
if err == nil {
break
}
}
}
if err != nil {
return nil, fmt.Errorf("failed to get blob, digest %s, ref %s: %w", d.Digest.String(), r.CommonName(), err)
}
if resp.HTTPResponse().StatusCode != 200 {
return nil, fmt.Errorf("failed to get blob, digest %s, ref %s: %w", d.Digest.String(), r.CommonName(), reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
b := blob.NewReader(
blob.WithRef(r),
blob.WithReader(resp),
blob.WithDesc(d),
blob.WithResp(resp.HTTPResponse()),
)
return b, nil
}
// BlobHead is used to verify if a blob exists and is accessible
func (reg *Reg) BlobHead(ctx context.Context, r ref.Ref, d descriptor.Descriptor) (blob.Reader, error) {
// build/send request
req := &reghttp.Req{
MetaKind: reqmeta.Head,
Host: r.Registry,
Method: "HEAD",
Repository: r.Repository,
Path: "blobs/" + d.Digest.String(),
}
resp, err := reg.reghttp.Do(ctx, req)
if err != nil && len(d.URLs) > 0 {
for _, curURL := range d.URLs {
// fallback for external blobs
var u *url.URL
u, err = url.Parse(curURL)
if err != nil {
return nil, fmt.Errorf("failed to parse external url \"%s\": %w", curURL, err)
}
req = &reghttp.Req{
MetaKind: reqmeta.Head,
Host: r.Registry,
Method: "HEAD",
Repository: r.Repository,
DirectURL: u,
NoMirrors: true,
}
resp, err = reg.reghttp.Do(ctx, req)
if err == nil {
break
}
}
}
if err != nil {
return nil, fmt.Errorf("failed to request blob head, digest %s, ref %s: %w", d.Digest.String(), r.CommonName(), err)
}
defer resp.Close()
if resp.HTTPResponse().StatusCode != 200 {
return nil, fmt.Errorf("failed to request blob head, digest %s, ref %s: %w", d.Digest.String(), r.CommonName(), reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
b := blob.NewReader(
blob.WithRef(r),
blob.WithDesc(d),
blob.WithResp(resp.HTTPResponse()),
)
return b, nil
}
// BlobMount attempts to perform a server side copy/mount of the blob between repositories
func (reg *Reg) BlobMount(ctx context.Context, rSrc ref.Ref, rTgt ref.Ref, d descriptor.Descriptor) error {
putURL, _, err := reg.blobMount(ctx, rTgt, d, rSrc)
// if mount fails and returns an upload location, cancel that upload
if err != nil {
_ = reg.blobUploadCancel(ctx, rTgt, putURL)
}
return err
}
// BlobPut uploads a blob to a repository.
// Descriptor is optional, leave size and digest to zero value if unknown.
// Reader must also be an [io.Seeker] to support chunked upload fallback.
//
// This will attempt an anonymous blob mount first which some registries may support.
// It will then try doing a full put of the blob without chunking (most widely supported).
// If the full put fails, it will fall back to a chunked upload (useful for flaky networks).
func (reg *Reg) BlobPut(ctx context.Context, r ref.Ref, d descriptor.Descriptor, rdr io.Reader) (descriptor.Descriptor, error) {
var putURL *url.URL
var err error
validDesc := (d.Size > 0 && d.Digest.Validate() == nil) || (d.Size == 0 && d.Digest == zeroDig)
// dedup warnings
if w := warning.FromContext(ctx); w == nil {
ctx = warning.NewContext(ctx, &warning.Warning{Hook: warning.DefaultHook()})
}
// attempt an anonymous blob mount
if validDesc {
putURL, _, err = reg.blobMount(ctx, r, d, ref.Ref{})
if err == nil {
return d, nil
}
if err != errs.ErrMountReturnedLocation {
putURL = nil
}
}
// fallback to requesting upload URL
if putURL == nil {
putURL, err = reg.blobGetUploadURL(ctx, r, d)
if err != nil {
return d, err
}
}
// send upload as one-chunk
tryPut := validDesc
if tryPut {
host := reg.hostGet(r.Registry)
maxPut := host.BlobMax
if maxPut == 0 {
maxPut = reg.blobMaxPut
}
if maxPut > 0 && d.Size > maxPut {
tryPut = false
}
}
if tryPut {
err = reg.blobPutUploadFull(ctx, r, d, putURL, rdr)
if err == nil {
return d, nil
}
// on failure, attempt to seek back to start to perform a chunked upload
rdrSeek, ok := rdr.(io.ReadSeeker)
if !ok {
_ = reg.blobUploadCancel(ctx, r, putURL)
return d, err
}
offset, errR := rdrSeek.Seek(0, io.SeekStart)
if errR != nil || offset != 0 {
_ = reg.blobUploadCancel(ctx, r, putURL)
return d, err
}
}
// send a chunked upload if full upload not possible or too large
d, err = reg.blobPutUploadChunked(ctx, r, d, putURL, rdr)
if err != nil {
_ = reg.blobUploadCancel(ctx, r, putURL)
}
return d, err
}
func (reg *Reg) blobGetUploadURL(ctx context.Context, r ref.Ref, d descriptor.Descriptor) (*url.URL, error) {
q := url.Values{}
if d.DigestAlgo() != digest.Canonical {
// TODO(bmitch): EXPERIMENTAL parameter, registry support and OCI spec change needed
q.Add(paramBlobDigestAlgo, d.DigestAlgo().String())
}
// request an upload location
req := &reghttp.Req{
MetaKind: reqmeta.Blob,
Host: r.Registry,
NoMirrors: true,
Method: "POST",
Repository: r.Repository,
Path: "blobs/uploads/",
Query: q,
TransactLen: d.Size,
}
resp, err := reg.reghttp.Do(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to send blob post, ref %s: %w", r.CommonName(), err)
}
defer resp.Close()
if resp.HTTPResponse().StatusCode != 202 {
return nil, fmt.Errorf("failed to send blob post, ref %s: %w", r.CommonName(), reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
// if min size header received, check/adjust host settings
minSizeStr := resp.HTTPResponse().Header.Get(blobChunkMinHeader)
if minSizeStr != "" {
minSize, err := strconv.ParseInt(minSizeStr, 10, 64)
if err != nil {
reg.slog.Warn("Failed to parse chunk size header",
slog.String("size", minSizeStr),
slog.String("err", err.Error()))
} else {
host := reg.hostGet(r.Registry)
reg.muHost.Lock()
if (host.BlobChunk > 0 && minSize > host.BlobChunk) || (host.BlobChunk <= 0 && minSize > reg.blobChunkSize) {
host.BlobChunk = min(minSize, reg.blobChunkLimit)
reg.slog.Debug("Registry requested min chunk size",
slog.Int64("size", host.BlobChunk),
slog.String("host", host.Name))
}
reg.muHost.Unlock()
}
}
// Extract the location into a new putURL based on whether it's relative, fqdn with a scheme, or without a scheme.
location := resp.HTTPResponse().Header.Get("Location")
if location == "" {
return nil, fmt.Errorf("failed to send blob post, ref %s: %w", r.CommonName(), errs.ErrMissingLocation)
}
reg.slog.Debug("Upload location received",
slog.String("location", location))
// put url may be relative to the above post URL, so parse in that context
postURL := resp.HTTPResponse().Request.URL
putURL, err := postURL.Parse(location)
if err != nil {
reg.slog.Warn("Location url failed to parse",
slog.String("location", location),
slog.String("err", err.Error()))
return nil, fmt.Errorf("blob upload url invalid, ref %s: %w", r.CommonName(), err)
}
return putURL, nil
}
func (reg *Reg) blobMount(ctx context.Context, rTgt ref.Ref, d descriptor.Descriptor, rSrc ref.Ref) (*url.URL, string, error) {
// build/send request
query := url.Values{}
query.Set("mount", d.Digest.String())
ignoreErr := true // ignore errors from anonymous blob mount attempts
if rSrc.Registry == rTgt.Registry && rSrc.Repository != "" {
query.Set("from", rSrc.Repository)
ignoreErr = false
}
req := &reghttp.Req{
MetaKind: reqmeta.Blob,
Host: rTgt.Registry,
NoMirrors: true,
Method: "POST",
Repository: rTgt.Repository,
Path: "blobs/uploads/",
Query: query,
IgnoreErr: ignoreErr,
TransactLen: d.Size,
}
resp, err := reg.reghttp.Do(ctx, req)
if err != nil {
return nil, "", fmt.Errorf("failed to mount blob, digest %s, ref %s: %w", d.Digest.String(), rTgt.CommonName(), err)
}
defer resp.Close()
// if min size header received, check/adjust host settings
minSizeStr := resp.HTTPResponse().Header.Get(blobChunkMinHeader)
if minSizeStr != "" {
minSize, err := strconv.ParseInt(minSizeStr, 10, 64)
if err != nil {
reg.slog.Warn("Failed to parse chunk size header",
slog.String("size", minSizeStr),
slog.String("err", err.Error()))
} else {
host := reg.hostGet(rTgt.Registry)
reg.muHost.Lock()
if (host.BlobChunk > 0 && minSize > host.BlobChunk) || (host.BlobChunk <= 0 && minSize > reg.blobChunkSize) {
host.BlobChunk = min(minSize, reg.blobChunkLimit)
reg.slog.Debug("Registry requested min chunk size",
slog.Int64("size", host.BlobChunk),
slog.String("host", host.Name))
}
reg.muHost.Unlock()
}
}
// 201 indicates the blob mount succeeded
if resp.HTTPResponse().StatusCode == 201 {
return nil, "", nil
}
// 202 indicates blob mount failed but server ready to receive an upload at location
location := resp.HTTPResponse().Header.Get("Location")
uuid := resp.HTTPResponse().Header.Get("Docker-Upload-UUID")
if resp.HTTPResponse().StatusCode == 202 && location != "" {
postURL := resp.HTTPResponse().Request.URL
putURL, err := postURL.Parse(location)
if err != nil {
reg.slog.Warn("Mount location header failed to parse",
slog.String("digest", d.Digest.String()),
slog.String("target", rTgt.CommonName()),
slog.String("location", location),
slog.String("err", err.Error()))
} else {
return putURL, uuid, errs.ErrMountReturnedLocation
}
}
// all other responses unhandled
return nil, "", fmt.Errorf("failed to mount blob, digest %s, ref %s: %w", d.Digest.String(), rTgt.CommonName(), reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
func (reg *Reg) blobPutUploadFull(ctx context.Context, r ref.Ref, d descriptor.Descriptor, putURL *url.URL, rdr io.Reader) error {
// append digest to request to use the monolithic upload option
if putURL.RawQuery != "" {
putURL.RawQuery = putURL.RawQuery + "&digest=" + url.QueryEscape(d.Digest.String())
} else {
putURL.RawQuery = "digest=" + url.QueryEscape(d.Digest.String())
}
// make a reader function for the blob
readOnce := false
bodyFunc := func() (io.ReadCloser, error) {
// handle attempt to reuse blob reader (e.g. on a connection retry or fallback)
if readOnce {
rdrSeek, ok := rdr.(io.ReadSeeker)
if !ok {
return nil, fmt.Errorf("blob source is not a seeker%.0w", errs.ErrNotRetryable)
}
_, err := rdrSeek.Seek(0, io.SeekStart)
if err != nil {
return nil, fmt.Errorf("seek on blob source failed: %w%.0w", err, errs.ErrNotRetryable)
}
}
readOnce = true
return io.NopCloser(rdr), nil
}
// special case for the empty blob
if d.Size == 0 && d.Digest == zeroDig {
bodyFunc = nil
}
// build/send request
header := http.Header{
"Content-Type": {"application/octet-stream"},
}
req := &reghttp.Req{
MetaKind: reqmeta.Blob,
Host: r.Registry,
Method: "PUT",
Repository: r.Repository,
DirectURL: putURL,
BodyFunc: bodyFunc,
BodyLen: d.Size,
Headers: header,
NoMirrors: true,
}
resp, err := reg.reghttp.Do(ctx, req)
if err != nil {
return fmt.Errorf("failed to send blob (put), digest %s, ref %s: %w", d.Digest.String(), r.CommonName(), err)
}
defer resp.Close()
// 201 follows distribution-spec, 204 is listed as possible in the Docker registry spec
if resp.HTTPResponse().StatusCode != 201 && resp.HTTPResponse().StatusCode != 204 {
return fmt.Errorf("failed to send blob (put), digest %s, ref %s: %w", d.Digest.String(), r.CommonName(), reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
return nil
}
func (reg *Reg) blobPutUploadChunked(ctx context.Context, r ref.Ref, d descriptor.Descriptor, putURL *url.URL, rdr io.Reader) (descriptor.Descriptor, error) {
host := reg.hostGet(r.Registry)
bufSize := host.BlobChunk
if bufSize <= 0 {
bufSize = reg.blobChunkSize
}
bufBytes := make([]byte, 0, bufSize)
bufRdr := bytes.NewReader(bufBytes)
bufStart := int64(0)
bufChange := false
// setup buffer and digest pipe
digester := d.DigestAlgo().Digester()
digestRdr := io.TeeReader(rdr, digester.Hash())
finalChunk := false
chunkStart := int64(0)
chunkSize := 0
bodyFunc := func() (io.ReadCloser, error) {
// reset to the start on every new read
_, err := bufRdr.Seek(0, io.SeekStart)
if err != nil {
return nil, err
}
return io.NopCloser(bufRdr), nil
}
chunkURL := *putURL
retryLimit := 10 // TODO: pull limit from reghttp
retryCur := 0
var err error
for !finalChunk || chunkStart < bufStart+int64(len(bufBytes)) {
bufChange = false
for chunkStart >= bufStart+int64(len(bufBytes)) && !finalChunk {
bufStart += int64(len(bufBytes))
// reset length if previous read was short
if cap(bufBytes) != len(bufBytes) {
bufBytes = bufBytes[:cap(bufBytes)]
bufChange = true
}
// read a chunk into an input buffer, computing the digest
chunkSize, err = io.ReadFull(digestRdr, bufBytes)
if err == io.EOF || err == io.ErrUnexpectedEOF {
finalChunk = true
} else if err != nil {
return d, fmt.Errorf("failed to send blob chunk, ref %s: %w", r.CommonName(), err)
}
// update length on partial read
if chunkSize != len(bufBytes) {
bufBytes = bufBytes[:chunkSize]
bufChange = true
}
}
if chunkStart > bufStart && chunkStart < bufStart+int64(len(bufBytes)) {
// next chunk is inside the existing buf
bufBytes = bufBytes[chunkStart-bufStart:]
bufStart = chunkStart
chunkSize = len(bufBytes)
bufChange = true
}
if chunkSize > 0 && chunkStart != bufStart {
return d, fmt.Errorf("chunkStart (%d) != bufStart (%d)", chunkStart, bufStart)
}
if bufChange {
// need to recreate the reader on a change to the slice length,
// old reader is looking at the old slice metadata
bufRdr = bytes.NewReader(bufBytes)
}
if chunkSize > 0 {
// write chunk
header := http.Header{
"Content-Type": {"application/octet-stream"},
"Content-Range": {fmt.Sprintf("%d-%d", chunkStart, chunkStart+int64(chunkSize)-1)},
}
req := &reghttp.Req{
MetaKind: reqmeta.Blob,
Host: r.Registry,
Method: "PATCH",
Repository: r.Repository,
DirectURL: &chunkURL,
BodyFunc: bodyFunc,
BodyLen: int64(chunkSize),
Headers: header,
NoMirrors: true,
TransactLen: d.Size - int64(chunkSize),
}
resp, err := reg.reghttp.Do(ctx, req)
if err != nil && !errors.Is(err, errs.ErrHTTPStatus) && !errors.Is(err, errs.ErrNotFound) {
return d, fmt.Errorf("failed to send blob (chunk), ref %s: http do: %w", r.CommonName(), err)
}
err = resp.Close()
if err != nil {
return d, fmt.Errorf("failed to close request: %w", err)
}
httpResp := resp.HTTPResponse()
// distribution-spec is 202, AWS ECR returns a 201 and rejects the put
if resp.HTTPResponse().StatusCode == 201 {
reg.slog.Debug("Early accept of chunk in PATCH before PUT request",
slog.String("ref", r.CommonName()),
slog.Int64("chunkStart", chunkStart),
slog.Int("chunkSize", chunkSize))
} else if resp.HTTPResponse().StatusCode >= 400 && resp.HTTPResponse().StatusCode < 500 &&
resp.HTTPResponse().Header.Get("Location") != "" &&
resp.HTTPResponse().Header.Get("Range") != "" {
retryCur++
if retryCur > retryLimit {
return d, fmt.Errorf("failed to send blob (chunk), ref %s: http status: %w", r.CommonName(), reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
reg.slog.Debug("Recoverable chunk upload error",
slog.String("ref", r.CommonName()),
slog.Int64("chunkStart", chunkStart),
slog.Int("chunkSize", chunkSize),
slog.String("range", resp.HTTPResponse().Header.Get("Range")))
} else if resp.HTTPResponse().StatusCode != 202 {
retryCur++
statusResp, statusErr := reg.blobUploadStatus(ctx, r, &chunkURL)
if retryCur > retryLimit || statusErr != nil {
return d, fmt.Errorf("failed to send blob (chunk), ref %s: http status: %w", r.CommonName(), reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
httpResp = statusResp
} else {
// successful request
if retryCur > 0 {
retryCur--
}
}
rangeEnd, err := blobUploadCurBytes(httpResp)
if err == nil {
chunkStart = rangeEnd + 1
} else {
chunkStart += int64(chunkSize)
}
location := httpResp.Header.Get("Location")
if location != "" {
reg.slog.Debug("Next chunk upload location received",
slog.String("location", location))
prevURL := httpResp.Request.URL
parseURL, err := prevURL.Parse(location)
if err != nil {
return d, fmt.Errorf("failed to send blob (parse next chunk location), ref %s: %w", r.CommonName(), err)
}
chunkURL = *parseURL
}
}
}
// compute digest
dOut := digester.Digest()
if d.Digest.Validate() == nil && dOut != d.Digest {
return d, fmt.Errorf("%w, expected %s, computed %s", errs.ErrDigestMismatch, d.Digest.String(), dOut.String())
}
if d.Size != 0 && chunkStart != d.Size {
return d, fmt.Errorf("blob content size does not match descriptor, expected %d, received %d%.0w", d.Size, chunkStart, errs.ErrMismatch)
}
d.Digest = dOut
d.Size = chunkStart
// send the final put
// append digest to request to use the monolithic upload option
if chunkURL.RawQuery != "" {
chunkURL.RawQuery = chunkURL.RawQuery + "&digest=" + url.QueryEscape(dOut.String())
} else {
chunkURL.RawQuery = "digest=" + url.QueryEscape(dOut.String())
}
header := http.Header{
"Content-Type": {"application/octet-stream"},
}
req := &reghttp.Req{
MetaKind: reqmeta.Query,
Host: r.Registry,
Method: "PUT",
Repository: r.Repository,
DirectURL: &chunkURL,
BodyLen: int64(0),
Headers: header,
NoMirrors: true,
}
resp, err := reg.reghttp.Do(ctx, req)
if err != nil {
return d, fmt.Errorf("failed to send blob (chunk digest), digest %s, ref %s: %w", dOut, r.CommonName(), err)
}
defer resp.Close()
// 201 follows distribution-spec, 204 is listed as possible in the Docker registry spec
if resp.HTTPResponse().StatusCode != 201 && resp.HTTPResponse().StatusCode != 204 {
return d, fmt.Errorf("failed to send blob (chunk digest), digest %s, ref %s: %w", dOut, r.CommonName(), reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
return d, nil
}
// blobUploadCancel stops an upload, releasing resources on the server.
func (reg *Reg) blobUploadCancel(ctx context.Context, r ref.Ref, putURL *url.URL) error {
if putURL == nil {
return fmt.Errorf("failed to cancel upload %s: url undefined", r.CommonName())
}
req := &reghttp.Req{
MetaKind: reqmeta.Query,
Host: r.Registry,
NoMirrors: true,
Method: "DELETE",
Repository: r.Repository,
DirectURL: putURL,
}
resp, err := reg.reghttp.Do(ctx, req)
if err != nil {
return fmt.Errorf("failed to cancel upload %s: %w", r.CommonName(), err)
}
defer resp.Close()
if resp.HTTPResponse().StatusCode != 202 {
return fmt.Errorf("failed to cancel upload %s: %w", r.CommonName(), reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
return nil
}
// blobUploadStatus provides a response with headers indicating the progress of an upload
func (reg *Reg) blobUploadStatus(ctx context.Context, r ref.Ref, putURL *url.URL) (*http.Response, error) {
req := &reghttp.Req{
MetaKind: reqmeta.Query,
Host: r.Registry,
Method: "GET",
Repository: r.Repository,
DirectURL: putURL,
NoMirrors: true,
}
resp, err := reg.reghttp.Do(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to get upload status: %w", err)
}
defer resp.Close()
if resp.HTTPResponse().StatusCode != 204 {
return resp.HTTPResponse(), fmt.Errorf("failed to get upload status: %w", reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
return resp.HTTPResponse(), nil
}
func blobUploadCurBytes(resp *http.Response) (int64, error) {
if resp == nil {
return 0, fmt.Errorf("missing response")
}
r := resp.Header.Get("Range")
if r == "" {
return 0, fmt.Errorf("missing range header")
}
rSplit := strings.SplitN(r, "-", 2)
if len(rSplit) < 2 {
return 0, fmt.Errorf("missing offset in range header")
}
return strconv.ParseInt(rSplit[1], 10, 64)
}
-360
View File
@@ -1,360 +0,0 @@
package reg
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"github.com/opencontainers/go-digest"
"github.com/regclient/regclient/internal/limitread"
"github.com/regclient/regclient/internal/reghttp"
"github.com/regclient/regclient/internal/reqmeta"
"github.com/regclient/regclient/scheme"
"github.com/regclient/regclient/types/errs"
"github.com/regclient/regclient/types/manifest"
"github.com/regclient/regclient/types/mediatype"
"github.com/regclient/regclient/types/ref"
"github.com/regclient/regclient/types/warning"
)
// ManifestDelete removes a manifest by reference (digest) from a registry.
// This will implicitly delete all tags pointing to that manifest.
func (reg *Reg) ManifestDelete(ctx context.Context, r ref.Ref, opts ...scheme.ManifestOpts) error {
// dedup warnings
if w := warning.FromContext(ctx); w == nil {
ctx = warning.NewContext(ctx, &warning.Warning{Hook: warning.DefaultHook()})
}
if r.Digest == "" {
return fmt.Errorf("digest required to delete manifest, reference %s%.0w", r.CommonName(), errs.ErrMissingDigest)
}
mc := scheme.ManifestConfig{}
for _, opt := range opts {
opt(&mc)
}
if mc.CheckReferrers && mc.Manifest == nil {
m, err := reg.ManifestGet(ctx, r)
if err != nil {
return fmt.Errorf("failed to pull manifest for refers: %w", err)
}
mc.Manifest = m
}
if mc.Manifest != nil {
if mr, ok := mc.Manifest.(manifest.Subjecter); ok {
sDesc, err := mr.GetSubject()
if err == nil && sDesc != nil && sDesc.Digest != "" {
// attempt to delete the referrer, but ignore if the referrer entry wasn't found
err = reg.referrerDelete(ctx, r, mc.Manifest)
if err != nil && !errors.Is(err, errs.ErrNotFound) {
return err
}
}
}
}
rCache := r.SetDigest(r.Digest)
reg.cacheMan.Delete(rCache)
// build/send request
req := &reghttp.Req{
MetaKind: reqmeta.Query,
Host: r.Registry,
NoMirrors: true,
Method: "DELETE",
Repository: r.Repository,
Path: "manifests/" + r.Digest,
}
resp, err := reg.reghttp.Do(ctx, req)
if err != nil {
return fmt.Errorf("failed to delete manifest %s: %w", r.CommonName(), err)
}
defer resp.Close()
if resp.HTTPResponse().StatusCode != 202 {
return fmt.Errorf("failed to delete manifest %s: %w", r.CommonName(), reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
return nil
}
// ManifestGet retrieves a manifest from the registry
func (reg *Reg) ManifestGet(ctx context.Context, r ref.Ref) (manifest.Manifest, error) {
var tagOrDigest string
if r.Digest != "" {
rCache := r.SetDigest(r.Digest)
if m, err := reg.cacheMan.Get(rCache); err == nil {
return m, nil
}
tagOrDigest = r.Digest
} else if r.Tag != "" {
tagOrDigest = r.Tag
} else {
return nil, fmt.Errorf("reference missing tag and digest: %s%.0w", r.CommonName(), errs.ErrMissingTagOrDigest)
}
// build/send request
headers := http.Header{
"Accept": []string{
mediatype.OCI1ManifestList,
mediatype.OCI1Manifest,
mediatype.Docker2ManifestList,
mediatype.Docker2Manifest,
mediatype.Docker1ManifestSigned,
mediatype.Docker1Manifest,
mediatype.OCI1Artifact,
},
}
req := &reghttp.Req{
MetaKind: reqmeta.Manifest,
Host: r.Registry,
Method: "GET",
Repository: r.Repository,
Path: "manifests/" + tagOrDigest,
Headers: headers,
}
resp, err := reg.reghttp.Do(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to get manifest %s: %w", r.CommonName(), err)
}
defer resp.Close()
if resp.HTTPResponse().StatusCode != 200 {
return nil, fmt.Errorf("failed to get manifest %s: %w", r.CommonName(), reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
// limit length
size, _ := strconv.Atoi(resp.HTTPResponse().Header.Get("Content-Length"))
if size > 0 && reg.manifestMaxPull > 0 && int64(size) > reg.manifestMaxPull {
return nil, fmt.Errorf("manifest too large, received %d, limit %d: %s%.0w", size, reg.manifestMaxPull, r.CommonName(), errs.ErrSizeLimitExceeded)
}
rdr := &limitread.LimitRead{
Reader: resp,
Limit: reg.manifestMaxPull,
}
// read manifest
rawBody, err := io.ReadAll(rdr)
if err != nil {
return nil, fmt.Errorf("error reading manifest for %s: %w", r.CommonName(), err)
}
m, err := manifest.New(
manifest.WithRef(r),
manifest.WithHeader(resp.HTTPResponse().Header),
manifest.WithRaw(rawBody),
)
if err != nil {
return nil, err
}
rCache := r.SetDigest(m.GetDescriptor().Digest.String())
reg.cacheMan.Set(rCache, m)
return m, nil
}
// ManifestHead returns metadata on the manifest from the registry
func (reg *Reg) ManifestHead(ctx context.Context, r ref.Ref) (manifest.Manifest, error) {
// build the request
var tagOrDigest string
if r.Digest != "" {
rCache := r.SetDigest(r.Digest)
if m, err := reg.cacheMan.Get(rCache); err == nil {
return m, nil
}
tagOrDigest = r.Digest
} else if r.Tag != "" {
tagOrDigest = r.Tag
} else {
return nil, fmt.Errorf("reference missing tag and digest: %s%.0w", r.CommonName(), errs.ErrMissingTagOrDigest)
}
// build/send request
headers := http.Header{
"Accept": []string{
mediatype.OCI1ManifestList,
mediatype.OCI1Manifest,
mediatype.Docker2ManifestList,
mediatype.Docker2Manifest,
mediatype.Docker1ManifestSigned,
mediatype.Docker1Manifest,
mediatype.OCI1Artifact,
},
}
req := &reghttp.Req{
MetaKind: reqmeta.Head,
Host: r.Registry,
Method: "HEAD",
Repository: r.Repository,
Path: "manifests/" + tagOrDigest,
Headers: headers,
}
resp, err := reg.reghttp.Do(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to request manifest head %s: %w", r.CommonName(), err)
}
defer resp.Close()
if resp.HTTPResponse().StatusCode != 200 {
return nil, fmt.Errorf("failed to request manifest head %s: %w", r.CommonName(), reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
return manifest.New(
manifest.WithRef(r),
manifest.WithHeader(resp.HTTPResponse().Header),
)
}
// ManifestPut uploads a manifest to a registry
func (reg *Reg) ManifestPut(ctx context.Context, r ref.Ref, m manifest.Manifest, opts ...scheme.ManifestOpts) error {
var tagOrDigest string
if r.Digest != "" {
tagOrDigest = r.Digest
} else if r.Tag != "" {
tagOrDigest = r.Tag
} else {
reg.slog.Warn("Manifest put requires a tag",
slog.String("ref", r.Reference))
return errs.ErrMissingTag
}
// dedup warnings
if w := warning.FromContext(ctx); w == nil {
ctx = warning.NewContext(ctx, &warning.Warning{Hook: warning.DefaultHook()})
}
// create the request body
mj, err := m.MarshalJSON()
if err != nil {
reg.slog.Warn("Error marshaling manifest",
slog.String("ref", r.Reference),
slog.String("err", err.Error()))
return fmt.Errorf("error marshalling manifest for %s: %w", r.CommonName(), err)
}
// limit length
if reg.manifestMaxPush > 0 && int64(len(mj)) > reg.manifestMaxPush {
return fmt.Errorf("manifest too large, calculated %d, limit %d: %s%.0w", len(mj), reg.manifestMaxPush, r.CommonName(), errs.ErrSizeLimitExceeded)
}
// if the ref provides a digest, verify it matches the manifest being pushed
desc := m.GetDescriptor()
if err := desc.Digest.Validate(); err != nil {
return fmt.Errorf("invalid digest for manifest: %s: %w", string(desc.Digest), err)
}
if r.Digest != "" && desc.Digest.String() != r.Digest {
// Digest algorithm may have changed, try recreating the manifest with the provided ref.
// This will fail if the ref digest does not match the manifest.
m, err = manifest.New(manifest.WithRef(r), manifest.WithRaw(mj))
if err != nil {
return fmt.Errorf("failed to rebuilding manifest with ref \"%s\": %w", r.CommonName(), err)
}
}
// build/send request
expectTags := []string{}
headers := http.Header{
"Content-Type": []string{manifest.GetMediaType(m)},
}
q := url.Values{}
if tagOrDigest == r.Tag && m.GetDescriptor().Digest.Algorithm() != digest.Canonical {
// TODO(bmitch): EXPERIMENTAL support for pushing tags with a digest: <https://github.com/opencontainers/distribution-spec/pull/600>
tagOrDigest = m.GetDescriptor().Digest.String()
q.Add("tag", r.Tag)
expectTags = append(expectTags, r.Tag)
}
req := &reghttp.Req{
MetaKind: reqmeta.Manifest,
Host: r.Registry,
NoMirrors: true,
Method: "PUT",
Repository: r.Repository,
Path: "manifests/" + tagOrDigest,
Query: q,
Headers: headers,
BodyLen: int64(len(mj)),
BodyBytes: mj,
}
resp, err := reg.reghttp.Do(ctx, req)
if err != nil {
return fmt.Errorf("failed to put manifest %s: %w", r.CommonName(), err)
}
err = resp.Close()
if err != nil {
return fmt.Errorf("failed to close request: %w", err)
}
if resp.HTTPResponse().StatusCode != 201 {
return fmt.Errorf("failed to put manifest %s: %w", r.CommonName(), reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
// if Docker-Content-Digest header was returned, verify the digest matches
if dig := resp.HTTPResponse().Header.Get("Docker-Content-Digest"); dig != "" && dig != m.GetDescriptor().Digest.String() {
return fmt.Errorf("failed to put manifest, unexpected digest returned, expected %s, received %s", m.GetDescriptor().Digest.String(), dig)
}
// if pushing tags by digest fails, fall back to pushing individual tags
respTags := []string{}
for _, hv := range resp.HTTPResponse().Header.Values("OCI-Tag") {
for sv := range strings.SplitSeq(hv, ",") {
respTags = append(respTags, strings.TrimSpace(sv))
}
}
for _, t := range expectTags {
if slices.Contains(respTags, t) {
continue
}
q := url.Values{}
if m.GetDescriptor().Digest.Algorithm() != digest.Canonical {
// TODO(bmitch): EXPERIMENTAL parameter, registry support and OCI spec change needed: <https://github.com/opencontainers/distribution-spec/pull/543>
q.Add(paramManifestDigest, m.GetDescriptor().Digest.String())
}
req := &reghttp.Req{
MetaKind: reqmeta.Manifest,
Host: r.Registry,
NoMirrors: true,
Method: "PUT",
Repository: r.Repository,
Path: "manifests/" + t,
Query: q,
Headers: headers,
BodyLen: int64(len(mj)),
BodyBytes: mj,
}
resp, err := reg.reghttp.Do(ctx, req)
if err != nil {
return fmt.Errorf("failed to put manifest %s: %w", r.CommonName(), err)
}
err = resp.Close()
if err != nil {
return fmt.Errorf("failed to close request: %w", err)
}
if resp.HTTPResponse().StatusCode != 201 {
return fmt.Errorf("failed to put manifest %s: %w", r.CommonName(), reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
// if Docker-Content-Digest header was returned, verify the digest matches
if dig := resp.HTTPResponse().Header.Get("Docker-Content-Digest"); dig != "" && dig != m.GetDescriptor().Digest.String() {
return fmt.Errorf("failed to put manifest, unexpected digest returned, expected %s, received %s", m.GetDescriptor().Digest.String(), dig)
}
}
rCache := r.SetDigest(m.GetDescriptor().Digest.String())
reg.cacheMan.Set(rCache, m)
// update referrers if defined on this manifest
if mr, ok := m.(manifest.Subjecter); ok {
mDesc, err := mr.GetSubject()
if err != nil {
return err
}
if mDesc != nil && mDesc.Digest.String() != "" {
rSubj := r.SetDigest(mDesc.Digest.String())
reg.cacheRL.Delete(rSubj)
if mDesc.Digest.String() != resp.HTTPResponse().Header.Get(OCISubjectHeader) {
err = reg.referrerPut(ctx, r, m)
if err != nil {
return err
}
}
}
}
return nil
}
-39
View File
@@ -1,39 +0,0 @@
package reg
import (
"context"
"fmt"
"github.com/regclient/regclient/internal/reghttp"
"github.com/regclient/regclient/internal/reqmeta"
"github.com/regclient/regclient/types/ping"
"github.com/regclient/regclient/types/ref"
)
// Ping queries the /v2/ API of the registry to verify connectivity and access.
func (reg *Reg) Ping(ctx context.Context, r ref.Ref) (ping.Result, error) {
ret := ping.Result{}
req := &reghttp.Req{
MetaKind: reqmeta.Query,
Host: r.Registry,
NoMirrors: true,
Method: "GET",
Path: "",
}
resp, err := reg.reghttp.Do(ctx, req)
if resp != nil && resp.HTTPResponse() != nil {
ret.Header = resp.HTTPResponse().Header
}
if err != nil {
return ret, fmt.Errorf("failed to ping registry %s: %w", r.Registry, err)
}
defer resp.Close()
if resp.HTTPResponse().StatusCode != 200 {
return ret, fmt.Errorf("failed to ping registry %s: %w",
r.Registry, reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
return ret, nil
}
-354
View File
@@ -1,354 +0,0 @@
package reg
import (
"context"
"errors"
"fmt"
"io"
"net/url"
"github.com/regclient/regclient/internal/httplink"
"github.com/regclient/regclient/internal/reghttp"
"github.com/regclient/regclient/internal/reqmeta"
"github.com/regclient/regclient/scheme"
"github.com/regclient/regclient/types/errs"
"github.com/regclient/regclient/types/manifest"
"github.com/regclient/regclient/types/mediatype"
v1 "github.com/regclient/regclient/types/oci/v1"
"github.com/regclient/regclient/types/ref"
"github.com/regclient/regclient/types/referrer"
"github.com/regclient/regclient/types/warning"
)
const OCISubjectHeader = "OCI-Subject"
// ReferrerList returns a list of referrers to a given reference.
// The reference must include the digest. Use [regclient.ReferrerList] to resolve the platform or tag.
func (reg *Reg) ReferrerList(ctx context.Context, rSubject ref.Ref, opts ...scheme.ReferrerOpts) (referrer.ReferrerList, error) {
config := scheme.ReferrerConfig{}
for _, opt := range opts {
opt(&config)
}
var r ref.Ref
if config.SrcRepo.IsSet() {
r = config.SrcRepo.SetDigest(rSubject.Digest)
} else {
r = rSubject.SetDigest(rSubject.Digest)
}
rl := referrer.New(rSubject)
if rSubject.Digest == "" {
return rl, fmt.Errorf("digest required to query referrers %s", rSubject.CommonName())
}
// dedup warnings
if w := warning.FromContext(ctx); w == nil {
ctx = warning.NewContext(ctx, &warning.Warning{Hook: warning.DefaultHook()})
}
found := false
// try cache
rl, err := reg.cacheRL.Get(r)
if err == nil {
found = true
}
// try referrers API
if !found {
referrerEnabled, ok := reg.featureGet("referrer", r.Registry, r.Repository)
if !ok || referrerEnabled {
// attempt to call the referrer API
rl, err = reg.referrerListByAPI(ctx, r, config)
if !ok {
// save the referrer API state
reg.featureSet("referrer", r.Registry, r.Repository, err == nil)
}
if err == nil {
if config.MatchOpt.ArtifactType == "" {
// only cache if successful and artifactType is not filtered
reg.cacheRL.Set(r, rl)
}
found = true
}
}
}
// fall back to tag
if !found {
rl, err = reg.referrerListByTag(ctx, r)
if err == nil {
reg.cacheRL.Set(r, rl)
}
}
rl.Subject = rSubject
if config.SrcRepo.IsSet() {
rl.Source = config.SrcRepo
}
if err != nil {
return rl, err
}
// apply client side filters and return result
rl = scheme.ReferrerFilter(config, rl)
return rl, nil
}
func (reg *Reg) referrerListByAPI(ctx context.Context, r ref.Ref, config scheme.ReferrerConfig) (referrer.ReferrerList, error) {
rl := referrer.New(r)
var link *url.URL
// loop for paging
for {
rlAdd, linkNext, err := reg.referrerListByAPIPage(ctx, r, config, link)
if err != nil {
return rl, err
}
err = rl.Merge(rlAdd)
if err != nil {
return rl, err
}
if linkNext == nil {
break
}
link = linkNext
}
return rl, nil
}
func (reg *Reg) referrerListByAPIPage(ctx context.Context, r ref.Ref, config scheme.ReferrerConfig, link *url.URL) (referrer.ReferrerList, *url.URL, error) {
rl := referrer.New(r)
query := url.Values{}
if config.MatchOpt.ArtifactType != "" {
query.Set("artifactType", config.MatchOpt.ArtifactType)
}
req := &reghttp.Req{
MetaKind: reqmeta.Query,
Host: r.Registry,
Method: "GET",
Repository: r.Repository,
}
if link == nil {
req.Path = "referrers/" + r.Digest
req.Query = query
req.IgnoreErr = true
}
if link != nil {
req.DirectURL = link
}
resp, err := reg.reghttp.Do(ctx, req)
if err != nil {
return rl, nil, fmt.Errorf("failed to get referrers %s: %w", r.CommonName(), err)
}
defer resp.Close()
if resp.HTTPResponse().StatusCode != 200 {
return rl, nil, fmt.Errorf("failed to get referrers %s: %w", r.CommonName(), reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
// read manifest
rawBody, err := io.ReadAll(resp)
if err != nil {
return rl, nil, fmt.Errorf("error reading referrers for %s: %w", r.CommonName(), err)
}
m, err := manifest.New(
manifest.WithRef(r.SetDigest("")),
manifest.WithHeader(resp.HTTPResponse().Header),
manifest.WithRaw(rawBody),
)
if err != nil {
return rl, nil, err
}
ociML, ok := m.GetOrig().(v1.Index)
if !ok {
return rl, nil, fmt.Errorf("unexpected manifest type for referrers: %s, %w", m.GetDescriptor().MediaType, errs.ErrUnsupportedMediaType)
}
rl.Manifest = m
rl.Descriptors = ociML.Manifests
rl.Annotations = ociML.Annotations
// lookup next link
respHead := resp.HTTPResponse().Header
links, err := httplink.Parse(respHead.Values("Link"))
if err != nil {
return rl, nil, err
}
next, err := links.Get("rel", "next")
if err != nil {
// no next link
link = nil
} else {
link = resp.HTTPResponse().Request.URL
if link == nil {
return rl, nil, fmt.Errorf("referrers list failed to get URL of previous request")
}
link, err = link.Parse(next.URI)
if err != nil {
return rl, nil, fmt.Errorf("referrers list failed to parse Link: %w", err)
}
}
return rl, link, nil
}
func (reg *Reg) referrerListByTag(ctx context.Context, r ref.Ref) (referrer.ReferrerList, error) {
rl := referrer.New(r)
rlTag, err := referrer.FallbackTag(r)
if err != nil {
return rl, err
}
m, err := reg.ManifestGet(ctx, rlTag)
if err != nil {
if errors.Is(err, errs.ErrNotFound) {
// empty list, initialize a new manifest
rl.Manifest, err = manifest.New(manifest.WithOrig(v1.Index{
Versioned: v1.IndexSchemaVersion,
MediaType: mediatype.OCI1ManifestList,
}))
if err != nil {
return rl, err
}
return rl, nil
}
return rl, err
}
ociML, ok := m.GetOrig().(v1.Index)
if !ok {
return rl, fmt.Errorf("manifest is not an OCI index: %s", rlTag.CommonName())
}
// return resulting index
rl.Manifest = m
rl.Descriptors = ociML.Manifests
rl.Annotations = ociML.Annotations
rl.Tags = append(rl.Tags, rlTag.Tag)
return rl, nil
}
// referrerDelete deletes a referrer associated with a manifest
func (reg *Reg) referrerDelete(ctx context.Context, r ref.Ref, m manifest.Manifest) error {
// dedup warnings
if w := warning.FromContext(ctx); w == nil {
ctx = warning.NewContext(ctx, &warning.Warning{Hook: warning.DefaultHook()})
}
// get subject field
mSubject, ok := m.(manifest.Subjecter)
if !ok {
return fmt.Errorf("manifest does not support the subject field: %w", errs.ErrUnsupportedMediaType)
}
subject, err := mSubject.GetSubject()
if err != nil {
return err
}
// validate/set subject descriptor
if subject == nil || subject.Digest == "" {
return fmt.Errorf("refers is not set%.0w", errs.ErrNotFound)
}
// remove from cache
rSubject := r.SetDigest(subject.Digest.String())
reg.cacheRL.Delete(rSubject)
// if referrer API is available, nothing to do, return
if reg.referrerPing(ctx, rSubject) {
return nil
}
// fallback to using tag schema for refers
rl, err := reg.referrerListByTag(ctx, rSubject)
if err != nil {
return err
}
err = rl.Delete(m)
if err != nil {
return err
}
// push updated referrer list by tag
rlTag, err := referrer.FallbackTag(rSubject)
if err != nil {
return err
}
if rl.IsEmpty() {
err = reg.TagDelete(ctx, rlTag)
if err == nil {
return nil
}
// if delete is not supported, fall back to pushing empty list
}
return reg.ManifestPut(ctx, rlTag, rl.Manifest)
}
// referrerPut pushes a new referrer associated with a manifest
func (reg *Reg) referrerPut(ctx context.Context, r ref.Ref, m manifest.Manifest) error {
// dedup warnings
if w := warning.FromContext(ctx); w == nil {
ctx = warning.NewContext(ctx, &warning.Warning{Hook: warning.DefaultHook()})
}
// get subject field
mSubject, ok := m.(manifest.Subjecter)
if !ok {
return fmt.Errorf("manifest does not support the subject field: %w", errs.ErrUnsupportedMediaType)
}
subject, err := mSubject.GetSubject()
if err != nil {
return err
}
// validate/set subject descriptor
if subject == nil || subject.Digest == "" {
return fmt.Errorf("subject is not set%.0w", errs.ErrNotFound)
}
// lock to avoid internal race conditions between pulling and pushing tag
reg.muRefTag.Lock()
defer reg.muRefTag.Unlock()
// fallback to using tag schema for refers
rSubject := r.SetDigest(subject.Digest.String())
rl, err := reg.referrerListByTag(ctx, rSubject)
if err != nil {
return err
}
err = rl.Add(m)
if err != nil {
return err
}
// ensure the referrer list does not have a subject itself (avoiding circular locks)
if ms, ok := rl.Manifest.(manifest.Subjecter); ok {
mDesc, err := ms.GetSubject()
if err != nil {
return err
}
if mDesc != nil && mDesc.Digest != "" {
return fmt.Errorf("fallback referrers manifest should not have a subject: %s", rSubject.CommonName())
}
}
// push updated referrer list by tag
rlTag, err := referrer.FallbackTag(rSubject)
if err != nil {
return err
}
if len(rl.Tags) == 0 {
rl.Tags = []string{rlTag.Tag}
}
err = reg.ManifestPut(ctx, rlTag, rl.Manifest)
if err == nil {
reg.cacheRL.Set(rSubject, rl)
}
return err
}
// referrerPing verifies the registry supports the referrers API
func (reg *Reg) referrerPing(ctx context.Context, r ref.Ref) bool {
referrerEnabled, ok := reg.featureGet("referrer", r.Registry, r.Repository)
if ok {
return referrerEnabled
}
req := &reghttp.Req{
MetaKind: reqmeta.Query,
Host: r.Registry,
Method: "GET",
Repository: r.Repository,
Path: "referrers/" + r.Digest,
}
resp, err := reg.reghttp.Do(ctx, req)
if err != nil {
reg.featureSet("referrer", r.Registry, r.Repository, false)
return false
}
_ = resp.Close()
result := resp.HTTPResponse().StatusCode == 200
reg.featureSet("referrer", r.Registry, r.Repository, result)
return result
}
-272
View File
@@ -1,272 +0,0 @@
// Package reg implements the OCI registry scheme used by most images (host:port/repo:tag)
package reg
import (
"log/slog"
"net/http"
"sync"
"time"
"github.com/regclient/regclient/config"
"github.com/regclient/regclient/internal/cache"
"github.com/regclient/regclient/internal/pqueue"
"github.com/regclient/regclient/internal/reghttp"
"github.com/regclient/regclient/internal/reqmeta"
"github.com/regclient/regclient/types/manifest"
"github.com/regclient/regclient/types/ref"
"github.com/regclient/regclient/types/referrer"
)
const (
// blobChunkMinHeader is returned by registries requesting a minimum chunk size
blobChunkMinHeader = "OCI-Chunk-Min-Length"
// defaultBlobChunk 1M chunks, this is allocated in a memory buffer
defaultBlobChunk = 1024 * 1024
// defaultBlobChunkLimit 1G chunks, prevents a memory exhaustion attack
defaultBlobChunkLimit = 1024 * 1024 * 1024
// defaultBlobMax is disabled to support registries without chunked upload support
defaultBlobMax = -1
// defaultManifestMaxPull limits the largest manifest that will be pulled
defaultManifestMaxPull = 1024 * 1024 * 8
// defaultManifestMaxPush limits the largest manifest that will be pushed
defaultManifestMaxPush = 1024 * 1024 * 4
// paramBlobDigestAlgo specifies the query parameter to request a specific digest algorithm.
// TODO(bmitch): EXPERIMENTAL field, registry support and OCI spec update needed
paramBlobDigestAlgo = "digest-algorithm"
// paramManifestDigest specifies the query parameter to specify the digest of a manifest pushed by tag.
// TODO(bmitch): EXPERIMENTAL field, registry support and OCI spec update needed
paramManifestDigest = "digest"
)
// Reg is used for interacting with remote registry servers
type Reg struct {
reghttp *reghttp.Client
reghttpOpts []reghttp.Opts
slog *slog.Logger
hosts map[string]*config.Host
hostDefault *config.Host
features map[featureKey]*featureVal
blobChunkSize int64
blobChunkLimit int64
blobMaxPut int64
manifestMaxPull int64
manifestMaxPush int64
cacheMan *cache.Cache[ref.Ref, manifest.Manifest]
cacheRL *cache.Cache[ref.Ref, referrer.ReferrerList]
muHost sync.Mutex
muRefTag sync.Mutex
}
type featureKey struct {
kind string
reg string
repo string
}
type featureVal struct {
enabled bool
expire time.Time
}
var featureExpire = time.Minute * time.Duration(5)
// Opts provides options to access registries
type Opts func(*Reg)
// New returns a Reg pointer with any provided options
func New(opts ...Opts) *Reg {
r := Reg{
reghttpOpts: []reghttp.Opts{},
blobChunkSize: defaultBlobChunk,
blobChunkLimit: defaultBlobChunkLimit,
blobMaxPut: defaultBlobMax,
manifestMaxPull: defaultManifestMaxPull,
manifestMaxPush: defaultManifestMaxPush,
hosts: map[string]*config.Host{},
features: map[featureKey]*featureVal{},
}
r.reghttpOpts = append(r.reghttpOpts, reghttp.WithConfigHostFn(r.hostGet))
for _, opt := range opts {
opt(&r)
}
r.reghttp = reghttp.NewClient(r.reghttpOpts...)
return &r
}
// Throttle is used to limit concurrency
func (reg *Reg) Throttle(r ref.Ref, put bool) []*pqueue.Queue[reqmeta.Data] {
tList := []*pqueue.Queue[reqmeta.Data]{}
host := reg.hostGet(r.Registry)
t := reg.reghttp.GetThrottle(r.Registry)
if t != nil {
tList = append(tList, t)
}
if !put {
for _, mirror := range host.Mirrors {
t := reg.reghttp.GetThrottle(mirror)
if t != nil {
tList = append(tList, t)
}
}
}
return tList
}
func (reg *Reg) hostGet(hostname string) *config.Host {
reg.muHost.Lock()
defer reg.muHost.Unlock()
if _, ok := reg.hosts[hostname]; !ok {
newHost := config.HostNewDefName(reg.hostDefault, hostname)
// check for normalized hostname
if newHost.Name != hostname {
hostname = newHost.Name
if h, ok := reg.hosts[hostname]; ok {
return h
}
}
reg.hosts[hostname] = newHost
}
return reg.hosts[hostname]
}
// featureGet returns enabled and ok
func (reg *Reg) featureGet(kind, registry, repo string) (bool, bool) {
reg.muHost.Lock()
defer reg.muHost.Unlock()
if v, ok := reg.features[featureKey{kind: kind, reg: registry, repo: repo}]; ok {
if time.Now().Before(v.expire) {
return v.enabled, true
}
}
return false, false
}
func (reg *Reg) featureSet(kind, registry, repo string, enabled bool) {
reg.muHost.Lock()
reg.features[featureKey{kind: kind, reg: registry, repo: repo}] = &featureVal{enabled: enabled, expire: time.Now().Add(featureExpire)}
reg.muHost.Unlock()
}
// WithBlobSize overrides default blob sizes
func WithBlobSize(size, max int64) Opts {
return func(r *Reg) {
if size > 0 {
r.blobChunkSize = size
}
if max != 0 {
r.blobMaxPut = max
}
}
}
// WithBlobLimit overrides default blob limit
func WithBlobLimit(limit int64) Opts {
return func(r *Reg) {
if limit > 0 {
r.blobChunkLimit = limit
}
if r.blobMaxPut > 0 && r.blobMaxPut < limit {
r.blobMaxPut = limit
}
}
}
// WithCache defines a cache used for various requests
func WithCache(timeout time.Duration, count int) Opts {
return func(r *Reg) {
cm := cache.New[ref.Ref, manifest.Manifest](cache.WithAge(timeout), cache.WithCount(count))
r.cacheMan = &cm
crl := cache.New[ref.Ref, referrer.ReferrerList](cache.WithAge(timeout), cache.WithCount(count))
r.cacheRL = &crl
}
}
// WithCerts adds certificates
func WithCerts(certs [][]byte) Opts {
return func(r *Reg) {
r.reghttpOpts = append(r.reghttpOpts, reghttp.WithCerts(certs))
}
}
// WithCertDirs adds certificate directories for host specific certs
func WithCertDirs(dirs []string) Opts {
return func(r *Reg) {
r.reghttpOpts = append(r.reghttpOpts, reghttp.WithCertDirs(dirs))
}
}
// WithCertFiles adds certificates by filename
func WithCertFiles(files []string) Opts {
return func(r *Reg) {
r.reghttpOpts = append(r.reghttpOpts, reghttp.WithCertFiles(files))
}
}
// WithConfigHostDefault provides default settings for hosts.
func WithConfigHostDefault(ch *config.Host) Opts {
return func(r *Reg) {
r.hostDefault = ch
}
}
// WithConfigHosts adds host configs for credentials
func WithConfigHosts(configHosts []*config.Host) Opts {
return func(r *Reg) {
for _, host := range configHosts {
if host.Name == "" {
continue
}
r.hosts[host.Name] = host
}
}
}
// WithDelay initial time to wait between retries (increased with exponential backoff)
func WithDelay(delayInit time.Duration, delayMax time.Duration) Opts {
return func(r *Reg) {
r.reghttpOpts = append(r.reghttpOpts, reghttp.WithDelay(delayInit, delayMax))
}
}
// WithHTTPClient uses a specific http client with retryable requests
func WithHTTPClient(hc *http.Client) Opts {
return func(r *Reg) {
r.reghttpOpts = append(r.reghttpOpts, reghttp.WithHTTPClient(hc))
}
}
// WithManifestMax sets the push and pull limits for manifests
func WithManifestMax(push, pull int64) Opts {
return func(r *Reg) {
r.manifestMaxPush = push
r.manifestMaxPull = pull
}
}
// WithRetryLimit restricts the number of retries (defaults to 5)
func WithRetryLimit(l int) Opts {
return func(r *Reg) {
r.reghttpOpts = append(r.reghttpOpts, reghttp.WithRetryLimit(l))
}
}
// WithSlog injects a slog Logger configuration
func WithSlog(slog *slog.Logger) Opts {
return func(r *Reg) {
r.slog = slog
r.reghttpOpts = append(r.reghttpOpts, reghttp.WithLog(slog))
}
}
// WithTransport uses a specific http transport with retryable requests
func WithTransport(t *http.Transport) Opts {
return func(r *Reg) {
r.reghttpOpts = append(r.reghttpOpts, reghttp.WithTransport(t))
}
}
// WithUserAgent sets a user agent header
func WithUserAgent(ua string) Opts {
return func(r *Reg) {
r.reghttpOpts = append(r.reghttpOpts, reghttp.WithUserAgent(ua))
}
}
-20
View File
@@ -1,20 +0,0 @@
//go:build !wasm
package reg
import (
"log/slog"
"github.com/sirupsen/logrus"
"github.com/regclient/regclient/internal/reghttp"
"github.com/regclient/regclient/internal/sloghandle"
)
// WithLog injects a logrus Logger configuration
func WithLog(log *logrus.Logger) Opts {
return func(r *Reg) {
r.slog = slog.New(sloghandle.Logrus(log))
r.reghttpOpts = append(r.reghttpOpts, reghttp.WithLog(r.slog))
}
}
-79
View File
@@ -1,79 +0,0 @@
package reg
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"github.com/regclient/regclient/internal/reghttp"
"github.com/regclient/regclient/internal/reqmeta"
"github.com/regclient/regclient/scheme"
"github.com/regclient/regclient/types/mediatype"
"github.com/regclient/regclient/types/repo"
)
// RepoList returns a list of repositories on a registry
// Note the underlying "_catalog" API is not supported on many cloud registries
func (reg *Reg) RepoList(ctx context.Context, hostname string, opts ...scheme.RepoOpts) (*repo.RepoList, error) {
config := scheme.RepoConfig{}
for _, opt := range opts {
opt(&config)
}
query := url.Values{}
if config.Last != "" {
query.Set("last", config.Last)
}
if config.Limit > 0 {
query.Set("n", strconv.Itoa(config.Limit))
}
headers := http.Header{
"Accept": []string{"application/json"},
}
req := &reghttp.Req{
MetaKind: reqmeta.Query,
Host: hostname,
NoMirrors: true,
Method: "GET",
Path: "_catalog",
NoPrefix: true,
Query: query,
Headers: headers,
}
resp, err := reg.reghttp.Do(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to list repositories for %s: %w", hostname, err)
}
defer resp.Close()
if resp.HTTPResponse().StatusCode != 200 {
return nil, fmt.Errorf("failed to list repositories for %s: %w", hostname, reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
respBody, err := io.ReadAll(resp)
if err != nil {
reg.slog.Warn("Failed to read repo list",
slog.String("err", err.Error()),
slog.String("host", hostname))
return nil, fmt.Errorf("failed to read repo list for %s: %w", hostname, err)
}
mt := mediatype.Base(resp.HTTPResponse().Header.Get("Content-Type"))
rl, err := repo.New(
repo.WithMT(mt),
repo.WithRaw(respBody),
repo.WithHost(hostname),
repo.WithHeaders(resp.HTTPResponse().Header),
)
if err != nil {
reg.slog.Warn("Failed to unmarshal repo list",
slog.String("err", err.Error()),
slog.String("body", string(respBody)),
slog.String("host", hostname))
return nil, fmt.Errorf("failed to parse repo list for %s: %w", hostname, err)
}
return rl, nil
}
-343
View File
@@ -1,343 +0,0 @@
package reg
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"time"
// crypto libraries included for go-digest
_ "crypto/sha256"
_ "crypto/sha512"
"github.com/opencontainers/go-digest"
"github.com/regclient/regclient/internal/httplink"
"github.com/regclient/regclient/internal/reghttp"
"github.com/regclient/regclient/internal/reqmeta"
"github.com/regclient/regclient/scheme"
"github.com/regclient/regclient/types/descriptor"
"github.com/regclient/regclient/types/docker/schema2"
"github.com/regclient/regclient/types/errs"
"github.com/regclient/regclient/types/manifest"
"github.com/regclient/regclient/types/mediatype"
v1 "github.com/regclient/regclient/types/oci/v1"
"github.com/regclient/regclient/types/platform"
"github.com/regclient/regclient/types/ref"
"github.com/regclient/regclient/types/tag"
"github.com/regclient/regclient/types/warning"
)
// TagDelete removes a tag from a repository.
// It first attempts the newer OCI API to delete by tag name (not widely supported).
// If the OCI API fails, it falls back to pushing a unique empty manifest and deleting that.
func (reg *Reg) TagDelete(ctx context.Context, r ref.Ref) error {
var tempManifest manifest.Manifest
if r.Tag == "" {
return errs.ErrMissingTag
}
// dedup warnings
if w := warning.FromContext(ctx); w == nil {
ctx = warning.NewContext(ctx, &warning.Warning{Hook: warning.DefaultHook()})
}
// attempt to delete the tag directly, available in OCI distribution-spec, and Hub API
req := &reghttp.Req{
MetaKind: reqmeta.Query,
Host: r.Registry,
NoMirrors: true,
Method: "DELETE",
Repository: r.Repository,
Path: "manifests/" + r.Tag,
IgnoreErr: true, // do not trigger backoffs if this fails
}
resp, err := reg.reghttp.Do(ctx, req)
if resp != nil {
defer resp.Close()
}
if err == nil && resp != nil && resp.HTTPResponse().StatusCode == 202 {
return nil
}
// ignore errors, fallback to creating a temporary manifest to replace the tag and deleting that manifest
// lookup the current manifest media type
curManifest, err := reg.ManifestHead(ctx, r)
if err != nil && errors.Is(err, errs.ErrUnsupportedAPI) {
curManifest, err = reg.ManifestGet(ctx, r)
}
if err != nil {
return err
}
// create empty image config with single label
// Note, this should be MediaType specific, but it appears that docker uses OCI for the config
now := time.Now()
conf := v1.Image{
Created: &now,
Config: v1.ImageConfig{
Labels: map[string]string{
"delete-tag": r.Tag,
"delete-date": now.String(),
},
},
Platform: platform.Platform{
OS: "linux",
Architecture: "amd64",
},
History: []v1.History{
{
Created: &now,
CreatedBy: "# regclient",
Comment: "empty JSON blob",
},
},
RootFS: v1.RootFS{
Type: "layers",
DiffIDs: []digest.Digest{
descriptor.EmptyDigest,
},
},
}
confB, err := json.Marshal(conf)
if err != nil {
return err
}
digester := digest.Canonical.Digester()
confBuf := bytes.NewBuffer(confB)
_, err = confBuf.WriteTo(digester.Hash())
if err != nil {
return err
}
confDigest := digester.Digest()
// create manifest with config, matching the original tag manifest type
switch manifest.GetMediaType(curManifest) {
case mediatype.OCI1Manifest, mediatype.OCI1ManifestList:
tempManifest, err = manifest.New(manifest.WithOrig(v1.Manifest{
Versioned: v1.ManifestSchemaVersion,
MediaType: mediatype.OCI1Manifest,
Config: descriptor.Descriptor{
MediaType: mediatype.OCI1ImageConfig,
Digest: confDigest,
Size: int64(len(confB)),
},
Layers: []descriptor.Descriptor{
{
MediaType: mediatype.OCI1Layer,
Size: int64(len(descriptor.EmptyData)),
Digest: descriptor.EmptyDigest,
},
},
}))
if err != nil {
return err
}
default: // default to the docker v2 schema
tempManifest, err = manifest.New(manifest.WithOrig(schema2.Manifest{
Versioned: schema2.ManifestSchemaVersion,
Config: descriptor.Descriptor{
MediaType: mediatype.Docker2ImageConfig,
Digest: confDigest,
Size: int64(len(confB)),
},
Layers: []descriptor.Descriptor{
{
MediaType: mediatype.Docker2LayerGzip,
Size: int64(len(descriptor.EmptyData)),
Digest: descriptor.EmptyDigest,
},
},
}))
if err != nil {
return err
}
}
reg.slog.Debug("Sending dummy manifest to replace tag",
slog.String("ref", r.Reference))
// push empty layer
_, err = reg.BlobPut(ctx, r, descriptor.Descriptor{Digest: descriptor.EmptyDigest, Size: int64(len(descriptor.EmptyData))}, bytes.NewReader(descriptor.EmptyData))
if err != nil {
return err
}
// push config
_, err = reg.BlobPut(ctx, r, descriptor.Descriptor{Digest: confDigest, Size: int64(len(confB))}, bytes.NewReader(confB))
if err != nil {
return fmt.Errorf("failed sending dummy config to delete %s: %w", r.CommonName(), err)
}
// push manifest to tag
err = reg.ManifestPut(ctx, r, tempManifest)
if err != nil {
return fmt.Errorf("failed sending dummy manifest to delete %s: %w", r.CommonName(), err)
}
// delete manifest by digest
r = r.AddDigest(tempManifest.GetDescriptor().Digest.String())
reg.slog.Debug("Deleting dummy manifest",
slog.String("ref", r.Reference),
slog.String("digest", r.Digest))
err = reg.ManifestDelete(ctx, r)
if err != nil {
return fmt.Errorf("failed deleting dummy manifest for %s: %w", r.CommonName(), err)
}
return nil
}
// TagList returns a listing to tags from the repository
func (reg *Reg) TagList(ctx context.Context, r ref.Ref, opts ...scheme.TagOpts) (*tag.List, error) {
var config scheme.TagConfig
for _, opt := range opts {
opt(&config)
}
tl, err := reg.tagListOCI(ctx, r, config)
if err != nil {
return tl, err
}
for {
// if limit reached, stop searching
if config.Limit > 0 && len(tl.Tags) >= config.Limit {
break
}
tlHead, err := tl.RawHeaders()
if err != nil {
return tl, err
}
links, err := httplink.Parse(tlHead.Values("Link"))
if err != nil {
return tl, err
}
next, err := links.Get("rel", "next")
// if Link header with rel="next" is defined
if err == nil {
link := tl.GetURL()
if link == nil {
return tl, fmt.Errorf("tag list, failed to get URL of previous request")
}
link, err = link.Parse(next.URI)
if err != nil {
return tl, fmt.Errorf("tag list failed to parse Link: %w", err)
}
tlAdd, err := reg.tagListLink(ctx, r, config, link)
if err != nil {
return tl, fmt.Errorf("tag list failed to get Link: %w", err)
}
err = tl.Append(tlAdd)
if err != nil {
return tl, fmt.Errorf("tag list failed to append entries: %w", err)
}
} else {
// do not automatically expand tags with OCI methods,
// OCI registries should send all possible entries up to the specified limit
break
}
}
return tl, nil
}
func (reg *Reg) tagListOCI(ctx context.Context, r ref.Ref, config scheme.TagConfig) (*tag.List, error) {
query := url.Values{}
if config.Last != "" {
query.Set("last", config.Last)
}
if config.Limit > 0 {
query.Set("n", strconv.Itoa(config.Limit))
}
headers := http.Header{
"Accept": []string{"application/json"},
}
req := &reghttp.Req{
MetaKind: reqmeta.Query,
Host: r.Registry,
Method: "GET",
Repository: r.Repository,
Path: "tags/list",
Query: query,
Headers: headers,
}
resp, err := reg.reghttp.Do(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to list tags for %s: %w", r.CommonName(), err)
}
defer resp.Close()
if resp.HTTPResponse().StatusCode != 200 {
return nil, fmt.Errorf("failed to list tags for %s: %w", r.CommonName(), reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
respBody, err := io.ReadAll(resp)
if err != nil {
reg.slog.Warn("Failed to read tag list",
slog.String("err", err.Error()),
slog.String("ref", r.CommonName()))
return nil, fmt.Errorf("failed to read tags for %s: %w", r.CommonName(), err)
}
tl, err := tag.New(
tag.WithRef(r),
tag.WithRaw(respBody),
tag.WithResp(resp.HTTPResponse()),
)
if err != nil {
reg.slog.Warn("Failed to unmarshal tag list",
slog.String("err", err.Error()),
slog.String("body", string(respBody)),
slog.String("ref", r.CommonName()))
return tl, fmt.Errorf("failed to unmarshal tag list for %s: %w", r.CommonName(), err)
}
return tl, nil
}
func (reg *Reg) tagListLink(ctx context.Context, r ref.Ref, _ scheme.TagConfig, link *url.URL) (*tag.List, error) {
headers := http.Header{
"Accept": []string{"application/json"},
}
req := &reghttp.Req{
MetaKind: reqmeta.Query,
Host: r.Registry,
Method: "GET",
DirectURL: link,
Repository: r.Repository,
Headers: headers,
}
resp, err := reg.reghttp.Do(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to list tags for %s: %w", r.CommonName(), err)
}
defer resp.Close()
if resp.HTTPResponse().StatusCode != 200 {
return nil, fmt.Errorf("failed to list tags for %s: %w", r.CommonName(), reghttp.HTTPError(resp.HTTPResponse().StatusCode))
}
respBody, err := io.ReadAll(resp)
if err != nil {
reg.slog.Warn("Failed to read tag list",
slog.String("err", err.Error()),
slog.String("ref", r.CommonName()))
return nil, fmt.Errorf("failed to read tags for %s: %w", r.CommonName(), err)
}
tl, err := tag.New(
tag.WithRef(r),
tag.WithRaw(respBody),
tag.WithResp(resp.HTTPResponse()),
)
if err != nil {
reg.slog.Warn("Failed to unmarshal tag list",
slog.String("err", err.Error()),
slog.String("body", string(respBody)),
slog.String("ref", r.CommonName()))
return tl, fmt.Errorf("failed to unmarshal tag list for %s: %w", r.CommonName(), err)
}
return tl, nil
}

Some files were not shown because too many files have changed in this diff Show More