From 9e4238059af0bc023d4649d7dbfa67948d3117a1 Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Tue, 14 Jul 2026 11:28:35 +0300 Subject: [PATCH 01/26] [dmt] implement remote lint Signed-off-by: Smyslov Maxim --- cmd/dmt/root.go | 16 +++- go.mod | 8 ++ go.sum | 25 +++++- internal/flags/flags.go | 13 ++- internal/remote-lint/extract.go | 114 +++++++++++++++++++++++++ internal/remote-lint/lint.go | 133 ++++++++++++++++++++++++++++++ internal/remote-lint/lint_test.go | 8 ++ 7 files changed, 309 insertions(+), 8 deletions(-) create mode 100644 internal/remote-lint/extract.go create mode 100644 internal/remote-lint/lint.go create mode 100644 internal/remote-lint/lint_test.go diff --git a/cmd/dmt/root.go b/cmd/dmt/root.go index 2f0826c6..e941bde1 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 62ee25b0..7e0cf551 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 5a1dafe4..7e8c8e03 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 456200cc..53c84c40 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/remote-lint/extract.go b/internal/remote-lint/extract.go new file mode 100644 index 00000000..0890d6c8 --- /dev/null +++ b/internal/remote-lint/extract.go @@ -0,0 +1,114 @@ +package remotelint + +import ( + "archive/tar" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +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 00000000..05678773 --- /dev/null +++ b/internal/remote-lint/lint.go @@ -0,0 +1,133 @@ +package remotelint + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + + "github.com/deckhouse/deckhouse/pkg/log" + regclient "github.com/deckhouse/deckhouse/pkg/registry/client" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" +) + +type RemoteLintOptions struct { + // 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) + + image, err := client.GetImage(ctx, tag) + if err != nil { + return fmt.Errorf("failed to get image: %w", err) + } + + tempDir, err := os.MkdirTemp("", "dmt-"+imagePath) + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + + log.Info("extracting image to temp directory", slog.String("tempDir", tempDir)) + + rc := image.Extract() + defer rc.Close() + + err = Extract(ctx, rc, tempDir) + if err != nil { + return fmt.Errorf("failed to extract image: %w", err) + } + + return nil +} + +// returns repository path and tag (or digest) 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" (or sha256:aaa) +func cutTagFromImagePath(imagePath string) (string, string, error) { + if parts := strings.Split(imagePath, "@"); len(parts) == 2 { + return parts[0], parts[1], nil + } + + if parts := strings.Split(imagePath, ":"); len(parts) == 2 { + return parts[0], parts[1], nil + } + + return "", "", fmt.Errorf("tag not found in image path: %s", imagePath) +} + +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 + } + + log.Warn("debug dockerConfigAuth", slog.String( + "registryHost", registryHost), + slog.String("ref", ref.String()), + slog.String("registry", ref.Context().RegistryStr()), + ) + + 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/internal/remote-lint/lint_test.go b/internal/remote-lint/lint_test.go new file mode 100644 index 00000000..ae61cd39 --- /dev/null +++ b/internal/remote-lint/lint_test.go @@ -0,0 +1,8 @@ +package remotelint + +import ( + "testing" +) + +func TestRunRemoteLint(t *testing.T) { +} From ab6e8381da91a8a257c9913fd7e8a6ed3d0400df Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Tue, 14 Jul 2026 11:45:37 +0300 Subject: [PATCH 02/26] divide by a files Signed-off-by: Smyslov Maxim --- internal/remote-lint/extract.go | 24 +++++++ internal/remote-lint/lint.go | 84 +------------------------ internal/remote-lint/registry-client.go | 75 ++++++++++++++++++++++ 3 files changed, 101 insertions(+), 82 deletions(-) create mode 100644 internal/remote-lint/registry-client.go diff --git a/internal/remote-lint/extract.go b/internal/remote-lint/extract.go index 0890d6c8..affbc8c4 100644 --- a/internal/remote-lint/extract.go +++ b/internal/remote-lint/extract.go @@ -5,11 +5,35 @@ import ( "context" "fmt" "io" + "log/slog" "os" "path/filepath" "strings" + + "github.com/deckhouse/deckhouse/pkg/log" + "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) + } + + log.Info("extracting image to temp directory", slog.String("tempDir", tempDir)) + + 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) diff --git a/internal/remote-lint/lint.go b/internal/remote-lint/lint.go index 05678773..93ef26d4 100644 --- a/internal/remote-lint/lint.go +++ b/internal/remote-lint/lint.go @@ -3,14 +3,8 @@ package remotelint import ( "context" "fmt" - "log/slog" "os" "strings" - - "github.com/deckhouse/deckhouse/pkg/log" - regclient "github.com/deckhouse/deckhouse/pkg/registry/client" - "github.com/google/go-containerregistry/pkg/authn" - "github.com/google/go-containerregistry/pkg/name" ) type RemoteLintOptions struct { @@ -35,20 +29,11 @@ func RunRemoteLint(ctx context.Context, imagePath string, opts *RemoteLintOption return fmt.Errorf("failed to get image: %w", err) } - tempDir, err := os.MkdirTemp("", "dmt-"+imagePath) - if err != nil { - return fmt.Errorf("failed to create temp directory: %w", err) - } - - log.Info("extracting image to temp directory", slog.String("tempDir", tempDir)) - - rc := image.Extract() - defer rc.Close() - - err = Extract(ctx, rc, tempDir) + tempDir, err := ExtractImage(ctx, image) if err != nil { return fmt.Errorf("failed to extract image: %w", err) } + defer os.RemoveAll(tempDir) return nil } @@ -66,68 +51,3 @@ func cutTagFromImagePath(imagePath string) (string, string, error) { return "", "", fmt.Errorf("tag not found in image path: %s", imagePath) } - -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 - } - - log.Warn("debug dockerConfigAuth", slog.String( - "registryHost", registryHost), - slog.String("ref", ref.String()), - slog.String("registry", ref.Context().RegistryStr()), - ) - - 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/internal/remote-lint/registry-client.go b/internal/remote-lint/registry-client.go new file mode 100644 index 00000000..dbda635b --- /dev/null +++ b/internal/remote-lint/registry-client.go @@ -0,0 +1,75 @@ +package remotelint + +import ( + "log/slog" + + "github.com/deckhouse/deckhouse/pkg/log" + regclient "github.com/deckhouse/deckhouse/pkg/registry/client" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" +) + +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 + } + + log.Warn("debug dockerConfigAuth", slog.String( + "registryHost", registryHost), + slog.String("ref", ref.String()), + slog.String("registry", ref.Context().RegistryStr()), + ) + + 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 +} From 4fa494f0a2c3f3e437f204d4d5abc88b37563d19 Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Tue, 14 Jul 2026 12:30:31 +0300 Subject: [PATCH 03/26] [dmt] add documentation linter with README validation Signed-off-by: Smyslov Maxim --- internal/remote-lint/lint.go | 32 +++++ pkg/remote-linters/docs/linter.go | 176 ++++++++++++++++++++++++ pkg/remote-linters/docs/rules/readme.go | 58 ++++++++ 3 files changed, 266 insertions(+) create mode 100644 pkg/remote-linters/docs/linter.go create mode 100644 pkg/remote-linters/docs/rules/readme.go diff --git a/internal/remote-lint/lint.go b/internal/remote-lint/lint.go index 93ef26d4..c0aa9366 100644 --- a/internal/remote-lint/lint.go +++ b/internal/remote-lint/lint.go @@ -4,7 +4,11 @@ import ( "context" "fmt" "os" + "path/filepath" "strings" + + "github.com/deckhouse/dmt/pkg/errors" + "github.com/deckhouse/dmt/pkg/remote-linters/docs" ) type RemoteLintOptions struct { @@ -35,6 +39,21 @@ func RunRemoteLint(ctx context.Context, imagePath string, opts *RemoteLintOption } defer os.RemoveAll(tempDir) + errorList := errors.NewLintRuleErrorsList() // .WithMaxLevel() + + linters, err := buildLinters(ctx, tempDir, errorList) + if err != nil { + return fmt.Errorf("failed to build linters: %w", err) + } + + os.Remove(filepath.Join(tempDir, "docs", "README.md")) + + for _, linter := range linters { + linter.Lint(ctx) + } + + docs.PrintResult(errorList) + return nil } @@ -51,3 +70,16 @@ func cutTagFromImagePath(imagePath string) (string, string, error) { return "", "", fmt.Errorf("tag not found in image path: %s", imagePath) } + +type Linter interface { + Lint(ctx context.Context) +} + +func buildLinters(_ context.Context, path string, errorList *errors.LintRuleErrorsList) ([]Linter, error) { + + docsLinter := docs.NewLinter(docs.Config{Path: path}, errorList) + + return []Linter{ + docsLinter, + }, nil +} diff --git a/pkg/remote-linters/docs/linter.go b/pkg/remote-linters/docs/linter.go new file mode 100644 index 00000000..786b7a51 --- /dev/null +++ b/pkg/remote-linters/docs/linter.go @@ -0,0 +1,176 @@ +package docs + +import ( + "bytes" + "cmp" + "context" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "text/tabwriter" + + "github.com/deckhouse/dmt/internal/flags" + "github.com/deckhouse/dmt/pkg" + "github.com/deckhouse/dmt/pkg/errors" + "github.com/deckhouse/dmt/pkg/remote-linters/docs/rules" + "github.com/fatih/color" + "github.com/kyokomi/emoji" + "github.com/mitchellh/go-wordwrap" +) + +// LinterID is the stable identifier used to reference this linter in configuration and diagnostics. +const LinterID = "docs" + +// Linter runs documentation rules against a package directory. +type Linter struct { + config Config + errorList *errors.LintRuleErrorsList +} + +// Config holds the path and settings required to construct a Linter. +type Config struct { + Path string +} + +// NewLinter constructs a Linter from cfg, scoping its diagnostics to this linter and capping severity at the configured level. +func NewLinter(cfg Config, errorList *errors.LintRuleErrorsList) *Linter { + return &Linter{ + config: cfg, + errorList: errorList.WithLinterID(LinterID), + } +} + +// Lint executes all documentation rules against the configured package path. +func (l *Linter) Lint(ctx context.Context) { + if !hasDocsDir(l.config.Path) { + l.errorList.WithFilePath(l.config.Path).Warn("docs folder not found in package root") + return + } + + rules.NewReadmeRule(l.config.Path, l.errorList).Check(ctx) + // rules.NewBilingualRule(l.config.Path).Check(ctx) + // rules.NewCyrillicInEnglishRule(l.config.Path).Check(ctx) +} + +// hasDocsDir reports whether docs/ exists as a directory in the package root. +func hasDocsDir(path string) bool { + info, err := os.Stat(filepath.Join(path, "docs")) + return err == nil && info.IsDir() +} + +func PrintResult(errorList *errors.LintRuleErrorsList) { + errs := errorList.GetErrors() + + if len(errs) == 0 { + return + } + + slices.SortFunc(errs, func(a, b pkg.LinterError) int { + return cmp.Or( + cmp.Compare(a.Level, b.Level), + cmp.Compare(a.LinterID, b.LinterID), + cmp.Compare(a.RuleID, b.RuleID), + ) + }) + + w := new(tabwriter.Writer) + + const minWidth = 5 + + buf := bytes.NewBuffer([]byte{}) + w.Init(buf, minWidth, 0, 0, ' ', 0) + + for idx := range errs { + err := errs[idx] + + msgColor := color.FgRed + + if err.Level == pkg.Ignored { + // TODO: make it not global + if !flags.ShowIgnored { + continue + } + + msgColor = color.FgWhite + } + + if err.Level == pkg.Warn { + // TODO: make it not global + if flags.HideWarnings { + continue + } + + msgColor = color.FgHiYellow + } + + // header + fmt.Fprint(w, emoji.Sprintf(":monkey:")) + fmt.Fprint(w, color.New(color.FgHiBlue).SprintFunc()("[")) + + if err.RuleID != "" { + fmt.Fprint(w, color.New(color.FgHiBlue).SprintFunc()(err.RuleID+" ")) + } + + fmt.Fprintf(w, "%s\n", color.New(color.FgHiBlue).SprintfFunc()("(#%s)]", err.LinterID)) + + // body + fmt.Fprintf(w, "\t%s\t\t%s\n", "Message:", color.New(msgColor).SprintfFunc()(prepareString(err.Text))) + + if err.ObjectID != "" && err.ObjectID != err.ModuleID { + fmt.Fprintf(w, "\t%s\t\t%s\n", "Object:", err.ObjectID) + } + + if err.ObjectValue != nil { + value := fmt.Sprintf("%v", err.ObjectValue) + + fmt.Fprintf(w, "\t%s\t\t%s\n", "Value:", prepareString(value)) + } + + if err.FilePath != "" { + fmt.Fprintf(w, "\t%s\t\t%s\n", "FilePath:", strings.TrimSpace(err.FilePath)) + } + + if err.LineNumber != 0 { + fmt.Fprintf(w, "\t%s\t\t%d\n", "LineNumber:", err.LineNumber) + } + + if err.FixError != nil { + fmt.Fprintf(w, "\t%s\t\t%s\n", "AutofixError:", color.New(color.FgHiYellow).Sprint(err.FixError.Error())) + } + + // if flags.ShowDocumentation { + // docURL := generateDocumentationURL(err.LinterID, err.RuleID) + // if docURL != "" { + // fmt.Fprintf(w, "\t%s\t\t%s\n", "Documentation:", docURL) + // } + // } + + fmt.Fprintln(w) + + w.Flush() + } + + fmt.Println(buf.String()) +} + +// prepareString handle ussual string and prepare it for tablewriter +func prepareString(input string) string { + // magic wrap const + const wrapLen = 100 + + w := &strings.Builder{} + + // split wraps for tablewrite + split := strings.Split(wordwrap.WrapString(input, wrapLen), "\n") + + // first string must be pure for correct handling + fmt.Fprint(w, strings.TrimSpace(split[0])) + + for i := 1; i < len(split); i++ { + fmt.Fprintf(w, "\n\t\t\t%s", strings.TrimSpace(split[i])) + } + + return w.String() +} diff --git a/pkg/remote-linters/docs/rules/readme.go b/pkg/remote-linters/docs/rules/readme.go new file mode 100644 index 00000000..f59430ef --- /dev/null +++ b/pkg/remote-linters/docs/rules/readme.go @@ -0,0 +1,58 @@ +package rules + +import ( + "context" + "os" + "path/filepath" + + "github.com/deckhouse/dmt/pkg/errors" +) + +// Rule purpose: require a non-empty docs/README.md as the package documentation entry point. + +// ReadmeRuleID is the stable identifier used to reference this rule in configuration. +const ReadmeRuleID = "readme" + +// ReadmeRule enforces that docs/README.md exists and is not empty. +type ReadmeRule struct { + path string + errorList *errors.LintRuleErrorsList +} + +// NewReadmeRule constructs a ReadmeRule scoped to path, tagging diagnostics with the rule ID. +func NewReadmeRule(path string, errorList *errors.LintRuleErrorsList) *ReadmeRule { + return &ReadmeRule{ + path: path, + errorList: errorList.WithRule(ReadmeRuleID), + } +} + +// Check verifies that docs/README.md exists and has content. +func (r *ReadmeRule) Check(_ context.Context) { + path := filepath.Join(r.path, "docs", "README.md") + + info, err := os.Stat(path) + if os.IsNotExist(err) { + r.errorList. + WithFilePath(path). + WithValue(err.Error()). + Error("README.md file is missing in docs/ directory") + return + } + + if err != nil { + r.errorList. + WithFilePath(path). + WithValue(err.Error()). + Error("failed to check README.md file") + + return + } + + if info.Size() == 0 { + r.errorList. + WithFilePath(path). + WithValue("file is empty"). + Error("README.md file is empty") + } +} From e8ff0404125ab144f82aa06dc0f1b1a7127f971a Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Tue, 14 Jul 2026 12:32:27 +0300 Subject: [PATCH 04/26] fix lint Signed-off-by: Smyslov Maxim --- internal/remote-lint/lint.go | 12 ++++-------- internal/remote-lint/registry-client.go | 5 +++-- pkg/remote-linters/docs/linter.go | 7 ++++--- pkg/remote-linters/docs/rules/readme.go | 1 + 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/internal/remote-lint/lint.go b/internal/remote-lint/lint.go index c0aa9366..132c8b63 100644 --- a/internal/remote-lint/lint.go +++ b/internal/remote-lint/lint.go @@ -41,12 +41,9 @@ func RunRemoteLint(ctx context.Context, imagePath string, opts *RemoteLintOption errorList := errors.NewLintRuleErrorsList() // .WithMaxLevel() - linters, err := buildLinters(ctx, tempDir, errorList) - if err != nil { - return fmt.Errorf("failed to build linters: %w", err) - } + linters := buildLinters(tempDir, errorList) - os.Remove(filepath.Join(tempDir, "docs", "README.md")) + os.Remove(filepath.Join(tempDir, "docs", "README.md")) // debug: delete this line for _, linter := range linters { linter.Lint(ctx) @@ -75,11 +72,10 @@ type Linter interface { Lint(ctx context.Context) } -func buildLinters(_ context.Context, path string, errorList *errors.LintRuleErrorsList) ([]Linter, error) { - +func buildLinters(path string, errorList *errors.LintRuleErrorsList) []Linter { docsLinter := docs.NewLinter(docs.Config{Path: path}, errorList) return []Linter{ docsLinter, - }, nil + } } diff --git a/internal/remote-lint/registry-client.go b/internal/remote-lint/registry-client.go index dbda635b..a3c3ce7e 100644 --- a/internal/remote-lint/registry-client.go +++ b/internal/remote-lint/registry-client.go @@ -3,10 +3,11 @@ package remotelint import ( "log/slog" - "github.com/deckhouse/deckhouse/pkg/log" - regclient "github.com/deckhouse/deckhouse/pkg/registry/client" "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 { diff --git a/pkg/remote-linters/docs/linter.go b/pkg/remote-linters/docs/linter.go index 786b7a51..a9c060c8 100644 --- a/pkg/remote-linters/docs/linter.go +++ b/pkg/remote-linters/docs/linter.go @@ -11,13 +11,14 @@ import ( "strings" "text/tabwriter" + "github.com/fatih/color" + "github.com/kyokomi/emoji" + "github.com/mitchellh/go-wordwrap" + "github.com/deckhouse/dmt/internal/flags" "github.com/deckhouse/dmt/pkg" "github.com/deckhouse/dmt/pkg/errors" "github.com/deckhouse/dmt/pkg/remote-linters/docs/rules" - "github.com/fatih/color" - "github.com/kyokomi/emoji" - "github.com/mitchellh/go-wordwrap" ) // LinterID is the stable identifier used to reference this linter in configuration and diagnostics. diff --git a/pkg/remote-linters/docs/rules/readme.go b/pkg/remote-linters/docs/rules/readme.go index f59430ef..0431afef 100644 --- a/pkg/remote-linters/docs/rules/readme.go +++ b/pkg/remote-linters/docs/rules/readme.go @@ -37,6 +37,7 @@ func (r *ReadmeRule) Check(_ context.Context) { WithFilePath(path). WithValue(err.Error()). Error("README.md file is missing in docs/ directory") + return } From 437f3c0888fbd5266e68285682bdbc60fc9a02e2 Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Tue, 14 Jul 2026 12:40:50 +0300 Subject: [PATCH 05/26] fix Signed-off-by: Smyslov Maxim --- internal/remote-lint/lint.go | 127 +++++++++++++++++++++++++++++- pkg/remote-linters/docs/linter.go | 127 ------------------------------ 2 files changed, 126 insertions(+), 128 deletions(-) diff --git a/internal/remote-lint/lint.go b/internal/remote-lint/lint.go index 132c8b63..9ae84c1b 100644 --- a/internal/remote-lint/lint.go +++ b/internal/remote-lint/lint.go @@ -1,12 +1,22 @@ package remotelint import ( + "bytes" + "cmp" "context" "fmt" "os" "path/filepath" + "slices" "strings" + "text/tabwriter" + "github.com/fatih/color" + "github.com/kyokomi/emoji" + "github.com/mitchellh/go-wordwrap" + + "github.com/deckhouse/dmt/internal/flags" + "github.com/deckhouse/dmt/pkg" "github.com/deckhouse/dmt/pkg/errors" "github.com/deckhouse/dmt/pkg/remote-linters/docs" ) @@ -49,7 +59,7 @@ func RunRemoteLint(ctx context.Context, imagePath string, opts *RemoteLintOption linter.Lint(ctx) } - docs.PrintResult(errorList) + PrintResult(errorList) return nil } @@ -79,3 +89,118 @@ func buildLinters(path string, errorList *errors.LintRuleErrorsList) []Linter { docsLinter, } } + +func PrintResult(errorList *errors.LintRuleErrorsList) { + errs := errorList.GetErrors() + + if len(errs) == 0 { + return + } + + slices.SortFunc(errs, func(a, b pkg.LinterError) int { + return cmp.Or( + cmp.Compare(a.Level, b.Level), + cmp.Compare(a.LinterID, b.LinterID), + cmp.Compare(a.RuleID, b.RuleID), + ) + }) + + w := new(tabwriter.Writer) + + const minWidth = 5 + + buf := bytes.NewBuffer([]byte{}) + w.Init(buf, minWidth, 0, 0, ' ', 0) + + for idx := range errs { + err := errs[idx] + + msgColor := color.FgRed + + if err.Level == pkg.Ignored { + // TODO: make it not global + if !flags.ShowIgnored { + continue + } + + msgColor = color.FgWhite + } + + if err.Level == pkg.Warn { + // TODO: make it not global + if flags.HideWarnings { + continue + } + + msgColor = color.FgHiYellow + } + + // header + fmt.Fprint(w, emoji.Sprintf(":monkey:")) + fmt.Fprint(w, color.New(color.FgHiBlue).SprintFunc()("[")) + + if err.RuleID != "" { + fmt.Fprint(w, color.New(color.FgHiBlue).SprintFunc()(err.RuleID+" ")) + } + + fmt.Fprintf(w, "%s\n", color.New(color.FgHiBlue).SprintfFunc()("(#%s)]", err.LinterID)) + + // body + fmt.Fprintf(w, "\t%s\t\t%s\n", "Message:", color.New(msgColor).SprintfFunc()(prepareString(err.Text))) + + if err.ObjectID != "" && err.ObjectID != err.ModuleID { + fmt.Fprintf(w, "\t%s\t\t%s\n", "Object:", err.ObjectID) + } + + if err.ObjectValue != nil { + value := fmt.Sprintf("%v", err.ObjectValue) + + fmt.Fprintf(w, "\t%s\t\t%s\n", "Value:", prepareString(value)) + } + + if err.FilePath != "" { + fmt.Fprintf(w, "\t%s\t\t%s\n", "FilePath:", strings.TrimSpace(err.FilePath)) + } + + if err.LineNumber != 0 { + fmt.Fprintf(w, "\t%s\t\t%d\n", "LineNumber:", err.LineNumber) + } + + if err.FixError != nil { + fmt.Fprintf(w, "\t%s\t\t%s\n", "AutofixError:", color.New(color.FgHiYellow).Sprint(err.FixError.Error())) + } + + // if flags.ShowDocumentation { + // docURL := generateDocumentationURL(err.LinterID, err.RuleID) + // if docURL != "" { + // fmt.Fprintf(w, "\t%s\t\t%s\n", "Documentation:", docURL) + // } + // } + + fmt.Fprintln(w) + + w.Flush() + } + + fmt.Println(buf.String()) +} + +// prepareString handle ussual string and prepare it for tablewriter +func prepareString(input string) string { + // magic wrap const + const wrapLen = 100 + + w := &strings.Builder{} + + // split wraps for tablewrite + split := strings.Split(wordwrap.WrapString(input, wrapLen), "\n") + + // first string must be pure for correct handling + fmt.Fprint(w, strings.TrimSpace(split[0])) + + for i := 1; i < len(split); i++ { + fmt.Fprintf(w, "\n\t\t\t%s", strings.TrimSpace(split[i])) + } + + return w.String() +} diff --git a/pkg/remote-linters/docs/linter.go b/pkg/remote-linters/docs/linter.go index a9c060c8..35be577f 100644 --- a/pkg/remote-linters/docs/linter.go +++ b/pkg/remote-linters/docs/linter.go @@ -1,22 +1,10 @@ package docs import ( - "bytes" - "cmp" "context" - "fmt" "os" "path/filepath" - "slices" - "strings" - "text/tabwriter" - "github.com/fatih/color" - "github.com/kyokomi/emoji" - "github.com/mitchellh/go-wordwrap" - - "github.com/deckhouse/dmt/internal/flags" - "github.com/deckhouse/dmt/pkg" "github.com/deckhouse/dmt/pkg/errors" "github.com/deckhouse/dmt/pkg/remote-linters/docs/rules" ) @@ -60,118 +48,3 @@ func hasDocsDir(path string) bool { info, err := os.Stat(filepath.Join(path, "docs")) return err == nil && info.IsDir() } - -func PrintResult(errorList *errors.LintRuleErrorsList) { - errs := errorList.GetErrors() - - if len(errs) == 0 { - return - } - - slices.SortFunc(errs, func(a, b pkg.LinterError) int { - return cmp.Or( - cmp.Compare(a.Level, b.Level), - cmp.Compare(a.LinterID, b.LinterID), - cmp.Compare(a.RuleID, b.RuleID), - ) - }) - - w := new(tabwriter.Writer) - - const minWidth = 5 - - buf := bytes.NewBuffer([]byte{}) - w.Init(buf, minWidth, 0, 0, ' ', 0) - - for idx := range errs { - err := errs[idx] - - msgColor := color.FgRed - - if err.Level == pkg.Ignored { - // TODO: make it not global - if !flags.ShowIgnored { - continue - } - - msgColor = color.FgWhite - } - - if err.Level == pkg.Warn { - // TODO: make it not global - if flags.HideWarnings { - continue - } - - msgColor = color.FgHiYellow - } - - // header - fmt.Fprint(w, emoji.Sprintf(":monkey:")) - fmt.Fprint(w, color.New(color.FgHiBlue).SprintFunc()("[")) - - if err.RuleID != "" { - fmt.Fprint(w, color.New(color.FgHiBlue).SprintFunc()(err.RuleID+" ")) - } - - fmt.Fprintf(w, "%s\n", color.New(color.FgHiBlue).SprintfFunc()("(#%s)]", err.LinterID)) - - // body - fmt.Fprintf(w, "\t%s\t\t%s\n", "Message:", color.New(msgColor).SprintfFunc()(prepareString(err.Text))) - - if err.ObjectID != "" && err.ObjectID != err.ModuleID { - fmt.Fprintf(w, "\t%s\t\t%s\n", "Object:", err.ObjectID) - } - - if err.ObjectValue != nil { - value := fmt.Sprintf("%v", err.ObjectValue) - - fmt.Fprintf(w, "\t%s\t\t%s\n", "Value:", prepareString(value)) - } - - if err.FilePath != "" { - fmt.Fprintf(w, "\t%s\t\t%s\n", "FilePath:", strings.TrimSpace(err.FilePath)) - } - - if err.LineNumber != 0 { - fmt.Fprintf(w, "\t%s\t\t%d\n", "LineNumber:", err.LineNumber) - } - - if err.FixError != nil { - fmt.Fprintf(w, "\t%s\t\t%s\n", "AutofixError:", color.New(color.FgHiYellow).Sprint(err.FixError.Error())) - } - - // if flags.ShowDocumentation { - // docURL := generateDocumentationURL(err.LinterID, err.RuleID) - // if docURL != "" { - // fmt.Fprintf(w, "\t%s\t\t%s\n", "Documentation:", docURL) - // } - // } - - fmt.Fprintln(w) - - w.Flush() - } - - fmt.Println(buf.String()) -} - -// prepareString handle ussual string and prepare it for tablewriter -func prepareString(input string) string { - // magic wrap const - const wrapLen = 100 - - w := &strings.Builder{} - - // split wraps for tablewrite - split := strings.Split(wordwrap.WrapString(input, wrapLen), "\n") - - // first string must be pure for correct handling - fmt.Fprint(w, strings.TrimSpace(split[0])) - - for i := 1; i < len(split); i++ { - fmt.Fprintf(w, "\n\t\t\t%s", strings.TrimSpace(split[i])) - } - - return w.String() -} From 1ee267cf27f88c3a8fa8e6b03e7a7e93a961031a Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Tue, 14 Jul 2026 13:06:46 +0300 Subject: [PATCH 06/26] add release linting Signed-off-by: Smyslov Maxim --- internal/remote-lint/lint.go | 59 +++++++++++++++++-- .../{ => bundle}/docs/linter.go | 2 +- .../{ => bundle}/docs/rules/readme.go | 0 pkg/remote-linters/release/docs/linter.go | 35 +++++++++++ .../release/docs/rules/changelog.go | 59 +++++++++++++++++++ 5 files changed, 148 insertions(+), 7 deletions(-) rename pkg/remote-linters/{ => bundle}/docs/linter.go (95%) rename pkg/remote-linters/{ => bundle}/docs/rules/readme.go (100%) create mode 100644 pkg/remote-linters/release/docs/linter.go create mode 100644 pkg/remote-linters/release/docs/rules/changelog.go diff --git a/internal/remote-lint/lint.go b/internal/remote-lint/lint.go index 9ae84c1b..0b532e46 100644 --- a/internal/remote-lint/lint.go +++ b/internal/remote-lint/lint.go @@ -15,10 +15,12 @@ import ( "github.com/kyokomi/emoji" "github.com/mitchellh/go-wordwrap" + "github.com/deckhouse/deckhouse/pkg/registry/client" "github.com/deckhouse/dmt/internal/flags" "github.com/deckhouse/dmt/pkg" "github.com/deckhouse/dmt/pkg/errors" - "github.com/deckhouse/dmt/pkg/remote-linters/docs" + "github.com/deckhouse/dmt/pkg/remote-linters/bundle/docs" + releaseDocs "github.com/deckhouse/dmt/pkg/remote-linters/release/docs" ) type RemoteLintOptions struct { @@ -38,6 +40,24 @@ func RunRemoteLint(ctx context.Context, imagePath string, opts *RemoteLintOption client := initRegistryClient(registryPath, opts.Login, opts.Password) + errorList := errors.NewLintRuleErrorsList() // .WithMaxLevel() + + err = lintBundle(ctx, client, tag, errorList) + if err != nil { + return fmt.Errorf("failed to lint bundle: %w", err) + } + + err = lintRelease(ctx, client, tag, errorList) + if err != nil { + return fmt.Errorf("failed to lint release: %w", err) + } + + PrintResult(errorList) + + return nil +} + +func lintBundle(ctx context.Context, client *client.Client, tag string, errorList *errors.LintRuleErrorsList) error { image, err := client.GetImage(ctx, tag) if err != nil { return fmt.Errorf("failed to get image: %w", err) @@ -49,9 +69,7 @@ func RunRemoteLint(ctx context.Context, imagePath string, opts *RemoteLintOption } defer os.RemoveAll(tempDir) - errorList := errors.NewLintRuleErrorsList() // .WithMaxLevel() - - linters := buildLinters(tempDir, errorList) + linters := buildBundleLinters(tempDir, errorList.WithObjectID("bundle")) os.Remove(filepath.Join(tempDir, "docs", "README.md")) // debug: delete this line @@ -59,7 +77,28 @@ func RunRemoteLint(ctx context.Context, imagePath string, opts *RemoteLintOption linter.Lint(ctx) } - PrintResult(errorList) + return nil +} + +func lintRelease(ctx context.Context, client *client.Client, tag string, errorList *errors.LintRuleErrorsList) error { + releaseImage, err := client.WithSegment("release").GetImage(ctx, tag) + if err != nil { + return fmt.Errorf("failed to get release image: %w", err) + } + + tempReleaseDir, err := ExtractImage(ctx, releaseImage) + if err != nil { + return fmt.Errorf("failed to extract release image: %w", err) + } + defer os.RemoveAll(tempReleaseDir) + + os.Remove(filepath.Join(tempReleaseDir, "changelog.yaml")) // debug: delete this line + + linters := buildReleaseLinters(tempReleaseDir, errorList.WithObjectID("release")) + + for _, linter := range linters { + linter.Lint(ctx) + } return nil } @@ -82,7 +121,7 @@ type Linter interface { Lint(ctx context.Context) } -func buildLinters(path string, errorList *errors.LintRuleErrorsList) []Linter { +func buildBundleLinters(path string, errorList *errors.LintRuleErrorsList) []Linter { docsLinter := docs.NewLinter(docs.Config{Path: path}, errorList) return []Linter{ @@ -90,6 +129,14 @@ func buildLinters(path string, errorList *errors.LintRuleErrorsList) []Linter { } } +func buildReleaseLinters(path string, errorList *errors.LintRuleErrorsList) []Linter { + docsLinter := releaseDocs.NewLinter(releaseDocs.Config{Path: path}, errorList) + + return []Linter{ + docsLinter, + } +} + func PrintResult(errorList *errors.LintRuleErrorsList) { errs := errorList.GetErrors() diff --git a/pkg/remote-linters/docs/linter.go b/pkg/remote-linters/bundle/docs/linter.go similarity index 95% rename from pkg/remote-linters/docs/linter.go rename to pkg/remote-linters/bundle/docs/linter.go index 35be577f..632cc743 100644 --- a/pkg/remote-linters/docs/linter.go +++ b/pkg/remote-linters/bundle/docs/linter.go @@ -6,7 +6,7 @@ import ( "path/filepath" "github.com/deckhouse/dmt/pkg/errors" - "github.com/deckhouse/dmt/pkg/remote-linters/docs/rules" + "github.com/deckhouse/dmt/pkg/remote-linters/bundle/docs/rules" ) // LinterID is the stable identifier used to reference this linter in configuration and diagnostics. diff --git a/pkg/remote-linters/docs/rules/readme.go b/pkg/remote-linters/bundle/docs/rules/readme.go similarity index 100% rename from pkg/remote-linters/docs/rules/readme.go rename to pkg/remote-linters/bundle/docs/rules/readme.go diff --git a/pkg/remote-linters/release/docs/linter.go b/pkg/remote-linters/release/docs/linter.go new file mode 100644 index 00000000..d09b5d97 --- /dev/null +++ b/pkg/remote-linters/release/docs/linter.go @@ -0,0 +1,35 @@ +package docs + +import ( + "context" + + "github.com/deckhouse/dmt/pkg/errors" + "github.com/deckhouse/dmt/pkg/remote-linters/release/docs/rules" +) + +// LinterID is the stable identifier used to reference this linter in configuration and diagnostics. +const LinterID = "docs" + +// Linter runs documentation rules against a package directory. +type Linter struct { + config Config + errorList *errors.LintRuleErrorsList +} + +// Config holds the path and settings required to construct a Linter. +type Config struct { + Path string +} + +// NewLinter constructs a Linter from cfg, scoping its diagnostics to this linter and capping severity at the configured level. +func NewLinter(cfg Config, errorList *errors.LintRuleErrorsList) *Linter { + return &Linter{ + config: cfg, + errorList: errorList.WithLinterID(LinterID), + } +} + +// Lint executes all documentation rules against the configured package path. +func (l *Linter) Lint(ctx context.Context) { + rules.NewChangelogRule(l.config.Path, l.errorList).Check(ctx) +} diff --git a/pkg/remote-linters/release/docs/rules/changelog.go b/pkg/remote-linters/release/docs/rules/changelog.go new file mode 100644 index 00000000..0633058a --- /dev/null +++ b/pkg/remote-linters/release/docs/rules/changelog.go @@ -0,0 +1,59 @@ +package rules + +import ( + "context" + "os" + "path/filepath" + + "github.com/deckhouse/dmt/pkg/errors" +) + +// Rule purpose: require a non-empty changelog.yaml as the release changelog entry point. + +// ChangelogRuleID is the stable identifier used to reference this rule in configuration. +const ChangelogRuleID = "changelog" + +// ChangelogRule enforces that changelog.yaml exists and is not empty. +type ChangelogRule struct { + path string + errorList *errors.LintRuleErrorsList +} + +// NewChangelogRule constructs a ChangelogRule scoped to path, tagging diagnostics with the rule ID. +func NewChangelogRule(path string, errorList *errors.LintRuleErrorsList) *ChangelogRule { + return &ChangelogRule{ + path: path, + errorList: errorList.WithRule(ChangelogRuleID), + } +} + +// Check verifies that changelog.yaml exists and has content. +func (r *ChangelogRule) Check(_ context.Context) { + path := filepath.Join(r.path, "changelog.yaml") + + info, err := os.Stat(path) + if os.IsNotExist(err) { + r.errorList. + WithFilePath(path). + WithValue(err.Error()). + Error("changelog.yaml file is missing") + + return + } + + if err != nil { + r.errorList. + WithFilePath(path). + WithValue(err.Error()). + Error("failed to check changelog.yaml file") + + return + } + + if info.Size() == 0 { + r.errorList. + WithFilePath(path). + WithValue("file is empty"). + Error("changelog.yaml file is empty") + } +} From 0e5a4bc1934c091b3550b9a5d70e3e0b0f1bf4c8 Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Tue, 14 Jul 2026 13:08:59 +0300 Subject: [PATCH 07/26] fix lint Signed-off-by: Smyslov Maxim --- internal/remote-lint/lint.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/remote-lint/lint.go b/internal/remote-lint/lint.go index 0b532e46..96962e53 100644 --- a/internal/remote-lint/lint.go +++ b/internal/remote-lint/lint.go @@ -16,6 +16,7 @@ import ( "github.com/mitchellh/go-wordwrap" "github.com/deckhouse/deckhouse/pkg/registry/client" + "github.com/deckhouse/dmt/internal/flags" "github.com/deckhouse/dmt/pkg" "github.com/deckhouse/dmt/pkg/errors" From 22fd06c12c5ef5b237e28ca08d6a9ee14363bbd8 Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Tue, 14 Jul 2026 13:13:53 +0300 Subject: [PATCH 08/26] rm debug Signed-off-by: Smyslov Maxim --- internal/remote-lint/extract.go | 4 ---- internal/remote-lint/lint.go | 13 ++++--------- internal/remote-lint/registry-client.go | 6 ------ 3 files changed, 4 insertions(+), 19 deletions(-) diff --git a/internal/remote-lint/extract.go b/internal/remote-lint/extract.go index affbc8c4..f2bcdba6 100644 --- a/internal/remote-lint/extract.go +++ b/internal/remote-lint/extract.go @@ -5,12 +5,10 @@ import ( "context" "fmt" "io" - "log/slog" "os" "path/filepath" "strings" - "github.com/deckhouse/deckhouse/pkg/log" "github.com/deckhouse/deckhouse/pkg/registry" ) @@ -21,8 +19,6 @@ func ExtractImage(ctx context.Context, image registry.Image) (string, error) { return "", fmt.Errorf("failed to create temp directory: %w", err) } - log.Info("extracting image to temp directory", slog.String("tempDir", tempDir)) - rc := image.Extract() defer rc.Close() diff --git a/internal/remote-lint/lint.go b/internal/remote-lint/lint.go index 96962e53..ed76778a 100644 --- a/internal/remote-lint/lint.go +++ b/internal/remote-lint/lint.go @@ -6,7 +6,6 @@ import ( "context" "fmt" "os" - "path/filepath" "slices" "strings" "text/tabwriter" @@ -72,8 +71,6 @@ func lintBundle(ctx context.Context, client *client.Client, tag string, errorLis linters := buildBundleLinters(tempDir, errorList.WithObjectID("bundle")) - os.Remove(filepath.Join(tempDir, "docs", "README.md")) // debug: delete this line - for _, linter := range linters { linter.Lint(ctx) } @@ -82,20 +79,18 @@ func lintBundle(ctx context.Context, client *client.Client, tag string, errorLis } func lintRelease(ctx context.Context, client *client.Client, tag string, errorList *errors.LintRuleErrorsList) error { - releaseImage, err := client.WithSegment("release").GetImage(ctx, tag) + image, err := client.WithSegment("release").GetImage(ctx, tag) if err != nil { return fmt.Errorf("failed to get release image: %w", err) } - tempReleaseDir, err := ExtractImage(ctx, releaseImage) + tempDir, err := ExtractImage(ctx, image) if err != nil { return fmt.Errorf("failed to extract release image: %w", err) } - defer os.RemoveAll(tempReleaseDir) - - os.Remove(filepath.Join(tempReleaseDir, "changelog.yaml")) // debug: delete this line + defer os.RemoveAll(tempDir) - linters := buildReleaseLinters(tempReleaseDir, errorList.WithObjectID("release")) + linters := buildReleaseLinters(tempDir, errorList.WithObjectID("release")) for _, linter := range linters { linter.Lint(ctx) diff --git a/internal/remote-lint/registry-client.go b/internal/remote-lint/registry-client.go index a3c3ce7e..3d3386ab 100644 --- a/internal/remote-lint/registry-client.go +++ b/internal/remote-lint/registry-client.go @@ -45,12 +45,6 @@ func dockerConfigAuth(registryHost string) (authn.Authenticator, bool) { return nil, false } - log.Warn("debug dockerConfigAuth", slog.String( - "registryHost", registryHost), - slog.String("ref", ref.String()), - slog.String("registry", ref.Context().RegistryStr()), - ) - reg, err := name.NewRegistry(ref.Context().RegistryStr()) if err != nil { return nil, false From 19004ab171f52bdf16a7897278f825207e541ac2 Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Tue, 14 Jul 2026 14:06:05 +0300 Subject: [PATCH 09/26] add test Signed-off-by: Smyslov Maxim --- internal/remote-lint/lint_test.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/internal/remote-lint/lint_test.go b/internal/remote-lint/lint_test.go index ae61cd39..88714352 100644 --- a/internal/remote-lint/lint_test.go +++ b/internal/remote-lint/lint_test.go @@ -2,7 +2,23 @@ package remotelint import ( "testing" + + "github.com/stretchr/testify/require" ) -func TestRunRemoteLint(t *testing.T) { +func TestCutTagFromImagePath(t *testing.T) { + 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) + + repository, tag, err = cutTagFromImagePath("registry.example.com/deckhouse/my-module@sha256:1234567890") + require.NoError(t, err) + require.Equal(t, "registry.example.com/deckhouse/my-module", repository) + require.Equal(t, "sha256:1234567890", tag) + + repository, tag, err = cutTagFromImagePath("registry.example.com/deckhouse/my-module") + require.Contains(t, err.Error(), "tag not found in image path") + require.Empty(t, repository) + require.Empty(t, tag) } From 936415d0700953950c60b843623cced9e63949cb Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Tue, 14 Jul 2026 14:06:47 +0300 Subject: [PATCH 10/26] complete docs linter Signed-off-by: Smyslov Maxim --- pkg/remote-linters/bundle/docs/linter.go | 4 +- .../bundle/docs/rules/bilingual.go | 90 +++++++++++ .../bundle/docs/rules/cyrillic_in_english.go | 142 ++++++++++++++++++ pkg/remote-linters/bundle/docs/rules/files.go | 46 ++++++ 4 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 pkg/remote-linters/bundle/docs/rules/bilingual.go create mode 100644 pkg/remote-linters/bundle/docs/rules/cyrillic_in_english.go create mode 100644 pkg/remote-linters/bundle/docs/rules/files.go diff --git a/pkg/remote-linters/bundle/docs/linter.go b/pkg/remote-linters/bundle/docs/linter.go index 632cc743..a22492d5 100644 --- a/pkg/remote-linters/bundle/docs/linter.go +++ b/pkg/remote-linters/bundle/docs/linter.go @@ -39,8 +39,8 @@ func (l *Linter) Lint(ctx context.Context) { } rules.NewReadmeRule(l.config.Path, l.errorList).Check(ctx) - // rules.NewBilingualRule(l.config.Path).Check(ctx) - // rules.NewCyrillicInEnglishRule(l.config.Path).Check(ctx) + rules.NewBilingualRule(l.config.Path, l.errorList).Check(ctx) + rules.NewCyrillicInEnglishRule(l.config.Path, l.errorList).Check(ctx) } // hasDocsDir reports whether docs/ exists as a directory in the package root. diff --git a/pkg/remote-linters/bundle/docs/rules/bilingual.go b/pkg/remote-linters/bundle/docs/rules/bilingual.go new file mode 100644 index 00000000..8cd3d00a --- /dev/null +++ b/pkg/remote-linters/bundle/docs/rules/bilingual.go @@ -0,0 +1,90 @@ +package rules + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/deckhouse/dmt/pkg/errors" +) + +// Rule purpose: require Russian translations for each top-level English markdown file in docs/. + +// BilingualRuleID is the stable identifier used to reference this rule in configuration. +const BilingualRuleID = "bilingual" + +// Russian documentation suffixes accepted by the bilingual rule. +const ( + ruSuffix = ".ru.md" + ruFallbackSuffix = "_ru.md" +) + +// BilingualRule enforces that top-level English docs have Russian counterparts. +type BilingualRule struct { + errorList *errors.LintRuleErrorsList + path string +} + +// NewBilingualRule constructs a BilingualRule scoped to path, tagging diagnostics with the rule ID. +func NewBilingualRule(path string, errorList *errors.LintRuleErrorsList) *BilingualRule { + return &BilingualRule{ + path: path, + errorList: errorList.WithRule(BilingualRuleID), + } +} + +// Check verifies that each top-level English markdown file in docs/ has a Russian translation. +func (r *BilingualRule) Check(_ context.Context) { + docsPath := filepath.Join(r.path, "docs") + if _, err := os.Stat(docsPath); err != nil { + return + } + + files, err := collectFiles(docsPath, ".md") + if err != nil { + r.errorList. + WithFilePath(docsPath). + WithValue(err.Error()). + Error("cannot read docs directory") + + return + } + + fileSet := make(map[string]struct{}, len(files)) + for _, filePath := range files { + rel := packageRelativePath(r.path, filePath) + if filepath.Dir(rel) != "docs" { + continue + } + + if strings.HasSuffix(strings.ToLower(rel), ruFallbackSuffix) { + rel = strings.ToLower(rel) + } + + fileSet[rel] = struct{}{} + } + + for rel := range fileSet { + if !strings.HasPrefix(rel, "docs/") { + continue + } + + if !strings.HasSuffix(rel, ".md") || strings.HasSuffix(rel, ruFallbackSuffix) || strings.HasSuffix(rel, ruSuffix) { + continue + } + + base := strings.TrimSuffix(rel, ".md") + + // TODO: Delete it after renaming to .ru.md view. + ruRelFallback := strings.ToLower(base) + ruFallbackSuffix + if _, ok := fileSet[ruRelFallback]; ok { + continue + } + + ruRel := base + ruSuffix + if _, ok := fileSet[ruRel]; !ok { + r.errorList.WithFilePath(rel).Error("Russian counterpart is missing: need to create a matching .ru.md in docs/") + } + } +} diff --git a/pkg/remote-linters/bundle/docs/rules/cyrillic_in_english.go b/pkg/remote-linters/bundle/docs/rules/cyrillic_in_english.go new file mode 100644 index 00000000..1daa195b --- /dev/null +++ b/pkg/remote-linters/bundle/docs/rules/cyrillic_in_english.go @@ -0,0 +1,142 @@ +package rules + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/deckhouse/dmt/pkg/errors" +) + +// Rule purpose: prevent cyrillic characters from appearing in English documentation files. + +// CyrillicInEnglishRuleID is the stable identifier used to reference this rule in configuration. +const CyrillicInEnglishRuleID = "cyrillic-in-english" + +var ( + // cyrillicWordPattern matches cyrillic words in English documentation. + cyrillicWordPattern = regexp.MustCompile(`[А-Яа-яЁё]+`) + // cyrillicCharPattern marks cyrillic characters in diagnostics. + cyrillicCharPattern = regexp.MustCompile(`[А-Яа-яЁё]`) + // nonCyrillicCharPattern replaces non-cyrillic characters in diagnostics. + nonCyrillicCharPattern = regexp.MustCompile(`[^А-Яа-яЁё]`) + // russianDocPattern matches canonical Russian documentation files. + russianDocPattern = regexp.MustCompile(`\.ru\.md$`) + // russianDocFallbackPattern matches legacy Russian documentation files. + russianDocFallbackPattern = regexp.MustCompile(`(?i)_ru\.md$`) +) + +// CyrillicInEnglishRule enforces that English documentation does not contain cyrillic text. +type CyrillicInEnglishRule struct { + errorList *errors.LintRuleErrorsList + path string +} + +// NewCyrillicInEnglishRule constructs a CyrillicInEnglishRule scoped to path, tagging diagnostics with the rule ID. +func NewCyrillicInEnglishRule(path string, errorList *errors.LintRuleErrorsList) *CyrillicInEnglishRule { + return &CyrillicInEnglishRule{ + path: path, + errorList: errorList.WithRule(CyrillicInEnglishRuleID), + } +} + +// Check scans top-level English markdown files in docs/ for cyrillic characters. +func (r *CyrillicInEnglishRule) Check(_ context.Context) { + docsPath := filepath.Join(r.path, "docs") + + files, err := collectFiles(docsPath, ".md", ".markdown") + if err != nil { + if os.IsNotExist(err) { + return + } + + r.errorList. + WithFilePath(docsPath). + WithValue(err.Error()). + Error("cannot read docs directory") + + return + } + + for _, filePath := range files { + rel := packageRelativePath(r.path, filePath) + if filepath.Dir(rel) != "docs" { + continue + } + + r.checkFile(filePath) + } +} + +// checkFile reports cyrillic text found in one English documentation file. +func (r *CyrillicInEnglishRule) checkFile(filePath string) { + relPath := packageRelativePath(r.path, filePath) + if russianDocPattern.MatchString(filePath) { + return + } + + if russianDocFallbackPattern.MatchString(filePath) { + return + } + + lines, err := getFileContent(filePath) + if err != nil { + r.errorList. + WithFilePath(relPath). + WithValue(err.Error()). + Error("failed to read file") + + return + } + + cyrMsg, hasCyr := checkCyrillicLettersInArray(lines) + if hasCyr { + r.errorList.WithFilePath(relPath).WithValue(cyrMsg).Error("English documentation contains cyrillic characters") + } +} + +// getFileContent reads filename and returns it split into lines. +func getFileContent(filename string) ([]string, error) { + fileBytes, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + + return strings.Split(string(fileBytes), "\n"), nil +} + +// checkCyrillicLettersInString returns a diagnostic snippet for line when it contains cyrillic text. +func checkCyrillicLettersInString(line string) (string, bool) { + if !cyrillicWordPattern.MatchString(line) { + return "", false + } + + line = strings.TrimSpace(line) + + cursor := nonCyrillicCharPattern.ReplaceAllString(line, "-") + cursor = cyrillicCharPattern.ReplaceAllString(cursor, "^") + cursor = strings.TrimRight(cursor, "-") + + return line + "\n" + cursor, true +} + +// checkCyrillicLettersInArray returns diagnostic snippets for all lines containing cyrillic text. +func checkCyrillicLettersInArray(lines []string) (string, bool) { + res := make([]string, 0) + + hasCyr := false + + for i, line := range lines { + msg, has := checkCyrillicLettersInString(line) + if has { + hasCyr = true + + res = append(res, fmt.Sprintf("Line %d: %s", i+1, msg)) + } + } + + return strings.Join(res, "\n"), hasCyr +} diff --git a/pkg/remote-linters/bundle/docs/rules/files.go b/pkg/remote-linters/bundle/docs/rules/files.go new file mode 100644 index 00000000..d44b93c8 --- /dev/null +++ b/pkg/remote-linters/bundle/docs/rules/files.go @@ -0,0 +1,46 @@ +package rules + +import ( + "os" + "path/filepath" + "strings" +) + +// collectFiles returns files under root whose names have one of the provided extensions. +func collectFiles(root string, extensions ...string) ([]string, error) { + files := make([]string, 0) + + err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + if d.IsDir() { + return nil + } + + for _, ext := range extensions { + if strings.HasSuffix(d.Name(), ext) { + files = append(files, path) + return nil + } + } + + return nil + }) + if err != nil { + return nil, err + } + + return files, nil +} + +// packageRelativePath returns fullPath relative to packagePath, falling back to fullPath on error. +func packageRelativePath(packagePath, fullPath string) string { + relPath, err := filepath.Rel(packagePath, fullPath) + if err != nil { + return fullPath + } + + return filepath.ToSlash(relPath) +} From 9053be5e15c8b00b534d4e5a0dac4fbb7f71200a Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Tue, 14 Jul 2026 14:07:00 +0300 Subject: [PATCH 11/26] add layour linter Signed-off-by: Smyslov Maxim --- pkg/remote-linters/bundle/layout/linter.go | 40 +++++++++ .../bundle/layout/rules/changelog.go | 15 ++++ .../bundle/layout/rules/docs.go | 13 +++ .../bundle/layout/rules/gitignore.go | 13 +++ .../bundle/layout/rules/no_chart.go | 52 +++++++++++ .../bundle/layout/rules/no_helmignore.go | 52 +++++++++++ .../bundle/layout/rules/no_werf.go | 72 ++++++++++++++++ .../bundle/layout/rules/required_paths.go | 86 +++++++++++++++++++ 8 files changed, 343 insertions(+) create mode 100644 pkg/remote-linters/bundle/layout/linter.go create mode 100644 pkg/remote-linters/bundle/layout/rules/changelog.go create mode 100644 pkg/remote-linters/bundle/layout/rules/docs.go create mode 100644 pkg/remote-linters/bundle/layout/rules/gitignore.go create mode 100644 pkg/remote-linters/bundle/layout/rules/no_chart.go create mode 100644 pkg/remote-linters/bundle/layout/rules/no_helmignore.go create mode 100644 pkg/remote-linters/bundle/layout/rules/no_werf.go create mode 100644 pkg/remote-linters/bundle/layout/rules/required_paths.go diff --git a/pkg/remote-linters/bundle/layout/linter.go b/pkg/remote-linters/bundle/layout/linter.go new file mode 100644 index 00000000..a4939d60 --- /dev/null +++ b/pkg/remote-linters/bundle/layout/linter.go @@ -0,0 +1,40 @@ +package layout + +import ( + "context" + + "github.com/deckhouse/dmt/pkg/errors" + "github.com/deckhouse/dmt/pkg/remote-linters/bundle/layout/rules" +) + +// LinterID is the stable identifier used to reference this linter in configuration and diagnostics. +const LinterID = "layout" + +// Linter runs layout rules against an application package directory. +type Linter struct { + config Config + errorList *errors.LintRuleErrorsList +} + +// Config holds the path and settings required to construct a Linter. +type Config struct { + Path string +} + +// NewLinter constructs a Linter from cfg, scoping its diagnostics to this linter and capping severity at the configured level. +func NewLinter(cfg Config, errorList *errors.LintRuleErrorsList) *Linter { + return &Linter{ + config: cfg, + errorList: errorList.WithRule(LinterID), + } +} + +// Lint executes all layout rules against the configured package path. +func (l *Linter) Lint(ctx context.Context) { + rules.NewNoWerfRule(l.config.Path, l.errorList).Check(ctx) + rules.NewNoChartRule(l.config.Path, l.errorList).Check(ctx) + rules.NewNoHelmignoreRule(l.config.Path, l.errorList).Check(ctx) + rules.NewGitignoreRule(l.config.Path, l.errorList).Check(ctx) + rules.NewChangelogRule(l.config.Path, l.errorList).Check(ctx) + rules.NewDocsRule(l.config.Path, l.errorList).Check(ctx) +} diff --git a/pkg/remote-linters/bundle/layout/rules/changelog.go b/pkg/remote-linters/bundle/layout/rules/changelog.go new file mode 100644 index 00000000..759b47cc --- /dev/null +++ b/pkg/remote-linters/bundle/layout/rules/changelog.go @@ -0,0 +1,15 @@ +package rules + +import ( + "github.com/deckhouse/dmt/pkg/errors" +) + +// Rule purpose: require changelog.yaml so package changes have a release history entry point. + +// ChangelogRuleID is the stable identifier used to reference this rule in configuration. +const ChangelogRuleID = "changelog" + +// NewChangelogRule constructs a rule that requires changelog.yaml in the package root. +func NewChangelogRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { + return newRequiredRootPathsRule(path, errorList.WithRule(ChangelogRuleID), []string{"changelog.yaml"}, nil) +} diff --git a/pkg/remote-linters/bundle/layout/rules/docs.go b/pkg/remote-linters/bundle/layout/rules/docs.go new file mode 100644 index 00000000..e5e020e1 --- /dev/null +++ b/pkg/remote-linters/bundle/layout/rules/docs.go @@ -0,0 +1,13 @@ +package rules + +import "github.com/deckhouse/dmt/pkg/errors" + +// Rule purpose: require docs/ so package documentation rules have a directory to inspect. + +// DocsRuleID is the stable identifier used to reference this rule in configuration. +const DocsRuleID = "docs" + +// NewDocsRule constructs a rule that requires docs/ in the package root. +func NewDocsRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { + return newRequiredRootPathsRule(path, errorList.WithRule(DocsRuleID), nil, []string{"docs"}) +} diff --git a/pkg/remote-linters/bundle/layout/rules/gitignore.go b/pkg/remote-linters/bundle/layout/rules/gitignore.go new file mode 100644 index 00000000..879fbb16 --- /dev/null +++ b/pkg/remote-linters/bundle/layout/rules/gitignore.go @@ -0,0 +1,13 @@ +package rules + +import "github.com/deckhouse/dmt/pkg/errors" + +// Rule purpose: require .gitignore so local and generated files stay out of package repositories. + +// GitignoreRuleID is the stable identifier used to reference this rule in configuration. +const GitignoreRuleID = "gitignore" + +// NewGitignoreRule constructs a rule that requires .gitignore in the package root. +func NewGitignoreRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { + return newRequiredRootPathsRule(path, errorList.WithRule(GitignoreRuleID), []string{".gitignore"}, nil) +} diff --git a/pkg/remote-linters/bundle/layout/rules/no_chart.go b/pkg/remote-linters/bundle/layout/rules/no_chart.go new file mode 100644 index 00000000..1502842f --- /dev/null +++ b/pkg/remote-linters/bundle/layout/rules/no_chart.go @@ -0,0 +1,52 @@ +package rules + +import ( + "context" + "os" + "path/filepath" + + "github.com/deckhouse/dmt/pkg/errors" +) + +// Rule purpose: warn about package-level Helm chart metadata because packages use package.yaml as the root descriptor. + +// chartFile is the Helm chart metadata file that should not exist in the package root. +const chartFile = "Chart.yaml" + +// NoChartRuleID is the stable identifier used to reference this rule in configuration. +const NoChartRuleID = "no-chart" + +// NoChartRule reports Helm chart metadata in the package root. +type NoChartRule struct { + errorList *errors.LintRuleErrorsList + path string +} + +// NewNoChartRule constructs a NoChartRule scoped to path, tagging diagnostics with the rule ID. +func NewNoChartRule(path string, errorList *errors.LintRuleErrorsList) *NoChartRule { + return &NoChartRule{ + path: path, + errorList: errorList.WithRule(NoChartRuleID), + } +} + +// Check runs the Chart.yaml check against the package directory. +func (r *NoChartRule) Check(_ context.Context) { + r.checkChartFile() +} + +// checkChartFile reports a finding if Chart.yaml is present. +func (r *NoChartRule) checkChartFile() { + path := filepath.Join(r.path, chartFile) + + _, err := os.Stat(path) + if os.IsNotExist(err) { + return + } + + if err != nil { + return + } + + r.errorList.WithFilePath(path).Errorf("Chart.yaml found - package root metadata must be defined in package.yaml") +} diff --git a/pkg/remote-linters/bundle/layout/rules/no_helmignore.go b/pkg/remote-linters/bundle/layout/rules/no_helmignore.go new file mode 100644 index 00000000..9c40eb61 --- /dev/null +++ b/pkg/remote-linters/bundle/layout/rules/no_helmignore.go @@ -0,0 +1,52 @@ +package rules + +import ( + "context" + "os" + "path/filepath" + + "github.com/deckhouse/dmt/pkg/errors" +) + +// Rule purpose: reject committed .helmignore because build generates it at runtime. + +// NoHelmignoreRuleID is the stable identifier used to reference this rule in configuration. +const NoHelmignoreRuleID = "no-helmignore" + +// helmignoreFile is the generated Helm ignore file that should not be committed. +const helmignoreFile = ".helmignore" + +// NoHelmignoreRule reports .helmignore in the package root. +type NoHelmignoreRule struct { + errorList *errors.LintRuleErrorsList + path string +} + +// NewNoHelmignoreRule constructs a NoHelmignoreRule scoped to path, tagging diagnostics with the rule ID. +func NewNoHelmignoreRule(path string, errorList *errors.LintRuleErrorsList) *NoHelmignoreRule { + return &NoHelmignoreRule{ + path: path, + errorList: errorList.WithRule(NoHelmignoreRuleID), + } +} + +// Check runs the .helmignore absence check against the package directory. +func (r *NoHelmignoreRule) Check(_ context.Context) { + r.checkHelmignoreFile() +} + +// checkHelmignoreFile reports an error if .helmignore is present. +func (r *NoHelmignoreRule) checkHelmignoreFile() { + path := filepath.Join(r.path, helmignoreFile) + + _, err := os.Stat(path) + if os.IsNotExist(err) { + return + } + + if err != nil { + return + } + + r.errorList.WithFilePath(path).Errorf(".helmignore found - file is generated at build time") +} diff --git a/pkg/remote-linters/bundle/layout/rules/no_werf.go b/pkg/remote-linters/bundle/layout/rules/no_werf.go new file mode 100644 index 00000000..fe89933f --- /dev/null +++ b/pkg/remote-linters/bundle/layout/rules/no_werf.go @@ -0,0 +1,72 @@ +package rules + +import ( + "context" + "os" + "path/filepath" + + "github.com/deckhouse/dmt/pkg/errors" +) + +// Rule purpose: reject package-level Werf files because custom builds must live in hooks/ or images/. + +// werf artifact paths that must not exist in an application package. +const ( + werfDir = ".werf" + werfFile = "werf.yaml" +) + +// NoWerfRuleID is the stable identifier used to reference this rule in configuration. +const NoWerfRuleID = "no-werf" + +// NoWerfRule enforces that werf build artifacts are absent from the package root. +type NoWerfRule struct { + errorList *errors.LintRuleErrorsList + path string +} + +// NewNoWerfRule constructs a NoWerfRule scoped to path, tagging diagnostics with the rule ID. +func NewNoWerfRule(path string, errorList *errors.LintRuleErrorsList) *NoWerfRule { + return &NoWerfRule{ + path: path, + errorList: errorList.WithRule(NoWerfRuleID), + } +} + +// Check runs both werf artifact checks against the package directory. +func (r *NoWerfRule) Check(_ context.Context) { + r.checkWerfDir() + r.checkWerfFile() +} + +// checkWerfDir reports an error if the .werf directory is present. +func (r *NoWerfRule) checkWerfDir() { + path := filepath.Join(r.path, werfDir) + + _, err := os.Stat(path) + if os.IsNotExist(err) { + return + } + + if err != nil { + return + } + + r.errorList.WithFilePath(path).Errorf(".werf directory found - custom build files allowed only in hooks/ or images/") +} + +// checkWerfFile reports an error if werf.yaml is present. +func (r *NoWerfRule) checkWerfFile() { + path := filepath.Join(r.path, werfFile) + + _, err := os.Stat(path) + if os.IsNotExist(err) { + return + } + + if err != nil { + return + } + + r.errorList.WithFilePath(path).Errorf("werf.yaml found - custom werf.yaml not allowed") +} diff --git a/pkg/remote-linters/bundle/layout/rules/required_paths.go b/pkg/remote-linters/bundle/layout/rules/required_paths.go new file mode 100644 index 00000000..642616d6 --- /dev/null +++ b/pkg/remote-linters/bundle/layout/rules/required_paths.go @@ -0,0 +1,86 @@ +package rules + +import ( + "context" + "os" + "path/filepath" + + "github.com/deckhouse/dmt/pkg/errors" +) + +// requiredRootPathsRule checks that configured files and directories exist in the package root. +type requiredRootPathsRule struct { + errorList *errors.LintRuleErrorsList + path string + files []string + dirs []string +} + +// newRequiredRootPathsRule constructs a reusable presence check for root-level files and directories. +func newRequiredRootPathsRule(path string, errorList *errors.LintRuleErrorsList, files, dirs []string) *requiredRootPathsRule { + return &requiredRootPathsRule{ + path: path, + errorList: errorList, + files: files, + dirs: dirs, + } +} + +// Check verifies that every configured file and directory exists in the package root. +func (r *requiredRootPathsRule) Check(_ context.Context) { + for _, file := range r.files { + r.checkFile(file) + } + + for _, dir := range r.dirs { + r.checkDir(dir) + } +} + +// checkFile reports a finding when name does not exist as a regular file. +func (r *requiredRootPathsRule) checkFile(name string) { + path := filepath.Join(r.path, name) + + info, err := os.Stat(path) + if err == nil { + if info.IsDir() { + r.errorList.WithFilePath(path).Errorf("%s must be a file in package root", name) + } + + return + } + + if os.IsNotExist(err) { + r.errorList.WithFilePath(path).Errorf("%s file is missing in package root", name) + return + } + + r.errorList. + WithFilePath(path). + WithValue(err.Error()). + Errorf("failed to check %s file", name) +} + +// checkDir reports a finding when name does not exist as a directory. +func (r *requiredRootPathsRule) checkDir(name string) { + path := filepath.Join(r.path, name) + + info, err := os.Stat(path) + if err == nil { + if !info.IsDir() { + r.errorList.WithFilePath(path).Errorf("%s must be a directory in package root", name) + } + + return + } + + if os.IsNotExist(err) { + r.errorList.WithFilePath(path).Errorf("%s directory is missing in package root", name) + return + } + + r.errorList. + WithFilePath(path). + WithValue(err.Error()). + Errorf("failed to check %s directory", name) +} From 53c2727b0a843918a8f0fccd7b7830993e8048fd Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Tue, 14 Jul 2026 14:21:52 +0300 Subject: [PATCH 12/26] add layout linter Signed-off-by: Smyslov Maxim --- pkg/remote-linters/release/layout/linter.go | 35 ++++++++ .../release/layout/rules/changelog.go | 15 ++++ .../release/layout/rules/required_paths.go | 86 +++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 pkg/remote-linters/release/layout/linter.go create mode 100644 pkg/remote-linters/release/layout/rules/changelog.go create mode 100644 pkg/remote-linters/release/layout/rules/required_paths.go diff --git a/pkg/remote-linters/release/layout/linter.go b/pkg/remote-linters/release/layout/linter.go new file mode 100644 index 00000000..c1b375c0 --- /dev/null +++ b/pkg/remote-linters/release/layout/linter.go @@ -0,0 +1,35 @@ +package layout + +import ( + "context" + + "github.com/deckhouse/dmt/pkg/errors" + "github.com/deckhouse/dmt/pkg/remote-linters/release/layout/rules" +) + +// LinterID is the stable identifier used to reference this linter in configuration and diagnostics. +const LinterID = "layout" + +// Linter runs layout rules against an application package directory. +type Linter struct { + config Config + errorList *errors.LintRuleErrorsList +} + +// Config holds the path and settings required to construct a Linter. +type Config struct { + Path string +} + +// NewLinter constructs a Linter from cfg, scoping its diagnostics to this linter and capping severity at the configured level. +func NewLinter(cfg Config, errorList *errors.LintRuleErrorsList) *Linter { + return &Linter{ + config: cfg, + errorList: errorList.WithLinterID(LinterID), + } +} + +// Lint executes all layout rules against the configured package path. +func (l *Linter) Lint(ctx context.Context) { + rules.NewChangelogRule(l.config.Path, l.errorList).Check(ctx) +} diff --git a/pkg/remote-linters/release/layout/rules/changelog.go b/pkg/remote-linters/release/layout/rules/changelog.go new file mode 100644 index 00000000..759b47cc --- /dev/null +++ b/pkg/remote-linters/release/layout/rules/changelog.go @@ -0,0 +1,15 @@ +package rules + +import ( + "github.com/deckhouse/dmt/pkg/errors" +) + +// Rule purpose: require changelog.yaml so package changes have a release history entry point. + +// ChangelogRuleID is the stable identifier used to reference this rule in configuration. +const ChangelogRuleID = "changelog" + +// NewChangelogRule constructs a rule that requires changelog.yaml in the package root. +func NewChangelogRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { + return newRequiredRootPathsRule(path, errorList.WithRule(ChangelogRuleID), []string{"changelog.yaml"}, nil) +} diff --git a/pkg/remote-linters/release/layout/rules/required_paths.go b/pkg/remote-linters/release/layout/rules/required_paths.go new file mode 100644 index 00000000..642616d6 --- /dev/null +++ b/pkg/remote-linters/release/layout/rules/required_paths.go @@ -0,0 +1,86 @@ +package rules + +import ( + "context" + "os" + "path/filepath" + + "github.com/deckhouse/dmt/pkg/errors" +) + +// requiredRootPathsRule checks that configured files and directories exist in the package root. +type requiredRootPathsRule struct { + errorList *errors.LintRuleErrorsList + path string + files []string + dirs []string +} + +// newRequiredRootPathsRule constructs a reusable presence check for root-level files and directories. +func newRequiredRootPathsRule(path string, errorList *errors.LintRuleErrorsList, files, dirs []string) *requiredRootPathsRule { + return &requiredRootPathsRule{ + path: path, + errorList: errorList, + files: files, + dirs: dirs, + } +} + +// Check verifies that every configured file and directory exists in the package root. +func (r *requiredRootPathsRule) Check(_ context.Context) { + for _, file := range r.files { + r.checkFile(file) + } + + for _, dir := range r.dirs { + r.checkDir(dir) + } +} + +// checkFile reports a finding when name does not exist as a regular file. +func (r *requiredRootPathsRule) checkFile(name string) { + path := filepath.Join(r.path, name) + + info, err := os.Stat(path) + if err == nil { + if info.IsDir() { + r.errorList.WithFilePath(path).Errorf("%s must be a file in package root", name) + } + + return + } + + if os.IsNotExist(err) { + r.errorList.WithFilePath(path).Errorf("%s file is missing in package root", name) + return + } + + r.errorList. + WithFilePath(path). + WithValue(err.Error()). + Errorf("failed to check %s file", name) +} + +// checkDir reports a finding when name does not exist as a directory. +func (r *requiredRootPathsRule) checkDir(name string) { + path := filepath.Join(r.path, name) + + info, err := os.Stat(path) + if err == nil { + if !info.IsDir() { + r.errorList.WithFilePath(path).Errorf("%s must be a directory in package root", name) + } + + return + } + + if os.IsNotExist(err) { + r.errorList.WithFilePath(path).Errorf("%s directory is missing in package root", name) + return + } + + r.errorList. + WithFilePath(path). + WithValue(err.Error()). + Errorf("failed to check %s directory", name) +} From 96564bbb6090cfd42641b27cb4f55b22962e30b6 Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Tue, 14 Jul 2026 14:22:03 +0300 Subject: [PATCH 13/26] fix Signed-off-by: Smyslov Maxim --- internal/remote-lint/lint.go | 9 ++- pkg/remote-linters/bundle/layout/linter.go | 5 +- .../bundle/layout/rules/no_chart.go | 52 -------------- .../bundle/layout/rules/no_helmignore.go | 52 -------------- .../bundle/layout/rules/no_werf.go | 72 ------------------- pkg/remote-linters/release/docs/linter.go | 35 --------- .../release/docs/rules/changelog.go | 59 --------------- 7 files changed, 7 insertions(+), 277 deletions(-) delete mode 100644 pkg/remote-linters/bundle/layout/rules/no_chart.go delete mode 100644 pkg/remote-linters/bundle/layout/rules/no_helmignore.go delete mode 100644 pkg/remote-linters/bundle/layout/rules/no_werf.go delete mode 100644 pkg/remote-linters/release/docs/linter.go delete mode 100644 pkg/remote-linters/release/docs/rules/changelog.go diff --git a/internal/remote-lint/lint.go b/internal/remote-lint/lint.go index ed76778a..441acbd1 100644 --- a/internal/remote-lint/lint.go +++ b/internal/remote-lint/lint.go @@ -20,7 +20,8 @@ import ( "github.com/deckhouse/dmt/pkg" "github.com/deckhouse/dmt/pkg/errors" "github.com/deckhouse/dmt/pkg/remote-linters/bundle/docs" - releaseDocs "github.com/deckhouse/dmt/pkg/remote-linters/release/docs" + "github.com/deckhouse/dmt/pkg/remote-linters/bundle/layout" + releaseLayout "github.com/deckhouse/dmt/pkg/remote-linters/release/layout" ) type RemoteLintOptions struct { @@ -119,17 +120,19 @@ type Linter interface { func buildBundleLinters(path string, errorList *errors.LintRuleErrorsList) []Linter { docsLinter := docs.NewLinter(docs.Config{Path: path}, errorList) + layoutLinter := layout.NewLinter(layout.Config{Path: path}, errorList) return []Linter{ docsLinter, + layoutLinter, } } func buildReleaseLinters(path string, errorList *errors.LintRuleErrorsList) []Linter { - docsLinter := releaseDocs.NewLinter(releaseDocs.Config{Path: path}, errorList) + layoutLinter := releaseLayout.NewLinter(releaseLayout.Config{Path: path}, errorList) return []Linter{ - docsLinter, + layoutLinter, } } diff --git a/pkg/remote-linters/bundle/layout/linter.go b/pkg/remote-linters/bundle/layout/linter.go index a4939d60..36b5950e 100644 --- a/pkg/remote-linters/bundle/layout/linter.go +++ b/pkg/remote-linters/bundle/layout/linter.go @@ -25,15 +25,12 @@ type Config struct { func NewLinter(cfg Config, errorList *errors.LintRuleErrorsList) *Linter { return &Linter{ config: cfg, - errorList: errorList.WithRule(LinterID), + errorList: errorList.WithLinterID(LinterID), } } // Lint executes all layout rules against the configured package path. func (l *Linter) Lint(ctx context.Context) { - rules.NewNoWerfRule(l.config.Path, l.errorList).Check(ctx) - rules.NewNoChartRule(l.config.Path, l.errorList).Check(ctx) - rules.NewNoHelmignoreRule(l.config.Path, l.errorList).Check(ctx) rules.NewGitignoreRule(l.config.Path, l.errorList).Check(ctx) rules.NewChangelogRule(l.config.Path, l.errorList).Check(ctx) rules.NewDocsRule(l.config.Path, l.errorList).Check(ctx) diff --git a/pkg/remote-linters/bundle/layout/rules/no_chart.go b/pkg/remote-linters/bundle/layout/rules/no_chart.go deleted file mode 100644 index 1502842f..00000000 --- a/pkg/remote-linters/bundle/layout/rules/no_chart.go +++ /dev/null @@ -1,52 +0,0 @@ -package rules - -import ( - "context" - "os" - "path/filepath" - - "github.com/deckhouse/dmt/pkg/errors" -) - -// Rule purpose: warn about package-level Helm chart metadata because packages use package.yaml as the root descriptor. - -// chartFile is the Helm chart metadata file that should not exist in the package root. -const chartFile = "Chart.yaml" - -// NoChartRuleID is the stable identifier used to reference this rule in configuration. -const NoChartRuleID = "no-chart" - -// NoChartRule reports Helm chart metadata in the package root. -type NoChartRule struct { - errorList *errors.LintRuleErrorsList - path string -} - -// NewNoChartRule constructs a NoChartRule scoped to path, tagging diagnostics with the rule ID. -func NewNoChartRule(path string, errorList *errors.LintRuleErrorsList) *NoChartRule { - return &NoChartRule{ - path: path, - errorList: errorList.WithRule(NoChartRuleID), - } -} - -// Check runs the Chart.yaml check against the package directory. -func (r *NoChartRule) Check(_ context.Context) { - r.checkChartFile() -} - -// checkChartFile reports a finding if Chart.yaml is present. -func (r *NoChartRule) checkChartFile() { - path := filepath.Join(r.path, chartFile) - - _, err := os.Stat(path) - if os.IsNotExist(err) { - return - } - - if err != nil { - return - } - - r.errorList.WithFilePath(path).Errorf("Chart.yaml found - package root metadata must be defined in package.yaml") -} diff --git a/pkg/remote-linters/bundle/layout/rules/no_helmignore.go b/pkg/remote-linters/bundle/layout/rules/no_helmignore.go deleted file mode 100644 index 9c40eb61..00000000 --- a/pkg/remote-linters/bundle/layout/rules/no_helmignore.go +++ /dev/null @@ -1,52 +0,0 @@ -package rules - -import ( - "context" - "os" - "path/filepath" - - "github.com/deckhouse/dmt/pkg/errors" -) - -// Rule purpose: reject committed .helmignore because build generates it at runtime. - -// NoHelmignoreRuleID is the stable identifier used to reference this rule in configuration. -const NoHelmignoreRuleID = "no-helmignore" - -// helmignoreFile is the generated Helm ignore file that should not be committed. -const helmignoreFile = ".helmignore" - -// NoHelmignoreRule reports .helmignore in the package root. -type NoHelmignoreRule struct { - errorList *errors.LintRuleErrorsList - path string -} - -// NewNoHelmignoreRule constructs a NoHelmignoreRule scoped to path, tagging diagnostics with the rule ID. -func NewNoHelmignoreRule(path string, errorList *errors.LintRuleErrorsList) *NoHelmignoreRule { - return &NoHelmignoreRule{ - path: path, - errorList: errorList.WithRule(NoHelmignoreRuleID), - } -} - -// Check runs the .helmignore absence check against the package directory. -func (r *NoHelmignoreRule) Check(_ context.Context) { - r.checkHelmignoreFile() -} - -// checkHelmignoreFile reports an error if .helmignore is present. -func (r *NoHelmignoreRule) checkHelmignoreFile() { - path := filepath.Join(r.path, helmignoreFile) - - _, err := os.Stat(path) - if os.IsNotExist(err) { - return - } - - if err != nil { - return - } - - r.errorList.WithFilePath(path).Errorf(".helmignore found - file is generated at build time") -} diff --git a/pkg/remote-linters/bundle/layout/rules/no_werf.go b/pkg/remote-linters/bundle/layout/rules/no_werf.go deleted file mode 100644 index fe89933f..00000000 --- a/pkg/remote-linters/bundle/layout/rules/no_werf.go +++ /dev/null @@ -1,72 +0,0 @@ -package rules - -import ( - "context" - "os" - "path/filepath" - - "github.com/deckhouse/dmt/pkg/errors" -) - -// Rule purpose: reject package-level Werf files because custom builds must live in hooks/ or images/. - -// werf artifact paths that must not exist in an application package. -const ( - werfDir = ".werf" - werfFile = "werf.yaml" -) - -// NoWerfRuleID is the stable identifier used to reference this rule in configuration. -const NoWerfRuleID = "no-werf" - -// NoWerfRule enforces that werf build artifacts are absent from the package root. -type NoWerfRule struct { - errorList *errors.LintRuleErrorsList - path string -} - -// NewNoWerfRule constructs a NoWerfRule scoped to path, tagging diagnostics with the rule ID. -func NewNoWerfRule(path string, errorList *errors.LintRuleErrorsList) *NoWerfRule { - return &NoWerfRule{ - path: path, - errorList: errorList.WithRule(NoWerfRuleID), - } -} - -// Check runs both werf artifact checks against the package directory. -func (r *NoWerfRule) Check(_ context.Context) { - r.checkWerfDir() - r.checkWerfFile() -} - -// checkWerfDir reports an error if the .werf directory is present. -func (r *NoWerfRule) checkWerfDir() { - path := filepath.Join(r.path, werfDir) - - _, err := os.Stat(path) - if os.IsNotExist(err) { - return - } - - if err != nil { - return - } - - r.errorList.WithFilePath(path).Errorf(".werf directory found - custom build files allowed only in hooks/ or images/") -} - -// checkWerfFile reports an error if werf.yaml is present. -func (r *NoWerfRule) checkWerfFile() { - path := filepath.Join(r.path, werfFile) - - _, err := os.Stat(path) - if os.IsNotExist(err) { - return - } - - if err != nil { - return - } - - r.errorList.WithFilePath(path).Errorf("werf.yaml found - custom werf.yaml not allowed") -} diff --git a/pkg/remote-linters/release/docs/linter.go b/pkg/remote-linters/release/docs/linter.go deleted file mode 100644 index d09b5d97..00000000 --- a/pkg/remote-linters/release/docs/linter.go +++ /dev/null @@ -1,35 +0,0 @@ -package docs - -import ( - "context" - - "github.com/deckhouse/dmt/pkg/errors" - "github.com/deckhouse/dmt/pkg/remote-linters/release/docs/rules" -) - -// LinterID is the stable identifier used to reference this linter in configuration and diagnostics. -const LinterID = "docs" - -// Linter runs documentation rules against a package directory. -type Linter struct { - config Config - errorList *errors.LintRuleErrorsList -} - -// Config holds the path and settings required to construct a Linter. -type Config struct { - Path string -} - -// NewLinter constructs a Linter from cfg, scoping its diagnostics to this linter and capping severity at the configured level. -func NewLinter(cfg Config, errorList *errors.LintRuleErrorsList) *Linter { - return &Linter{ - config: cfg, - errorList: errorList.WithLinterID(LinterID), - } -} - -// Lint executes all documentation rules against the configured package path. -func (l *Linter) Lint(ctx context.Context) { - rules.NewChangelogRule(l.config.Path, l.errorList).Check(ctx) -} diff --git a/pkg/remote-linters/release/docs/rules/changelog.go b/pkg/remote-linters/release/docs/rules/changelog.go deleted file mode 100644 index 0633058a..00000000 --- a/pkg/remote-linters/release/docs/rules/changelog.go +++ /dev/null @@ -1,59 +0,0 @@ -package rules - -import ( - "context" - "os" - "path/filepath" - - "github.com/deckhouse/dmt/pkg/errors" -) - -// Rule purpose: require a non-empty changelog.yaml as the release changelog entry point. - -// ChangelogRuleID is the stable identifier used to reference this rule in configuration. -const ChangelogRuleID = "changelog" - -// ChangelogRule enforces that changelog.yaml exists and is not empty. -type ChangelogRule struct { - path string - errorList *errors.LintRuleErrorsList -} - -// NewChangelogRule constructs a ChangelogRule scoped to path, tagging diagnostics with the rule ID. -func NewChangelogRule(path string, errorList *errors.LintRuleErrorsList) *ChangelogRule { - return &ChangelogRule{ - path: path, - errorList: errorList.WithRule(ChangelogRuleID), - } -} - -// Check verifies that changelog.yaml exists and has content. -func (r *ChangelogRule) Check(_ context.Context) { - path := filepath.Join(r.path, "changelog.yaml") - - info, err := os.Stat(path) - if os.IsNotExist(err) { - r.errorList. - WithFilePath(path). - WithValue(err.Error()). - Error("changelog.yaml file is missing") - - return - } - - if err != nil { - r.errorList. - WithFilePath(path). - WithValue(err.Error()). - Error("failed to check changelog.yaml file") - - return - } - - if info.Size() == 0 { - r.errorList. - WithFilePath(path). - WithValue("file is empty"). - Error("changelog.yaml file is empty") - } -} From 0ed7b24a696a13adf8dd3af6c5a9089c03498535 Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Tue, 14 Jul 2026 15:52:05 +0300 Subject: [PATCH 14/26] update cutTagFromImagePath to handle unsupported digest formats; adjust tests accordingly Signed-off-by: Smyslov Maxim --- internal/remote-lint/lint.go | 8 ++++---- internal/remote-lint/lint_test.go | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/remote-lint/lint.go b/internal/remote-lint/lint.go index 441acbd1..25d83d30 100644 --- a/internal/remote-lint/lint.go +++ b/internal/remote-lint/lint.go @@ -100,11 +100,11 @@ func lintRelease(ctx context.Context, client *client.Client, tag string, errorLi return nil } -// returns repository path and tag (or digest) 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" (or sha256:aaa) +// 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 parts := strings.Split(imagePath, "@"); len(parts) == 2 { - return parts[0], parts[1], nil + if strings.Contains(imagePath, "@") { + return "", "", fmt.Errorf("digest not supported") } if parts := strings.Split(imagePath, ":"); len(parts) == 2 { diff --git a/internal/remote-lint/lint_test.go b/internal/remote-lint/lint_test.go index 88714352..c7bd97e6 100644 --- a/internal/remote-lint/lint_test.go +++ b/internal/remote-lint/lint_test.go @@ -13,9 +13,9 @@ func TestCutTagFromImagePath(t *testing.T) { require.Equal(t, "v0.0.1", tag) repository, tag, err = cutTagFromImagePath("registry.example.com/deckhouse/my-module@sha256:1234567890") - require.NoError(t, err) - require.Equal(t, "registry.example.com/deckhouse/my-module", repository) - require.Equal(t, "sha256:1234567890", tag) + require.Contains(t, err.Error(), "digest not supported") + require.Empty(t, repository) + require.Empty(t, tag) repository, tag, err = cutTagFromImagePath("registry.example.com/deckhouse/my-module") require.Contains(t, err.Error(), "tag not found in image path") From 6406c73480d83add29944302a42661b6b3245877 Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Tue, 14 Jul 2026 15:52:12 +0300 Subject: [PATCH 15/26] remove CyrillicInEnglishRule to streamline linter functionality Signed-off-by: Smyslov Maxim --- pkg/remote-linters/bundle/docs/linter.go | 1 - .../bundle/docs/rules/cyrillic_in_english.go | 142 ------------------ 2 files changed, 143 deletions(-) delete mode 100644 pkg/remote-linters/bundle/docs/rules/cyrillic_in_english.go diff --git a/pkg/remote-linters/bundle/docs/linter.go b/pkg/remote-linters/bundle/docs/linter.go index a22492d5..742fbc4c 100644 --- a/pkg/remote-linters/bundle/docs/linter.go +++ b/pkg/remote-linters/bundle/docs/linter.go @@ -40,7 +40,6 @@ func (l *Linter) Lint(ctx context.Context) { rules.NewReadmeRule(l.config.Path, l.errorList).Check(ctx) rules.NewBilingualRule(l.config.Path, l.errorList).Check(ctx) - rules.NewCyrillicInEnglishRule(l.config.Path, l.errorList).Check(ctx) } // hasDocsDir reports whether docs/ exists as a directory in the package root. diff --git a/pkg/remote-linters/bundle/docs/rules/cyrillic_in_english.go b/pkg/remote-linters/bundle/docs/rules/cyrillic_in_english.go deleted file mode 100644 index 1daa195b..00000000 --- a/pkg/remote-linters/bundle/docs/rules/cyrillic_in_english.go +++ /dev/null @@ -1,142 +0,0 @@ -package rules - -import ( - "context" - "fmt" - "os" - "path/filepath" - "regexp" - "strings" - - "github.com/deckhouse/dmt/pkg/errors" -) - -// Rule purpose: prevent cyrillic characters from appearing in English documentation files. - -// CyrillicInEnglishRuleID is the stable identifier used to reference this rule in configuration. -const CyrillicInEnglishRuleID = "cyrillic-in-english" - -var ( - // cyrillicWordPattern matches cyrillic words in English documentation. - cyrillicWordPattern = regexp.MustCompile(`[А-Яа-яЁё]+`) - // cyrillicCharPattern marks cyrillic characters in diagnostics. - cyrillicCharPattern = regexp.MustCompile(`[А-Яа-яЁё]`) - // nonCyrillicCharPattern replaces non-cyrillic characters in diagnostics. - nonCyrillicCharPattern = regexp.MustCompile(`[^А-Яа-яЁё]`) - // russianDocPattern matches canonical Russian documentation files. - russianDocPattern = regexp.MustCompile(`\.ru\.md$`) - // russianDocFallbackPattern matches legacy Russian documentation files. - russianDocFallbackPattern = regexp.MustCompile(`(?i)_ru\.md$`) -) - -// CyrillicInEnglishRule enforces that English documentation does not contain cyrillic text. -type CyrillicInEnglishRule struct { - errorList *errors.LintRuleErrorsList - path string -} - -// NewCyrillicInEnglishRule constructs a CyrillicInEnglishRule scoped to path, tagging diagnostics with the rule ID. -func NewCyrillicInEnglishRule(path string, errorList *errors.LintRuleErrorsList) *CyrillicInEnglishRule { - return &CyrillicInEnglishRule{ - path: path, - errorList: errorList.WithRule(CyrillicInEnglishRuleID), - } -} - -// Check scans top-level English markdown files in docs/ for cyrillic characters. -func (r *CyrillicInEnglishRule) Check(_ context.Context) { - docsPath := filepath.Join(r.path, "docs") - - files, err := collectFiles(docsPath, ".md", ".markdown") - if err != nil { - if os.IsNotExist(err) { - return - } - - r.errorList. - WithFilePath(docsPath). - WithValue(err.Error()). - Error("cannot read docs directory") - - return - } - - for _, filePath := range files { - rel := packageRelativePath(r.path, filePath) - if filepath.Dir(rel) != "docs" { - continue - } - - r.checkFile(filePath) - } -} - -// checkFile reports cyrillic text found in one English documentation file. -func (r *CyrillicInEnglishRule) checkFile(filePath string) { - relPath := packageRelativePath(r.path, filePath) - if russianDocPattern.MatchString(filePath) { - return - } - - if russianDocFallbackPattern.MatchString(filePath) { - return - } - - lines, err := getFileContent(filePath) - if err != nil { - r.errorList. - WithFilePath(relPath). - WithValue(err.Error()). - Error("failed to read file") - - return - } - - cyrMsg, hasCyr := checkCyrillicLettersInArray(lines) - if hasCyr { - r.errorList.WithFilePath(relPath).WithValue(cyrMsg).Error("English documentation contains cyrillic characters") - } -} - -// getFileContent reads filename and returns it split into lines. -func getFileContent(filename string) ([]string, error) { - fileBytes, err := os.ReadFile(filename) - if err != nil { - return nil, err - } - - return strings.Split(string(fileBytes), "\n"), nil -} - -// checkCyrillicLettersInString returns a diagnostic snippet for line when it contains cyrillic text. -func checkCyrillicLettersInString(line string) (string, bool) { - if !cyrillicWordPattern.MatchString(line) { - return "", false - } - - line = strings.TrimSpace(line) - - cursor := nonCyrillicCharPattern.ReplaceAllString(line, "-") - cursor = cyrillicCharPattern.ReplaceAllString(cursor, "^") - cursor = strings.TrimRight(cursor, "-") - - return line + "\n" + cursor, true -} - -// checkCyrillicLettersInArray returns diagnostic snippets for all lines containing cyrillic text. -func checkCyrillicLettersInArray(lines []string) (string, bool) { - res := make([]string, 0) - - hasCyr := false - - for i, line := range lines { - msg, has := checkCyrillicLettersInString(line) - if has { - hasCyr = true - - res = append(res, fmt.Sprintf("Line %d: %s", i+1, msg)) - } - } - - return strings.Join(res, "\n"), hasCyr -} From 29fb05ee2b4085c5a171964dcbdd53d1239d219d Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Tue, 14 Jul 2026 15:52:45 +0300 Subject: [PATCH 16/26] add documentation Signed-off-by: Smyslov Maxim --- pkg/remote-linters/bundle/docs/README.md | 290 ++++++++++++++++++++ pkg/remote-linters/bundle/layout/README.md | 212 ++++++++++++++ pkg/remote-linters/release/layout/README.md | 90 ++++++ 3 files changed, 592 insertions(+) create mode 100644 pkg/remote-linters/bundle/docs/README.md create mode 100644 pkg/remote-linters/bundle/layout/README.md create mode 100644 pkg/remote-linters/release/layout/README.md diff --git a/pkg/remote-linters/bundle/docs/README.md b/pkg/remote-linters/bundle/docs/README.md new file mode 100644 index 00000000..76fa356a --- /dev/null +++ b/pkg/remote-linters/bundle/docs/README.md @@ -0,0 +1,290 @@ +# Documentation Linter + +## Overview + +The **Documentation Linter** validates module documentation to ensure proper structure, completeness, and bilingual coverage. This linter enforces bilingual documentation requirements and checks for documentation file presence. + +Proper documentation is critical for Deckhouse modules as it helps users understand module features, configuration options, and usage patterns. The linter ensures documentation meets quality standards and is accessible to both English and Russian-speaking audiences. + +## Rules + +| Rule | Description | Configurable | Default | +|------|-------------|--------------|---------| +| [readme](#readme) | Validates presence of README.md in docs/ directory | ❌ | enabled | +| [bilingual](#bilingual) | Validates documentation exists in both English and Russian | ❌ | enabled | + +"Configurable" means that this rule can be configured using the `.dmtlint.yaml` file, including customizing the rule's parameters and/or disabling the rule. + +## Rule Details + +### readme + +**Purpose:** Ensures every module has a primary documentation entry point in the `docs/README.md` file. This provides a consistent location for users to find module information and prevents modules from being deployed without documentation. + +**Description:** + +Every Deckhouse module must have a `docs/README.md` file that serves as the main documentation entry point. This file should contain an overview of the module, its features, configuration options, and usage examples. + +**What it checks:** + +1. Verifies that `docs/README.md` file exists in the module directory +2. Checks that the README.md file is not empty (size > 0 bytes) +3. Validates file is readable and accessible + +**Why it matters:** + +Documentation is essential for module adoption and proper usage. Without a README.md file, users won't understand what the module does, how to configure it, or how to troubleshoot issues. This rule ensures every module meets minimum documentation standards. + +**Examples:** + +❌ **Incorrect** - Missing README.md: + +``` +my-module/ +├── templates/ +│ └── deployment.yaml +├── openapi/ +│ └── config-values.yaml +└── docs/ + └── CONFIGURATION.md # Other docs exist but no README.md +``` + +**Error:** +``` +README.md file is missing in docs/ directory +``` + +❌ **Incorrect** - Empty README.md: + +``` +my-module/ +└── docs/ + └── README.md # File exists but is empty (0 bytes) +``` + +**Error:** +``` +README.md file is empty +``` + +✅ **Correct** - Proper README.md: + +``` +my-module/ +└── docs/ + └── README.md # Contains module documentation +``` + +```markdown +# My Module + +## Overview +This module provides... + +## Configuration +... + +## Usage +... +``` + +--- + +### bilingual + +**Purpose:** Ensures module documentation is accessible to both English and Russian-speaking audiences by requiring documentation files in both languages. This maintains Deckhouse's commitment to bilingual support and helps users in different regions. + +**Description:** + +For every English documentation file in the `docs/` directory, a corresponding Russian translation must exist. Russian documentation files should use the `.ru.md` suffix (e.g., `README.ru.md` for `README.md`). + +**What it checks:** + +1. Scans all `.md` files in the `docs/` directory (top-level only) +2. For each English documentation file, checks for a corresponding `.ru.md` or `_RU.md` file +3. Validates that Russian counterparts exist for all documentation files +4. Ignores files that are already Russian (ending in `.ru.md` or `_RU.md`) + +**Why it matters:** + +Deckhouse is used by organizations globally, with significant adoption in Russian-speaking regions. Bilingual documentation ensures all users can effectively use and configure modules regardless of their language preference. Missing translations create accessibility barriers and reduce module adoption. + +**Examples:** + +❌ **Incorrect** - Missing Russian translation: + +``` +my-module/ +└── docs/ + ├── README.md # English version exists + ├── CONFIGURATION.md # English version exists + └── CONFIGURATION.ru.md # Russian version exists for CONFIGURATION + # ❌ Missing README.ru.md +``` + +**Error:** +``` +Russian counterpart is missing: need to create a matching .ru.md in docs/ +File: docs/README.md +``` + +✅ **Correct** - Complete bilingual documentation: + +``` +my-module/ +└── docs/ + ├── README.md # English version + ├── README.ru.md # Russian translation + ├── CONFIGURATION.md # English version + └── CONFIGURATION.ru.md # Russian translation +``` + +✅ **Correct** - Legacy naming (still supported): + +``` +my-module/ +└── docs/ + ├── README.md # English version + ├── README_RU.md # Russian translation (legacy format) + ├── CONFIGURATION.md + └── CONFIGURATION_RU.md +``` + +**Supported file naming conventions:** + +- **Preferred:** `FILENAME.ru.md` (e.g., `README.ru.md`, `FAQ.ru.md`) +- **Legacy:** `FILENAME_RU.md` (e.g., `README_RU.md`, `FAQ_RU.md`) - case insensitive + +--- + +## Common Issues + +### Issue: Missing README.md + +**Symptom:** +``` +Error: README.md file is missing in docs/ directory +``` + +**Cause:** The module doesn't have a `docs/README.md` file. + +**Solutions:** + +1. **Create the README.md file:** + + ```bash + mkdir -p modules/my-module/docs + cat > modules/my-module/docs/README.md << 'EOF' + # My Module + + ## Overview + Brief description of what this module does. + + ## Configuration + Configuration options and examples. + + ## Usage + How to use this module. + EOF + ``` + +2. **Use a documentation template:** + + ```bash + # Copy from another module + cp modules/reference-module/docs/README.md modules/my-module/docs/README.md + # Then customize the content + ``` + +### Issue: Missing Russian translation + +**Symptom:** +``` +Error: Russian counterpart is missing: need to create a matching .ru.md in docs/ +File: docs/CONFIGURATION.md +``` + +**Cause:** An English documentation file exists without a corresponding Russian translation. + +**Solutions:** + +1. **Create the Russian translation:** + + ```bash + # Create matching .ru.md file + touch modules/my-module/docs/CONFIGURATION.ru.md + ``` + +2. **Translate the content:** + + ```bash + # Start with copying the English version + cp modules/my-module/docs/CONFIGURATION.md \ + modules/my-module/docs/CONFIGURATION.ru.md + # Then translate the content to Russian + ``` + +### Issue: Empty README.md file + +**Symptom:** +``` +Error: README.md file is empty +``` + +**Cause:** The `docs/README.md` file exists but contains no content (0 bytes). + +**Solutions:** + +1. **Add content to the file:** + + ```bash + cat > modules/my-module/docs/README.md << 'EOF' + # My Module + + [Add module description here] + EOF + ``` + +2. **Use a minimal template:** + + ```markdown + # Module Name + + ## Description + Brief description of the module functionality. + + ## Configuration + See the OpenAPI schema for configuration options. + ``` + +### Issue: Wrong file naming convention + +**Symptom:** +``` +Error: Russian counterpart is missing: need to create a matching .ru.md in docs/ +File: docs/README.md +``` + +But you have `docs/README-RU.md` or `docs/README_ru.md` (lowercase). + +**Cause:** The Russian file uses an incorrect naming convention. + +**Solutions:** + +1. **Rename to the correct format:** + + ```bash + # Preferred format + mv modules/my-module/docs/README-RU.md \ + modules/my-module/docs/README.ru.md + + # Legacy format (also acceptable) + mv modules/my-module/docs/README-RU.md \ + modules/my-module/docs/README_RU.md + ``` + +2. **Supported naming conventions:** + - ✅ `FILENAME.ru.md` (preferred) + - ✅ `FILENAME_RU.md` (legacy, case insensitive) + - ❌ `FILENAME-RU.md` (not supported) + diff --git a/pkg/remote-linters/bundle/layout/README.md b/pkg/remote-linters/bundle/layout/README.md new file mode 100644 index 00000000..91bdfa98 --- /dev/null +++ b/pkg/remote-linters/bundle/layout/README.md @@ -0,0 +1,212 @@ +# Layout Linter + +## Overview + +The **Layout Linter** validates the top-level file and directory layout of a module (bundle) image. It checks that a small set of required root-level entries — `.gitignore`, `changelog.yaml`, and `docs/` — are present and are of the expected kind (file vs. directory). + +Enforcing a consistent root layout makes modules predictable to browse, package, and automate against: tooling that expects `changelog.yaml` or `docs/` to exist doesn't have to special-case modules that are missing them. + +This linter is part of the `dmt` remote-lint bundle used to validate a module image pulled from a registry (see `dmt remote-lint`). It runs standalone and does not read `.dmtlint.yaml`, so none of its rules are configurable or excludable. + +## Rules + +| Rule | Description | Configurable | Default | +|------|-------------|--------------|---------| +| [gitignore](#gitignore) | Validates presence of `.gitignore` in the package root | ❌ | enabled | +| [changelog](#changelog) | Validates presence of `changelog.yaml` in the package root | ❌ | enabled | +| [docs](#docs) | Validates presence of `docs/` directory in the package root | ❌ | enabled | + +None of these rules can be configured or disabled: this linter has no settings beyond the module path, and there is no `.dmtlint.yaml` support for it. + +All three rules run unconditionally and independently of each other, so a single `Lint` call can report findings for more than one of them at once. + +## Rule Details + +### gitignore + +**Purpose:** Ensures every module ships a `.gitignore` file in its root, so local artifacts and generated files don't accidentally get committed or bundled. + +**What it checks:** + +1. Verifies that `.gitignore` exists in the package root +2. Verifies that `.gitignore` is a regular file, not a directory + +**Examples:** + +❌ **Incorrect** - Missing `.gitignore`: + +``` +my-module/ +├── changelog.yaml +├── docs/ +│ └── README.md +└── templates/ + └── deployment.yaml +``` + +**Error:** +``` +.gitignore file is missing in package root +``` + +❌ **Incorrect** - `.gitignore` is a directory: + +``` +my-module/ +└── .gitignore/ # Wrong: must be a file + └── notes.txt +``` + +**Error:** +``` +.gitignore must be a file in package root +``` + +✅ **Correct:** + +``` +my-module/ +├── .gitignore +├── changelog.yaml +└── docs/ + └── README.md +``` + +--- + +### changelog + +**Purpose:** Ensures every module has a `changelog.yaml` file in its root, giving every release a machine-readable history entry point. + +**What it checks:** + +1. Verifies that `changelog.yaml` exists in the package root +2. Verifies that `changelog.yaml` is a regular file, not a directory + +**Examples:** + +❌ **Incorrect** - Missing `changelog.yaml`: + +``` +my-module/ +├── .gitignore +└── docs/ + └── README.md +``` + +**Error:** +``` +changelog.yaml file is missing in package root +``` + +❌ **Incorrect** - `changelog.yaml` is a directory: + +``` +my-module/ +└── changelog.yaml/ # Wrong: must be a file + └── v1.0.0.yaml +``` + +**Error:** +``` +changelog.yaml must be a file in package root +``` + +✅ **Correct:** + +``` +my-module/ +├── .gitignore +├── changelog.yaml +└── docs/ + └── README.md +``` + +--- + +### docs + +**Purpose:** Ensures every module has a `docs/` directory in its root, so downstream documentation rules (see the [Documentation Linter](../docs/README.md)) have a directory to inspect. + +**What it checks:** + +1. Verifies that `docs/` exists in the package root +2. Verifies that `docs/` is a directory, not a regular file + +**Examples:** + +❌ **Incorrect** - Missing `docs/` directory: + +``` +my-module/ +├── .gitignore +└── changelog.yaml +``` + +**Error:** +``` +docs directory is missing in package root +``` + +❌ **Incorrect** - `docs` is a file: + +``` +my-module/ +└── docs # Wrong: must be a directory +``` + +**Error:** +``` +docs must be a directory in package root +``` + +✅ **Correct:** + +``` +my-module/ +├── .gitignore +├── changelog.yaml +└── docs/ + └── README.md +``` + +--- + +## Common Issues + +### Issue: Missing `.gitignore`, `changelog.yaml`, or `docs/` + +**Symptom:** +``` +Error: .gitignore file is missing in package root +Error: changelog.yaml file is missing in package root +Error: docs directory is missing in package root +``` + +**Cause:** The module root is missing one or more of the required entries. + +**Solution:** + +```bash +cd modules/my-module +touch .gitignore +touch changelog.yaml +mkdir -p docs +``` + +### Issue: Required entry has the wrong kind (file vs. directory) + +**Symptom:** +``` +Error: .gitignore must be a file in package root +Error: docs must be a directory in package root +``` + +**Cause:** A path that should be a file is a directory (or vice versa) — e.g. `docs` was created as an empty file instead of a directory. + +**Solution:** Remove the wrong entry and recreate it with the expected kind. + +```bash +rm docs # was created as a file by mistake +mkdir -p docs +``` diff --git a/pkg/remote-linters/release/layout/README.md b/pkg/remote-linters/release/layout/README.md new file mode 100644 index 00000000..61abadfc --- /dev/null +++ b/pkg/remote-linters/release/layout/README.md @@ -0,0 +1,90 @@ +# Layout Linter (release) + +## Overview + +The **Layout Linter** validates the top-level file layout of a module's **release** image. + +This linter is part of the `dmt` remote-lint bundle used to validate a module image pulled from a registry. + +## Rules + +| Rule | Description | Configurable | Default | +|------|-------------|--------------|---------| +| [changelog](#changelog) | Validates presence of `changelog.yaml` | ❌ | enabled | + + +## Rule Details + +### changelog + +**Purpose:** Ensures the release image segment has a `changelog.yaml` file in its root, giving every release a machine-readable history entry point. + +**What it checks:** + +1. Verifies that `changelog.yaml` exists in the root of the release segment +2. Verifies that `changelog.yaml` is a regular file, not a directory + +**Examples:** + +❌ **Incorrect** - Missing `changelog.yaml`: + +``` +release/ +└── (no changelog.yaml) +``` + +**Error:** +``` +changelog.yaml file is missing in package root +``` + +❌ **Incorrect** - `changelog.yaml` is a directory: + +``` +release/ +└── changelog.yaml/ # Wrong: must be a file + └── v1.0.0.yaml +``` + +**Error:** +``` +changelog.yaml must be a file in package root +``` + +✅ **Correct:** + +``` +release/ +└── changelog.yaml +``` + +--- + +## Common Issues + +### Issue: Missing `changelog.yaml` in the release segment + +**Symptom:** +``` +Error: changelog.yaml file is missing in package root +``` + +**Cause:** The release image segment doesn't have a `changelog.yaml` file in its root. + +**Solution:** Add a `changelog.yaml` to whatever build step produces the release image segment. + +### Issue: `changelog.yaml` has the wrong kind + +**Symptom:** +``` +Error: changelog.yaml must be a file in package root +``` + +**Cause:** `changelog.yaml` was created as a directory instead of a file. + +**Solution:** Remove the directory and replace it with a regular `changelog.yaml` file. + +```bash +rm -rf changelog.yaml +touch changelog.yaml +``` From 77b47276184d2b3614b9276eac901543a1c62a88 Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Tue, 14 Jul 2026 16:16:17 +0300 Subject: [PATCH 17/26] Enhance layout linter to include module.yaml and version.json checks; update documentation accordingly Signed-off-by: Smyslov Maxim --- internal/remote-lint/lint.go | 8 +- pkg/remote-linters/release/layout/README.md | 125 ++++++++++++++++-- pkg/remote-linters/release/layout/linter.go | 2 + .../release/layout/rules/module_yaml.go | 13 ++ .../release/layout/rules/version_json.go | 13 ++ 5 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 pkg/remote-linters/release/layout/rules/module_yaml.go create mode 100644 pkg/remote-linters/release/layout/rules/version_json.go diff --git a/internal/remote-lint/lint.go b/internal/remote-lint/lint.go index 25d83d30..a1ee4001 100644 --- a/internal/remote-lint/lint.go +++ b/internal/remote-lint/lint.go @@ -89,7 +89,13 @@ func lintRelease(ctx context.Context, client *client.Client, tag string, errorLi if err != nil { return fmt.Errorf("failed to extract release image: %w", err) } - defer os.RemoveAll(tempDir) + + // remove temp dir if there are no errors + defer func(errs *errors.LintRuleErrorsList) { + if !errs.ContainsErrors() { + os.RemoveAll(tempDir) + } + }(errorList) linters := buildReleaseLinters(tempDir, errorList.WithObjectID("release")) diff --git a/pkg/remote-linters/release/layout/README.md b/pkg/remote-linters/release/layout/README.md index 61abadfc..dc4ebe60 100644 --- a/pkg/remote-linters/release/layout/README.md +++ b/pkg/remote-linters/release/layout/README.md @@ -11,7 +11,8 @@ This linter is part of the `dmt` remote-lint bundle used to validate a module im | Rule | Description | Configurable | Default | |------|-------------|--------------|---------| | [changelog](#changelog) | Validates presence of `changelog.yaml` | ❌ | enabled | - +| [module-definition](#module-definition) | Validates presence of `module.yaml` | ❌ | enabled | +| [version-json](#version-json) | Validates presence of `version.json` | ❌ | enabled | ## Rule Details @@ -53,38 +54,144 @@ changelog.yaml must be a file in package root ✅ **Correct:** +``` +release/ +├── changelog.yaml +├── module.yaml +└── version.json +``` + +--- + +### module-definition + +**Purpose:** Ensures the release image segment has a `module.yaml` file in its root, so the module definition shipped in a release can always be read without falling back to the main bundle image. + +**What it checks:** + +1. Verifies that `module.yaml` exists in the root of the release segment +2. Verifies that `module.yaml` is a regular file, not a directory + +**Examples:** + +❌ **Incorrect** - Missing `module.yaml`: + ``` release/ └── changelog.yaml ``` +**Error:** +``` +module.yaml file is missing in package root +``` + +❌ **Incorrect** - `module.yaml` is a directory: + +``` +release/ +└── module.yaml/ # Wrong: must be a file + └── module.yaml +``` + +**Error:** +``` +module.yaml must be a file in package root +``` + +✅ **Correct:** + +``` +release/ +├── changelog.yaml +├── module.yaml +└── version.json +``` + +--- + +### version-json + +**Purpose:** Ensures the release image segment has a `version.json` file in its root, so the released version can be read directly from the release segment. + +**What it checks:** + +1. Verifies that `version.json` exists in the root of the release segment +2. Verifies that `version.json` is a regular file, not a directory + +**Examples:** + +❌ **Incorrect** - Missing `version.json`: + +``` +release/ +├── changelog.yaml +└── module.yaml +``` + +**Error:** +``` +version.json file is missing in package root +``` + +❌ **Incorrect** - `version.json` is a directory: + +``` +release/ +└── version.json/ # Wrong: must be a file + └── v1.0.0.json +``` + +**Error:** +``` +version.json must be a file in package root +``` + +✅ **Correct:** + +``` +release/ +├── changelog.yaml +├── module.yaml +└── version.json +``` + --- ## Common Issues -### Issue: Missing `changelog.yaml` in the release segment +### Issue: Missing `changelog.yaml`, `module.yaml`, or `version.json` in the release segment **Symptom:** ``` Error: changelog.yaml file is missing in package root +Error: module.yaml file is missing in package root +Error: version.json file is missing in package root ``` -**Cause:** The release image segment doesn't have a `changelog.yaml` file in its root. +**Cause:** The release image segment doesn't have one or more of the required files in its root. + +**Solution:** Add the missing file(s) to whatever build step produces the release image segment. -**Solution:** Add a `changelog.yaml` to whatever build step produces the release image segment. +```bash +cd release +touch changelog.yaml module.yaml version.json +``` -### Issue: `changelog.yaml` has the wrong kind +### Issue: Required file has the wrong kind (must be a file, not a directory) **Symptom:** ``` Error: changelog.yaml must be a file in package root +Error: module.yaml must be a file in package root +Error: version.json must be a file in package root ``` -**Cause:** `changelog.yaml` was created as a directory instead of a file. +**Cause:** One of the required files was created as a directory instead of a regular file. -**Solution:** Remove the directory and replace it with a regular `changelog.yaml` file. +**Solution:** Remove the directory and replace it with a regular file. ```bash -rm -rf changelog.yaml -touch changelog.yaml +rm -rf version.json +touch version.json ``` diff --git a/pkg/remote-linters/release/layout/linter.go b/pkg/remote-linters/release/layout/linter.go index c1b375c0..cb09e99c 100644 --- a/pkg/remote-linters/release/layout/linter.go +++ b/pkg/remote-linters/release/layout/linter.go @@ -32,4 +32,6 @@ func NewLinter(cfg Config, errorList *errors.LintRuleErrorsList) *Linter { // Lint executes all layout rules against the configured package path. func (l *Linter) Lint(ctx context.Context) { rules.NewChangelogRule(l.config.Path, l.errorList).Check(ctx) + rules.NewModuleYAMLRule(l.config.Path, l.errorList).Check(ctx) + rules.NewVersionJSONRule(l.config.Path, l.errorList).Check(ctx) } diff --git a/pkg/remote-linters/release/layout/rules/module_yaml.go b/pkg/remote-linters/release/layout/rules/module_yaml.go new file mode 100644 index 00000000..d9289d97 --- /dev/null +++ b/pkg/remote-linters/release/layout/rules/module_yaml.go @@ -0,0 +1,13 @@ +package rules + +import "github.com/deckhouse/dmt/pkg/errors" + +// Rule purpose: require module.yaml so package changes have a module definition. + +// ModuleYAMLRuleID is the stable identifier used to reference this rule in configuration. +const ModuleYAMLRuleID = "module-definition" + +// NewModuleYAMLRule constructs a rule that requires module.yaml in the package root. +func NewModuleYAMLRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { + return newRequiredRootPathsRule(path, errorList.WithRule(ModuleYAMLRuleID), []string{"module.yaml"}, nil) +} diff --git a/pkg/remote-linters/release/layout/rules/version_json.go b/pkg/remote-linters/release/layout/rules/version_json.go new file mode 100644 index 00000000..1665178b --- /dev/null +++ b/pkg/remote-linters/release/layout/rules/version_json.go @@ -0,0 +1,13 @@ +package rules + +import "github.com/deckhouse/dmt/pkg/errors" + +// Rule purpose: require version.json so package changes have a version definition. + +// VersionJSONRuleID is the stable identifier used to reference this rule in configuration. +const VersionJSONRuleID = "version-json" + +// NewVersionJSONRule constructs a rule that requires version.json in the package root. +func NewVersionJSONRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { + return newRequiredRootPathsRule(path, errorList.WithRule(VersionJSONRuleID), []string{"version.json"}, nil) +} From 7cf108cbff698ebf2523c8d1a1866c0fbc952044 Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Tue, 14 Jul 2026 16:32:43 +0300 Subject: [PATCH 18/26] Add new rules to layout linter for templates, charts, digests, Chart.yaml, and version.json; update README documentation to reflect changes Signed-off-by: Smyslov Maxim --- pkg/remote-linters/bundle/layout/README.md | 318 +++++++++++++++++- pkg/remote-linters/bundle/layout/linter.go | 6 + .../bundle/layout/rules/changelog.go | 2 +- .../bundle/layout/rules/chart_yaml.go | 13 + .../bundle/layout/rules/charts.go | 13 + .../bundle/layout/rules/digests.go | 13 + .../bundle/layout/rules/module_yaml.go | 13 + .../bundle/layout/rules/templates.go | 13 + .../bundle/layout/rules/version_json.go | 13 + 9 files changed, 396 insertions(+), 8 deletions(-) create mode 100644 pkg/remote-linters/bundle/layout/rules/chart_yaml.go create mode 100644 pkg/remote-linters/bundle/layout/rules/charts.go create mode 100644 pkg/remote-linters/bundle/layout/rules/digests.go create mode 100644 pkg/remote-linters/bundle/layout/rules/module_yaml.go create mode 100644 pkg/remote-linters/bundle/layout/rules/templates.go create mode 100644 pkg/remote-linters/bundle/layout/rules/version_json.go diff --git a/pkg/remote-linters/bundle/layout/README.md b/pkg/remote-linters/bundle/layout/README.md index 91bdfa98..406157e0 100644 --- a/pkg/remote-linters/bundle/layout/README.md +++ b/pkg/remote-linters/bundle/layout/README.md @@ -2,9 +2,9 @@ ## Overview -The **Layout Linter** validates the top-level file and directory layout of a module (bundle) image. It checks that a small set of required root-level entries — `.gitignore`, `changelog.yaml`, and `docs/` — are present and are of the expected kind (file vs. directory). +The **Layout Linter** validates the top-level file and directory layout of a module (bundle) image. It checks that a set of required root-level entries — `.gitignore`, `changelog.yaml`, `docs/`, `templates/`, `charts/`, `images_digests.json`, `Chart.yaml`, `version.json`, and `module.yaml` — are present and are of the expected kind (file vs. directory). -Enforcing a consistent root layout makes modules predictable to browse, package, and automate against: tooling that expects `changelog.yaml` or `docs/` to exist doesn't have to special-case modules that are missing them. +Enforcing a consistent root layout makes modules predictable to browse, package, and automate against: tooling that expects these entries to exist doesn't have to special-case modules that are missing them. This linter is part of the `dmt` remote-lint bundle used to validate a module image pulled from a registry (see `dmt remote-lint`). It runs standalone and does not read `.dmtlint.yaml`, so none of its rules are configurable or excludable. @@ -15,10 +15,16 @@ This linter is part of the `dmt` remote-lint bundle used to validate a module im | [gitignore](#gitignore) | Validates presence of `.gitignore` in the package root | ❌ | enabled | | [changelog](#changelog) | Validates presence of `changelog.yaml` in the package root | ❌ | enabled | | [docs](#docs) | Validates presence of `docs/` directory in the package root | ❌ | enabled | +| [templates](#templates) | Validates presence of `templates/` directory in the package root | ❌ | enabled | +| [charts](#charts) | Validates presence of `charts/` directory in the package root | ❌ | enabled | +| [digests](#digests) | Validates presence of `images_digests.json` in the package root | ❌ | enabled | +| [chart-yaml](#chart-yaml) | Validates presence of `Chart.yaml` in the package root | ❌ | enabled | +| [version-json](#version-json) | Validates presence of `version.json` in the package root | ❌ | enabled | +| [module-definition](#module-definition) | Validates presence of `module.yaml` in the package root | ❌ | enabled | None of these rules can be configured or disabled: this linter has no settings beyond the module path, and there is no `.dmtlint.yaml` support for it. -All three rules run unconditionally and independently of each other, so a single `Lint` call can report findings for more than one of them at once. +All rules run unconditionally and independently of each other, so a single `Lint` call can report findings for more than one of them at once. ## Rule Details @@ -172,15 +178,312 @@ my-module/ --- +### templates + +**Purpose:** Ensures every module has a `templates/` directory in its root, so the bundle ships the Helm templates it's meant to render. + +**What it checks:** + +1. Verifies that `templates/` exists in the package root +2. Verifies that `templates/` is a directory, not a regular file + +**Examples:** + +❌ **Incorrect** - Missing `templates/` directory: + +``` +my-module/ +├── .gitignore +└── Chart.yaml +``` + +**Error:** +``` +templates directory is missing in package root +``` + +❌ **Incorrect** - `templates` is a file: + +``` +my-module/ +└── templates # Wrong: must be a directory +``` + +**Error:** +``` +templates must be a directory in package root +``` + +✅ **Correct:** + +``` +my-module/ +├── Chart.yaml +└── templates/ + └── deployment.yaml +``` + +--- + +### charts + +**Purpose:** Ensures every module has a `charts/` directory in its root, so the bundle ships the shared helm-lib with helper templates used by `templates/`. + +**What it checks:** + +1. Verifies that `charts/` exists in the package root +2. Verifies that `charts/` is a directory, not a regular file + +**Examples:** + +❌ **Incorrect** - Missing `charts/` directory: + +``` +my-module/ +├── Chart.yaml +└── templates/ + └── deployment.yaml +``` + +**Error:** +``` +charts directory is missing in package root +``` + +❌ **Incorrect** - `charts` is a file: + +``` +my-module/ +└── charts # Wrong: must be a directory +``` + +**Error:** +``` +charts must be a directory in package root +``` + +✅ **Correct:** + +``` +my-module/ +├── Chart.yaml +├── charts/ +│ └── helm_lib/ +└── templates/ + └── deployment.yaml +``` + +--- + +### digests + +**Purpose:** Ensures every module has an `images_digests.json` file in its root, so the bundle carries resolved digests for every image it references. + +**What it checks:** + +1. Verifies that `images_digests.json` exists in the package root +2. Verifies that `images_digests.json` is a regular file, not a directory + +**Examples:** + +❌ **Incorrect** - Missing `images_digests.json`: + +``` +my-module/ +├── Chart.yaml +└── templates/ + └── deployment.yaml +``` + +**Error:** +``` +images_digests.json file is missing in package root +``` + +❌ **Incorrect** - `images_digests.json` is a directory: + +``` +my-module/ +└── images_digests.json/ # Wrong: must be a file + └── digests.json +``` + +**Error:** +``` +images_digests.json must be a file in package root +``` + +✅ **Correct:** + +``` +my-module/ +├── Chart.yaml +├── images_digests.json +└── templates/ + └── deployment.yaml +``` + +--- + +### chart-yaml + +**Purpose:** Ensures every module has a `Chart.yaml` file in its root, so the bundle carries a valid Helm chart definition. + +**What it checks:** + +1. Verifies that `Chart.yaml` exists in the package root +2. Verifies that `Chart.yaml` is a regular file, not a directory + +**Examples:** + +❌ **Incorrect** - Missing `Chart.yaml`: + +``` +my-module/ +└── templates/ + └── deployment.yaml +``` + +**Error:** +``` +Chart.yaml file is missing in package root +``` + +❌ **Incorrect** - `Chart.yaml` is a directory: + +``` +my-module/ +└── Chart.yaml/ # Wrong: must be a file + └── Chart.yaml +``` + +**Error:** +``` +Chart.yaml must be a file in package root +``` + +✅ **Correct:** + +``` +my-module/ +├── Chart.yaml +└── templates/ + └── deployment.yaml +``` + +--- + +### version-json + +**Purpose:** Ensures every module has a `version.json` file in its root, so the released version can be read directly from the bundle image. + +**What it checks:** + +1. Verifies that `version.json` exists in the package root +2. Verifies that `version.json` is a regular file, not a directory + +**Examples:** + +❌ **Incorrect** - Missing `version.json`: + +``` +my-module/ +├── Chart.yaml +└── module.yaml +``` + +**Error:** +``` +version.json file is missing in package root +``` + +❌ **Incorrect** - `version.json` is a directory: + +``` +my-module/ +└── version.json/ # Wrong: must be a file + └── v1.0.0.json +``` + +**Error:** +``` +version.json must be a file in package root +``` + +✅ **Correct:** + +``` +my-module/ +├── Chart.yaml +├── module.yaml +└── version.json +``` + +--- + +### module-definition + +**Purpose:** Ensures every module has a `module.yaml` file in its root, so the module definition is always available directly from the bundle image. + +**What it checks:** + +1. Verifies that `module.yaml` exists in the package root +2. Verifies that `module.yaml` is a regular file, not a directory + +**Examples:** + +❌ **Incorrect** - Missing `module.yaml`: + +``` +my-module/ +├── Chart.yaml +└── version.json +``` + +**Error:** +``` +module.yaml file is missing in package root +``` + +❌ **Incorrect** - `module.yaml` is a directory: + +``` +my-module/ +└── module.yaml/ # Wrong: must be a file + └── module.yaml +``` + +**Error:** +``` +module.yaml must be a file in package root +``` + +✅ **Correct:** + +``` +my-module/ +├── Chart.yaml +├── module.yaml +└── version.json +``` + +--- + ## Common Issues -### Issue: Missing `.gitignore`, `changelog.yaml`, or `docs/` +### Issue: Missing a required root-level file or directory **Symptom:** ``` Error: .gitignore file is missing in package root Error: changelog.yaml file is missing in package root Error: docs directory is missing in package root +Error: templates directory is missing in package root +Error: charts directory is missing in package root +Error: images_digests.json file is missing in package root +Error: Chart.yaml file is missing in package root +Error: version.json file is missing in package root +Error: module.yaml file is missing in package root ``` **Cause:** The module root is missing one or more of the required entries. @@ -189,9 +492,8 @@ Error: docs directory is missing in package root ```bash cd modules/my-module -touch .gitignore -touch changelog.yaml -mkdir -p docs +touch .gitignore changelog.yaml images_digests.json Chart.yaml version.json module.yaml +mkdir -p docs templates charts ``` ### Issue: Required entry has the wrong kind (file vs. directory) @@ -200,6 +502,8 @@ mkdir -p docs ``` Error: .gitignore must be a file in package root Error: docs must be a directory in package root +Error: templates must be a directory in package root +Error: charts must be a directory in package root ``` **Cause:** A path that should be a file is a directory (or vice versa) — e.g. `docs` was created as an empty file instead of a directory. diff --git a/pkg/remote-linters/bundle/layout/linter.go b/pkg/remote-linters/bundle/layout/linter.go index 36b5950e..40365906 100644 --- a/pkg/remote-linters/bundle/layout/linter.go +++ b/pkg/remote-linters/bundle/layout/linter.go @@ -34,4 +34,10 @@ func (l *Linter) Lint(ctx context.Context) { rules.NewGitignoreRule(l.config.Path, l.errorList).Check(ctx) rules.NewChangelogRule(l.config.Path, l.errorList).Check(ctx) rules.NewDocsRule(l.config.Path, l.errorList).Check(ctx) + rules.NewTemplatesRule(l.config.Path, l.errorList).Check(ctx) + rules.NewChartsRule(l.config.Path, l.errorList).Check(ctx) + rules.NewDigestsRule(l.config.Path, l.errorList).Check(ctx) + rules.NewChartYAMLRule(l.config.Path, l.errorList).Check(ctx) + rules.NewVersionJSONRule(l.config.Path, l.errorList).Check(ctx) + rules.NewModuleYAMLRule(l.config.Path, l.errorList).Check(ctx) } diff --git a/pkg/remote-linters/bundle/layout/rules/changelog.go b/pkg/remote-linters/bundle/layout/rules/changelog.go index 759b47cc..e5d6b609 100644 --- a/pkg/remote-linters/bundle/layout/rules/changelog.go +++ b/pkg/remote-linters/bundle/layout/rules/changelog.go @@ -4,7 +4,7 @@ import ( "github.com/deckhouse/dmt/pkg/errors" ) -// Rule purpose: require changelog.yaml so package changes have a release history entry point. +// Rule purpose: require changelog.yaml so bundle image have a release history entry point. // ChangelogRuleID is the stable identifier used to reference this rule in configuration. const ChangelogRuleID = "changelog" diff --git a/pkg/remote-linters/bundle/layout/rules/chart_yaml.go b/pkg/remote-linters/bundle/layout/rules/chart_yaml.go new file mode 100644 index 00000000..b94bf43f --- /dev/null +++ b/pkg/remote-linters/bundle/layout/rules/chart_yaml.go @@ -0,0 +1,13 @@ +package rules + +import "github.com/deckhouse/dmt/pkg/errors" + +// Rule purpose: require Chart.yaml so bundle image have a chart definition. + +// ChartYAMLRuleID is the stable identifier used to reference this rule in configuration. +const ChartYAMLRuleID = "chart-yaml" + +// NewChartYAMLRule constructs a rule that requires Chart.yaml in the bundle root. +func NewChartYAMLRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { + return newRequiredRootPathsRule(path, errorList.WithRule(ChartYAMLRuleID), []string{"Chart.yaml"}, nil) +} diff --git a/pkg/remote-linters/bundle/layout/rules/charts.go b/pkg/remote-linters/bundle/layout/rules/charts.go new file mode 100644 index 00000000..312788ac --- /dev/null +++ b/pkg/remote-linters/bundle/layout/rules/charts.go @@ -0,0 +1,13 @@ +package rules + +import "github.com/deckhouse/dmt/pkg/errors" + +// Rule purpose: require charts directory so bundle image have a helm-lib with a helpers for templates. + +// ChartsRuleID is the stable identifier used to reference this rule in configuration. +const ChartsRuleID = "charts" + +// NewChartsRule constructs a rule that requires charts directory in the bundle root. +func NewChartsRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { + return newRequiredRootPathsRule(path, errorList.WithRule(ChartsRuleID), nil, []string{"charts"}) +} diff --git a/pkg/remote-linters/bundle/layout/rules/digests.go b/pkg/remote-linters/bundle/layout/rules/digests.go new file mode 100644 index 00000000..44b47fb1 --- /dev/null +++ b/pkg/remote-linters/bundle/layout/rules/digests.go @@ -0,0 +1,13 @@ +package rules + +import "github.com/deckhouse/dmt/pkg/errors" + +// Rule purpose: require images_digests.json so bundle image have a digests for all images. + +// ChartsRuleID is the stable identifier used to reference this rule in configuration. +const DigestsRuleID = "digests" + +// NewDigestsRule constructs a rule that requires images_digests.json in the bundle root. +func NewDigestsRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { + return newRequiredRootPathsRule(path, errorList.WithRule(DigestsRuleID), []string{"images_digests.json"}, nil) +} diff --git a/pkg/remote-linters/bundle/layout/rules/module_yaml.go b/pkg/remote-linters/bundle/layout/rules/module_yaml.go new file mode 100644 index 00000000..d9289d97 --- /dev/null +++ b/pkg/remote-linters/bundle/layout/rules/module_yaml.go @@ -0,0 +1,13 @@ +package rules + +import "github.com/deckhouse/dmt/pkg/errors" + +// Rule purpose: require module.yaml so package changes have a module definition. + +// ModuleYAMLRuleID is the stable identifier used to reference this rule in configuration. +const ModuleYAMLRuleID = "module-definition" + +// NewModuleYAMLRule constructs a rule that requires module.yaml in the package root. +func NewModuleYAMLRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { + return newRequiredRootPathsRule(path, errorList.WithRule(ModuleYAMLRuleID), []string{"module.yaml"}, nil) +} diff --git a/pkg/remote-linters/bundle/layout/rules/templates.go b/pkg/remote-linters/bundle/layout/rules/templates.go new file mode 100644 index 00000000..f424be62 --- /dev/null +++ b/pkg/remote-linters/bundle/layout/rules/templates.go @@ -0,0 +1,13 @@ +package rules + +import "github.com/deckhouse/dmt/pkg/errors" + +// Rule purpose: require templates/ so bundle image have a helm templates. + +// TemplatesRuleID is the stable identifier used to reference this rule in configuration. +const TemplatesRuleID = "templates" + +// NewTemplatesRule constructs a rule that requires templates/ in the bundle root. +func NewTemplatesRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { + return newRequiredRootPathsRule(path, errorList.WithRule(TemplatesRuleID), nil, []string{"templates"}) +} diff --git a/pkg/remote-linters/bundle/layout/rules/version_json.go b/pkg/remote-linters/bundle/layout/rules/version_json.go new file mode 100644 index 00000000..1665178b --- /dev/null +++ b/pkg/remote-linters/bundle/layout/rules/version_json.go @@ -0,0 +1,13 @@ +package rules + +import "github.com/deckhouse/dmt/pkg/errors" + +// Rule purpose: require version.json so package changes have a version definition. + +// VersionJSONRuleID is the stable identifier used to reference this rule in configuration. +const VersionJSONRuleID = "version-json" + +// NewVersionJSONRule constructs a rule that requires version.json in the package root. +func NewVersionJSONRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { + return newRequiredRootPathsRule(path, errorList.WithRule(VersionJSONRuleID), []string{"version.json"}, nil) +} From 351bb5a70c023ffc99a38cb938dfbfee8959fc17 Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Wed, 15 Jul 2026 14:31:29 +0300 Subject: [PATCH 19/26] make doc linter configurable Signed-off-by: Smyslov Maxim --- internal/module/bilingual_config_test.go | 4 +- internal/module/module.go | 6 +-- internal/remote-lint/lint.go | 56 ++++++++++++++++-------- pkg/remote-linters/bundle/docs/linter.go | 24 +++++----- 4 files changed, 54 insertions(+), 36 deletions(-) diff --git a/internal/module/bilingual_config_test.go b/internal/module/bilingual_config_test.go index 606f2dd2..1f25efcf 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 e5bc7622..80fd4e58 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/lint.go b/internal/remote-lint/lint.go index a1ee4001..18d621f7 100644 --- a/internal/remote-lint/lint.go +++ b/internal/remote-lint/lint.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "os" + "path/filepath" "slices" "strings" "text/tabwriter" @@ -14,10 +15,13 @@ import ( "github.com/kyokomi/emoji" "github.com/mitchellh/go-wordwrap" + "github.com/deckhouse/deckhouse/pkg/log" "github.com/deckhouse/deckhouse/pkg/registry/client" "github.com/deckhouse/dmt/internal/flags" + "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/remote-linters/bundle/docs" "github.com/deckhouse/dmt/pkg/remote-linters/bundle/layout" @@ -25,6 +29,7 @@ import ( ) 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 @@ -43,12 +48,17 @@ func RunRemoteLint(ctx context.Context, imagePath string, opts *RemoteLintOption errorList := errors.NewLintRuleErrorsList() // .WithMaxLevel() - err = lintBundle(ctx, client, tag, errorList) + 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, errorList) + err = lintRelease(ctx, client, tag, cfg, errorList) if err != nil { return fmt.Errorf("failed to lint release: %w", err) } @@ -58,7 +68,7 @@ func RunRemoteLint(ctx context.Context, imagePath string, opts *RemoteLintOption return nil } -func lintBundle(ctx context.Context, client *client.Client, tag string, errorList *errors.LintRuleErrorsList) error { +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) @@ -70,7 +80,9 @@ func lintBundle(ctx context.Context, client *client.Client, tag string, errorLis } defer os.RemoveAll(tempDir) - linters := buildBundleLinters(tempDir, errorList.WithObjectID("bundle")) + os.RemoveAll(filepath.Join(tempDir, "docs")) + + linters := buildBundleLinters(tempDir, cfg, errorList.WithObjectID("bundle")) for _, linter := range linters { linter.Lint(ctx) @@ -79,7 +91,7 @@ func lintBundle(ctx context.Context, client *client.Client, tag string, errorLis return nil } -func lintRelease(ctx context.Context, client *client.Client, tag string, errorList *errors.LintRuleErrorsList) error { +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) @@ -89,13 +101,7 @@ func lintRelease(ctx context.Context, client *client.Client, tag string, errorLi if err != nil { return fmt.Errorf("failed to extract release image: %w", err) } - - // remove temp dir if there are no errors - defer func(errs *errors.LintRuleErrorsList) { - if !errs.ContainsErrors() { - os.RemoveAll(tempDir) - } - }(errorList) + defer os.RemoveAll(tempDir) linters := buildReleaseLinters(tempDir, errorList.WithObjectID("release")) @@ -124,13 +130,10 @@ type Linter interface { Lint(ctx context.Context) } -func buildBundleLinters(path string, errorList *errors.LintRuleErrorsList) []Linter { - docsLinter := docs.NewLinter(docs.Config{Path: path}, errorList) - layoutLinter := layout.NewLinter(layout.Config{Path: path}, errorList) - +func buildBundleLinters(path string, cfg *pkg.LintersSettings, errorList *errors.LintRuleErrorsList) []Linter { return []Linter{ - docsLinter, - layoutLinter, + docs.NewLinter(path, &cfg.Documentation, errorList), + layout.NewLinter(layout.Config{Path: path}, errorList), } } @@ -142,6 +145,23 @@ func buildReleaseLinters(path string, errorList *errors.LintRuleErrorsList) []Li } } +func parseConfig() (*pkg.LintersSettings, error) { + rootConfig, err := config.NewDefaultRootConfig(".") + if err != nil { + log.Fatal("default root config", log.Err(err)) //nolint:gocritic + } + + // 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 +} + func PrintResult(errorList *errors.LintRuleErrorsList) { errs := errorList.GetErrors() diff --git a/pkg/remote-linters/bundle/docs/linter.go b/pkg/remote-linters/bundle/docs/linter.go index 742fbc4c..866c83b5 100644 --- a/pkg/remote-linters/bundle/docs/linter.go +++ b/pkg/remote-linters/bundle/docs/linter.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" + "github.com/deckhouse/dmt/pkg" "github.com/deckhouse/dmt/pkg/errors" "github.com/deckhouse/dmt/pkg/remote-linters/bundle/docs/rules" ) @@ -14,32 +15,29 @@ const LinterID = "docs" // Linter runs documentation rules against a package directory. type Linter struct { - config Config + path string + cfg *pkg.DocumentationLinterConfig errorList *errors.LintRuleErrorsList } -// Config holds the path and settings required to construct a Linter. -type Config struct { - Path string -} - // NewLinter constructs a Linter from cfg, scoping its diagnostics to this linter and capping severity at the configured level. -func NewLinter(cfg Config, errorList *errors.LintRuleErrorsList) *Linter { +func NewLinter(path string, cfg *pkg.DocumentationLinterConfig, errorList *errors.LintRuleErrorsList) *Linter { return &Linter{ - config: cfg, - errorList: errorList.WithLinterID(LinterID), + path: path, + cfg: cfg, + errorList: errorList.WithLinterID(LinterID).WithMaxLevel(cfg.Impact), } } // Lint executes all documentation rules against the configured package path. func (l *Linter) Lint(ctx context.Context) { - if !hasDocsDir(l.config.Path) { - l.errorList.WithFilePath(l.config.Path).Warn("docs folder not found in package root") + if !hasDocsDir(l.path) { + l.errorList.WithFilePath(l.path).Warn("docs folder not found in package root") return } - rules.NewReadmeRule(l.config.Path, l.errorList).Check(ctx) - rules.NewBilingualRule(l.config.Path, l.errorList).Check(ctx) + rules.NewReadmeRule(l.path, l.errorList.WithMaxLevel(l.cfg.Rules.ReadmeRule.GetLevel())).Check(ctx) + rules.NewBilingualRule(l.path, l.errorList.WithMaxLevel(l.cfg.Rules.BilingualRule.GetLevel())).Check(ctx) } // hasDocsDir reports whether docs/ exists as a directory in the package root. From 5932b76af73cfe6063b10611c28802718b734850 Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Wed, 15 Jul 2026 18:27:31 +0300 Subject: [PATCH 20/26] rm trash arch Signed-off-by: Smyslov Maxim --- internal/remote-lint/extract.go | 4 +- internal/remote-lint/lint.go | 47 +- internal/remote-lint/lint_test.go | 21 +- pkg/remote-linters/bundle/docs/README.md | 290 ---------- pkg/remote-linters/bundle/docs/linter.go | 47 -- .../bundle/docs/rules/bilingual.go | 90 --- pkg/remote-linters/bundle/docs/rules/files.go | 46 -- .../bundle/docs/rules/readme.go | 59 -- pkg/remote-linters/bundle/layout/README.md | 516 ------------------ pkg/remote-linters/bundle/layout/linter.go | 43 -- .../bundle/layout/rules/changelog.go | 15 - .../bundle/layout/rules/chart_yaml.go | 13 - .../bundle/layout/rules/charts.go | 13 - .../bundle/layout/rules/digests.go | 13 - .../bundle/layout/rules/docs.go | 13 - .../bundle/layout/rules/gitignore.go | 13 - .../bundle/layout/rules/module_yaml.go | 13 - .../bundle/layout/rules/required_paths.go | 86 --- .../bundle/layout/rules/templates.go | 13 - .../bundle/layout/rules/version_json.go | 13 - pkg/remote-linters/release/layout/README.md | 197 ------- pkg/remote-linters/release/layout/linter.go | 37 -- .../release/layout/rules/changelog.go | 15 - .../release/layout/rules/module_yaml.go | 13 - .../release/layout/rules/required_paths.go | 86 --- .../release/layout/rules/version_json.go | 13 - 26 files changed, 44 insertions(+), 1685 deletions(-) delete mode 100644 pkg/remote-linters/bundle/docs/README.md delete mode 100644 pkg/remote-linters/bundle/docs/linter.go delete mode 100644 pkg/remote-linters/bundle/docs/rules/bilingual.go delete mode 100644 pkg/remote-linters/bundle/docs/rules/files.go delete mode 100644 pkg/remote-linters/bundle/docs/rules/readme.go delete mode 100644 pkg/remote-linters/bundle/layout/README.md delete mode 100644 pkg/remote-linters/bundle/layout/linter.go delete mode 100644 pkg/remote-linters/bundle/layout/rules/changelog.go delete mode 100644 pkg/remote-linters/bundle/layout/rules/chart_yaml.go delete mode 100644 pkg/remote-linters/bundle/layout/rules/charts.go delete mode 100644 pkg/remote-linters/bundle/layout/rules/digests.go delete mode 100644 pkg/remote-linters/bundle/layout/rules/docs.go delete mode 100644 pkg/remote-linters/bundle/layout/rules/gitignore.go delete mode 100644 pkg/remote-linters/bundle/layout/rules/module_yaml.go delete mode 100644 pkg/remote-linters/bundle/layout/rules/required_paths.go delete mode 100644 pkg/remote-linters/bundle/layout/rules/templates.go delete mode 100644 pkg/remote-linters/bundle/layout/rules/version_json.go delete mode 100644 pkg/remote-linters/release/layout/README.md delete mode 100644 pkg/remote-linters/release/layout/linter.go delete mode 100644 pkg/remote-linters/release/layout/rules/changelog.go delete mode 100644 pkg/remote-linters/release/layout/rules/module_yaml.go delete mode 100644 pkg/remote-linters/release/layout/rules/required_paths.go delete mode 100644 pkg/remote-linters/release/layout/rules/version_json.go diff --git a/internal/remote-lint/extract.go b/internal/remote-lint/extract.go index f2bcdba6..1963058e 100644 --- a/internal/remote-lint/extract.go +++ b/internal/remote-lint/extract.go @@ -22,7 +22,7 @@ func ExtractImage(ctx context.Context, image registry.Image) (string, error) { rc := image.Extract() defer rc.Close() - err = Extract(ctx, rc, tempDir) + err = extract(ctx, rc, tempDir) if err != nil { return "", fmt.Errorf("failed to extract image: %w", err) } @@ -30,7 +30,7 @@ func ExtractImage(ctx context.Context, image registry.Image) (string, error) { return tempDir, nil } -func Extract(ctx context.Context, rc io.ReadCloser, target string) error { +func extract(ctx context.Context, rc io.ReadCloser, target string) error { tr := tar.NewReader(rc) for { diff --git a/internal/remote-lint/lint.go b/internal/remote-lint/lint.go index 18d621f7..d5962ec7 100644 --- a/internal/remote-lint/lint.go +++ b/internal/remote-lint/lint.go @@ -12,6 +12,7 @@ import ( "text/tabwriter" "github.com/fatih/color" + "github.com/google/go-containerregistry/pkg/name" "github.com/kyokomi/emoji" "github.com/mitchellh/go-wordwrap" @@ -19,13 +20,12 @@ import ( "github.com/deckhouse/deckhouse/pkg/registry/client" "github.com/deckhouse/dmt/internal/flags" + "github.com/deckhouse/dmt/internal/manager" "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/remote-linters/bundle/docs" - "github.com/deckhouse/dmt/pkg/remote-linters/bundle/layout" - releaseLayout "github.com/deckhouse/dmt/pkg/remote-linters/release/layout" + "github.com/deckhouse/dmt/pkg/linters/docs" ) type RemoteLintOptions struct { @@ -46,7 +46,8 @@ func RunRemoteLint(ctx context.Context, imagePath string, opts *RemoteLintOption client := initRegistryClient(registryPath, opts.Login, opts.Password) - errorList := errors.NewLintRuleErrorsList() // .WithMaxLevel() + level := pkg.Error + errorList := errors.NewLintRuleErrorsList().WithMaxLevel(&level) cfg, err := parseConfig() if err != nil { @@ -65,6 +66,10 @@ func RunRemoteLint(ctx context.Context, imagePath string, opts *RemoteLintOption PrintResult(errorList) + if errorList.ContainsErrors() { + return fmt.Errorf("critical errors found") + } + return nil } @@ -80,12 +85,17 @@ func lintBundle(ctx context.Context, client *client.Client, tag string, cfg *pkg } defer os.RemoveAll(tempDir) - os.RemoveAll(filepath.Join(tempDir, "docs")) + os.RemoveAll(filepath.Join(tempDir, "docs")) // debug: remove docs directory linters := buildBundleLinters(tempDir, cfg, errorList.WithObjectID("bundle")) for _, linter := range linters { - linter.Lint(ctx) + m, err := module.NewModule(tempDir, nil, nil, nil, errorList.WithObjectID("bundle")) + if err != nil { + return fmt.Errorf("failed to create module: %w", err) + } + + linter.Run(m) } return nil @@ -106,7 +116,7 @@ func lintRelease(ctx context.Context, client *client.Client, tag string, cfg *pk linters := buildReleaseLinters(tempDir, errorList.WithObjectID("release")) for _, linter := range linters { - linter.Lint(ctx) + linter.Run(nil) } return nil @@ -119,30 +129,31 @@ func cutTagFromImagePath(imagePath string) (string, string, error) { return "", "", fmt.Errorf("digest not supported") } - if parts := strings.Split(imagePath, ":"); len(parts) == 2 { - return parts[0], parts[1], nil + 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 "", "", fmt.Errorf("tag not found in image path: %s", imagePath) + return ref.Context().Name(), tag, nil } type Linter interface { - Lint(ctx context.Context) + manager.Linter } func buildBundleLinters(path string, cfg *pkg.LintersSettings, errorList *errors.LintRuleErrorsList) []Linter { return []Linter{ - docs.NewLinter(path, &cfg.Documentation, errorList), - layout.NewLinter(layout.Config{Path: path}, errorList), + docs.New(&cfg.Documentation, errorList.WithMaxLevel(cfg.Documentation.Impact)), } } func buildReleaseLinters(path string, errorList *errors.LintRuleErrorsList) []Linter { - layoutLinter := releaseLayout.NewLinter(releaseLayout.Config{Path: path}, errorList) - - return []Linter{ - layoutLinter, - } + return []Linter{} } func parseConfig() (*pkg.LintersSettings, error) { diff --git a/internal/remote-lint/lint_test.go b/internal/remote-lint/lint_test.go index c7bd97e6..14749b32 100644 --- a/internal/remote-lint/lint_test.go +++ b/internal/remote-lint/lint_test.go @@ -7,18 +7,23 @@ import ( ) 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) - repository, tag, err = cutTagFromImagePath("registry.example.com/deckhouse/my-module@sha256:1234567890") - require.Contains(t, err.Error(), "digest not supported") - require.Empty(t, repository) - require.Empty(t, tag) + // Image with digest + _, _, err = cutTagFromImagePath("registry.example.com/deckhouse/my-module@sha256:1234567890") + require.ErrorContains(t, err, "digest not supported") - repository, tag, err = cutTagFromImagePath("registry.example.com/deckhouse/my-module") - require.Contains(t, err.Error(), "tag not found in image path") - require.Empty(t, repository) - require.Empty(t, tag) + // 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/pkg/remote-linters/bundle/docs/README.md b/pkg/remote-linters/bundle/docs/README.md deleted file mode 100644 index 76fa356a..00000000 --- a/pkg/remote-linters/bundle/docs/README.md +++ /dev/null @@ -1,290 +0,0 @@ -# Documentation Linter - -## Overview - -The **Documentation Linter** validates module documentation to ensure proper structure, completeness, and bilingual coverage. This linter enforces bilingual documentation requirements and checks for documentation file presence. - -Proper documentation is critical for Deckhouse modules as it helps users understand module features, configuration options, and usage patterns. The linter ensures documentation meets quality standards and is accessible to both English and Russian-speaking audiences. - -## Rules - -| Rule | Description | Configurable | Default | -|------|-------------|--------------|---------| -| [readme](#readme) | Validates presence of README.md in docs/ directory | ❌ | enabled | -| [bilingual](#bilingual) | Validates documentation exists in both English and Russian | ❌ | enabled | - -"Configurable" means that this rule can be configured using the `.dmtlint.yaml` file, including customizing the rule's parameters and/or disabling the rule. - -## Rule Details - -### readme - -**Purpose:** Ensures every module has a primary documentation entry point in the `docs/README.md` file. This provides a consistent location for users to find module information and prevents modules from being deployed without documentation. - -**Description:** - -Every Deckhouse module must have a `docs/README.md` file that serves as the main documentation entry point. This file should contain an overview of the module, its features, configuration options, and usage examples. - -**What it checks:** - -1. Verifies that `docs/README.md` file exists in the module directory -2. Checks that the README.md file is not empty (size > 0 bytes) -3. Validates file is readable and accessible - -**Why it matters:** - -Documentation is essential for module adoption and proper usage. Without a README.md file, users won't understand what the module does, how to configure it, or how to troubleshoot issues. This rule ensures every module meets minimum documentation standards. - -**Examples:** - -❌ **Incorrect** - Missing README.md: - -``` -my-module/ -├── templates/ -│ └── deployment.yaml -├── openapi/ -│ └── config-values.yaml -└── docs/ - └── CONFIGURATION.md # Other docs exist but no README.md -``` - -**Error:** -``` -README.md file is missing in docs/ directory -``` - -❌ **Incorrect** - Empty README.md: - -``` -my-module/ -└── docs/ - └── README.md # File exists but is empty (0 bytes) -``` - -**Error:** -``` -README.md file is empty -``` - -✅ **Correct** - Proper README.md: - -``` -my-module/ -└── docs/ - └── README.md # Contains module documentation -``` - -```markdown -# My Module - -## Overview -This module provides... - -## Configuration -... - -## Usage -... -``` - ---- - -### bilingual - -**Purpose:** Ensures module documentation is accessible to both English and Russian-speaking audiences by requiring documentation files in both languages. This maintains Deckhouse's commitment to bilingual support and helps users in different regions. - -**Description:** - -For every English documentation file in the `docs/` directory, a corresponding Russian translation must exist. Russian documentation files should use the `.ru.md` suffix (e.g., `README.ru.md` for `README.md`). - -**What it checks:** - -1. Scans all `.md` files in the `docs/` directory (top-level only) -2. For each English documentation file, checks for a corresponding `.ru.md` or `_RU.md` file -3. Validates that Russian counterparts exist for all documentation files -4. Ignores files that are already Russian (ending in `.ru.md` or `_RU.md`) - -**Why it matters:** - -Deckhouse is used by organizations globally, with significant adoption in Russian-speaking regions. Bilingual documentation ensures all users can effectively use and configure modules regardless of their language preference. Missing translations create accessibility barriers and reduce module adoption. - -**Examples:** - -❌ **Incorrect** - Missing Russian translation: - -``` -my-module/ -└── docs/ - ├── README.md # English version exists - ├── CONFIGURATION.md # English version exists - └── CONFIGURATION.ru.md # Russian version exists for CONFIGURATION - # ❌ Missing README.ru.md -``` - -**Error:** -``` -Russian counterpart is missing: need to create a matching .ru.md in docs/ -File: docs/README.md -``` - -✅ **Correct** - Complete bilingual documentation: - -``` -my-module/ -└── docs/ - ├── README.md # English version - ├── README.ru.md # Russian translation - ├── CONFIGURATION.md # English version - └── CONFIGURATION.ru.md # Russian translation -``` - -✅ **Correct** - Legacy naming (still supported): - -``` -my-module/ -└── docs/ - ├── README.md # English version - ├── README_RU.md # Russian translation (legacy format) - ├── CONFIGURATION.md - └── CONFIGURATION_RU.md -``` - -**Supported file naming conventions:** - -- **Preferred:** `FILENAME.ru.md` (e.g., `README.ru.md`, `FAQ.ru.md`) -- **Legacy:** `FILENAME_RU.md` (e.g., `README_RU.md`, `FAQ_RU.md`) - case insensitive - ---- - -## Common Issues - -### Issue: Missing README.md - -**Symptom:** -``` -Error: README.md file is missing in docs/ directory -``` - -**Cause:** The module doesn't have a `docs/README.md` file. - -**Solutions:** - -1. **Create the README.md file:** - - ```bash - mkdir -p modules/my-module/docs - cat > modules/my-module/docs/README.md << 'EOF' - # My Module - - ## Overview - Brief description of what this module does. - - ## Configuration - Configuration options and examples. - - ## Usage - How to use this module. - EOF - ``` - -2. **Use a documentation template:** - - ```bash - # Copy from another module - cp modules/reference-module/docs/README.md modules/my-module/docs/README.md - # Then customize the content - ``` - -### Issue: Missing Russian translation - -**Symptom:** -``` -Error: Russian counterpart is missing: need to create a matching .ru.md in docs/ -File: docs/CONFIGURATION.md -``` - -**Cause:** An English documentation file exists without a corresponding Russian translation. - -**Solutions:** - -1. **Create the Russian translation:** - - ```bash - # Create matching .ru.md file - touch modules/my-module/docs/CONFIGURATION.ru.md - ``` - -2. **Translate the content:** - - ```bash - # Start with copying the English version - cp modules/my-module/docs/CONFIGURATION.md \ - modules/my-module/docs/CONFIGURATION.ru.md - # Then translate the content to Russian - ``` - -### Issue: Empty README.md file - -**Symptom:** -``` -Error: README.md file is empty -``` - -**Cause:** The `docs/README.md` file exists but contains no content (0 bytes). - -**Solutions:** - -1. **Add content to the file:** - - ```bash - cat > modules/my-module/docs/README.md << 'EOF' - # My Module - - [Add module description here] - EOF - ``` - -2. **Use a minimal template:** - - ```markdown - # Module Name - - ## Description - Brief description of the module functionality. - - ## Configuration - See the OpenAPI schema for configuration options. - ``` - -### Issue: Wrong file naming convention - -**Symptom:** -``` -Error: Russian counterpart is missing: need to create a matching .ru.md in docs/ -File: docs/README.md -``` - -But you have `docs/README-RU.md` or `docs/README_ru.md` (lowercase). - -**Cause:** The Russian file uses an incorrect naming convention. - -**Solutions:** - -1. **Rename to the correct format:** - - ```bash - # Preferred format - mv modules/my-module/docs/README-RU.md \ - modules/my-module/docs/README.ru.md - - # Legacy format (also acceptable) - mv modules/my-module/docs/README-RU.md \ - modules/my-module/docs/README_RU.md - ``` - -2. **Supported naming conventions:** - - ✅ `FILENAME.ru.md` (preferred) - - ✅ `FILENAME_RU.md` (legacy, case insensitive) - - ❌ `FILENAME-RU.md` (not supported) - diff --git a/pkg/remote-linters/bundle/docs/linter.go b/pkg/remote-linters/bundle/docs/linter.go deleted file mode 100644 index 866c83b5..00000000 --- a/pkg/remote-linters/bundle/docs/linter.go +++ /dev/null @@ -1,47 +0,0 @@ -package docs - -import ( - "context" - "os" - "path/filepath" - - "github.com/deckhouse/dmt/pkg" - "github.com/deckhouse/dmt/pkg/errors" - "github.com/deckhouse/dmt/pkg/remote-linters/bundle/docs/rules" -) - -// LinterID is the stable identifier used to reference this linter in configuration and diagnostics. -const LinterID = "docs" - -// Linter runs documentation rules against a package directory. -type Linter struct { - path string - cfg *pkg.DocumentationLinterConfig - errorList *errors.LintRuleErrorsList -} - -// NewLinter constructs a Linter from cfg, scoping its diagnostics to this linter and capping severity at the configured level. -func NewLinter(path string, cfg *pkg.DocumentationLinterConfig, errorList *errors.LintRuleErrorsList) *Linter { - return &Linter{ - path: path, - cfg: cfg, - errorList: errorList.WithLinterID(LinterID).WithMaxLevel(cfg.Impact), - } -} - -// Lint executes all documentation rules against the configured package path. -func (l *Linter) Lint(ctx context.Context) { - if !hasDocsDir(l.path) { - l.errorList.WithFilePath(l.path).Warn("docs folder not found in package root") - return - } - - rules.NewReadmeRule(l.path, l.errorList.WithMaxLevel(l.cfg.Rules.ReadmeRule.GetLevel())).Check(ctx) - rules.NewBilingualRule(l.path, l.errorList.WithMaxLevel(l.cfg.Rules.BilingualRule.GetLevel())).Check(ctx) -} - -// hasDocsDir reports whether docs/ exists as a directory in the package root. -func hasDocsDir(path string) bool { - info, err := os.Stat(filepath.Join(path, "docs")) - return err == nil && info.IsDir() -} diff --git a/pkg/remote-linters/bundle/docs/rules/bilingual.go b/pkg/remote-linters/bundle/docs/rules/bilingual.go deleted file mode 100644 index 8cd3d00a..00000000 --- a/pkg/remote-linters/bundle/docs/rules/bilingual.go +++ /dev/null @@ -1,90 +0,0 @@ -package rules - -import ( - "context" - "os" - "path/filepath" - "strings" - - "github.com/deckhouse/dmt/pkg/errors" -) - -// Rule purpose: require Russian translations for each top-level English markdown file in docs/. - -// BilingualRuleID is the stable identifier used to reference this rule in configuration. -const BilingualRuleID = "bilingual" - -// Russian documentation suffixes accepted by the bilingual rule. -const ( - ruSuffix = ".ru.md" - ruFallbackSuffix = "_ru.md" -) - -// BilingualRule enforces that top-level English docs have Russian counterparts. -type BilingualRule struct { - errorList *errors.LintRuleErrorsList - path string -} - -// NewBilingualRule constructs a BilingualRule scoped to path, tagging diagnostics with the rule ID. -func NewBilingualRule(path string, errorList *errors.LintRuleErrorsList) *BilingualRule { - return &BilingualRule{ - path: path, - errorList: errorList.WithRule(BilingualRuleID), - } -} - -// Check verifies that each top-level English markdown file in docs/ has a Russian translation. -func (r *BilingualRule) Check(_ context.Context) { - docsPath := filepath.Join(r.path, "docs") - if _, err := os.Stat(docsPath); err != nil { - return - } - - files, err := collectFiles(docsPath, ".md") - if err != nil { - r.errorList. - WithFilePath(docsPath). - WithValue(err.Error()). - Error("cannot read docs directory") - - return - } - - fileSet := make(map[string]struct{}, len(files)) - for _, filePath := range files { - rel := packageRelativePath(r.path, filePath) - if filepath.Dir(rel) != "docs" { - continue - } - - if strings.HasSuffix(strings.ToLower(rel), ruFallbackSuffix) { - rel = strings.ToLower(rel) - } - - fileSet[rel] = struct{}{} - } - - for rel := range fileSet { - if !strings.HasPrefix(rel, "docs/") { - continue - } - - if !strings.HasSuffix(rel, ".md") || strings.HasSuffix(rel, ruFallbackSuffix) || strings.HasSuffix(rel, ruSuffix) { - continue - } - - base := strings.TrimSuffix(rel, ".md") - - // TODO: Delete it after renaming to .ru.md view. - ruRelFallback := strings.ToLower(base) + ruFallbackSuffix - if _, ok := fileSet[ruRelFallback]; ok { - continue - } - - ruRel := base + ruSuffix - if _, ok := fileSet[ruRel]; !ok { - r.errorList.WithFilePath(rel).Error("Russian counterpart is missing: need to create a matching .ru.md in docs/") - } - } -} diff --git a/pkg/remote-linters/bundle/docs/rules/files.go b/pkg/remote-linters/bundle/docs/rules/files.go deleted file mode 100644 index d44b93c8..00000000 --- a/pkg/remote-linters/bundle/docs/rules/files.go +++ /dev/null @@ -1,46 +0,0 @@ -package rules - -import ( - "os" - "path/filepath" - "strings" -) - -// collectFiles returns files under root whose names have one of the provided extensions. -func collectFiles(root string, extensions ...string) ([]string, error) { - files := make([]string, 0) - - err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - - if d.IsDir() { - return nil - } - - for _, ext := range extensions { - if strings.HasSuffix(d.Name(), ext) { - files = append(files, path) - return nil - } - } - - return nil - }) - if err != nil { - return nil, err - } - - return files, nil -} - -// packageRelativePath returns fullPath relative to packagePath, falling back to fullPath on error. -func packageRelativePath(packagePath, fullPath string) string { - relPath, err := filepath.Rel(packagePath, fullPath) - if err != nil { - return fullPath - } - - return filepath.ToSlash(relPath) -} diff --git a/pkg/remote-linters/bundle/docs/rules/readme.go b/pkg/remote-linters/bundle/docs/rules/readme.go deleted file mode 100644 index 0431afef..00000000 --- a/pkg/remote-linters/bundle/docs/rules/readme.go +++ /dev/null @@ -1,59 +0,0 @@ -package rules - -import ( - "context" - "os" - "path/filepath" - - "github.com/deckhouse/dmt/pkg/errors" -) - -// Rule purpose: require a non-empty docs/README.md as the package documentation entry point. - -// ReadmeRuleID is the stable identifier used to reference this rule in configuration. -const ReadmeRuleID = "readme" - -// ReadmeRule enforces that docs/README.md exists and is not empty. -type ReadmeRule struct { - path string - errorList *errors.LintRuleErrorsList -} - -// NewReadmeRule constructs a ReadmeRule scoped to path, tagging diagnostics with the rule ID. -func NewReadmeRule(path string, errorList *errors.LintRuleErrorsList) *ReadmeRule { - return &ReadmeRule{ - path: path, - errorList: errorList.WithRule(ReadmeRuleID), - } -} - -// Check verifies that docs/README.md exists and has content. -func (r *ReadmeRule) Check(_ context.Context) { - path := filepath.Join(r.path, "docs", "README.md") - - info, err := os.Stat(path) - if os.IsNotExist(err) { - r.errorList. - WithFilePath(path). - WithValue(err.Error()). - Error("README.md file is missing in docs/ directory") - - return - } - - if err != nil { - r.errorList. - WithFilePath(path). - WithValue(err.Error()). - Error("failed to check README.md file") - - return - } - - if info.Size() == 0 { - r.errorList. - WithFilePath(path). - WithValue("file is empty"). - Error("README.md file is empty") - } -} diff --git a/pkg/remote-linters/bundle/layout/README.md b/pkg/remote-linters/bundle/layout/README.md deleted file mode 100644 index 406157e0..00000000 --- a/pkg/remote-linters/bundle/layout/README.md +++ /dev/null @@ -1,516 +0,0 @@ -# Layout Linter - -## Overview - -The **Layout Linter** validates the top-level file and directory layout of a module (bundle) image. It checks that a set of required root-level entries — `.gitignore`, `changelog.yaml`, `docs/`, `templates/`, `charts/`, `images_digests.json`, `Chart.yaml`, `version.json`, and `module.yaml` — are present and are of the expected kind (file vs. directory). - -Enforcing a consistent root layout makes modules predictable to browse, package, and automate against: tooling that expects these entries to exist doesn't have to special-case modules that are missing them. - -This linter is part of the `dmt` remote-lint bundle used to validate a module image pulled from a registry (see `dmt remote-lint`). It runs standalone and does not read `.dmtlint.yaml`, so none of its rules are configurable or excludable. - -## Rules - -| Rule | Description | Configurable | Default | -|------|-------------|--------------|---------| -| [gitignore](#gitignore) | Validates presence of `.gitignore` in the package root | ❌ | enabled | -| [changelog](#changelog) | Validates presence of `changelog.yaml` in the package root | ❌ | enabled | -| [docs](#docs) | Validates presence of `docs/` directory in the package root | ❌ | enabled | -| [templates](#templates) | Validates presence of `templates/` directory in the package root | ❌ | enabled | -| [charts](#charts) | Validates presence of `charts/` directory in the package root | ❌ | enabled | -| [digests](#digests) | Validates presence of `images_digests.json` in the package root | ❌ | enabled | -| [chart-yaml](#chart-yaml) | Validates presence of `Chart.yaml` in the package root | ❌ | enabled | -| [version-json](#version-json) | Validates presence of `version.json` in the package root | ❌ | enabled | -| [module-definition](#module-definition) | Validates presence of `module.yaml` in the package root | ❌ | enabled | - -None of these rules can be configured or disabled: this linter has no settings beyond the module path, and there is no `.dmtlint.yaml` support for it. - -All rules run unconditionally and independently of each other, so a single `Lint` call can report findings for more than one of them at once. - -## Rule Details - -### gitignore - -**Purpose:** Ensures every module ships a `.gitignore` file in its root, so local artifacts and generated files don't accidentally get committed or bundled. - -**What it checks:** - -1. Verifies that `.gitignore` exists in the package root -2. Verifies that `.gitignore` is a regular file, not a directory - -**Examples:** - -❌ **Incorrect** - Missing `.gitignore`: - -``` -my-module/ -├── changelog.yaml -├── docs/ -│ └── README.md -└── templates/ - └── deployment.yaml -``` - -**Error:** -``` -.gitignore file is missing in package root -``` - -❌ **Incorrect** - `.gitignore` is a directory: - -``` -my-module/ -└── .gitignore/ # Wrong: must be a file - └── notes.txt -``` - -**Error:** -``` -.gitignore must be a file in package root -``` - -✅ **Correct:** - -``` -my-module/ -├── .gitignore -├── changelog.yaml -└── docs/ - └── README.md -``` - ---- - -### changelog - -**Purpose:** Ensures every module has a `changelog.yaml` file in its root, giving every release a machine-readable history entry point. - -**What it checks:** - -1. Verifies that `changelog.yaml` exists in the package root -2. Verifies that `changelog.yaml` is a regular file, not a directory - -**Examples:** - -❌ **Incorrect** - Missing `changelog.yaml`: - -``` -my-module/ -├── .gitignore -└── docs/ - └── README.md -``` - -**Error:** -``` -changelog.yaml file is missing in package root -``` - -❌ **Incorrect** - `changelog.yaml` is a directory: - -``` -my-module/ -└── changelog.yaml/ # Wrong: must be a file - └── v1.0.0.yaml -``` - -**Error:** -``` -changelog.yaml must be a file in package root -``` - -✅ **Correct:** - -``` -my-module/ -├── .gitignore -├── changelog.yaml -└── docs/ - └── README.md -``` - ---- - -### docs - -**Purpose:** Ensures every module has a `docs/` directory in its root, so downstream documentation rules (see the [Documentation Linter](../docs/README.md)) have a directory to inspect. - -**What it checks:** - -1. Verifies that `docs/` exists in the package root -2. Verifies that `docs/` is a directory, not a regular file - -**Examples:** - -❌ **Incorrect** - Missing `docs/` directory: - -``` -my-module/ -├── .gitignore -└── changelog.yaml -``` - -**Error:** -``` -docs directory is missing in package root -``` - -❌ **Incorrect** - `docs` is a file: - -``` -my-module/ -└── docs # Wrong: must be a directory -``` - -**Error:** -``` -docs must be a directory in package root -``` - -✅ **Correct:** - -``` -my-module/ -├── .gitignore -├── changelog.yaml -└── docs/ - └── README.md -``` - ---- - -### templates - -**Purpose:** Ensures every module has a `templates/` directory in its root, so the bundle ships the Helm templates it's meant to render. - -**What it checks:** - -1. Verifies that `templates/` exists in the package root -2. Verifies that `templates/` is a directory, not a regular file - -**Examples:** - -❌ **Incorrect** - Missing `templates/` directory: - -``` -my-module/ -├── .gitignore -└── Chart.yaml -``` - -**Error:** -``` -templates directory is missing in package root -``` - -❌ **Incorrect** - `templates` is a file: - -``` -my-module/ -└── templates # Wrong: must be a directory -``` - -**Error:** -``` -templates must be a directory in package root -``` - -✅ **Correct:** - -``` -my-module/ -├── Chart.yaml -└── templates/ - └── deployment.yaml -``` - ---- - -### charts - -**Purpose:** Ensures every module has a `charts/` directory in its root, so the bundle ships the shared helm-lib with helper templates used by `templates/`. - -**What it checks:** - -1. Verifies that `charts/` exists in the package root -2. Verifies that `charts/` is a directory, not a regular file - -**Examples:** - -❌ **Incorrect** - Missing `charts/` directory: - -``` -my-module/ -├── Chart.yaml -└── templates/ - └── deployment.yaml -``` - -**Error:** -``` -charts directory is missing in package root -``` - -❌ **Incorrect** - `charts` is a file: - -``` -my-module/ -└── charts # Wrong: must be a directory -``` - -**Error:** -``` -charts must be a directory in package root -``` - -✅ **Correct:** - -``` -my-module/ -├── Chart.yaml -├── charts/ -│ └── helm_lib/ -└── templates/ - └── deployment.yaml -``` - ---- - -### digests - -**Purpose:** Ensures every module has an `images_digests.json` file in its root, so the bundle carries resolved digests for every image it references. - -**What it checks:** - -1. Verifies that `images_digests.json` exists in the package root -2. Verifies that `images_digests.json` is a regular file, not a directory - -**Examples:** - -❌ **Incorrect** - Missing `images_digests.json`: - -``` -my-module/ -├── Chart.yaml -└── templates/ - └── deployment.yaml -``` - -**Error:** -``` -images_digests.json file is missing in package root -``` - -❌ **Incorrect** - `images_digests.json` is a directory: - -``` -my-module/ -└── images_digests.json/ # Wrong: must be a file - └── digests.json -``` - -**Error:** -``` -images_digests.json must be a file in package root -``` - -✅ **Correct:** - -``` -my-module/ -├── Chart.yaml -├── images_digests.json -└── templates/ - └── deployment.yaml -``` - ---- - -### chart-yaml - -**Purpose:** Ensures every module has a `Chart.yaml` file in its root, so the bundle carries a valid Helm chart definition. - -**What it checks:** - -1. Verifies that `Chart.yaml` exists in the package root -2. Verifies that `Chart.yaml` is a regular file, not a directory - -**Examples:** - -❌ **Incorrect** - Missing `Chart.yaml`: - -``` -my-module/ -└── templates/ - └── deployment.yaml -``` - -**Error:** -``` -Chart.yaml file is missing in package root -``` - -❌ **Incorrect** - `Chart.yaml` is a directory: - -``` -my-module/ -└── Chart.yaml/ # Wrong: must be a file - └── Chart.yaml -``` - -**Error:** -``` -Chart.yaml must be a file in package root -``` - -✅ **Correct:** - -``` -my-module/ -├── Chart.yaml -└── templates/ - └── deployment.yaml -``` - ---- - -### version-json - -**Purpose:** Ensures every module has a `version.json` file in its root, so the released version can be read directly from the bundle image. - -**What it checks:** - -1. Verifies that `version.json` exists in the package root -2. Verifies that `version.json` is a regular file, not a directory - -**Examples:** - -❌ **Incorrect** - Missing `version.json`: - -``` -my-module/ -├── Chart.yaml -└── module.yaml -``` - -**Error:** -``` -version.json file is missing in package root -``` - -❌ **Incorrect** - `version.json` is a directory: - -``` -my-module/ -└── version.json/ # Wrong: must be a file - └── v1.0.0.json -``` - -**Error:** -``` -version.json must be a file in package root -``` - -✅ **Correct:** - -``` -my-module/ -├── Chart.yaml -├── module.yaml -└── version.json -``` - ---- - -### module-definition - -**Purpose:** Ensures every module has a `module.yaml` file in its root, so the module definition is always available directly from the bundle image. - -**What it checks:** - -1. Verifies that `module.yaml` exists in the package root -2. Verifies that `module.yaml` is a regular file, not a directory - -**Examples:** - -❌ **Incorrect** - Missing `module.yaml`: - -``` -my-module/ -├── Chart.yaml -└── version.json -``` - -**Error:** -``` -module.yaml file is missing in package root -``` - -❌ **Incorrect** - `module.yaml` is a directory: - -``` -my-module/ -└── module.yaml/ # Wrong: must be a file - └── module.yaml -``` - -**Error:** -``` -module.yaml must be a file in package root -``` - -✅ **Correct:** - -``` -my-module/ -├── Chart.yaml -├── module.yaml -└── version.json -``` - ---- - -## Common Issues - -### Issue: Missing a required root-level file or directory - -**Symptom:** -``` -Error: .gitignore file is missing in package root -Error: changelog.yaml file is missing in package root -Error: docs directory is missing in package root -Error: templates directory is missing in package root -Error: charts directory is missing in package root -Error: images_digests.json file is missing in package root -Error: Chart.yaml file is missing in package root -Error: version.json file is missing in package root -Error: module.yaml file is missing in package root -``` - -**Cause:** The module root is missing one or more of the required entries. - -**Solution:** - -```bash -cd modules/my-module -touch .gitignore changelog.yaml images_digests.json Chart.yaml version.json module.yaml -mkdir -p docs templates charts -``` - -### Issue: Required entry has the wrong kind (file vs. directory) - -**Symptom:** -``` -Error: .gitignore must be a file in package root -Error: docs must be a directory in package root -Error: templates must be a directory in package root -Error: charts must be a directory in package root -``` - -**Cause:** A path that should be a file is a directory (or vice versa) — e.g. `docs` was created as an empty file instead of a directory. - -**Solution:** Remove the wrong entry and recreate it with the expected kind. - -```bash -rm docs # was created as a file by mistake -mkdir -p docs -``` diff --git a/pkg/remote-linters/bundle/layout/linter.go b/pkg/remote-linters/bundle/layout/linter.go deleted file mode 100644 index 40365906..00000000 --- a/pkg/remote-linters/bundle/layout/linter.go +++ /dev/null @@ -1,43 +0,0 @@ -package layout - -import ( - "context" - - "github.com/deckhouse/dmt/pkg/errors" - "github.com/deckhouse/dmt/pkg/remote-linters/bundle/layout/rules" -) - -// LinterID is the stable identifier used to reference this linter in configuration and diagnostics. -const LinterID = "layout" - -// Linter runs layout rules against an application package directory. -type Linter struct { - config Config - errorList *errors.LintRuleErrorsList -} - -// Config holds the path and settings required to construct a Linter. -type Config struct { - Path string -} - -// NewLinter constructs a Linter from cfg, scoping its diagnostics to this linter and capping severity at the configured level. -func NewLinter(cfg Config, errorList *errors.LintRuleErrorsList) *Linter { - return &Linter{ - config: cfg, - errorList: errorList.WithLinterID(LinterID), - } -} - -// Lint executes all layout rules against the configured package path. -func (l *Linter) Lint(ctx context.Context) { - rules.NewGitignoreRule(l.config.Path, l.errorList).Check(ctx) - rules.NewChangelogRule(l.config.Path, l.errorList).Check(ctx) - rules.NewDocsRule(l.config.Path, l.errorList).Check(ctx) - rules.NewTemplatesRule(l.config.Path, l.errorList).Check(ctx) - rules.NewChartsRule(l.config.Path, l.errorList).Check(ctx) - rules.NewDigestsRule(l.config.Path, l.errorList).Check(ctx) - rules.NewChartYAMLRule(l.config.Path, l.errorList).Check(ctx) - rules.NewVersionJSONRule(l.config.Path, l.errorList).Check(ctx) - rules.NewModuleYAMLRule(l.config.Path, l.errorList).Check(ctx) -} diff --git a/pkg/remote-linters/bundle/layout/rules/changelog.go b/pkg/remote-linters/bundle/layout/rules/changelog.go deleted file mode 100644 index e5d6b609..00000000 --- a/pkg/remote-linters/bundle/layout/rules/changelog.go +++ /dev/null @@ -1,15 +0,0 @@ -package rules - -import ( - "github.com/deckhouse/dmt/pkg/errors" -) - -// Rule purpose: require changelog.yaml so bundle image have a release history entry point. - -// ChangelogRuleID is the stable identifier used to reference this rule in configuration. -const ChangelogRuleID = "changelog" - -// NewChangelogRule constructs a rule that requires changelog.yaml in the package root. -func NewChangelogRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { - return newRequiredRootPathsRule(path, errorList.WithRule(ChangelogRuleID), []string{"changelog.yaml"}, nil) -} diff --git a/pkg/remote-linters/bundle/layout/rules/chart_yaml.go b/pkg/remote-linters/bundle/layout/rules/chart_yaml.go deleted file mode 100644 index b94bf43f..00000000 --- a/pkg/remote-linters/bundle/layout/rules/chart_yaml.go +++ /dev/null @@ -1,13 +0,0 @@ -package rules - -import "github.com/deckhouse/dmt/pkg/errors" - -// Rule purpose: require Chart.yaml so bundle image have a chart definition. - -// ChartYAMLRuleID is the stable identifier used to reference this rule in configuration. -const ChartYAMLRuleID = "chart-yaml" - -// NewChartYAMLRule constructs a rule that requires Chart.yaml in the bundle root. -func NewChartYAMLRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { - return newRequiredRootPathsRule(path, errorList.WithRule(ChartYAMLRuleID), []string{"Chart.yaml"}, nil) -} diff --git a/pkg/remote-linters/bundle/layout/rules/charts.go b/pkg/remote-linters/bundle/layout/rules/charts.go deleted file mode 100644 index 312788ac..00000000 --- a/pkg/remote-linters/bundle/layout/rules/charts.go +++ /dev/null @@ -1,13 +0,0 @@ -package rules - -import "github.com/deckhouse/dmt/pkg/errors" - -// Rule purpose: require charts directory so bundle image have a helm-lib with a helpers for templates. - -// ChartsRuleID is the stable identifier used to reference this rule in configuration. -const ChartsRuleID = "charts" - -// NewChartsRule constructs a rule that requires charts directory in the bundle root. -func NewChartsRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { - return newRequiredRootPathsRule(path, errorList.WithRule(ChartsRuleID), nil, []string{"charts"}) -} diff --git a/pkg/remote-linters/bundle/layout/rules/digests.go b/pkg/remote-linters/bundle/layout/rules/digests.go deleted file mode 100644 index 44b47fb1..00000000 --- a/pkg/remote-linters/bundle/layout/rules/digests.go +++ /dev/null @@ -1,13 +0,0 @@ -package rules - -import "github.com/deckhouse/dmt/pkg/errors" - -// Rule purpose: require images_digests.json so bundle image have a digests for all images. - -// ChartsRuleID is the stable identifier used to reference this rule in configuration. -const DigestsRuleID = "digests" - -// NewDigestsRule constructs a rule that requires images_digests.json in the bundle root. -func NewDigestsRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { - return newRequiredRootPathsRule(path, errorList.WithRule(DigestsRuleID), []string{"images_digests.json"}, nil) -} diff --git a/pkg/remote-linters/bundle/layout/rules/docs.go b/pkg/remote-linters/bundle/layout/rules/docs.go deleted file mode 100644 index e5e020e1..00000000 --- a/pkg/remote-linters/bundle/layout/rules/docs.go +++ /dev/null @@ -1,13 +0,0 @@ -package rules - -import "github.com/deckhouse/dmt/pkg/errors" - -// Rule purpose: require docs/ so package documentation rules have a directory to inspect. - -// DocsRuleID is the stable identifier used to reference this rule in configuration. -const DocsRuleID = "docs" - -// NewDocsRule constructs a rule that requires docs/ in the package root. -func NewDocsRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { - return newRequiredRootPathsRule(path, errorList.WithRule(DocsRuleID), nil, []string{"docs"}) -} diff --git a/pkg/remote-linters/bundle/layout/rules/gitignore.go b/pkg/remote-linters/bundle/layout/rules/gitignore.go deleted file mode 100644 index 879fbb16..00000000 --- a/pkg/remote-linters/bundle/layout/rules/gitignore.go +++ /dev/null @@ -1,13 +0,0 @@ -package rules - -import "github.com/deckhouse/dmt/pkg/errors" - -// Rule purpose: require .gitignore so local and generated files stay out of package repositories. - -// GitignoreRuleID is the stable identifier used to reference this rule in configuration. -const GitignoreRuleID = "gitignore" - -// NewGitignoreRule constructs a rule that requires .gitignore in the package root. -func NewGitignoreRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { - return newRequiredRootPathsRule(path, errorList.WithRule(GitignoreRuleID), []string{".gitignore"}, nil) -} diff --git a/pkg/remote-linters/bundle/layout/rules/module_yaml.go b/pkg/remote-linters/bundle/layout/rules/module_yaml.go deleted file mode 100644 index d9289d97..00000000 --- a/pkg/remote-linters/bundle/layout/rules/module_yaml.go +++ /dev/null @@ -1,13 +0,0 @@ -package rules - -import "github.com/deckhouse/dmt/pkg/errors" - -// Rule purpose: require module.yaml so package changes have a module definition. - -// ModuleYAMLRuleID is the stable identifier used to reference this rule in configuration. -const ModuleYAMLRuleID = "module-definition" - -// NewModuleYAMLRule constructs a rule that requires module.yaml in the package root. -func NewModuleYAMLRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { - return newRequiredRootPathsRule(path, errorList.WithRule(ModuleYAMLRuleID), []string{"module.yaml"}, nil) -} diff --git a/pkg/remote-linters/bundle/layout/rules/required_paths.go b/pkg/remote-linters/bundle/layout/rules/required_paths.go deleted file mode 100644 index 642616d6..00000000 --- a/pkg/remote-linters/bundle/layout/rules/required_paths.go +++ /dev/null @@ -1,86 +0,0 @@ -package rules - -import ( - "context" - "os" - "path/filepath" - - "github.com/deckhouse/dmt/pkg/errors" -) - -// requiredRootPathsRule checks that configured files and directories exist in the package root. -type requiredRootPathsRule struct { - errorList *errors.LintRuleErrorsList - path string - files []string - dirs []string -} - -// newRequiredRootPathsRule constructs a reusable presence check for root-level files and directories. -func newRequiredRootPathsRule(path string, errorList *errors.LintRuleErrorsList, files, dirs []string) *requiredRootPathsRule { - return &requiredRootPathsRule{ - path: path, - errorList: errorList, - files: files, - dirs: dirs, - } -} - -// Check verifies that every configured file and directory exists in the package root. -func (r *requiredRootPathsRule) Check(_ context.Context) { - for _, file := range r.files { - r.checkFile(file) - } - - for _, dir := range r.dirs { - r.checkDir(dir) - } -} - -// checkFile reports a finding when name does not exist as a regular file. -func (r *requiredRootPathsRule) checkFile(name string) { - path := filepath.Join(r.path, name) - - info, err := os.Stat(path) - if err == nil { - if info.IsDir() { - r.errorList.WithFilePath(path).Errorf("%s must be a file in package root", name) - } - - return - } - - if os.IsNotExist(err) { - r.errorList.WithFilePath(path).Errorf("%s file is missing in package root", name) - return - } - - r.errorList. - WithFilePath(path). - WithValue(err.Error()). - Errorf("failed to check %s file", name) -} - -// checkDir reports a finding when name does not exist as a directory. -func (r *requiredRootPathsRule) checkDir(name string) { - path := filepath.Join(r.path, name) - - info, err := os.Stat(path) - if err == nil { - if !info.IsDir() { - r.errorList.WithFilePath(path).Errorf("%s must be a directory in package root", name) - } - - return - } - - if os.IsNotExist(err) { - r.errorList.WithFilePath(path).Errorf("%s directory is missing in package root", name) - return - } - - r.errorList. - WithFilePath(path). - WithValue(err.Error()). - Errorf("failed to check %s directory", name) -} diff --git a/pkg/remote-linters/bundle/layout/rules/templates.go b/pkg/remote-linters/bundle/layout/rules/templates.go deleted file mode 100644 index f424be62..00000000 --- a/pkg/remote-linters/bundle/layout/rules/templates.go +++ /dev/null @@ -1,13 +0,0 @@ -package rules - -import "github.com/deckhouse/dmt/pkg/errors" - -// Rule purpose: require templates/ so bundle image have a helm templates. - -// TemplatesRuleID is the stable identifier used to reference this rule in configuration. -const TemplatesRuleID = "templates" - -// NewTemplatesRule constructs a rule that requires templates/ in the bundle root. -func NewTemplatesRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { - return newRequiredRootPathsRule(path, errorList.WithRule(TemplatesRuleID), nil, []string{"templates"}) -} diff --git a/pkg/remote-linters/bundle/layout/rules/version_json.go b/pkg/remote-linters/bundle/layout/rules/version_json.go deleted file mode 100644 index 1665178b..00000000 --- a/pkg/remote-linters/bundle/layout/rules/version_json.go +++ /dev/null @@ -1,13 +0,0 @@ -package rules - -import "github.com/deckhouse/dmt/pkg/errors" - -// Rule purpose: require version.json so package changes have a version definition. - -// VersionJSONRuleID is the stable identifier used to reference this rule in configuration. -const VersionJSONRuleID = "version-json" - -// NewVersionJSONRule constructs a rule that requires version.json in the package root. -func NewVersionJSONRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { - return newRequiredRootPathsRule(path, errorList.WithRule(VersionJSONRuleID), []string{"version.json"}, nil) -} diff --git a/pkg/remote-linters/release/layout/README.md b/pkg/remote-linters/release/layout/README.md deleted file mode 100644 index dc4ebe60..00000000 --- a/pkg/remote-linters/release/layout/README.md +++ /dev/null @@ -1,197 +0,0 @@ -# Layout Linter (release) - -## Overview - -The **Layout Linter** validates the top-level file layout of a module's **release** image. - -This linter is part of the `dmt` remote-lint bundle used to validate a module image pulled from a registry. - -## Rules - -| Rule | Description | Configurable | Default | -|------|-------------|--------------|---------| -| [changelog](#changelog) | Validates presence of `changelog.yaml` | ❌ | enabled | -| [module-definition](#module-definition) | Validates presence of `module.yaml` | ❌ | enabled | -| [version-json](#version-json) | Validates presence of `version.json` | ❌ | enabled | - -## Rule Details - -### changelog - -**Purpose:** Ensures the release image segment has a `changelog.yaml` file in its root, giving every release a machine-readable history entry point. - -**What it checks:** - -1. Verifies that `changelog.yaml` exists in the root of the release segment -2. Verifies that `changelog.yaml` is a regular file, not a directory - -**Examples:** - -❌ **Incorrect** - Missing `changelog.yaml`: - -``` -release/ -└── (no changelog.yaml) -``` - -**Error:** -``` -changelog.yaml file is missing in package root -``` - -❌ **Incorrect** - `changelog.yaml` is a directory: - -``` -release/ -└── changelog.yaml/ # Wrong: must be a file - └── v1.0.0.yaml -``` - -**Error:** -``` -changelog.yaml must be a file in package root -``` - -✅ **Correct:** - -``` -release/ -├── changelog.yaml -├── module.yaml -└── version.json -``` - ---- - -### module-definition - -**Purpose:** Ensures the release image segment has a `module.yaml` file in its root, so the module definition shipped in a release can always be read without falling back to the main bundle image. - -**What it checks:** - -1. Verifies that `module.yaml` exists in the root of the release segment -2. Verifies that `module.yaml` is a regular file, not a directory - -**Examples:** - -❌ **Incorrect** - Missing `module.yaml`: - -``` -release/ -└── changelog.yaml -``` - -**Error:** -``` -module.yaml file is missing in package root -``` - -❌ **Incorrect** - `module.yaml` is a directory: - -``` -release/ -└── module.yaml/ # Wrong: must be a file - └── module.yaml -``` - -**Error:** -``` -module.yaml must be a file in package root -``` - -✅ **Correct:** - -``` -release/ -├── changelog.yaml -├── module.yaml -└── version.json -``` - ---- - -### version-json - -**Purpose:** Ensures the release image segment has a `version.json` file in its root, so the released version can be read directly from the release segment. - -**What it checks:** - -1. Verifies that `version.json` exists in the root of the release segment -2. Verifies that `version.json` is a regular file, not a directory - -**Examples:** - -❌ **Incorrect** - Missing `version.json`: - -``` -release/ -├── changelog.yaml -└── module.yaml -``` - -**Error:** -``` -version.json file is missing in package root -``` - -❌ **Incorrect** - `version.json` is a directory: - -``` -release/ -└── version.json/ # Wrong: must be a file - └── v1.0.0.json -``` - -**Error:** -``` -version.json must be a file in package root -``` - -✅ **Correct:** - -``` -release/ -├── changelog.yaml -├── module.yaml -└── version.json -``` - ---- - -## Common Issues - -### Issue: Missing `changelog.yaml`, `module.yaml`, or `version.json` in the release segment - -**Symptom:** -``` -Error: changelog.yaml file is missing in package root -Error: module.yaml file is missing in package root -Error: version.json file is missing in package root -``` - -**Cause:** The release image segment doesn't have one or more of the required files in its root. - -**Solution:** Add the missing file(s) to whatever build step produces the release image segment. - -```bash -cd release -touch changelog.yaml module.yaml version.json -``` - -### Issue: Required file has the wrong kind (must be a file, not a directory) - -**Symptom:** -``` -Error: changelog.yaml must be a file in package root -Error: module.yaml must be a file in package root -Error: version.json must be a file in package root -``` - -**Cause:** One of the required files was created as a directory instead of a regular file. - -**Solution:** Remove the directory and replace it with a regular file. - -```bash -rm -rf version.json -touch version.json -``` diff --git a/pkg/remote-linters/release/layout/linter.go b/pkg/remote-linters/release/layout/linter.go deleted file mode 100644 index cb09e99c..00000000 --- a/pkg/remote-linters/release/layout/linter.go +++ /dev/null @@ -1,37 +0,0 @@ -package layout - -import ( - "context" - - "github.com/deckhouse/dmt/pkg/errors" - "github.com/deckhouse/dmt/pkg/remote-linters/release/layout/rules" -) - -// LinterID is the stable identifier used to reference this linter in configuration and diagnostics. -const LinterID = "layout" - -// Linter runs layout rules against an application package directory. -type Linter struct { - config Config - errorList *errors.LintRuleErrorsList -} - -// Config holds the path and settings required to construct a Linter. -type Config struct { - Path string -} - -// NewLinter constructs a Linter from cfg, scoping its diagnostics to this linter and capping severity at the configured level. -func NewLinter(cfg Config, errorList *errors.LintRuleErrorsList) *Linter { - return &Linter{ - config: cfg, - errorList: errorList.WithLinterID(LinterID), - } -} - -// Lint executes all layout rules against the configured package path. -func (l *Linter) Lint(ctx context.Context) { - rules.NewChangelogRule(l.config.Path, l.errorList).Check(ctx) - rules.NewModuleYAMLRule(l.config.Path, l.errorList).Check(ctx) - rules.NewVersionJSONRule(l.config.Path, l.errorList).Check(ctx) -} diff --git a/pkg/remote-linters/release/layout/rules/changelog.go b/pkg/remote-linters/release/layout/rules/changelog.go deleted file mode 100644 index 759b47cc..00000000 --- a/pkg/remote-linters/release/layout/rules/changelog.go +++ /dev/null @@ -1,15 +0,0 @@ -package rules - -import ( - "github.com/deckhouse/dmt/pkg/errors" -) - -// Rule purpose: require changelog.yaml so package changes have a release history entry point. - -// ChangelogRuleID is the stable identifier used to reference this rule in configuration. -const ChangelogRuleID = "changelog" - -// NewChangelogRule constructs a rule that requires changelog.yaml in the package root. -func NewChangelogRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { - return newRequiredRootPathsRule(path, errorList.WithRule(ChangelogRuleID), []string{"changelog.yaml"}, nil) -} diff --git a/pkg/remote-linters/release/layout/rules/module_yaml.go b/pkg/remote-linters/release/layout/rules/module_yaml.go deleted file mode 100644 index d9289d97..00000000 --- a/pkg/remote-linters/release/layout/rules/module_yaml.go +++ /dev/null @@ -1,13 +0,0 @@ -package rules - -import "github.com/deckhouse/dmt/pkg/errors" - -// Rule purpose: require module.yaml so package changes have a module definition. - -// ModuleYAMLRuleID is the stable identifier used to reference this rule in configuration. -const ModuleYAMLRuleID = "module-definition" - -// NewModuleYAMLRule constructs a rule that requires module.yaml in the package root. -func NewModuleYAMLRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { - return newRequiredRootPathsRule(path, errorList.WithRule(ModuleYAMLRuleID), []string{"module.yaml"}, nil) -} diff --git a/pkg/remote-linters/release/layout/rules/required_paths.go b/pkg/remote-linters/release/layout/rules/required_paths.go deleted file mode 100644 index 642616d6..00000000 --- a/pkg/remote-linters/release/layout/rules/required_paths.go +++ /dev/null @@ -1,86 +0,0 @@ -package rules - -import ( - "context" - "os" - "path/filepath" - - "github.com/deckhouse/dmt/pkg/errors" -) - -// requiredRootPathsRule checks that configured files and directories exist in the package root. -type requiredRootPathsRule struct { - errorList *errors.LintRuleErrorsList - path string - files []string - dirs []string -} - -// newRequiredRootPathsRule constructs a reusable presence check for root-level files and directories. -func newRequiredRootPathsRule(path string, errorList *errors.LintRuleErrorsList, files, dirs []string) *requiredRootPathsRule { - return &requiredRootPathsRule{ - path: path, - errorList: errorList, - files: files, - dirs: dirs, - } -} - -// Check verifies that every configured file and directory exists in the package root. -func (r *requiredRootPathsRule) Check(_ context.Context) { - for _, file := range r.files { - r.checkFile(file) - } - - for _, dir := range r.dirs { - r.checkDir(dir) - } -} - -// checkFile reports a finding when name does not exist as a regular file. -func (r *requiredRootPathsRule) checkFile(name string) { - path := filepath.Join(r.path, name) - - info, err := os.Stat(path) - if err == nil { - if info.IsDir() { - r.errorList.WithFilePath(path).Errorf("%s must be a file in package root", name) - } - - return - } - - if os.IsNotExist(err) { - r.errorList.WithFilePath(path).Errorf("%s file is missing in package root", name) - return - } - - r.errorList. - WithFilePath(path). - WithValue(err.Error()). - Errorf("failed to check %s file", name) -} - -// checkDir reports a finding when name does not exist as a directory. -func (r *requiredRootPathsRule) checkDir(name string) { - path := filepath.Join(r.path, name) - - info, err := os.Stat(path) - if err == nil { - if !info.IsDir() { - r.errorList.WithFilePath(path).Errorf("%s must be a directory in package root", name) - } - - return - } - - if os.IsNotExist(err) { - r.errorList.WithFilePath(path).Errorf("%s directory is missing in package root", name) - return - } - - r.errorList. - WithFilePath(path). - WithValue(err.Error()). - Errorf("failed to check %s directory", name) -} diff --git a/pkg/remote-linters/release/layout/rules/version_json.go b/pkg/remote-linters/release/layout/rules/version_json.go deleted file mode 100644 index 1665178b..00000000 --- a/pkg/remote-linters/release/layout/rules/version_json.go +++ /dev/null @@ -1,13 +0,0 @@ -package rules - -import "github.com/deckhouse/dmt/pkg/errors" - -// Rule purpose: require version.json so package changes have a version definition. - -// VersionJSONRuleID is the stable identifier used to reference this rule in configuration. -const VersionJSONRuleID = "version-json" - -// NewVersionJSONRule constructs a rule that requires version.json in the package root. -func NewVersionJSONRule(path string, errorList *errors.LintRuleErrorsList) *requiredRootPathsRule { - return newRequiredRootPathsRule(path, errorList.WithRule(VersionJSONRuleID), []string{"version.json"}, nil) -} From a0dc96693f779536c76ce4484fa0e82237566eec Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Wed, 15 Jul 2026 19:09:55 +0300 Subject: [PATCH 21/26] fix Signed-off-by: Smyslov Maxim --- internal/manager/manager.go | 6 +- internal/remote-lint/lint.go | 145 ++++------------------------------- 2 files changed, 20 insertions(+), 131 deletions(-) diff --git a/internal/manager/manager.go b/internal/manager/manager.go index 61ee7eb9..627c5fb9 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/remote-lint/lint.go b/internal/remote-lint/lint.go index d5962ec7..003eae36 100644 --- a/internal/remote-lint/lint.go +++ b/internal/remote-lint/lint.go @@ -1,31 +1,24 @@ package remotelint import ( - "bytes" - "cmp" "context" "fmt" "os" "path/filepath" - "slices" "strings" - "text/tabwriter" - "github.com/fatih/color" "github.com/google/go-containerregistry/pkg/name" - "github.com/kyokomi/emoji" - "github.com/mitchellh/go-wordwrap" "github.com/deckhouse/deckhouse/pkg/log" "github.com/deckhouse/deckhouse/pkg/registry/client" - "github.com/deckhouse/dmt/internal/flags" "github.com/deckhouse/dmt/internal/manager" "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/docs" + moduleLinter "github.com/deckhouse/dmt/pkg/linters/module" ) type RemoteLintOptions struct { @@ -64,7 +57,7 @@ func RunRemoteLint(ctx context.Context, imagePath string, opts *RemoteLintOption return fmt.Errorf("failed to lint release: %w", err) } - PrintResult(errorList) + manager.PrintResult(errorList) if errorList.ContainsErrors() { return fmt.Errorf("critical errors found") @@ -87,7 +80,7 @@ func lintBundle(ctx context.Context, client *client.Client, tag string, cfg *pkg os.RemoveAll(filepath.Join(tempDir, "docs")) // debug: remove docs directory - linters := buildBundleLinters(tempDir, cfg, errorList.WithObjectID("bundle")) + linters := buildBundleLinters(cfg, errorList.WithObjectID("bundle")) for _, linter := range linters { m, err := module.NewModule(tempDir, nil, nil, nil, errorList.WithObjectID("bundle")) @@ -113,10 +106,15 @@ func lintRelease(ctx context.Context, client *client.Client, tag string, cfg *pk } defer os.RemoveAll(tempDir) - linters := buildReleaseLinters(tempDir, errorList.WithObjectID("release")) + linters := buildReleaseLinters(cfg, errorList.WithObjectID("release")) for _, linter := range linters { - linter.Run(nil) + m, err := module.NewModule(tempDir, nil, nil, nil, errorList.WithObjectID("release")) + if err != nil { + return fmt.Errorf("failed to create module: %w", err) + } + + linter.Run(m) } return nil @@ -146,14 +144,16 @@ type Linter interface { manager.Linter } -func buildBundleLinters(path string, cfg *pkg.LintersSettings, errorList *errors.LintRuleErrorsList) []Linter { +func buildBundleLinters(cfg *pkg.LintersSettings, errorList *errors.LintRuleErrorsList) []Linter { return []Linter{ docs.New(&cfg.Documentation, errorList.WithMaxLevel(cfg.Documentation.Impact)), } } -func buildReleaseLinters(path string, errorList *errors.LintRuleErrorsList) []Linter { - return []Linter{} +func buildReleaseLinters(cfg *pkg.LintersSettings, errorList *errors.LintRuleErrorsList) []Linter { + return []Linter{ + moduleLinter.New(&cfg.Module, errorList.WithMaxLevel(cfg.Module.Impact)), + } } func parseConfig() (*pkg.LintersSettings, error) { @@ -172,118 +172,3 @@ func parseConfig() (*pkg.LintersSettings, error) { return module.RemapLinterSettings(&cfg.LintersSettings, &rootConfig.GlobalSettings.Linters), nil } - -func PrintResult(errorList *errors.LintRuleErrorsList) { - errs := errorList.GetErrors() - - if len(errs) == 0 { - return - } - - slices.SortFunc(errs, func(a, b pkg.LinterError) int { - return cmp.Or( - cmp.Compare(a.Level, b.Level), - cmp.Compare(a.LinterID, b.LinterID), - cmp.Compare(a.RuleID, b.RuleID), - ) - }) - - w := new(tabwriter.Writer) - - const minWidth = 5 - - buf := bytes.NewBuffer([]byte{}) - w.Init(buf, minWidth, 0, 0, ' ', 0) - - for idx := range errs { - err := errs[idx] - - msgColor := color.FgRed - - if err.Level == pkg.Ignored { - // TODO: make it not global - if !flags.ShowIgnored { - continue - } - - msgColor = color.FgWhite - } - - if err.Level == pkg.Warn { - // TODO: make it not global - if flags.HideWarnings { - continue - } - - msgColor = color.FgHiYellow - } - - // header - fmt.Fprint(w, emoji.Sprintf(":monkey:")) - fmt.Fprint(w, color.New(color.FgHiBlue).SprintFunc()("[")) - - if err.RuleID != "" { - fmt.Fprint(w, color.New(color.FgHiBlue).SprintFunc()(err.RuleID+" ")) - } - - fmt.Fprintf(w, "%s\n", color.New(color.FgHiBlue).SprintfFunc()("(#%s)]", err.LinterID)) - - // body - fmt.Fprintf(w, "\t%s\t\t%s\n", "Message:", color.New(msgColor).SprintfFunc()(prepareString(err.Text))) - - if err.ObjectID != "" && err.ObjectID != err.ModuleID { - fmt.Fprintf(w, "\t%s\t\t%s\n", "Object:", err.ObjectID) - } - - if err.ObjectValue != nil { - value := fmt.Sprintf("%v", err.ObjectValue) - - fmt.Fprintf(w, "\t%s\t\t%s\n", "Value:", prepareString(value)) - } - - if err.FilePath != "" { - fmt.Fprintf(w, "\t%s\t\t%s\n", "FilePath:", strings.TrimSpace(err.FilePath)) - } - - if err.LineNumber != 0 { - fmt.Fprintf(w, "\t%s\t\t%d\n", "LineNumber:", err.LineNumber) - } - - if err.FixError != nil { - fmt.Fprintf(w, "\t%s\t\t%s\n", "AutofixError:", color.New(color.FgHiYellow).Sprint(err.FixError.Error())) - } - - // if flags.ShowDocumentation { - // docURL := generateDocumentationURL(err.LinterID, err.RuleID) - // if docURL != "" { - // fmt.Fprintf(w, "\t%s\t\t%s\n", "Documentation:", docURL) - // } - // } - - fmt.Fprintln(w) - - w.Flush() - } - - fmt.Println(buf.String()) -} - -// prepareString handle ussual string and prepare it for tablewriter -func prepareString(input string) string { - // magic wrap const - const wrapLen = 100 - - w := &strings.Builder{} - - // split wraps for tablewrite - split := strings.Split(wordwrap.WrapString(input, wrapLen), "\n") - - // first string must be pure for correct handling - fmt.Fprint(w, strings.TrimSpace(split[0])) - - for i := 1; i < len(split); i++ { - fmt.Fprintf(w, "\n\t\t\t%s", strings.TrimSpace(split[i])) - } - - return w.String() -} From cf151be80ed385f137c9907143dad9d8b3c41308 Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Thu, 16 Jul 2026 11:15:16 +0300 Subject: [PATCH 22/26] add comment for digests not supported Signed-off-by: Smyslov Maxim --- internal/remote-lint/lint.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/remote-lint/lint.go b/internal/remote-lint/lint.go index 003eae36..0f8a83a1 100644 --- a/internal/remote-lint/lint.go +++ b/internal/remote-lint/lint.go @@ -123,6 +123,7 @@ func lintRelease(ctx context.Context, client *client.Client, tag string, cfg *pk // 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") } From 0c9699db59c29a349d11b7075bd87aa73ecf0d2f Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Thu, 16 Jul 2026 12:01:19 +0300 Subject: [PATCH 23/26] add remote linting support Signed-off-by: Smyslov Maxim --- internal/remote-lint/lint.go | 45 +++++++++++++------------ pkg/linters/docs/documentation.go | 10 ++++++ pkg/linters/docs/rules/readme.go | 13 +++++-- pkg/linters/linters.go | 16 +++++++++ pkg/linters/module/module.go | 10 ++++++ pkg/linters/module/rules/module_yaml.go | 8 +++++ 6 files changed, 77 insertions(+), 25 deletions(-) create mode 100644 pkg/linters/linters.go diff --git a/internal/remote-lint/lint.go b/internal/remote-lint/lint.go index 0f8a83a1..9ad64556 100644 --- a/internal/remote-lint/lint.go +++ b/internal/remote-lint/lint.go @@ -17,6 +17,7 @@ import ( "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" ) @@ -54,6 +55,8 @@ func RunRemoteLint(ctx context.Context, imagePath string, opts *RemoteLintOption 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) } @@ -80,15 +83,16 @@ func lintBundle(ctx context.Context, client *client.Client, tag string, cfg *pkg os.RemoveAll(filepath.Join(tempDir, "docs")) // debug: remove docs directory - linters := buildBundleLinters(cfg, errorList.WithObjectID("bundle")) + bundleLinters := buildBundleLinters(cfg, errorList.WithObjectID("bundle")) - for _, linter := range linters { - m, err := module.NewModule(tempDir, nil, nil, nil, errorList.WithObjectID("bundle")) - if err != nil { - return fmt.Errorf("failed to create module: %w", err) + for _, linter := range bundleLinters { + cfg := &linters.LinterConfig{ + Name: client.GetRegistry(), + Namespace: "bundle", + Path: tempDir, + LinterSettings: cfg, } - - linter.Run(m) + linter.RunRemote(cfg) } return nil @@ -106,15 +110,16 @@ func lintRelease(ctx context.Context, client *client.Client, tag string, cfg *pk } defer os.RemoveAll(tempDir) - linters := buildReleaseLinters(cfg, errorList.WithObjectID("release")) + releaseLinters := buildReleaseLinters(cfg, errorList.WithObjectID("release")) - for _, linter := range linters { - m, err := module.NewModule(tempDir, nil, nil, nil, errorList.WithObjectID("release")) - if err != nil { - return fmt.Errorf("failed to create module: %w", err) + for _, linter := range releaseLinters { + cfg := &linters.LinterConfig{ + Name: client.GetRegistry(), + Namespace: "release", + Path: tempDir, + LinterSettings: cfg, } - - linter.Run(m) + linter.RunRemote(cfg) } return nil @@ -141,18 +146,14 @@ func cutTagFromImagePath(imagePath string) (string, string, error) { return ref.Context().Name(), tag, nil } -type Linter interface { - manager.Linter -} - -func buildBundleLinters(cfg *pkg.LintersSettings, errorList *errors.LintRuleErrorsList) []Linter { - return []Linter{ +func buildBundleLinters(cfg *pkg.LintersSettings, errorList *errors.LintRuleErrorsList) []linters.RemoteLinter { + return []linters.RemoteLinter{ docs.New(&cfg.Documentation, errorList.WithMaxLevel(cfg.Documentation.Impact)), } } -func buildReleaseLinters(cfg *pkg.LintersSettings, errorList *errors.LintRuleErrorsList) []Linter { - return []Linter{ +func buildReleaseLinters(cfg *pkg.LintersSettings, errorList *errors.LintRuleErrorsList) []linters.RemoteLinter { + return []linters.RemoteLinter{ moduleLinter.New(&cfg.Module, errorList.WithMaxLevel(cfg.Module.Impact)), } } diff --git a/pkg/linters/docs/documentation.go b/pkg/linters/docs/documentation.go index e05bb1c3..6795f07e 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,15 @@ func New(cfg *pkg.DocumentationLinterConfig, errorList *errors.LintRuleErrorsLis } } +func (l *Documentation) RunRemote(cfg *linters.LinterConfig) { + if cfg == nil || cfg.Path == "" { + return + } + + errorList := l.ErrorList.WithModule(cfg.Name) + rules.NewReadmeRule().CheckReadmeRemote(cfg.Path, errorList.WithMaxLevel(cfg.LinterSettings.Documentation.Rules.ReadmeRule.GetLevel())) +} + func (l *Documentation) Run(m *module.Module) { if m == nil || m.GetPath() == "" { return diff --git a/pkg/linters/docs/rules/readme.go b/pkg/linters/docs/rules/readme.go index f8d93870..0e560662 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 00000000..66337e7f --- /dev/null +++ b/pkg/linters/linters.go @@ -0,0 +1,16 @@ +package linters + +import "github.com/deckhouse/dmt/pkg" + +type RemoteLinter interface { + RunRemote(cfg *LinterConfig) + Name() string +} + +type LinterConfig struct { + Name string + Namespace string + Path string + + LinterSettings *pkg.LintersSettings +} diff --git a/pkg/linters/module/module.go b/pkg/linters/module/module.go index 145ec183..a1ddc170 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,15 @@ func New(cfg *pkg.ModuleLinterConfig, errorList *errors.LintRuleErrorsList) *Mod } } +func (l *Module) RunRemote(cfg *linters.LinterConfig) { + if cfg == nil || cfg.Path == "" { + return + } + + errorList := l.ErrorList.WithModule(cfg.Name) + rules.NewDefinitionFileRule(l.cfg.DefinitionFileRuleSettings.Disable).CheckDefinitionFile(cfg.Path, errorList.WithMaxLevel(l.cfg.Rules.DefinitionFileRule.GetLevel())) +} + 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 f06668f3..69615327 100644 --- a/pkg/linters/module/rules/module_yaml.go +++ b/pkg/linters/module/rules/module_yaml.go @@ -206,7 +206,15 @@ func convertURLToModuleName(repoURL string) string { return parts[len(parts)-1] } +func (r *DefinitionFileRule) CheckDefinitionFileRemote(path string, errorList *errors.LintRuleErrorsList) { + // +} + func (r *DefinitionFileRule) CheckDefinitionFile(modulePath string, errorList *errors.LintRuleErrorsList) { + r.checkDefinitionFile(modulePath, errorList) +} + +func (r *DefinitionFileRule) checkDefinitionFile(modulePath string, errorList *errors.LintRuleErrorsList) { errorList = errorList.WithRule(r.GetName()).WithFilePath(ModuleConfigFilename) if !r.Enabled() { From 91650cabfeefee8eb64302f82887a002a138ce37 Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Thu, 16 Jul 2026 12:27:04 +0300 Subject: [PATCH 24/26] bump Signed-off-by: Smyslov Maxim --- internal/remote-lint/lint.go | 23 +++++++++++++---------- pkg/linters/docs/documentation.go | 2 +- pkg/linters/linters.go | 4 ---- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/internal/remote-lint/lint.go b/internal/remote-lint/lint.go index 9ad64556..489097e6 100644 --- a/internal/remote-lint/lint.go +++ b/internal/remote-lint/lint.go @@ -9,10 +9,10 @@ import ( "github.com/google/go-containerregistry/pkg/name" - "github.com/deckhouse/deckhouse/pkg/log" "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" @@ -40,6 +40,10 @@ func RunRemoteLint(ctx context.Context, imagePath string, opts *RemoteLintOption 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) @@ -87,10 +91,9 @@ func lintBundle(ctx context.Context, client *client.Client, tag string, cfg *pkg for _, linter := range bundleLinters { cfg := &linters.LinterConfig{ - Name: client.GetRegistry(), - Namespace: "bundle", - Path: tempDir, - LinterSettings: cfg, + Name: client.GetRegistry(), + Namespace: "bundle", + Path: tempDir, } linter.RunRemote(cfg) } @@ -114,10 +117,9 @@ func lintRelease(ctx context.Context, client *client.Client, tag string, cfg *pk for _, linter := range releaseLinters { cfg := &linters.LinterConfig{ - Name: client.GetRegistry(), - Namespace: "release", - Path: tempDir, - LinterSettings: cfg, + Name: client.GetRegistry(), + Namespace: "release", + Path: tempDir, } linter.RunRemote(cfg) } @@ -158,10 +160,11 @@ func buildReleaseLinters(cfg *pkg.LintersSettings, errorList *errors.LintRuleErr } } +// parsing config from .dmtlint.yaml file func parseConfig() (*pkg.LintersSettings, error) { rootConfig, err := config.NewDefaultRootConfig(".") if err != nil { - log.Fatal("default root config", log.Err(err)) //nolint:gocritic + return nil, fmt.Errorf("failed to parse default root config: %w", err) } // Load module config diff --git a/pkg/linters/docs/documentation.go b/pkg/linters/docs/documentation.go index 6795f07e..dbfbec16 100644 --- a/pkg/linters/docs/documentation.go +++ b/pkg/linters/docs/documentation.go @@ -37,7 +37,7 @@ func (l *Documentation) RunRemote(cfg *linters.LinterConfig) { } errorList := l.ErrorList.WithModule(cfg.Name) - rules.NewReadmeRule().CheckReadmeRemote(cfg.Path, errorList.WithMaxLevel(cfg.LinterSettings.Documentation.Rules.ReadmeRule.GetLevel())) + rules.NewReadmeRule().CheckReadmeRemote(cfg.Path, errorList.WithMaxLevel(l.cfg.Rules.ReadmeRule.GetLevel())) } func (l *Documentation) Run(m *module.Module) { diff --git a/pkg/linters/linters.go b/pkg/linters/linters.go index 66337e7f..895139d9 100644 --- a/pkg/linters/linters.go +++ b/pkg/linters/linters.go @@ -1,7 +1,5 @@ package linters -import "github.com/deckhouse/dmt/pkg" - type RemoteLinter interface { RunRemote(cfg *LinterConfig) Name() string @@ -11,6 +9,4 @@ type LinterConfig struct { Name string Namespace string Path string - - LinterSettings *pkg.LintersSettings } From f5d8482df9b30d4770e0b268dfba134072189304 Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Thu, 16 Jul 2026 16:42:11 +0300 Subject: [PATCH 25/26] add linters to remote adapt rules to support 2 realizations Signed-off-by: Smyslov Maxim --- internal/remote-lint/lint.go | 3 -- pkg/linters/docs/documentation.go | 1 + pkg/linters/docs/rules/changelog.go | 56 ++++++++++++++++++++++++ pkg/linters/module/module.go | 8 +++- pkg/linters/module/rules/module_yaml.go | 19 +++++--- pkg/linters/module/rules/package_yaml.go | 17 ++++++- 6 files changed, 92 insertions(+), 12 deletions(-) create mode 100644 pkg/linters/docs/rules/changelog.go diff --git a/internal/remote-lint/lint.go b/internal/remote-lint/lint.go index 489097e6..02170739 100644 --- a/internal/remote-lint/lint.go +++ b/internal/remote-lint/lint.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "path/filepath" "strings" "github.com/google/go-containerregistry/pkg/name" @@ -85,8 +84,6 @@ func lintBundle(ctx context.Context, client *client.Client, tag string, cfg *pkg } defer os.RemoveAll(tempDir) - os.RemoveAll(filepath.Join(tempDir, "docs")) // debug: remove docs directory - bundleLinters := buildBundleLinters(cfg, errorList.WithObjectID("bundle")) for _, linter := range bundleLinters { diff --git a/pkg/linters/docs/documentation.go b/pkg/linters/docs/documentation.go index dbfbec16..fa667064 100644 --- a/pkg/linters/docs/documentation.go +++ b/pkg/linters/docs/documentation.go @@ -38,6 +38,7 @@ func (l *Documentation) RunRemote(cfg *linters.LinterConfig) { 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) { diff --git a/pkg/linters/docs/rules/changelog.go b/pkg/linters/docs/rules/changelog.go new file mode 100644 index 00000000..c8b27227 --- /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/module/module.go b/pkg/linters/module/module.go index a1ddc170..4066385a 100644 --- a/pkg/linters/module/module.go +++ b/pkg/linters/module/module.go @@ -43,12 +43,16 @@ func New(cfg *pkg.ModuleLinterConfig, errorList *errors.LintRuleErrorsList) *Mod } func (l *Module) RunRemote(cfg *linters.LinterConfig) { - if cfg == nil || cfg.Path == "" { + 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.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) { diff --git a/pkg/linters/module/rules/module_yaml.go b/pkg/linters/module/rules/module_yaml.go index 69615327..72b5bad2 100644 --- a/pkg/linters/module/rules/module_yaml.go +++ b/pkg/linters/module/rules/module_yaml.go @@ -206,17 +206,24 @@ func convertURLToModuleName(repoURL string) string { return parts[len(parts)-1] } -func (r *DefinitionFileRule) CheckDefinitionFileRemote(path string, errorList *errors.LintRuleErrorsList) { - // -} +type CheckDefinitionFileOptions func(modulePath string, errorList *errors.LintRuleErrorsList) -func (r *DefinitionFileRule) CheckDefinitionFile(modulePath string, errorList *errors.LintRuleErrorsList) { - r.checkDefinitionFile(modulePath, errorList) +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) { +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 1c29e632..29ebabcd 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 From d0fae4e1d5a0834f0c76411d5c9c24b30d632022 Mon Sep 17 00:00:00 2001 From: Smyslov Maxim Date: Fri, 17 Jul 2026 11:29:48 +0300 Subject: [PATCH 26/26] divide interface by release and bundle linters Signed-off-by: Smyslov Maxim --- internal/remote-lint/lint.go | 12 ++++++------ pkg/linters/docs/documentation.go | 2 +- pkg/linters/linters.go | 9 +++++++-- pkg/linters/module/module.go | 2 +- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/internal/remote-lint/lint.go b/internal/remote-lint/lint.go index 02170739..6867c79f 100644 --- a/internal/remote-lint/lint.go +++ b/internal/remote-lint/lint.go @@ -92,7 +92,7 @@ func lintBundle(ctx context.Context, client *client.Client, tag string, cfg *pkg Namespace: "bundle", Path: tempDir, } - linter.RunRemote(cfg) + linter.RunRemoteForBundle(cfg) } return nil @@ -118,7 +118,7 @@ func lintRelease(ctx context.Context, client *client.Client, tag string, cfg *pk Namespace: "release", Path: tempDir, } - linter.RunRemote(cfg) + linter.RunRemoteForRelease(cfg) } return nil @@ -145,14 +145,14 @@ func cutTagFromImagePath(imagePath string) (string, string, error) { return ref.Context().Name(), tag, nil } -func buildBundleLinters(cfg *pkg.LintersSettings, errorList *errors.LintRuleErrorsList) []linters.RemoteLinter { - return []linters.RemoteLinter{ +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.RemoteLinter { - return []linters.RemoteLinter{ +func buildReleaseLinters(cfg *pkg.LintersSettings, errorList *errors.LintRuleErrorsList) []linters.RemoteReleaseLinter { + return []linters.RemoteReleaseLinter{ moduleLinter.New(&cfg.Module, errorList.WithMaxLevel(cfg.Module.Impact)), } } diff --git a/pkg/linters/docs/documentation.go b/pkg/linters/docs/documentation.go index fa667064..022614a2 100644 --- a/pkg/linters/docs/documentation.go +++ b/pkg/linters/docs/documentation.go @@ -31,7 +31,7 @@ func New(cfg *pkg.DocumentationLinterConfig, errorList *errors.LintRuleErrorsLis } } -func (l *Documentation) RunRemote(cfg *linters.LinterConfig) { +func (l *Documentation) RunRemoteForBundle(cfg *linters.LinterConfig) { if cfg == nil || cfg.Path == "" { return } diff --git a/pkg/linters/linters.go b/pkg/linters/linters.go index 895139d9..8fd67b24 100644 --- a/pkg/linters/linters.go +++ b/pkg/linters/linters.go @@ -1,7 +1,12 @@ package linters -type RemoteLinter interface { - RunRemote(cfg *LinterConfig) +type RemoteBundleLinter interface { + RunRemoteForBundle(cfg *LinterConfig) + Name() string +} + +type RemoteReleaseLinter interface { + RunRemoteForRelease(cfg *LinterConfig) Name() string } diff --git a/pkg/linters/module/module.go b/pkg/linters/module/module.go index 4066385a..392f92f4 100644 --- a/pkg/linters/module/module.go +++ b/pkg/linters/module/module.go @@ -42,7 +42,7 @@ func New(cfg *pkg.ModuleLinterConfig, errorList *errors.LintRuleErrorsList) *Mod } } -func (l *Module) RunRemote(cfg *linters.LinterConfig) { +func (l *Module) RunRemoteForRelease(cfg *linters.LinterConfig) { if cfg == nil { return }