diff --git a/cmd/dmt/root.go b/cmd/dmt/root.go index 2f0826c6c..e941bde10 100644 --- a/cmd/dmt/root.go +++ b/cmd/dmt/root.go @@ -34,6 +34,7 @@ import ( "github.com/deckhouse/dmt/internal/bootstrap" "github.com/deckhouse/dmt/internal/flags" "github.com/deckhouse/dmt/internal/fsutils" + remotelint "github.com/deckhouse/dmt/internal/remote-lint" "github.com/deckhouse/dmt/internal/render" "github.com/deckhouse/dmt/internal/test" "github.com/deckhouse/dmt/internal/version" @@ -268,7 +269,20 @@ func runTests(dir string, opts ...test.Option) error { return nil } -func lintCmdFunc(_ *cobra.Command, args []string) { +func lintCmdFunc(cmd *cobra.Command, args []string) { + if flags.Remote != "" { + opts := &remotelint.RemoteLintOptions{ + Login: flags.RemoteLogin, + Password: flags.RemotePassword, + } + if err := remotelint.RunRemoteLint(cmd.Context(), flags.Remote, opts); err != nil { + log.Error("Error running remote lint", log.Err(err)) + os.Exit(1) + } + + return + } + var dirs = args[0:] if len(dirs) == 0 { diff --git a/go.mod b/go.mod index 62ee25b0a..7e0cf551a 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/Masterminds/sprig/v3 v3.3.0 github.com/bmatcuk/doublestar v1.3.4 github.com/deckhouse/deckhouse/pkg/log v0.2.1 + github.com/deckhouse/deckhouse/pkg/registry v0.0.0-20260714071459-349ceee5aa36 github.com/fatih/color v1.19.0 github.com/go-openapi/spec v0.22.4 github.com/gobwas/glob v0.2.3 @@ -62,11 +63,15 @@ require ( github.com/aws/aws-sdk-go v1.55.6 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dennwc/varint v1.0.0 // indirect github.com/djherbis/buffer v1.2.0 // indirect github.com/djherbis/nio/v3 v3.0.1 // indirect + github.com/docker/cli v29.2.0+incompatible // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/edsrzf/mmap-go v1.2.0 // indirect github.com/emicklei/go-restful/v3 v3.11.2 // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.3 // indirect @@ -125,6 +130,7 @@ require ( github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatautil v0.116.0 // indirect github.com/open-telemetry/opentelemetry-collector-contrib/processor/deltatocumulativeprocessor v0.116.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect @@ -137,6 +143,7 @@ require ( github.com/samber/lo v1.49.1 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/shopspring/decimal v1.4.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spf13/afero v1.11.0 // indirect @@ -144,6 +151,7 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect + github.com/vbatts/tar-split v0.12.1 // indirect github.com/werf/common-go v0.0.0-20251113140850-a1a98e909e9b // indirect github.com/werf/lockgate v0.1.1 // indirect github.com/werf/logboek v0.6.1 // indirect diff --git a/go.sum b/go.sum index 5a1dafe4f..7e8c8e03d 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 h1:QVw89YDxXxEe+l8gU8ETbOasdwEV+avkR75ZzsVV9WI= github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= +github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= @@ -67,6 +69,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/deckhouse/deckhouse/pkg/log v0.2.1 h1:7SSD+QJPnziAO3l8ycgRwN5wlQeC312Tf7f2Wa/hWDg= github.com/deckhouse/deckhouse/pkg/log v0.2.1/go.mod h1:pbAxTSDcPmwyl3wwKDcEB3qdxHnRxqTV+J0K+sha8bw= +github.com/deckhouse/deckhouse/pkg/registry v0.0.0-20260714071459-349ceee5aa36 h1:BHMxY2s8X/MvN2mlM58gP0AwzHCpHgDBZPG8TiOG5mQ= +github.com/deckhouse/deckhouse/pkg/registry v0.0.0-20260714071459-349ceee5aa36/go.mod h1:KDf44MqEif8jAKCehKJqOg0k4sJcnetKJKDGd0IFQjI= github.com/dennwc/varint v1.0.0 h1:kGNFFSSw8ToIy3obO/kKr8U9GZYUAxQEVuix4zfDWzE= github.com/dennwc/varint v1.0.0/go.mod h1:hnItb35rvZvJrbTALZtY/iQfDs48JKRG1RPpgziApxA= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= @@ -82,8 +86,14 @@ github.com/djherbis/nio/v3 v3.0.1 h1:6wxhnuppteMa6RHA4L81Dq7ThkZH8SwnDzXDYy95vB4 github.com/djherbis/nio/v3 v3.0.1/go.mod h1:Ng4h80pbZFMla1yKzm61cF0tqqilXZYrogmWgZxOcmg= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM= +github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4= github.com/docker/docker v27.4.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -251,8 +261,8 @@ github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2E github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 h1:IsMZxCuZqKuao2vNdfD82fjjgPLfyHLpR41Z88viRWs= -github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6/go.mod h1:3VeWNIJaW+O5xpRQbPp0Ybqu1vJd/pm7s2F473HRrkw= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -355,8 +365,8 @@ github.com/prometheus/sigv4 v0.1.1 h1:UJxjOqVcXctZlwDjpUpZ2OiMWJdFijgSofwLzO1Xk0 github.com/prometheus/sigv4 v0.1.1/go.mod h1:RAmWVKqx0bwi0Qm4lrKMXFM0nhpesBcenfCtz9qRyH8= github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= @@ -370,6 +380,8 @@ github.com/scaleway/scaleway-sdk-go v1.0.0-beta.30 h1:yoKAVkEVwAqbGbR8n87rHQ1dul github.com/scaleway/scaleway-sdk-go v1.0.0-beta.30/go.mod h1:sH0u6fq6x4R5M7WxkoQFY/o7UaiItec0o1LinLCJNq8= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= @@ -407,6 +419,8 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= +github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/vultr/govultr/v2 v2.17.2 h1:gej/rwr91Puc/tgh+j33p/BLR16UrIPnSr+AIwYWZQs= github.com/vultr/govultr/v2 v2.17.2/go.mod h1:ZFOKGWmgjytfyjeyAdhQlSWwTjh2ig+X49cAp50dzXI= github.com/werf/common-go v0.0.0-20251113140850-a1a98e909e9b h1:58850oFrnw5Jy5YaB8QifXz75qpGotfx6qqZ9Q2my1A= @@ -519,6 +533,7 @@ golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= @@ -567,6 +582,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= helm.sh/helm/v3 v3.18.5 h1:Cc3Z5vd6kDrZq9wO9KxKLNEickiTho6/H/dBNRVSos4= helm.sh/helm/v3 v3.18.5/go.mod h1:L/dXDR2r539oPlFP1PJqKAC1CUgqHJDLkxKpDGrWnyg= k8s.io/api v0.33.3 h1:SRd5t//hhkI1buzxb288fy2xvjubstenEKL9K51KBI8= diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 456200cc6..53c84c40c 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -27,9 +27,12 @@ const ( ) var ( - LintersLimit int - LogLevel string - LinterName string + Remote string + RemoteLogin string + RemotePassword string + LintersLimit int + LogLevel string + LinterName string ) var ( @@ -62,6 +65,10 @@ func InitDefaultFlagSet() *pflag.FlagSet { func InitLintFlagSet() *pflag.FlagSet { lint := pflag.NewFlagSet("lint", pflag.ContinueOnError) + lint.StringVarP(&Remote, "remote", "r", "", "lint registry artifact, e.g. 'registry.example.com/my-module:v0.0.1'") + lint.StringVar(&RemoteLogin, "login", "", "login for the registry, used when --remote is set") + lint.StringVar(&RemotePassword, "password", "", "password for the registry, used when --remote is set") + lint.IntVarP(&LintersLimit, "parallel", "p", numThreads, "number of threads for parallel processing") lint.StringVar(&LinterName, "linter", "", "linter name to run") diff --git a/internal/manager/manager.go b/internal/manager/manager.go index 61ee7eb94..627c5fb98 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -196,7 +196,11 @@ func getLintersForModule(cfg *pkg.LintersSettings, errList *errors.LintRuleError } func (m *Manager) PrintResult() { - errs := m.errors.GetErrors() + PrintResult(m.errors) +} + +func PrintResult(errorList *errors.LintRuleErrorsList) { + errs := errorList.GetErrors() if len(errs) == 0 { return diff --git a/internal/module/bilingual_config_test.go b/internal/module/bilingual_config_test.go index 606f2dd23..1f25efcf8 100644 --- a/internal/module/bilingual_config_test.go +++ b/internal/module/bilingual_config_test.go @@ -12,13 +12,13 @@ import ( func TestRemapOpenAPIBilingualRuleLevel(t *testing.T) { t.Run("defaults to error", func(t *testing.T) { - settings := remapLinterSettings(&config.LintersSettings{}, &global.Linters{}) + settings := RemapLinterSettings(&config.LintersSettings{}, &global.Linters{}) require.Equal(t, pkg.Error, *settings.OpenAPI.Rules.BilingualRule.GetLevel()) }) t.Run("uses global warning level", func(t *testing.T) { - settings := remapLinterSettings(&config.LintersSettings{}, &global.Linters{ + settings := RemapLinterSettings(&config.LintersSettings{}, &global.Linters{ OpenAPI: global.OpenAPILinterConfig{ Rules: global.OpenAPIRules{ BilingualRule: global.RuleConfig{Impact: pkg.Warn.String()}, diff --git a/internal/module/module.go b/internal/module/module.go index e5bc7622f..80fd4e582 100644 --- a/internal/module/module.go +++ b/internal/module/module.go @@ -145,10 +145,10 @@ func (m *Module) GetModuleConfig() *pkg.LintersSettings { return m.linterConfig } -// remapLinterSettings converts configuration settings from the config package format +// RemapLinterSettings converts configuration settings from the config package format // to the pkg package format, mapping both rule-level configurations and exclusion rules // across all linter domains (Container, Image, NoCyrillic, OpenAPI, Templates, RBAC, Hooks, Module). -func remapLinterSettings(configSettings *config.LintersSettings, globalConfig *global.Linters) *pkg.LintersSettings { +func RemapLinterSettings(configSettings *config.LintersSettings, globalConfig *global.Linters) *pkg.LintersSettings { linterSettings := &pkg.LintersSettings{} // Step 1: Configure linter-level impact settings @@ -543,7 +543,7 @@ func NewModule(path string, vals *chartutil.Values, globalSchema *spec.Schema, r cfg.LintersSettings.MergeGlobal(&rootConfig.GlobalSettings.Linters) - module.linterConfig = remapLinterSettings(&cfg.LintersSettings, &rootConfig.GlobalSettings.Linters) + module.linterConfig = RemapLinterSettings(&cfg.LintersSettings, &rootConfig.GlobalSettings.Linters) return module, nil } diff --git a/internal/remote-lint/extract.go b/internal/remote-lint/extract.go new file mode 100644 index 000000000..1963058e1 --- /dev/null +++ b/internal/remote-lint/extract.go @@ -0,0 +1,134 @@ +package remotelint + +import ( + "archive/tar" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/deckhouse/deckhouse/pkg/registry" +) + +// ExtractImage extracts the image to a temporary directory and returns the path to the directory +func ExtractImage(ctx context.Context, image registry.Image) (string, error) { + tempDir, err := os.MkdirTemp("", "dmt-*") + if err != nil { + return "", fmt.Errorf("failed to create temp directory: %w", err) + } + + rc := image.Extract() + defer rc.Close() + + err = extract(ctx, rc, tempDir) + if err != nil { + return "", fmt.Errorf("failed to extract image: %w", err) + } + + return tempDir, nil +} + +func extract(ctx context.Context, rc io.ReadCloser, target string) error { + tr := tar.NewReader(rc) + + for { + if err := ctx.Err(); err != nil { + return err + } + + hdr, err := tr.Next() + if err == io.EOF { + break + } + + if err != nil { + return fmt.Errorf("read tar: %w", err) + } + + target, err := safeJoin(target, hdr.Name) + if err != nil { + return err + } + + switch hdr.Typeflag { + case tar.TypeDir: + if err = os.MkdirAll(target, os.FileMode(hdr.Mode)); err != nil { + return fmt.Errorf("mkdir %q: %w", hdr.Name, err) + } + case tar.TypeReg: + if err = writeRegularFile(target, tr, os.FileMode(hdr.Mode)); err != nil { + return fmt.Errorf("write file %q: %w", hdr.Name, err) + } + case tar.TypeSymlink: + if filepath.IsAbs(hdr.Linkname) || !staysWithin(target, filepath.Dir(target), hdr.Linkname) { + return fmt.Errorf("symlink %q escapes output directory", hdr.Name) + } + + if err = os.Symlink(hdr.Linkname, target); err != nil { + return fmt.Errorf("create symlink %q: %w", hdr.Name, err) + } + case tar.TypeLink: + linkTarget, err := safeJoin(target, hdr.Linkname) + if err != nil { + return err + } + + if err = os.Link(linkTarget, target); err != nil { + return fmt.Errorf("create hardlink %q: %w", hdr.Name, err) + } + } + } + + return nil +} + +// writeRegularFile writes one regular tar entry and limits restored permissions to owner bits. +func writeRegularFile(target string, src io.Reader, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + return fmt.Errorf("create parent directory: %w", err) + } + + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode&0o700) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + + if _, err = io.Copy(out, src); err != nil { + closeErr := out.Close() + if closeErr != nil { + return fmt.Errorf("copy file: %w; close file: %v", err, closeErr) + } + + return fmt.Errorf("copy file: %w", err) + } + + if err = out.Close(); err != nil { + return fmt.Errorf("close file: %w", err) + } + + return nil +} + +// safeJoin joins name under root and rejects absolute paths or parent-directory escapes. +func safeJoin(root, name string) (string, error) { + if filepath.IsAbs(name) { + return "", fmt.Errorf("path %q escapes output directory", name) + } + + target := filepath.Join(root, name) + if !staysWithin(root, root, name) { + return "", fmt.Errorf("path %q escapes output directory", name) + } + + return target, nil +} + +// staysWithin reports whether name resolves under root when interpreted relative to base. +func staysWithin(root, base, name string) bool { + target := filepath.Clean(filepath.Join(base, name)) + rel, err := filepath.Rel(root, target) + + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} diff --git a/internal/remote-lint/lint.go b/internal/remote-lint/lint.go new file mode 100644 index 000000000..6867c79fe --- /dev/null +++ b/internal/remote-lint/lint.go @@ -0,0 +1,176 @@ +package remotelint + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/google/go-containerregistry/pkg/name" + + "github.com/deckhouse/deckhouse/pkg/registry/client" + + "github.com/deckhouse/dmt/internal/manager" + "github.com/deckhouse/dmt/internal/metrics" + "github.com/deckhouse/dmt/internal/module" + "github.com/deckhouse/dmt/pkg" + "github.com/deckhouse/dmt/pkg/config" + "github.com/deckhouse/dmt/pkg/errors" + "github.com/deckhouse/dmt/pkg/linters" + "github.com/deckhouse/dmt/pkg/linters/docs" + moduleLinter "github.com/deckhouse/dmt/pkg/linters/module" +) + +type RemoteLintOptions struct { + Config *config.RootConfig + // Login is the username to use for the registry e.g. license-token + Login string + // Password is the password to use for the registry + Password string +} + +// RunRemoteLint runs the remote linting for the given registry image and options +// RegistryPath is the path to the image e.g. registry.example.com/deckhouse/deckhouse:latest +func RunRemoteLint(ctx context.Context, imagePath string, opts *RemoteLintOptions) error { + registryPath, tag, err := cutTagFromImagePath(imagePath) + if err != nil { + return fmt.Errorf("failed to cut tag from image path: %w", err) + } + + client := initRegistryClient(registryPath, opts.Login, opts.Password) + + // init metrics storage, should be done before printing results, as + // PrintResult reports per-error metrics through the shared metrics client. + metrics.GetClient(".") + + level := pkg.Error + errorList := errors.NewLintRuleErrorsList().WithMaxLevel(&level) + + cfg, err := parseConfig() + if err != nil { + return fmt.Errorf("failed to parse config: %w", err) + } + + err = lintBundle(ctx, client, tag, cfg, errorList) + if err != nil { + return fmt.Errorf("failed to lint bundle: %w", err) + } + + err = lintRelease(ctx, client, tag, cfg, errorList) + if err != nil { + // print result of successful bundle linting + manager.PrintResult(errorList) + return fmt.Errorf("failed to lint release: %w", err) + } + + manager.PrintResult(errorList) + + if errorList.ContainsErrors() { + return fmt.Errorf("critical errors found") + } + + return nil +} + +func lintBundle(ctx context.Context, client *client.Client, tag string, cfg *pkg.LintersSettings, errorList *errors.LintRuleErrorsList) error { + image, err := client.GetImage(ctx, tag) + if err != nil { + return fmt.Errorf("failed to get image: %w", err) + } + + tempDir, err := ExtractImage(ctx, image) + if err != nil { + return fmt.Errorf("failed to extract image: %w", err) + } + defer os.RemoveAll(tempDir) + + bundleLinters := buildBundleLinters(cfg, errorList.WithObjectID("bundle")) + + for _, linter := range bundleLinters { + cfg := &linters.LinterConfig{ + Name: client.GetRegistry(), + Namespace: "bundle", + Path: tempDir, + } + linter.RunRemoteForBundle(cfg) + } + + return nil +} + +func lintRelease(ctx context.Context, client *client.Client, tag string, cfg *pkg.LintersSettings, errorList *errors.LintRuleErrorsList) error { + image, err := client.WithSegment("release").GetImage(ctx, tag) + if err != nil { + return fmt.Errorf("failed to get release image: %w", err) + } + + tempDir, err := ExtractImage(ctx, image) + if err != nil { + return fmt.Errorf("failed to extract release image: %w", err) + } + defer os.RemoveAll(tempDir) + + releaseLinters := buildReleaseLinters(cfg, errorList.WithObjectID("release")) + + for _, linter := range releaseLinters { + cfg := &linters.LinterConfig{ + Name: client.GetRegistry(), + Namespace: "release", + Path: tempDir, + } + linter.RunRemoteForRelease(cfg) + } + + return nil +} + +// returns repository path and tag from the image path +// turns strings like "registry.example.com/my-module:v0.0.1" into "registry.example.com/my-module" and "v0.0.1" +func cutTagFromImagePath(imagePath string) (string, string, error) { + // if digest was provided we can't know the release tag in future steps, so we can't pull it + if strings.Contains(imagePath, "@") { + return "", "", fmt.Errorf("digest not supported") + } + + ref, err := name.ParseReference(imagePath, name.WithDefaultTag("")) + if err != nil { + return "", "", fmt.Errorf("failed to parse image path: %w", err) + } + + tag := ref.Identifier() + if tag == "" { + return "", "", fmt.Errorf("tag not found in image path") + } + + return ref.Context().Name(), tag, nil +} + +func buildBundleLinters(cfg *pkg.LintersSettings, errorList *errors.LintRuleErrorsList) []linters.RemoteBundleLinter { + return []linters.RemoteBundleLinter{ + docs.New(&cfg.Documentation, errorList.WithMaxLevel(cfg.Documentation.Impact)), + } +} + +func buildReleaseLinters(cfg *pkg.LintersSettings, errorList *errors.LintRuleErrorsList) []linters.RemoteReleaseLinter { + return []linters.RemoteReleaseLinter{ + moduleLinter.New(&cfg.Module, errorList.WithMaxLevel(cfg.Module.Impact)), + } +} + +// parsing config from .dmtlint.yaml file +func parseConfig() (*pkg.LintersSettings, error) { + rootConfig, err := config.NewDefaultRootConfig(".") + if err != nil { + return nil, fmt.Errorf("failed to parse default root config: %w", err) + } + + // Load module config + cfg := &config.ModuleConfig{} + if err := config.NewLoader(cfg, ".").Load(); err != nil { + return nil, fmt.Errorf("can not parse module config: %w", err) + } + + cfg.LintersSettings.MergeGlobal(&rootConfig.GlobalSettings.Linters) + + return module.RemapLinterSettings(&cfg.LintersSettings, &rootConfig.GlobalSettings.Linters), nil +} diff --git a/internal/remote-lint/lint_test.go b/internal/remote-lint/lint_test.go new file mode 100644 index 000000000..14749b322 --- /dev/null +++ b/internal/remote-lint/lint_test.go @@ -0,0 +1,29 @@ +package remotelint + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCutTagFromImagePath(t *testing.T) { + // Simple successful case + repository, tag, err := cutTagFromImagePath("registry.example.com/deckhouse/my-module:v0.0.1") + require.NoError(t, err) + require.Equal(t, "registry.example.com/deckhouse/my-module", repository) + require.Equal(t, "v0.0.1", tag) + + // Image with digest + _, _, err = cutTagFromImagePath("registry.example.com/deckhouse/my-module@sha256:1234567890") + require.ErrorContains(t, err, "digest not supported") + + // Image without tag + _, _, err = cutTagFromImagePath("registry.example.com/deckhouse/my-module") + require.ErrorContains(t, err, "tag not found in image path") + + // Registry with a port + repository, tag, err = cutTagFromImagePath("registry.example.com:8080/deckhouse/my-module:v0.0.1") + require.NoError(t, err) + require.Equal(t, "registry.example.com:8080/deckhouse/my-module", repository) + require.Equal(t, "v0.0.1", tag) +} diff --git a/internal/remote-lint/registry-client.go b/internal/remote-lint/registry-client.go new file mode 100644 index 000000000..3d3386abe --- /dev/null +++ b/internal/remote-lint/registry-client.go @@ -0,0 +1,70 @@ +package remotelint + +import ( + "log/slog" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + + "github.com/deckhouse/deckhouse/pkg/log" + regclient "github.com/deckhouse/deckhouse/pkg/registry/client" +) + +func initRegistryClient(registryHost string, login, password string) *regclient.Client { + auth := registryAuth(registryHost, login, password) + + return regclient.New(registryHost, regclient.WithAuth(auth)) +} + +// registryAuth resolves credentials for the source registry, mirroring the +// pre-#386 priority: explicit login/password, then license token, then the +// Docker config, then anonymous. +func registryAuth(registryHost string, login, password string) authn.Authenticator { + if login != "" { + return authn.FromConfig(authn.AuthConfig{ + Username: login, + Password: password, + }) + } + + if auth, ok := dockerConfigAuth(registryHost); ok { + return auth + } + + log.Debug("using anonymous access for the source registry", slog.String("registry", registryHost)) + + return authn.Anonymous +} + +// dockerConfigAuth resolves credentials for registryHost from the Docker config +// (~/.docker/config.json, written by `d8 dk cr login`). ok is false when the +// config holds no usable entry for the host. +func dockerConfigAuth(registryHost string) (authn.Authenticator, bool) { + ref, err := name.ParseReference(registryHost) + if err != nil { + return nil, false + } + + reg, err := name.NewRegistry(ref.Context().RegistryStr()) + if err != nil { + return nil, false + } + + auth, err := authn.DefaultKeychain.Resolve(reg) + if err != nil || auth == authn.Anonymous { + return nil, false + } + + cfg, err := auth.Authorization() + if err != nil { + return nil, false + } + + if cfg.Username == "" && cfg.Password == "" && cfg.Auth == "" && cfg.IdentityToken == "" { + return nil, false + } + + log.Debug("using Docker config credentials", slog.String("registry", reg.String())) + + return auth, true +} diff --git a/pkg/linters/docs/documentation.go b/pkg/linters/docs/documentation.go index e05bb1c3a..022614a20 100644 --- a/pkg/linters/docs/documentation.go +++ b/pkg/linters/docs/documentation.go @@ -7,6 +7,7 @@ import ( "github.com/deckhouse/dmt/internal/module" "github.com/deckhouse/dmt/pkg" "github.com/deckhouse/dmt/pkg/errors" + linters "github.com/deckhouse/dmt/pkg/linters" "github.com/deckhouse/dmt/pkg/linters/docs/rules" ) @@ -30,6 +31,16 @@ func New(cfg *pkg.DocumentationLinterConfig, errorList *errors.LintRuleErrorsLis } } +func (l *Documentation) RunRemoteForBundle(cfg *linters.LinterConfig) { + if cfg == nil || cfg.Path == "" { + return + } + + errorList := l.ErrorList.WithModule(cfg.Name) + rules.NewReadmeRule().CheckReadmeRemote(cfg.Path, errorList.WithMaxLevel(l.cfg.Rules.ReadmeRule.GetLevel())) + rules.NewChangelogRule().CheckChangelog(cfg.Path, errorList) +} + func (l *Documentation) Run(m *module.Module) { if m == nil || m.GetPath() == "" { return diff --git a/pkg/linters/docs/rules/changelog.go b/pkg/linters/docs/rules/changelog.go new file mode 100644 index 000000000..c8b272277 --- /dev/null +++ b/pkg/linters/docs/rules/changelog.go @@ -0,0 +1,56 @@ +package rules + +import ( + "os" + "path/filepath" + + "github.com/deckhouse/dmt/pkg" + "github.com/deckhouse/dmt/pkg/errors" +) + +const ( + ChangelogRuleName = "changelog" +) + +func NewChangelogRule() *ChangelogRule { + return &ChangelogRule{ + RuleMeta: pkg.RuleMeta{ + Name: ChangelogRuleName, + }, + } +} + +type ChangelogRule struct { + pkg.RuleMeta + pkg.PathRule +} + +func (r *ChangelogRule) CheckChangelog(modulePath string, errorList *errors.LintRuleErrorsList) { + errorList = errorList.WithRule(r.GetName()) + + path := filepath.Join(modulePath, "changelog.yaml") + + if _, err := os.Stat(path); err != nil { + errorList. + WithFilePath(path). + Error("changelog.yaml file is missing") + + return + } + + info, err := os.Stat(path) + if err != nil { + errorList. + WithFilePath(path). + WithValue(err.Error()). + Error("failed to check changelog.yaml file") + + return + } + + if info.Size() == 0 { + errorList. + WithFilePath(path). + Error("changelog.yaml file is empty") + } +} diff --git a/pkg/linters/docs/rules/readme.go b/pkg/linters/docs/rules/readme.go index f8d93870c..0e5606627 100644 --- a/pkg/linters/docs/rules/readme.go +++ b/pkg/linters/docs/rules/readme.go @@ -28,14 +28,21 @@ type ReadmeRule struct { pkg.PathRule } -func (r *ReadmeRule) CheckReadme(m pkg.Module, errorList *errors.LintRuleErrorsList) { - errorList = errorList.WithRule(r.GetName()) +func (r *ReadmeRule) CheckReadmeRemote(path string, errorList *errors.LintRuleErrorsList) { + r.checkReadme(path, errorList) +} +func (r *ReadmeRule) CheckReadme(m pkg.Module, errorList *errors.LintRuleErrorsList) { if !r.Enabled(m.GetName()) { return } - modulePath := m.GetPath() + r.checkReadme(m.GetPath(), errorList) +} + +func (r *ReadmeRule) checkReadme(modulePath string, errorList *errors.LintRuleErrorsList) { + errorList = errorList.WithRule(r.GetName()) + path := filepath.Join(modulePath, "docs", "README.md") if _, err := os.Stat(path); err != nil { diff --git a/pkg/linters/linters.go b/pkg/linters/linters.go new file mode 100644 index 000000000..8fd67b24b --- /dev/null +++ b/pkg/linters/linters.go @@ -0,0 +1,17 @@ +package linters + +type RemoteBundleLinter interface { + RunRemoteForBundle(cfg *LinterConfig) + Name() string +} + +type RemoteReleaseLinter interface { + RunRemoteForRelease(cfg *LinterConfig) + Name() string +} + +type LinterConfig struct { + Name string + Namespace string + Path string +} diff --git a/pkg/linters/module/module.go b/pkg/linters/module/module.go index 145ec1832..392f92f41 100644 --- a/pkg/linters/module/module.go +++ b/pkg/linters/module/module.go @@ -20,6 +20,7 @@ import ( "github.com/deckhouse/dmt/internal/module" "github.com/deckhouse/dmt/pkg" "github.com/deckhouse/dmt/pkg/errors" + "github.com/deckhouse/dmt/pkg/linters" "github.com/deckhouse/dmt/pkg/linters/module/rules" ) @@ -41,6 +42,19 @@ func New(cfg *pkg.ModuleLinterConfig, errorList *errors.LintRuleErrorsList) *Mod } } +func (l *Module) RunRemoteForRelease(cfg *linters.LinterConfig) { + if cfg == nil { + return + } + + errorList := l.ErrorList.WithModule(cfg.Name) + + rules.NewDefinitionFileRule(l.cfg.DefinitionFileRuleSettings.Disable). + CheckDefinitionFile(cfg.Path, errorList.WithMaxLevel(l.cfg.Rules.DefinitionFileRule.GetLevel()), rules.WithModuleYamlExists()) + rules.NewPackageYAMLRule(). + CheckPackageYAML(cfg.Path, errorList.WithMaxLevel(l.cfg.Rules.PackageYAMLRule.GetLevel()), rules.WithPackageYamlExists()) +} + func (l *Module) Run(m *module.Module) { if m == nil { return diff --git a/pkg/linters/module/rules/module_yaml.go b/pkg/linters/module/rules/module_yaml.go index f06668f37..72b5bad2a 100644 --- a/pkg/linters/module/rules/module_yaml.go +++ b/pkg/linters/module/rules/module_yaml.go @@ -206,9 +206,24 @@ func convertURLToModuleName(repoURL string) string { return parts[len(parts)-1] } -func (r *DefinitionFileRule) CheckDefinitionFile(modulePath string, errorList *errors.LintRuleErrorsList) { +type CheckDefinitionFileOptions func(modulePath string, errorList *errors.LintRuleErrorsList) + +func WithModuleYamlExists() CheckDefinitionFileOptions { + return func(modulePath string, errorList *errors.LintRuleErrorsList) { + _, err := os.Stat(filepath.Join(modulePath, ModuleConfigFilename)) + if err != nil { + errorList.Errorf("Cannot stat file %q: %s", ModuleConfigFilename, err) + } + } +} + +func (r *DefinitionFileRule) CheckDefinitionFile(modulePath string, errorList *errors.LintRuleErrorsList, options ...CheckDefinitionFileOptions) { errorList = errorList.WithRule(r.GetName()).WithFilePath(ModuleConfigFilename) + for _, opt := range options { + opt(modulePath, errorList) + } + if !r.Enabled() { errorList = errorList.WithMaxLevel(ptr.To(pkg.Ignored)) } diff --git a/pkg/linters/module/rules/package_yaml.go b/pkg/linters/module/rules/package_yaml.go index 1c29e6326..29ebabcd6 100644 --- a/pkg/linters/module/rules/package_yaml.go +++ b/pkg/linters/module/rules/package_yaml.go @@ -136,10 +136,25 @@ func getModulePackage(modulePath string, errorList *errors.LintRuleErrorsList) ( return &yml, nil } +type CheckPackageYAMLOptions func(modulePath string, errorList *errors.LintRuleErrorsList) + +func WithPackageYamlExists() CheckPackageYAMLOptions { + return func(modulePath string, errorList *errors.LintRuleErrorsList) { + _, err := os.Stat(filepath.Join(modulePath, PackageConfigFilename)) + if err != nil { + errorList.Errorf("Cannot stat file %q: %s", PackageConfigFilename, err) + } + } +} + // CheckPackageYAML validates package.yaml in the module root. -func (r *PackageYAMLRule) CheckPackageYAML(modulePath string, errorList *errors.LintRuleErrorsList) { +func (r *PackageYAMLRule) CheckPackageYAML(modulePath string, errorList *errors.LintRuleErrorsList, options ...CheckPackageYAMLOptions) { errorList = errorList.WithRule(r.GetName()) + for _, opt := range options { + opt(modulePath, errorList) + } + modulePackage, err := getModulePackage(modulePath, errorList) if err != nil { return