From 12243e138fff2c3aedaf63831799539e2e8493d3 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Wed, 21 Jan 2026 19:28:06 +0300 Subject: [PATCH 1/9] Transfer imagedigest utility from deckhouse-cse to d8 tools Signed-off-by: Roman Berezkin --- go.sum | 4 - internal/tools/imagedigest/cmd/add/add.go | 82 ++++++ .../imagedigest/cmd/calculate/calculate.go | 80 ++++++ .../calculatefromfile/calculatefromfile.go | 80 ++++++ internal/tools/imagedigest/cmd/imagedigest.go | 60 +++++ .../imagedigest/cmd/validate/validate.go | 108 ++++++++ internal/tools/imagedigest/imagedigest.go | 236 ++++++++++++++++++ internal/tools/tools.go | 2 + 8 files changed, 648 insertions(+), 4 deletions(-) create mode 100644 internal/tools/imagedigest/cmd/add/add.go create mode 100644 internal/tools/imagedigest/cmd/calculate/calculate.go create mode 100644 internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go create mode 100644 internal/tools/imagedigest/cmd/imagedigest.go create mode 100644 internal/tools/imagedigest/cmd/validate/validate.go create mode 100644 internal/tools/imagedigest/imagedigest.go diff --git a/go.sum b/go.sum index b3a96045..b4f7e0af 100644 --- a/go.sum +++ b/go.sum @@ -415,10 +415,6 @@ github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80N github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/deckhouse/deckhouse/pkg/log v0.1.0 h1:2aPfyiHHSIJlX4x7ysyPOaIb7CLmyY+hUf9uDb8TYd8= github.com/deckhouse/deckhouse/pkg/log v0.1.0/go.mod h1:pbAxTSDcPmwyl3wwKDcEB3qdxHnRxqTV+J0K+sha8bw= -github.com/deckhouse/deckhouse/pkg/registry v0.0.0-20251120122028-65011cba39f4 h1:puYW42+BF8fYuoq/dMDd+oxNprMuuSACWqDss6IQulE= -github.com/deckhouse/deckhouse/pkg/registry v0.0.0-20251120122028-65011cba39f4/go.mod h1:+oNXMQMOaVpDq00i+PX9NXptzIybUDRmxAO7iRWM32s= -github.com/deckhouse/deckhouse/pkg/registry v0.0.0-20260119191635-04ce9157d702 h1:HdfASfTGK2124itxEKqFNqEIEdjJ2XfD0DA+8ONBTok= -github.com/deckhouse/deckhouse/pkg/registry v0.0.0-20260119191635-04ce9157d702/go.mod h1:OdmJduRktTXVMNLAULkzoPbzLbtaU/jBuwSoAUbnxRM= github.com/deckhouse/deckhouse/pkg/registry v0.0.0-20260120103154-2be5575578db h1:xq4DMxGgDk0IaqUzIqwkKOiY9dtQlpVnEupfE/TBU6c= github.com/deckhouse/deckhouse/pkg/registry v0.0.0-20260120103154-2be5575578db/go.mod h1:OdmJduRktTXVMNLAULkzoPbzLbtaU/jBuwSoAUbnxRM= github.com/deckhouse/virtualization/api v1.0.0 h1:q4TvC74tpjk25k0byXJCYP4HjvRexBSeI0cC8QeCMTQ= diff --git a/internal/tools/imagedigest/cmd/add/add.go b/internal/tools/imagedigest/cmd/add/add.go new file mode 100644 index 00000000..63e70118 --- /dev/null +++ b/internal/tools/imagedigest/cmd/add/add.go @@ -0,0 +1,82 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package add + +import ( + "fmt" + + "github.com/google/go-containerregistry/pkg/crane" + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest" +) + +var addLong = templates.LongDesc(` +Calculate and add GOST R 34.11-2012 (Streebog-256) digest to image metadata. + +The digest is calculated based on sorted layer digests and stored in the image +annotation "deckhouse.io/gost-digest". + +Example: + d8 tools imagedigest add registry.example.com/image:tag + d8 tools imagedigest add --insecure localhost:5000/image:latest`) + +func NewCommand() *cobra.Command { + addCmd := &cobra.Command{ + Use: "add ", + Short: "Calculate and add GOST digest to image metadata", + Long: addLong, + SilenceErrors: true, + SilenceUsage: true, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return fmt.Errorf("this command requires exactly 1 argument (image reference), got %d", len(args)) + } + return nil + }, + RunE: runAdd, + } + + return addCmd +} + +func runAdd(cmd *cobra.Command, args []string) error { + imageName := args[0] + + insecure, err := cmd.Flags().GetBool("insecure") + if err != nil { + return fmt.Errorf("failed to get insecure flag: %w", err) + } + + var opts []crane.Option + if insecure { + opts = append(opts, crane.Insecure) + } + + fmt.Printf("Calculating GOST digest for image: %s\n", imageName) + + digest, err := imagedigest.AddGostImageDigest(imageName, opts...) + if err != nil { + return fmt.Errorf("failed to add GOST digest: %w", err) + } + + fmt.Printf("GOST digest: %s\n", digest) + fmt.Println("Digest added successfully") + + return nil +} diff --git a/internal/tools/imagedigest/cmd/calculate/calculate.go b/internal/tools/imagedigest/cmd/calculate/calculate.go new file mode 100644 index 00000000..5f15df51 --- /dev/null +++ b/internal/tools/imagedigest/cmd/calculate/calculate.go @@ -0,0 +1,80 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package calculate + +import ( + "encoding/hex" + "fmt" + + "github.com/google/go-containerregistry/pkg/crane" + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest" +) + +var calculateLong = templates.LongDesc(` +Calculate GOST R 34.11-2012 (Streebog-256) digest for a container image. + +The digest is calculated based on sorted layer digests but is NOT stored +in the image metadata. Use 'add' command to store the digest. + +Example: + d8 tools imagedigest calculate registry.example.com/image:tag + d8 tools imagedigest calculate --insecure localhost:5000/image:latest`) + +func NewCommand() *cobra.Command { + calculateCmd := &cobra.Command{ + Use: "calculate ", + Short: "Calculate GOST digest for a container image", + Long: calculateLong, + SilenceErrors: true, + SilenceUsage: true, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return fmt.Errorf("this command requires exactly 1 argument (image reference), got %d", len(args)) + } + return nil + }, + RunE: runCalculate, + } + + return calculateCmd +} + +func runCalculate(cmd *cobra.Command, args []string) error { + imageName := args[0] + + insecure, err := cmd.Flags().GetBool("insecure") + if err != nil { + return fmt.Errorf("failed to get insecure flag: %w", err) + } + + var opts []crane.Option + if insecure { + opts = append(opts, crane.Insecure) + } + + digest, err := imagedigest.CalculateGostImageDigest(imageName, opts...) + if err != nil { + return fmt.Errorf("failed to calculate GOST digest: %w", err) + } + + fmt.Println(hex.EncodeToString(digest)) + + return nil +} diff --git a/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go b/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go new file mode 100644 index 00000000..860f60d9 --- /dev/null +++ b/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go @@ -0,0 +1,80 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package calculatefromfile + +import ( + "encoding/hex" + "fmt" + "os" + + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest" +) + +var calculateFromFileLong = templates.LongDesc(` +Calculate GOST R 34.11-2012 (Streebog-256) digest for a file. + +Use '-' to read from stdin. + +Example: + d8 tools imagedigest calculate-from-file /path/to/file + d8 tools imagedigest calculate-from-file - + cat file.tar | d8 tools imagedigest calculate-from-file -`) + +func NewCommand() *cobra.Command { + calculateFromFileCmd := &cobra.Command{ + Use: "calculate-from-file ", + Short: "Calculate GOST digest for a file (use '-' for stdin)", + Long: calculateFromFileLong, + SilenceErrors: true, + SilenceUsage: true, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return fmt.Errorf("this command requires exactly 1 argument (file path or '-' for stdin), got %d", len(args)) + } + return nil + }, + RunE: runCalculateFromFile, + } + + return calculateFromFileCmd +} + +func runCalculateFromFile(cmd *cobra.Command, args []string) error { + filename := args[0] + + reader := os.Stdin + if filename != "-" { + file, err := os.Open(filename) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + reader = file + } + + digest, err := imagedigest.CalculateFromReader(reader) + if err != nil { + return fmt.Errorf("failed to calculate GOST digest: %w", err) + } + + fmt.Println(hex.EncodeToString(digest)) + + return nil +} diff --git a/internal/tools/imagedigest/cmd/imagedigest.go b/internal/tools/imagedigest/cmd/imagedigest.go new file mode 100644 index 00000000..d4eca0cb --- /dev/null +++ b/internal/tools/imagedigest/cmd/imagedigest.go @@ -0,0 +1,60 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest/cmd/add" + "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest/cmd/calculate" + "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest/cmd/calculatefromfile" + "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest/cmd/validate" +) + +var imagedigestLong = templates.LongDesc(` +Manage GOST R 34.11-2012 (Streebog) digests for container images. + +This tool calculates GOST digests based on sorted layer digests of container images +and stores them in image annotations for integrity verification. + +Available Commands: + calculate Calculate GOST digest for a container image + calculate-from-file Calculate GOST digest for a file + add Calculate and add GOST digest to image metadata + validate Validate stored GOST digest against recalculated value + +© Flant JSC 2025`) + +func NewCommand() *cobra.Command { + imagedigestCmd := &cobra.Command{ + Use: "imagedigest", + Short: "Manage GOST R 34.11-2012 (Streebog) digests for container images", + Long: imagedigestLong, + } + + imagedigestCmd.PersistentFlags().BoolP("insecure", "i", false, "Allow insecure connections to registries (skip TLS verification)") + + imagedigestCmd.AddCommand( + calculate.NewCommand(), + calculatefromfile.NewCommand(), + add.NewCommand(), + validate.NewCommand(), + ) + + return imagedigestCmd +} diff --git a/internal/tools/imagedigest/cmd/validate/validate.go b/internal/tools/imagedigest/cmd/validate/validate.go new file mode 100644 index 00000000..30244166 --- /dev/null +++ b/internal/tools/imagedigest/cmd/validate/validate.go @@ -0,0 +1,108 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validate + +import ( + "fmt" + + "github.com/google/go-containerregistry/pkg/crane" + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest" +) + +var validateLong = templates.LongDesc(` +Validate GOST R 34.11-2012 (Streebog-256) digest stored in image metadata. + +Compares the stored digest from annotation "deckhouse.io/gost-digest" with +the recalculated value based on current layer digests. + +Use --fix flag to automatically repair the digest if validation fails. + +Example: + d8 tools imagedigest validate registry.example.com/image:tag + d8 tools imagedigest validate --fix registry.example.com/image:tag`) + +func NewCommand() *cobra.Command { + validateCmd := &cobra.Command{ + Use: "validate ", + Short: "Validate stored GOST digest against recalculated value", + Long: validateLong, + SilenceErrors: true, + SilenceUsage: true, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return fmt.Errorf("this command requires exactly 1 argument (image reference), got %d", len(args)) + } + return nil + }, + RunE: runValidate, + } + + validateCmd.Flags().Bool("fix", false, "Automatically fix GOST digest if validation fails") + + return validateCmd +} + +func runValidate(cmd *cobra.Command, args []string) error { + imageName := args[0] + + insecure, err := cmd.Flags().GetBool("insecure") + if err != nil { + return fmt.Errorf("failed to get insecure flag: %w", err) + } + + fix, err := cmd.Flags().GetBool("fix") + if err != nil { + return fmt.Errorf("failed to get fix flag: %w", err) + } + + var opts []crane.Option + if insecure { + opts = append(opts, crane.Insecure) + } + + fmt.Printf("Validating GOST digest for image: %s\n", imageName) + + result, err := imagedigest.ValidateGostImageDigest(imageName, opts...) + if err != nil { + if result != nil { + fmt.Printf("Stored GOST digest: %s\n", result.StoredDigest) + fmt.Printf("Calculated GOST digest: %s\n", result.CalculatedDigest) + } + fmt.Printf("Validation failed: %v\n", err) + + if fix { + fmt.Println("Attempting to fix GOST digest...") + newDigest, fixErr := imagedigest.AddGostImageDigest(imageName, opts...) + if fixErr != nil { + return fmt.Errorf("failed to fix GOST digest: %w", fixErr) + } + fmt.Printf("New GOST digest: %s\n", newDigest) + fmt.Println("Digest fixed successfully") + return nil + } + + return err + } + + fmt.Printf("GOST digest: %s\n", result.StoredDigest) + fmt.Println("Validation successful") + + return nil +} diff --git a/internal/tools/imagedigest/imagedigest.go b/internal/tools/imagedigest/imagedigest.go new file mode 100644 index 00000000..c28db069 --- /dev/null +++ b/internal/tools/imagedigest/imagedigest.go @@ -0,0 +1,236 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package imagedigest + +import ( + "crypto/subtle" + "encoding/hex" + "fmt" + "io" + "sort" + "strings" + + "github.com/google/go-containerregistry/pkg/crane" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "go.cypherpunks.ru/gogost/v5/gost34112012256" +) + +const ( + GostDigestAnnotationKey = "deckhouse.io/gost-digest" +) + +type ImageMetadata struct { + ImageName string + ImageDigest string + ImageGostDigest string + LayersDigest []string +} + +// ValidationResult contains the result of GOST digest validation. +type ValidationResult struct { + StoredDigest string // Digest read from image annotation + CalculatedDigest string // Freshly calculated digest from layers +} + +// CalculateGostImageDigest calculates GOST R 34.11-2012 (Streebog-256) digest +// for a container image based on sorted layer digests. +func CalculateGostImageDigest(imageName string, opts ...crane.Option) ([]byte, error) { + im, err := getImageMetadataFromRegistry(imageName, opts...) + if err != nil { + return nil, err + } + + return calculateLayersGostDigest(im) +} + +// AddGostImageDigest calculates and adds GOST digest to image annotations. +func AddGostImageDigest(imageName string, opts ...crane.Option) (string, error) { + image, err := getImageFromRegistry(imageName, opts...) + if err != nil { + return "", err + } + im, err := imageToImageMetadata(imageName, image) + if err != nil { + return "", err + } + + gostImageDigest, err := calculateLayersGostDigest(im) + if err != nil { + return "", err + } + + digestHex := hex.EncodeToString(gostImageDigest) + + err = updateImageInRegistry( + imageName, + image, + map[string]string{ + GostDigestAnnotationKey: digestHex, + }, + opts..., + ) + if err != nil { + return "", err + } + + return digestHex, nil +} + +// ValidateGostImageDigest validates stored GOST digest against recalculated digest. +func ValidateGostImageDigest(imageName string, opts ...crane.Option) (*ValidationResult, error) { + im, err := getImageMetadataFromRegistry(imageName, opts...) + if err != nil { + return nil, err + } + + if len(im.ImageGostDigest) == 0 { + return nil, fmt.Errorf("image %s does not contain GOST digest annotation (%s)", imageName, GostDigestAnnotationKey) + } + + result := &ValidationResult{ + StoredDigest: im.ImageGostDigest, + } + + gostImageDigest, err := calculateLayersGostDigest(im) + if err != nil { + return result, err + } + result.CalculatedDigest = hex.EncodeToString(gostImageDigest) + + err = compareImageGostHash(im, gostImageDigest) + if err != nil { + return result, err + } + + return result, nil +} + +func getImageMetadataFromRegistry(imageName string, opts ...crane.Option) (*ImageMetadata, error) { + image, err := getImageFromRegistry(imageName, opts...) + if err != nil { + return nil, err + } + return imageToImageMetadata(imageName, image) +} + +func getImageFromRegistry(imageName string, opts ...crane.Option) (v1.Image, error) { + return crane.Pull(imageName, opts...) +} + +func updateImageInRegistry( + imageName string, + image v1.Image, + annotations map[string]string, + opts ...crane.Option, +) error { + image = mutate.Annotations(image, annotations).(v1.Image) + return crane.Push(image, imageName, opts...) +} + +func imageToImageMetadata(imageName string, image v1.Image) (*ImageMetadata, error) { + im := &ImageMetadata{ImageName: imageName} + + imageDigest, err := image.Digest() + if err != nil { + return nil, err + } + im.ImageDigest = imageDigest.String() + + manifest, err := image.Manifest() + if err != nil { + return nil, err + } + + imageGostDigestStr, ok := manifest.Annotations[GostDigestAnnotationKey] + if ok { + im.ImageGostDigest = imageGostDigestStr + } + + layers, err := image.Layers() + if err != nil { + return nil, err + } + + for _, layer := range layers { + digest, err := layer.Digest() + if err != nil { + return nil, err + } + im.LayersDigest = append(im.LayersDigest, digest.String()) + } + + sort.Slice( + im.LayersDigest, + func(i, j int) bool { + return strings.Compare(im.LayersDigest[i], im.LayersDigest[j]) == -1 + }, + ) + + return im, nil +} + +func calculateLayersGostDigest(im *ImageMetadata) ([]byte, error) { + layersDigestBuilder := strings.Builder{} + for _, digest := range im.LayersDigest { + layersDigestBuilder.WriteString(digest) + } + + data := layersDigestBuilder.String() + + if len(data) == 0 { + return nil, fmt.Errorf("invalid layers hash data: no layers found") + } + + hasher := gost34112012256.New() + _, err := hasher.Write([]byte(data)) + if err != nil { + return nil, err + } + + return hasher.Sum(nil), nil +} + +func compareImageGostHash(im *ImageMetadata, gostHash []byte) error { + imageGostHashByte, err := hex.DecodeString(im.ImageGostDigest) + if err != nil { + return fmt.Errorf("invalid stored GOST digest format: %w", err) + } + + if subtle.ConstantTimeCompare(imageGostHashByte, gostHash) == 0 { + return fmt.Errorf("GOST digest mismatch: stored=%s, calculated=%s", + im.ImageGostDigest, hex.EncodeToString(gostHash)) + } + return nil +} + +// CalculateFromReader calculates GOST R 34.11-2012 (Streebog-256) digest +// from an io.Reader (file or stdin). +func CalculateFromReader(reader io.Reader) ([]byte, error) { + data, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + + hasher := gost34112012256.New() + _, err = hasher.Write(data) + if err != nil { + return nil, err + } + + return hasher.Sum(nil), nil +} diff --git a/internal/tools/tools.go b/internal/tools/tools.go index 8ed63dbd..0b6a39a7 100644 --- a/internal/tools/tools.go +++ b/internal/tools/tools.go @@ -22,6 +22,7 @@ import ( farconverter "github.com/deckhouse/deckhouse-cli/internal/tools/farconverter/cmd" gostsum "github.com/deckhouse/deckhouse-cli/internal/tools/gostsum/cmd" + imagedigest "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest/cmd" sigmigrate "github.com/deckhouse/deckhouse-cli/internal/tools/sigmigrate/cmd" ) @@ -41,6 +42,7 @@ func NewCommand() *cobra.Command { toolsCmd.AddCommand( farconverter.NewCommand(), gostsum.NewCommand(), + imagedigest.NewCommand(), sigmigrate.NewCommand(), ) From 4912beed7b1ef2cd57a9a3268f2e6127a53e68cf Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Wed, 21 Jan 2026 22:30:52 +0300 Subject: [PATCH 2/9] Refactor imagedigest logic + add tests Signed-off-by: Roman Berezkin --- internal/tools/imagedigest/imagedigest.go | 104 +++++---- .../tools/imagedigest/imagedigest_test.go | 205 ++++++++++++++++++ 2 files changed, 265 insertions(+), 44 deletions(-) create mode 100644 internal/tools/imagedigest/imagedigest_test.go diff --git a/internal/tools/imagedigest/imagedigest.go b/internal/tools/imagedigest/imagedigest.go index c28db069..a517980e 100644 --- a/internal/tools/imagedigest/imagedigest.go +++ b/internal/tools/imagedigest/imagedigest.go @@ -41,6 +41,12 @@ type ImageMetadata struct { LayersDigest []string } +// AnnotatedImageResult contains the result of adding GOST digest to an image. +type AnnotatedImageResult struct { + Image v1.Image // Annotated image with GOST digest + DigestHex string // Calculated GOST digest in hex format +} + // ValidationResult contains the result of GOST digest validation. type ValidationResult struct { StoredDigest string // Digest read from image annotation @@ -50,50 +56,79 @@ type ValidationResult struct { // CalculateGostImageDigest calculates GOST R 34.11-2012 (Streebog-256) digest // for a container image based on sorted layer digests. func CalculateGostImageDigest(imageName string, opts ...crane.Option) ([]byte, error) { - im, err := getImageMetadataFromRegistry(imageName, opts...) + image, err := crane.Pull(imageName, opts...) if err != nil { return nil, err } + return CalculateGostDigestFromImage(imageName, image) +} - return calculateLayersGostDigest(im) +// CalculateGostDigestFromImage calculates GOST digest from an already-pulled image. +func CalculateGostDigestFromImage(imageName string, image v1.Image) ([]byte, error) { + im, err := ImageToImageMetadata(imageName, image) + if err != nil { + return nil, err + } + return CalculateLayersGostDigest(im) } // AddGostImageDigest calculates and adds GOST digest to image annotations. func AddGostImageDigest(imageName string, opts ...crane.Option) (string, error) { - image, err := getImageFromRegistry(imageName, opts...) + image, err := crane.Pull(imageName, opts...) if err != nil { return "", err } - im, err := imageToImageMetadata(imageName, image) + + result, err := AddGostDigestToImage(imageName, image) if err != nil { return "", err } - gostImageDigest, err := calculateLayersGostDigest(im) + err = crane.Push(result.Image, imageName, opts...) if err != nil { return "", err } - digestHex := hex.EncodeToString(gostImageDigest) + return result.DigestHex, nil +} - err = updateImageInRegistry( - imageName, - image, - map[string]string{ - GostDigestAnnotationKey: digestHex, - }, - opts..., - ) +// AddGostDigestToImage calculates GOST digest and returns annotated image. +// Does not push to registry - caller is responsible for that. +func AddGostDigestToImage(imageName string, image v1.Image) (*AnnotatedImageResult, error) { + im, err := ImageToImageMetadata(imageName, image) if err != nil { - return "", err + return nil, err + } + + gostImageDigest, err := CalculateLayersGostDigest(im) + if err != nil { + return nil, err } - return digestHex, nil + digestHex := hex.EncodeToString(gostImageDigest) + + annotatedImage := mutate.Annotations(image, map[string]string{ + GostDigestAnnotationKey: digestHex, + }).(v1.Image) + + return &AnnotatedImageResult{ + Image: annotatedImage, + DigestHex: digestHex, + }, nil } // ValidateGostImageDigest validates stored GOST digest against recalculated digest. func ValidateGostImageDigest(imageName string, opts ...crane.Option) (*ValidationResult, error) { - im, err := getImageMetadataFromRegistry(imageName, opts...) + image, err := crane.Pull(imageName, opts...) + if err != nil { + return nil, err + } + return ValidateGostDigestFromImage(imageName, image) +} + +// ValidateGostDigestFromImage validates GOST digest from an already-pulled image. +func ValidateGostDigestFromImage(imageName string, image v1.Image) (*ValidationResult, error) { + im, err := ImageToImageMetadata(imageName, image) if err != nil { return nil, err } @@ -106,13 +141,13 @@ func ValidateGostImageDigest(imageName string, opts ...crane.Option) (*Validatio StoredDigest: im.ImageGostDigest, } - gostImageDigest, err := calculateLayersGostDigest(im) + gostImageDigest, err := CalculateLayersGostDigest(im) if err != nil { return result, err } result.CalculatedDigest = hex.EncodeToString(gostImageDigest) - err = compareImageGostHash(im, gostImageDigest) + err = CompareImageGostHash(im, gostImageDigest) if err != nil { return result, err } @@ -120,29 +155,8 @@ func ValidateGostImageDigest(imageName string, opts ...crane.Option) (*Validatio return result, nil } -func getImageMetadataFromRegistry(imageName string, opts ...crane.Option) (*ImageMetadata, error) { - image, err := getImageFromRegistry(imageName, opts...) - if err != nil { - return nil, err - } - return imageToImageMetadata(imageName, image) -} - -func getImageFromRegistry(imageName string, opts ...crane.Option) (v1.Image, error) { - return crane.Pull(imageName, opts...) -} - -func updateImageInRegistry( - imageName string, - image v1.Image, - annotations map[string]string, - opts ...crane.Option, -) error { - image = mutate.Annotations(image, annotations).(v1.Image) - return crane.Push(image, imageName, opts...) -} - -func imageToImageMetadata(imageName string, image v1.Image) (*ImageMetadata, error) { +// ImageToImageMetadata extracts metadata from a v1.Image. +func ImageToImageMetadata(imageName string, image v1.Image) (*ImageMetadata, error) { im := &ImageMetadata{ImageName: imageName} imageDigest, err := image.Digest() @@ -184,7 +198,8 @@ func imageToImageMetadata(imageName string, image v1.Image) (*ImageMetadata, err return im, nil } -func calculateLayersGostDigest(im *ImageMetadata) ([]byte, error) { +// CalculateLayersGostDigest calculates GOST digest from concatenated sorted layer digests. +func CalculateLayersGostDigest(im *ImageMetadata) ([]byte, error) { layersDigestBuilder := strings.Builder{} for _, digest := range im.LayersDigest { layersDigestBuilder.WriteString(digest) @@ -205,7 +220,8 @@ func calculateLayersGostDigest(im *ImageMetadata) ([]byte, error) { return hasher.Sum(nil), nil } -func compareImageGostHash(im *ImageMetadata, gostHash []byte) error { +// CompareImageGostHash compares stored GOST digest with calculated hash using constant-time comparison. +func CompareImageGostHash(im *ImageMetadata, gostHash []byte) error { imageGostHashByte, err := hex.DecodeString(im.ImageGostDigest) if err != nil { return fmt.Errorf("invalid stored GOST digest format: %w", err) diff --git a/internal/tools/imagedigest/imagedigest_test.go b/internal/tools/imagedigest/imagedigest_test.go new file mode 100644 index 00000000..4bed72d8 --- /dev/null +++ b/internal/tools/imagedigest/imagedigest_test.go @@ -0,0 +1,205 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package imagedigest + +import ( + "encoding/hex" + "strings" + "testing" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/random" +) + +func TestCalculateLayersGostDigest(t *testing.T) { + t.Run("empty layers error", func(t *testing.T) { + _, err := CalculateLayersGostDigest(&ImageMetadata{}) + if err == nil || !strings.Contains(err.Error(), "no layers found") { + t.Errorf("expected 'no layers found' error, got %v", err) + } + }) +} + +func TestCompareImageGostHash(t *testing.T) { + hash, _ := CalculateLayersGostDigest(&ImageMetadata{LayersDigest: []string{"sha256:test"}}) + hashHex := hex.EncodeToString(hash) + + t.Run("matching", func(t *testing.T) { + err := CompareImageGostHash(&ImageMetadata{ImageGostDigest: hashHex}, hash) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("mismatch", func(t *testing.T) { + err := CompareImageGostHash(&ImageMetadata{ImageGostDigest: hashHex}, make([]byte, 32)) + if err == nil || !strings.Contains(err.Error(), "mismatch") { + t.Errorf("expected mismatch error, got %v", err) + } + }) + + t.Run("invalid hex", func(t *testing.T) { + err := CompareImageGostHash(&ImageMetadata{ImageGostDigest: "not-hex"}, hash) + if err == nil || !strings.Contains(err.Error(), "invalid") { + t.Errorf("expected invalid format error, got %v", err) + } + }) +} + +func TestImageToImageMetadata(t *testing.T) { + img, err := random.Image(256, 2) // 256 bytes, 2 layers + if err != nil { + t.Fatalf("failed to create test image: %v", err) + } + + t.Run("extracts metadata", func(t *testing.T) { + im, err := ImageToImageMetadata("test:v1", img) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if im.ImageName != "test:v1" { + t.Errorf("ImageName = %s, want test:v1", im.ImageName) + } + if im.ImageDigest == "" { + t.Error("ImageDigest is empty") + } + if len(im.LayersDigest) != 2 { + t.Errorf("LayersDigest length = %d, want 2", len(im.LayersDigest)) + } + }) + + t.Run("layers are sorted", func(t *testing.T) { + im, _ := ImageToImageMetadata("test", img) + for i := 1; i < len(im.LayersDigest); i++ { + if im.LayersDigest[i-1] > im.LayersDigest[i] { + t.Error("layers not sorted") + } + } + }) +} + +func TestCalculateGostDigestFromImage(t *testing.T) { + img, _ := random.Image(256, 2) + + t.Run("success", func(t *testing.T) { + got, err := CalculateGostDigestFromImage("test:v1", img) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 32 { + t.Errorf("expected 32-byte hash, got %d", len(got)) + } + }) + + t.Run("deterministic", func(t *testing.T) { + hash1, _ := CalculateGostDigestFromImage("test:v1", img) + hash2, _ := CalculateGostDigestFromImage("test:v1", img) + if hex.EncodeToString(hash1) != hex.EncodeToString(hash2) { + t.Error("expected same hash for same image") + } + }) +} + +func TestAddGostDigestToImage(t *testing.T) { + img, _ := random.Image(256, 2) + + t.Run("success", func(t *testing.T) { + result, err := AddGostDigestToImage("test:v1", img) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.DigestHex) != 64 { // hex-encoded 32 bytes + t.Errorf("expected 64-char hex, got %d", len(result.DigestHex)) + } + if result.Image == nil { + t.Fatal("expected annotated image") + } + + // Verify annotation was added + manifest, _ := result.Image.Manifest() + if manifest.Annotations[GostDigestAnnotationKey] != result.DigestHex { + t.Errorf("annotation not set correctly") + } + }) + + t.Run("digest matches recalculation", func(t *testing.T) { + result, _ := AddGostDigestToImage("test:v1", img) + + // Recalculate and compare + recalculated, _ := CalculateGostDigestFromImage("test:v1", img) + if result.DigestHex != hex.EncodeToString(recalculated) { + t.Error("digest doesn't match recalculation") + } + }) +} + +func TestValidateGostDigestFromImage(t *testing.T) { + t.Run("no annotation error", func(t *testing.T) { + img, _ := random.Image(256, 1) + + _, err := ValidateGostDigestFromImage("test:v1", img) + if err == nil || !strings.Contains(err.Error(), "does not contain GOST digest") { + t.Errorf("expected no annotation error, got %v", err) + } + }) + + t.Run("success with valid annotation", func(t *testing.T) { + img, _ := random.Image(256, 2) + + // Use AddGostDigestToImage to create properly annotated image + addResult, err := AddGostDigestToImage("test:v1", img) + if err != nil { + t.Fatalf("failed to add digest: %v", err) + } + + result, err := ValidateGostDigestFromImage("test:v1", addResult.Image) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.StoredDigest != addResult.DigestHex { + t.Errorf("StoredDigest = %s, want %s", result.StoredDigest, addResult.DigestHex) + } + if result.CalculatedDigest != addResult.DigestHex { + t.Errorf("CalculatedDigest = %s, want %s", result.CalculatedDigest, addResult.DigestHex) + } + }) + + t.Run("mismatch with incorrect annotation", func(t *testing.T) { + img, _ := random.Image(256, 2) + + // Create image with incorrect GOST annotation + wrongDigest := hex.EncodeToString(make([]byte, 32)) // all zeros + annotatedImg := mutate.Annotations(img, map[string]string{ + GostDigestAnnotationKey: wrongDigest, + }).(v1.Image) + + result, err := ValidateGostDigestFromImage("test:v1", annotatedImg) + if err == nil || !strings.Contains(err.Error(), "mismatch") { + t.Errorf("expected mismatch error, got %v", err) + } + if result == nil { + t.Fatal("expected result to be returned even on mismatch") + } + if result.StoredDigest != wrongDigest { + t.Errorf("StoredDigest = %s, want %s", result.StoredDigest, wrongDigest) + } + if result.CalculatedDigest == wrongDigest { + t.Error("CalculatedDigest should differ from wrong stored digest") + } + }) +} From 437da1422dcd259893ff136f22995f111c0e72e0 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Wed, 21 Jan 2026 22:42:57 +0300 Subject: [PATCH 3/9] General refactoring to ensure separation of concerns and readability Signed-off-by: Roman Berezkin --- internal/tools/imagedigest/cmd/add/add.go | 2 +- .../imagedigest/cmd/calculate/calculate.go | 2 +- .../calculatefromfile/calculatefromfile.go | 2 +- .../imagedigest/cmd/validate/validate.go | 4 +- internal/tools/imagedigest/imagedigest.go | 271 +++++++++--------- .../tools/imagedigest/imagedigest_test.go | 243 ++++++++++------ 6 files changed, 303 insertions(+), 221 deletions(-) diff --git a/internal/tools/imagedigest/cmd/add/add.go b/internal/tools/imagedigest/cmd/add/add.go index 63e70118..f33faa95 100644 --- a/internal/tools/imagedigest/cmd/add/add.go +++ b/internal/tools/imagedigest/cmd/add/add.go @@ -70,7 +70,7 @@ func runAdd(cmd *cobra.Command, args []string) error { fmt.Printf("Calculating GOST digest for image: %s\n", imageName) - digest, err := imagedigest.AddGostImageDigest(imageName, opts...) + digest, err := imagedigest.PullAnnotatePush(imageName, opts...) if err != nil { return fmt.Errorf("failed to add GOST digest: %w", err) } diff --git a/internal/tools/imagedigest/cmd/calculate/calculate.go b/internal/tools/imagedigest/cmd/calculate/calculate.go index 5f15df51..227d1e05 100644 --- a/internal/tools/imagedigest/cmd/calculate/calculate.go +++ b/internal/tools/imagedigest/cmd/calculate/calculate.go @@ -69,7 +69,7 @@ func runCalculate(cmd *cobra.Command, args []string) error { opts = append(opts, crane.Insecure) } - digest, err := imagedigest.CalculateGostImageDigest(imageName, opts...) + digest, err := imagedigest.PullAndCalculate(imageName, opts...) if err != nil { return fmt.Errorf("failed to calculate GOST digest: %w", err) } diff --git a/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go b/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go index 860f60d9..be845e2a 100644 --- a/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go +++ b/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go @@ -69,7 +69,7 @@ func runCalculateFromFile(cmd *cobra.Command, args []string) error { reader = file } - digest, err := imagedigest.CalculateFromReader(reader) + digest, err := imagedigest.CalculateGostHashFromReader(reader) if err != nil { return fmt.Errorf("failed to calculate GOST digest: %w", err) } diff --git a/internal/tools/imagedigest/cmd/validate/validate.go b/internal/tools/imagedigest/cmd/validate/validate.go index 30244166..b6600a6b 100644 --- a/internal/tools/imagedigest/cmd/validate/validate.go +++ b/internal/tools/imagedigest/cmd/validate/validate.go @@ -79,7 +79,7 @@ func runValidate(cmd *cobra.Command, args []string) error { fmt.Printf("Validating GOST digest for image: %s\n", imageName) - result, err := imagedigest.ValidateGostImageDigest(imageName, opts...) + result, err := imagedigest.PullAndValidate(imageName, opts...) if err != nil { if result != nil { fmt.Printf("Stored GOST digest: %s\n", result.StoredDigest) @@ -89,7 +89,7 @@ func runValidate(cmd *cobra.Command, args []string) error { if fix { fmt.Println("Attempting to fix GOST digest...") - newDigest, fixErr := imagedigest.AddGostImageDigest(imageName, opts...) + newDigest, fixErr := imagedigest.PullAnnotatePush(imageName, opts...) if fixErr != nil { return fmt.Errorf("failed to fix GOST digest: %w", fixErr) } diff --git a/internal/tools/imagedigest/imagedigest.go b/internal/tools/imagedigest/imagedigest.go index a517980e..7c9c3a95 100644 --- a/internal/tools/imagedigest/imagedigest.go +++ b/internal/tools/imagedigest/imagedigest.go @@ -21,7 +21,7 @@ import ( "encoding/hex" "fmt" "io" - "sort" + "slices" "strings" "github.com/google/go-containerregistry/pkg/crane" @@ -34,219 +34,220 @@ const ( GostDigestAnnotationKey = "deckhouse.io/gost-digest" ) -type ImageMetadata struct { - ImageName string - ImageDigest string - ImageGostDigest string - LayersDigest []string -} - -// AnnotatedImageResult contains the result of adding GOST digest to an image. -type AnnotatedImageResult struct { - Image v1.Image // Annotated image with GOST digest - DigestHex string // Calculated GOST digest in hex format -} - // ValidationResult contains the result of GOST digest validation. type ValidationResult struct { StoredDigest string // Digest read from image annotation CalculatedDigest string // Freshly calculated digest from layers } -// CalculateGostImageDigest calculates GOST R 34.11-2012 (Streebog-256) digest -// for a container image based on sorted layer digests. -func CalculateGostImageDigest(imageName string, opts ...crane.Option) ([]byte, error) { - image, err := crane.Pull(imageName, opts...) - if err != nil { - return nil, err - } - return CalculateGostDigestFromImage(imageName, image) +// ImageInfo contains display information about an image (read-only DTO). +type ImageInfo struct { + Name string + Digest string + GostDigest string // May be empty if not annotated + LayerDigests []string // Sorted +} + +// ============================================================================= +// Layer 1: Pure Hash Computation (no dependencies on image types) +// ============================================================================= + +// CalculateGostHash computes GOST R 34.11-2012 (Streebog-256) from raw bytes. +func CalculateGostHash(data []byte) []byte { + hasher := gost34112012256.New() + hasher.Write(data) + return hasher.Sum(nil) } -// CalculateGostDigestFromImage calculates GOST digest from an already-pulled image. -func CalculateGostDigestFromImage(imageName string, image v1.Image) ([]byte, error) { - im, err := ImageToImageMetadata(imageName, image) +// CalculateGostHashFromReader computes GOST R 34.11-2012 hash from io.Reader. +func CalculateGostHashFromReader(r io.Reader) ([]byte, error) { + data, err := io.ReadAll(r) if err != nil { return nil, err } - return CalculateLayersGostDigest(im) + return CalculateGostHash(data), nil } -// AddGostImageDigest calculates and adds GOST digest to image annotations. -func AddGostImageDigest(imageName string, opts ...crane.Option) (string, error) { - image, err := crane.Pull(imageName, opts...) - if err != nil { - return "", err - } +// ============================================================================= +// Layer 2: Layer Digest Extraction (depends only on v1.Image) +// ============================================================================= - result, err := AddGostDigestToImage(imageName, image) +// ExtractSortedLayerDigests returns sorted layer digests from an image. +func ExtractSortedLayerDigests(image v1.Image) ([]string, error) { + layers, err := image.Layers() if err != nil { - return "", err + return nil, err } - err = crane.Push(result.Image, imageName, opts...) - if err != nil { - return "", err + digests := make([]string, 0, len(layers)) + for _, layer := range layers { + digest, err := layer.Digest() + if err != nil { + return nil, err + } + digests = append(digests, digest.String()) } - return result.DigestHex, nil + slices.Sort(digests) + return digests, nil } -// AddGostDigestToImage calculates GOST digest and returns annotated image. -// Does not push to registry - caller is responsible for that. -func AddGostDigestToImage(imageName string, image v1.Image) (*AnnotatedImageResult, error) { - im, err := ImageToImageMetadata(imageName, image) - if err != nil { - return nil, err - } - - gostImageDigest, err := CalculateLayersGostDigest(im) +// ReadGostAnnotation reads the GOST digest annotation if present. +// Returns the digest value, whether it was found, and any error. +func ReadGostAnnotation(image v1.Image) (string, bool, error) { + manifest, err := image.Manifest() if err != nil { - return nil, err + return "", false, err } - digestHex := hex.EncodeToString(gostImageDigest) + digest, ok := manifest.Annotations[GostDigestAnnotationKey] + return digest, ok, nil +} - annotatedImage := mutate.Annotations(image, map[string]string{ +// AddGostAnnotation returns a new image with the GOST annotation added. +func AddGostAnnotation(image v1.Image, digestHex string) v1.Image { + return mutate.Annotations(image, map[string]string{ GostDigestAnnotationKey: digestHex, }).(v1.Image) - - return &AnnotatedImageResult{ - Image: annotatedImage, - DigestHex: digestHex, - }, nil } -// ValidateGostImageDigest validates stored GOST digest against recalculated digest. -func ValidateGostImageDigest(imageName string, opts ...crane.Option) (*ValidationResult, error) { - image, err := crane.Pull(imageName, opts...) +// ============================================================================= +// Layer 3: Composed Operations (combines layers 1 and 2) +// ============================================================================= + +// CalculateImageGostDigest calculates GOST digest from an image's layers. +func CalculateImageGostDigest(image v1.Image) ([]byte, error) { + digests, err := ExtractSortedLayerDigests(image) if err != nil { return nil, err } - return ValidateGostDigestFromImage(imageName, image) + if len(digests) == 0 { + return nil, fmt.Errorf("invalid layers hash data: no layers found") + } + return CalculateGostHash([]byte(strings.Join(digests, ""))), nil +} + +// AnnotateWithGostDigest calculates GOST digest and returns annotated image. +// Returns the annotated image and the calculated digest in hex format. +func AnnotateWithGostDigest(image v1.Image) (v1.Image, string, error) { + digest, err := CalculateImageGostDigest(image) + if err != nil { + return nil, "", err + } + digestHex := hex.EncodeToString(digest) + return AddGostAnnotation(image, digestHex), digestHex, nil } -// ValidateGostDigestFromImage validates GOST digest from an already-pulled image. -func ValidateGostDigestFromImage(imageName string, image v1.Image) (*ValidationResult, error) { - im, err := ImageToImageMetadata(imageName, image) +// ValidateGostDigest verifies the stored annotation matches recalculated digest. +// Returns ValidationResult with both digests for comparison, and error if mismatch. +func ValidateGostDigest(image v1.Image) (*ValidationResult, error) { + stored, ok, err := ReadGostAnnotation(image) if err != nil { return nil, err } + if !ok { + return nil, fmt.Errorf("image does not contain GOST digest annotation (%s)", GostDigestAnnotationKey) + } - if len(im.ImageGostDigest) == 0 { - return nil, fmt.Errorf("image %s does not contain GOST digest annotation (%s)", imageName, GostDigestAnnotationKey) + calculated, err := CalculateImageGostDigest(image) + if err != nil { + return &ValidationResult{StoredDigest: stored}, err } result := &ValidationResult{ - StoredDigest: im.ImageGostDigest, + StoredDigest: stored, + CalculatedDigest: hex.EncodeToString(calculated), } - gostImageDigest, err := CalculateLayersGostDigest(im) - if err != nil { + if err := compareDigests(stored, calculated); err != nil { return result, err } - result.CalculatedDigest = hex.EncodeToString(gostImageDigest) + return result, nil +} - err = CompareImageGostHash(im, gostImageDigest) +// compareDigests compares stored hex digest with calculated hash using constant-time comparison. +func compareDigests(storedHex string, calculatedHash []byte) error { + storedBytes, err := hex.DecodeString(storedHex) if err != nil { - return result, err + return fmt.Errorf("invalid stored GOST digest format: %w", err) } - return result, nil + if subtle.ConstantTimeCompare(storedBytes, calculatedHash) == 0 { + return fmt.Errorf("GOST digest mismatch: stored=%s, calculated=%s", + storedHex, hex.EncodeToString(calculatedHash)) + } + return nil } -// ImageToImageMetadata extracts metadata from a v1.Image. -func ImageToImageMetadata(imageName string, image v1.Image) (*ImageMetadata, error) { - im := &ImageMetadata{ImageName: imageName} +// ============================================================================= +// Layer 4: Registry Operations (high-level convenience with network I/O) +// ============================================================================= - imageDigest, err := image.Digest() +// PullAndCalculate pulls image and calculates GOST digest. +func PullAndCalculate(imageName string, opts ...crane.Option) ([]byte, error) { + image, err := crane.Pull(imageName, opts...) if err != nil { return nil, err } - im.ImageDigest = imageDigest.String() + return CalculateImageGostDigest(image) +} - manifest, err := image.Manifest() +// PullAnnotatePush pulls image, annotates with GOST digest, and pushes back. +// Returns the calculated digest in hex format. +func PullAnnotatePush(imageName string, opts ...crane.Option) (string, error) { + image, err := crane.Pull(imageName, opts...) if err != nil { - return nil, err - } - - imageGostDigestStr, ok := manifest.Annotations[GostDigestAnnotationKey] - if ok { - im.ImageGostDigest = imageGostDigestStr + return "", err } - layers, err := image.Layers() + annotated, digestHex, err := AnnotateWithGostDigest(image) if err != nil { - return nil, err + return "", err } - for _, layer := range layers { - digest, err := layer.Digest() - if err != nil { - return nil, err - } - im.LayersDigest = append(im.LayersDigest, digest.String()) + if err := crane.Push(annotated, imageName, opts...); err != nil { + return "", err } - - sort.Slice( - im.LayersDigest, - func(i, j int) bool { - return strings.Compare(im.LayersDigest[i], im.LayersDigest[j]) == -1 - }, - ) - - return im, nil + return digestHex, nil } -// CalculateLayersGostDigest calculates GOST digest from concatenated sorted layer digests. -func CalculateLayersGostDigest(im *ImageMetadata) ([]byte, error) { - layersDigestBuilder := strings.Builder{} - for _, digest := range im.LayersDigest { - layersDigestBuilder.WriteString(digest) - } - - data := layersDigestBuilder.String() - - if len(data) == 0 { - return nil, fmt.Errorf("invalid layers hash data: no layers found") - } - - hasher := gost34112012256.New() - _, err := hasher.Write([]byte(data)) +// PullAndValidate pulls image and validates its GOST digest. +func PullAndValidate(imageName string, opts ...crane.Option) (*ValidationResult, error) { + image, err := crane.Pull(imageName, opts...) if err != nil { return nil, err } - - return hasher.Sum(nil), nil + return ValidateGostDigest(image) } -// CompareImageGostHash compares stored GOST digest with calculated hash using constant-time comparison. -func CompareImageGostHash(im *ImageMetadata, gostHash []byte) error { - imageGostHashByte, err := hex.DecodeString(im.ImageGostDigest) - if err != nil { - return fmt.Errorf("invalid stored GOST digest format: %w", err) - } +// ============================================================================= +// Utility: ImageInfo for display purposes +// ============================================================================= - if subtle.ConstantTimeCompare(imageGostHashByte, gostHash) == 0 { - return fmt.Errorf("GOST digest mismatch: stored=%s, calculated=%s", - im.ImageGostDigest, hex.EncodeToString(gostHash)) +// GetImageInfo extracts display information from an image. +func GetImageInfo(name string, image v1.Image) (*ImageInfo, error) { + info := &ImageInfo{Name: name} + + imageDigest, err := image.Digest() + if err != nil { + return nil, err } - return nil -} + info.Digest = imageDigest.String() -// CalculateFromReader calculates GOST R 34.11-2012 (Streebog-256) digest -// from an io.Reader (file or stdin). -func CalculateFromReader(reader io.Reader) ([]byte, error) { - data, err := io.ReadAll(reader) + gostDigest, ok, err := ReadGostAnnotation(image) if err != nil { return nil, err } + if ok { + info.GostDigest = gostDigest + } - hasher := gost34112012256.New() - _, err = hasher.Write(data) + layerDigests, err := ExtractSortedLayerDigests(image) if err != nil { return nil, err } + info.LayerDigests = layerDigests - return hasher.Sum(nil), nil + return info, nil } + diff --git a/internal/tools/imagedigest/imagedigest_test.go b/internal/tools/imagedigest/imagedigest_test.go index 4bed72d8..f9f1d480 100644 --- a/internal/tools/imagedigest/imagedigest_test.go +++ b/internal/tools/imagedigest/imagedigest_test.go @@ -17,142 +17,200 @@ limitations under the License. package imagedigest import ( + "bytes" "encoding/hex" "strings" "testing" - v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/google/go-containerregistry/pkg/v1/random" ) -func TestCalculateLayersGostDigest(t *testing.T) { - t.Run("empty layers error", func(t *testing.T) { - _, err := CalculateLayersGostDigest(&ImageMetadata{}) - if err == nil || !strings.Contains(err.Error(), "no layers found") { - t.Errorf("expected 'no layers found' error, got %v", err) +// ============================================================================= +// Layer 1: Pure Hash Computation Tests +// ============================================================================= + +func TestCalculateGostHash(t *testing.T) { + t.Run("returns 32 bytes", func(t *testing.T) { + hash := CalculateGostHash([]byte("test data")) + if len(hash) != 32 { + t.Errorf("expected 32-byte hash, got %d", len(hash)) } }) -} -func TestCompareImageGostHash(t *testing.T) { - hash, _ := CalculateLayersGostDigest(&ImageMetadata{LayersDigest: []string{"sha256:test"}}) - hashHex := hex.EncodeToString(hash) + t.Run("deterministic", func(t *testing.T) { + data := []byte("test data") + hash1 := CalculateGostHash(data) + hash2 := CalculateGostHash(data) + if !bytes.Equal(hash1, hash2) { + t.Error("expected same hash for same input") + } + }) - t.Run("matching", func(t *testing.T) { - err := CompareImageGostHash(&ImageMetadata{ImageGostDigest: hashHex}, hash) - if err != nil { - t.Errorf("unexpected error: %v", err) + t.Run("different input different output", func(t *testing.T) { + hash1 := CalculateGostHash([]byte("data1")) + hash2 := CalculateGostHash([]byte("data2")) + if bytes.Equal(hash1, hash2) { + t.Error("expected different hashes for different inputs") } }) +} - t.Run("mismatch", func(t *testing.T) { - err := CompareImageGostHash(&ImageMetadata{ImageGostDigest: hashHex}, make([]byte, 32)) - if err == nil || !strings.Contains(err.Error(), "mismatch") { - t.Errorf("expected mismatch error, got %v", err) +func TestCalculateGostHashFromReader(t *testing.T) { + t.Run("success", func(t *testing.T) { + reader := strings.NewReader("test data") + hash, err := CalculateGostHashFromReader(reader) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(hash) != 32 { + t.Errorf("expected 32-byte hash, got %d", len(hash)) } }) - t.Run("invalid hex", func(t *testing.T) { - err := CompareImageGostHash(&ImageMetadata{ImageGostDigest: "not-hex"}, hash) - if err == nil || !strings.Contains(err.Error(), "invalid") { - t.Errorf("expected invalid format error, got %v", err) + t.Run("matches direct calculation", func(t *testing.T) { + data := "test data" + directHash := CalculateGostHash([]byte(data)) + readerHash, _ := CalculateGostHashFromReader(strings.NewReader(data)) + if !bytes.Equal(directHash, readerHash) { + t.Error("reader hash should match direct hash") } }) } -func TestImageToImageMetadata(t *testing.T) { - img, err := random.Image(256, 2) // 256 bytes, 2 layers +// ============================================================================= +// Layer 2: Layer Digest Extraction Tests +// ============================================================================= + +func TestExtractSortedLayerDigests(t *testing.T) { + img, err := random.Image(256, 3) if err != nil { t.Fatalf("failed to create test image: %v", err) } - t.Run("extracts metadata", func(t *testing.T) { - im, err := ImageToImageMetadata("test:v1", img) + t.Run("extracts correct count", func(t *testing.T) { + digests, err := ExtractSortedLayerDigests(img) if err != nil { t.Fatalf("unexpected error: %v", err) } - if im.ImageName != "test:v1" { - t.Errorf("ImageName = %s, want test:v1", im.ImageName) + if len(digests) != 3 { + t.Errorf("expected 3 digests, got %d", len(digests)) + } + }) + + t.Run("digests are sorted", func(t *testing.T) { + digests, _ := ExtractSortedLayerDigests(img) + for i := 1; i < len(digests); i++ { + if digests[i-1] > digests[i] { + t.Error("digests not sorted") + } } - if im.ImageDigest == "" { - t.Error("ImageDigest is empty") + }) +} + +func TestReadGostAnnotation(t *testing.T) { + img, _ := random.Image(256, 1) + + t.Run("no annotation", func(t *testing.T) { + digest, ok, err := ReadGostAnnotation(img) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Error("expected ok=false for image without annotation") } - if len(im.LayersDigest) != 2 { - t.Errorf("LayersDigest length = %d, want 2", len(im.LayersDigest)) + if digest != "" { + t.Error("expected empty digest") } }) - t.Run("layers are sorted", func(t *testing.T) { - im, _ := ImageToImageMetadata("test", img) - for i := 1; i < len(im.LayersDigest); i++ { - if im.LayersDigest[i-1] > im.LayersDigest[i] { - t.Error("layers not sorted") - } + t.Run("with annotation", func(t *testing.T) { + annotated := AddGostAnnotation(img, "abc123") + digest, ok, err := ReadGostAnnotation(annotated) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Error("expected ok=true for annotated image") + } + if digest != "abc123" { + t.Errorf("expected 'abc123', got '%s'", digest) } }) } -func TestCalculateGostDigestFromImage(t *testing.T) { - img, _ := random.Image(256, 2) +func TestAddGostAnnotation(t *testing.T) { + img, _ := random.Image(256, 1) + + t.Run("adds annotation", func(t *testing.T) { + annotated := AddGostAnnotation(img, "test-digest") + manifest, _ := annotated.Manifest() + if manifest.Annotations[GostDigestAnnotationKey] != "test-digest" { + t.Error("annotation not set correctly") + } + }) +} + +// ============================================================================= +// Layer 3: Composed Operations Tests +// ============================================================================= +func TestCalculateImageGostDigest(t *testing.T) { t.Run("success", func(t *testing.T) { - got, err := CalculateGostDigestFromImage("test:v1", img) + img, _ := random.Image(256, 2) + digest, err := CalculateImageGostDigest(img) if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(got) != 32 { - t.Errorf("expected 32-byte hash, got %d", len(got)) + if len(digest) != 32 { + t.Errorf("expected 32-byte hash, got %d", len(digest)) } }) t.Run("deterministic", func(t *testing.T) { - hash1, _ := CalculateGostDigestFromImage("test:v1", img) - hash2, _ := CalculateGostDigestFromImage("test:v1", img) - if hex.EncodeToString(hash1) != hex.EncodeToString(hash2) { + img, _ := random.Image(256, 2) + hash1, _ := CalculateImageGostDigest(img) + hash2, _ := CalculateImageGostDigest(img) + if !bytes.Equal(hash1, hash2) { t.Error("expected same hash for same image") } }) } -func TestAddGostDigestToImage(t *testing.T) { +func TestAnnotateWithGostDigest(t *testing.T) { img, _ := random.Image(256, 2) t.Run("success", func(t *testing.T) { - result, err := AddGostDigestToImage("test:v1", img) + annotated, digestHex, err := AnnotateWithGostDigest(img) if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(result.DigestHex) != 64 { // hex-encoded 32 bytes - t.Errorf("expected 64-char hex, got %d", len(result.DigestHex)) + if len(digestHex) != 64 { + t.Errorf("expected 64-char hex, got %d", len(digestHex)) } - if result.Image == nil { + if annotated == nil { t.Fatal("expected annotated image") } // Verify annotation was added - manifest, _ := result.Image.Manifest() - if manifest.Annotations[GostDigestAnnotationKey] != result.DigestHex { - t.Errorf("annotation not set correctly") + manifest, _ := annotated.Manifest() + if manifest.Annotations[GostDigestAnnotationKey] != digestHex { + t.Error("annotation not set correctly") } }) t.Run("digest matches recalculation", func(t *testing.T) { - result, _ := AddGostDigestToImage("test:v1", img) - - // Recalculate and compare - recalculated, _ := CalculateGostDigestFromImage("test:v1", img) - if result.DigestHex != hex.EncodeToString(recalculated) { + _, digestHex, _ := AnnotateWithGostDigest(img) + recalculated, _ := CalculateImageGostDigest(img) + if digestHex != hex.EncodeToString(recalculated) { t.Error("digest doesn't match recalculation") } }) } -func TestValidateGostDigestFromImage(t *testing.T) { +func TestValidateGostDigest(t *testing.T) { t.Run("no annotation error", func(t *testing.T) { img, _ := random.Image(256, 1) - - _, err := ValidateGostDigestFromImage("test:v1", img) + _, err := ValidateGostDigest(img) if err == nil || !strings.Contains(err.Error(), "does not contain GOST digest") { t.Errorf("expected no annotation error, got %v", err) } @@ -160,35 +218,26 @@ func TestValidateGostDigestFromImage(t *testing.T) { t.Run("success with valid annotation", func(t *testing.T) { img, _ := random.Image(256, 2) + annotated, expectedDigest, _ := AnnotateWithGostDigest(img) - // Use AddGostDigestToImage to create properly annotated image - addResult, err := AddGostDigestToImage("test:v1", img) - if err != nil { - t.Fatalf("failed to add digest: %v", err) - } - - result, err := ValidateGostDigestFromImage("test:v1", addResult.Image) + result, err := ValidateGostDigest(annotated) if err != nil { t.Fatalf("unexpected error: %v", err) } - if result.StoredDigest != addResult.DigestHex { - t.Errorf("StoredDigest = %s, want %s", result.StoredDigest, addResult.DigestHex) + if result.StoredDigest != expectedDigest { + t.Errorf("StoredDigest = %s, want %s", result.StoredDigest, expectedDigest) } - if result.CalculatedDigest != addResult.DigestHex { - t.Errorf("CalculatedDigest = %s, want %s", result.CalculatedDigest, addResult.DigestHex) + if result.CalculatedDigest != expectedDigest { + t.Errorf("CalculatedDigest = %s, want %s", result.CalculatedDigest, expectedDigest) } }) t.Run("mismatch with incorrect annotation", func(t *testing.T) { img, _ := random.Image(256, 2) + wrongDigest := hex.EncodeToString(make([]byte, 32)) + annotated := AddGostAnnotation(img, wrongDigest) - // Create image with incorrect GOST annotation - wrongDigest := hex.EncodeToString(make([]byte, 32)) // all zeros - annotatedImg := mutate.Annotations(img, map[string]string{ - GostDigestAnnotationKey: wrongDigest, - }).(v1.Image) - - result, err := ValidateGostDigestFromImage("test:v1", annotatedImg) + result, err := ValidateGostDigest(annotated) if err == nil || !strings.Contains(err.Error(), "mismatch") { t.Errorf("expected mismatch error, got %v", err) } @@ -203,3 +252,35 @@ func TestValidateGostDigestFromImage(t *testing.T) { } }) } + +// ============================================================================= +// Utility: ImageInfo Tests +// ============================================================================= + +func TestGetImageInfo(t *testing.T) { + img, _ := random.Image(256, 2) + + t.Run("extracts all fields", func(t *testing.T) { + info, err := GetImageInfo("test:v1", img) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Name != "test:v1" { + t.Errorf("Name = %s, want test:v1", info.Name) + } + if info.Digest == "" { + t.Error("Digest is empty") + } + if len(info.LayerDigests) != 2 { + t.Errorf("LayerDigests length = %d, want 2", len(info.LayerDigests)) + } + }) + + t.Run("includes GOST digest if present", func(t *testing.T) { + annotated, expectedDigest, _ := AnnotateWithGostDigest(img) + info, _ := GetImageInfo("test:v1", annotated) + if info.GostDigest != expectedDigest { + t.Errorf("GostDigest = %s, want %s", info.GostDigest, expectedDigest) + } + }) +} From 527fdc47bb5ff58a7d480420a5f810f5b7e783db Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Wed, 21 Jan 2026 23:01:24 +0300 Subject: [PATCH 4/9] Remove duplication of the description Signed-off-by: Roman Berezkin --- internal/tools/imagedigest/cmd/imagedigest.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/internal/tools/imagedigest/cmd/imagedigest.go b/internal/tools/imagedigest/cmd/imagedigest.go index d4eca0cb..39d8186b 100644 --- a/internal/tools/imagedigest/cmd/imagedigest.go +++ b/internal/tools/imagedigest/cmd/imagedigest.go @@ -29,14 +29,8 @@ import ( var imagedigestLong = templates.LongDesc(` Manage GOST R 34.11-2012 (Streebog) digests for container images. -This tool calculates GOST digests based on sorted layer digests of container images -and stores them in image annotations for integrity verification. - -Available Commands: - calculate Calculate GOST digest for a container image - calculate-from-file Calculate GOST digest for a file - add Calculate and add GOST digest to image metadata - validate Validate stored GOST digest against recalculated value +Computes GOST digests from sorted image layer digests and stores them +in annotations for integrity verification. © Flant JSC 2025`) From c2ffba5449233df690ed3aa639abd88ada008a42 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Wed, 21 Jan 2026 23:03:39 +0300 Subject: [PATCH 5/9] Remove description long/short as in original command Signed-off-by: Roman Berezkin --- internal/tools/imagedigest/cmd/imagedigest.go | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/internal/tools/imagedigest/cmd/imagedigest.go b/internal/tools/imagedigest/cmd/imagedigest.go index 39d8186b..d94742fb 100644 --- a/internal/tools/imagedigest/cmd/imagedigest.go +++ b/internal/tools/imagedigest/cmd/imagedigest.go @@ -18,7 +18,6 @@ package cmd import ( "github.com/spf13/cobra" - "k8s.io/kubectl/pkg/util/templates" "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest/cmd/add" "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest/cmd/calculate" @@ -26,19 +25,11 @@ import ( "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest/cmd/validate" ) -var imagedigestLong = templates.LongDesc(` -Manage GOST R 34.11-2012 (Streebog) digests for container images. - -Computes GOST digests from sorted image layer digests and stores them -in annotations for integrity verification. - -© Flant JSC 2025`) - func NewCommand() *cobra.Command { imagedigestCmd := &cobra.Command{ Use: "imagedigest", - Short: "Manage GOST R 34.11-2012 (Streebog) digests for container images", - Long: imagedigestLong, + Short: "", + Long: "", } imagedigestCmd.PersistentFlags().BoolP("insecure", "i", false, "Allow insecure connections to registries (skip TLS verification)") From f08aadffdcebf09cfd36380ee470d11faf6a3f42 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Wed, 21 Jan 2026 23:11:59 +0300 Subject: [PATCH 6/9] Use original commands description Signed-off-by: Roman Berezkin --- internal/tools/imagedigest/cmd/add/add.go | 19 +++---------------- .../imagedigest/cmd/calculate/calculate.go | 19 +++---------------- .../calculatefromfile/calculatefromfile.go | 8 +++----- .../imagedigest/cmd/validate/validate.go | 19 ++----------------- 4 files changed, 11 insertions(+), 54 deletions(-) diff --git a/internal/tools/imagedigest/cmd/add/add.go b/internal/tools/imagedigest/cmd/add/add.go index f33faa95..6c818bc1 100644 --- a/internal/tools/imagedigest/cmd/add/add.go +++ b/internal/tools/imagedigest/cmd/add/add.go @@ -21,28 +21,15 @@ import ( "github.com/google/go-containerregistry/pkg/crane" "github.com/spf13/cobra" - "k8s.io/kubectl/pkg/util/templates" "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest" ) -var addLong = templates.LongDesc(` -Calculate and add GOST R 34.11-2012 (Streebog-256) digest to image metadata. - -The digest is calculated based on sorted layer digests and stored in the image -annotation "deckhouse.io/gost-digest". - -Example: - d8 tools imagedigest add registry.example.com/image:tag - d8 tools imagedigest add --insecure localhost:5000/image:latest`) - func NewCommand() *cobra.Command { addCmd := &cobra.Command{ - Use: "add ", - Short: "Calculate and add GOST digest to image metadata", - Long: addLong, - SilenceErrors: true, - SilenceUsage: true, + Use: "add ", + Short: "Calculating and adding the image digest to the image metadata according to the GOST standard Streebog (GOST R 34.11-2012)", + Long: `Calculating and adding the image digest to the image metadata according to the GOST standard Streebog (GOST R 34.11-2012)`, Args: func(cmd *cobra.Command, args []string) error { if len(args) != 1 { return fmt.Errorf("this command requires exactly 1 argument (image reference), got %d", len(args)) diff --git a/internal/tools/imagedigest/cmd/calculate/calculate.go b/internal/tools/imagedigest/cmd/calculate/calculate.go index 227d1e05..22058d3b 100644 --- a/internal/tools/imagedigest/cmd/calculate/calculate.go +++ b/internal/tools/imagedigest/cmd/calculate/calculate.go @@ -22,28 +22,15 @@ import ( "github.com/google/go-containerregistry/pkg/crane" "github.com/spf13/cobra" - "k8s.io/kubectl/pkg/util/templates" "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest" ) -var calculateLong = templates.LongDesc(` -Calculate GOST R 34.11-2012 (Streebog-256) digest for a container image. - -The digest is calculated based on sorted layer digests but is NOT stored -in the image metadata. Use 'add' command to store the digest. - -Example: - d8 tools imagedigest calculate registry.example.com/image:tag - d8 tools imagedigest calculate --insecure localhost:5000/image:latest`) - func NewCommand() *cobra.Command { calculateCmd := &cobra.Command{ - Use: "calculate ", - Short: "Calculate GOST digest for a container image", - Long: calculateLong, - SilenceErrors: true, - SilenceUsage: true, + Use: "calculate ", + Short: "Calculating the image digest according to the GOST standard Streebog (GOST R 34.11-2012)", + Long: `Calculating the image digest according to the GOST standard Streebog (GOST R 34.11-2012)`, Args: func(cmd *cobra.Command, args []string) error { if len(args) != 1 { return fmt.Errorf("this command requires exactly 1 argument (image reference), got %d", len(args)) diff --git a/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go b/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go index be845e2a..3561aec4 100644 --- a/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go +++ b/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go @@ -39,11 +39,9 @@ Example: func NewCommand() *cobra.Command { calculateFromFileCmd := &cobra.Command{ - Use: "calculate-from-file ", - Short: "Calculate GOST digest for a file (use '-' for stdin)", - Long: calculateFromFileLong, - SilenceErrors: true, - SilenceUsage: true, + Use: "calculate-from-file ", + Short: "Calculate GOST digest for a file (use '-' for stdin)", + Long: calculateFromFileLong, Args: func(cmd *cobra.Command, args []string) error { if len(args) != 1 { return fmt.Errorf("this command requires exactly 1 argument (file path or '-' for stdin), got %d", len(args)) diff --git a/internal/tools/imagedigest/cmd/validate/validate.go b/internal/tools/imagedigest/cmd/validate/validate.go index b6600a6b..3a6a821c 100644 --- a/internal/tools/imagedigest/cmd/validate/validate.go +++ b/internal/tools/imagedigest/cmd/validate/validate.go @@ -21,30 +21,15 @@ import ( "github.com/google/go-containerregistry/pkg/crane" "github.com/spf13/cobra" - "k8s.io/kubectl/pkg/util/templates" "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest" ) -var validateLong = templates.LongDesc(` -Validate GOST R 34.11-2012 (Streebog-256) digest stored in image metadata. - -Compares the stored digest from annotation "deckhouse.io/gost-digest" with -the recalculated value based on current layer digests. - -Use --fix flag to automatically repair the digest if validation fails. - -Example: - d8 tools imagedigest validate registry.example.com/image:tag - d8 tools imagedigest validate --fix registry.example.com/image:tag`) - func NewCommand() *cobra.Command { validateCmd := &cobra.Command{ Use: "validate ", - Short: "Validate stored GOST digest against recalculated value", - Long: validateLong, - SilenceErrors: true, - SilenceUsage: true, + Short: "Validating the image digest in the image metadata calculated according to the GOST standard Streebog (GOST R 34.11-2012)", + Long: `Validating the image digest in the image metadata calculated according to the GOST standard Streebog (GOST R 34.11-2012)`, Args: func(cmd *cobra.Command, args []string) error { if len(args) != 1 { return fmt.Errorf("this command requires exactly 1 argument (image reference), got %d", len(args)) From 626383e1ef1254e7c7523d6b49b81dccfca00299 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Wed, 21 Jan 2026 23:14:11 +0300 Subject: [PATCH 7/9] Use original descriptions for calculate-from-file command Signed-off-by: Roman Berezkin --- .../cmd/calculatefromfile/calculatefromfile.go | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go b/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go index 3561aec4..c5780e3e 100644 --- a/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go +++ b/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go @@ -22,26 +22,15 @@ import ( "os" "github.com/spf13/cobra" - "k8s.io/kubectl/pkg/util/templates" "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest" ) -var calculateFromFileLong = templates.LongDesc(` -Calculate GOST R 34.11-2012 (Streebog-256) digest for a file. - -Use '-' to read from stdin. - -Example: - d8 tools imagedigest calculate-from-file /path/to/file - d8 tools imagedigest calculate-from-file - - cat file.tar | d8 tools imagedigest calculate-from-file -`) - func NewCommand() *cobra.Command { calculateFromFileCmd := &cobra.Command{ Use: "calculate-from-file ", - Short: "Calculate GOST digest for a file (use '-' for stdin)", - Long: calculateFromFileLong, + Short: "Calculating the file digest according to the GOST standard Streebog (GOST R 34.11-2012). For stdin use '-'", + Long: `Calculating the file digest according to the GOST standard Streebog (GOST R 34.11-2012)`, Args: func(cmd *cobra.Command, args []string) error { if len(args) != 1 { return fmt.Errorf("this command requires exactly 1 argument (file path or '-' for stdin), got %d", len(args)) From 5c29a7a7b4569a5c07ef8ced2b6bfcc70ec7d46c Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Wed, 21 Jan 2026 23:42:20 +0300 Subject: [PATCH 8/9] Add --json / --debug support Signed-off-by: Roman Berezkin --- go.mod | 1 + go.sum | 3 ++ internal/tools/imagedigest/cmd/add/add.go | 13 ++++--- .../imagedigest/cmd/calculate/calculate.go | 11 +++--- .../calculatefromfile/calculatefromfile.go | 15 ++++---- internal/tools/imagedigest/cmd/imagedigest.go | 18 ++++++++++ .../imagedigest/cmd/validate/validate.go | 36 +++++++++---------- internal/tools/imagedigest/imagedigest.go | 6 ++++ 8 files changed, 65 insertions(+), 38 deletions(-) diff --git a/go.mod b/go.mod index bafd7323..b1a2a68b 100644 --- a/go.mod +++ b/go.mod @@ -494,6 +494,7 @@ require ( github.com/rodaine/table v1.1.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rs/xid v1.6.0 // indirect + github.com/rs/zerolog v1.34.0 // indirect github.com/rubenv/sql-migrate v1.6.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/ryanuber/columnize v2.1.2+incompatible // indirect diff --git a/go.sum b/go.sum index b4f7e0af..89f79bf5 100644 --- a/go.sum +++ b/go.sum @@ -1286,6 +1286,7 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= @@ -1639,6 +1640,8 @@ github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.4.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/rubenv/sql-migrate v1.6.1 h1:bo6/sjsan9HaXAsNxYP/jCEDUGibHp8JmOBw7NTGRos= github.com/rubenv/sql-migrate v1.6.1/go.mod h1:tPzespupJS0jacLfhbwto/UjSX+8h2FdWB7ar+QlHa0= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= diff --git a/internal/tools/imagedigest/cmd/add/add.go b/internal/tools/imagedigest/cmd/add/add.go index 6c818bc1..162d8180 100644 --- a/internal/tools/imagedigest/cmd/add/add.go +++ b/internal/tools/imagedigest/cmd/add/add.go @@ -20,6 +20,7 @@ import ( "fmt" "github.com/google/go-containerregistry/pkg/crane" + "github.com/rs/zerolog/log" "github.com/spf13/cobra" "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest" @@ -31,8 +32,8 @@ func NewCommand() *cobra.Command { Short: "Calculating and adding the image digest to the image metadata according to the GOST standard Streebog (GOST R 34.11-2012)", Long: `Calculating and adding the image digest to the image metadata according to the GOST standard Streebog (GOST R 34.11-2012)`, Args: func(cmd *cobra.Command, args []string) error { - if len(args) != 1 { - return fmt.Errorf("this command requires exactly 1 argument (image reference), got %d", len(args)) + if err := cobra.MinimumNArgs(1)(cmd, args); err != nil { + return err } return nil }, @@ -55,15 +56,13 @@ func runAdd(cmd *cobra.Command, args []string) error { opts = append(opts, crane.Insecure) } - fmt.Printf("Calculating GOST digest for image: %s\n", imageName) - digest, err := imagedigest.PullAnnotatePush(imageName, opts...) if err != nil { - return fmt.Errorf("failed to add GOST digest: %w", err) + log.Fatal().Err(err).Msg("AddGostImageDigest") } - fmt.Printf("GOST digest: %s\n", digest) - fmt.Println("Digest added successfully") + log.Info().Msgf("GOST Image Digest: %s", digest) + log.Info().Msg("Added successfully") return nil } diff --git a/internal/tools/imagedigest/cmd/calculate/calculate.go b/internal/tools/imagedigest/cmd/calculate/calculate.go index 22058d3b..8d62f5b2 100644 --- a/internal/tools/imagedigest/cmd/calculate/calculate.go +++ b/internal/tools/imagedigest/cmd/calculate/calculate.go @@ -21,6 +21,7 @@ import ( "fmt" "github.com/google/go-containerregistry/pkg/crane" + "github.com/rs/zerolog/log" "github.com/spf13/cobra" "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest" @@ -32,8 +33,8 @@ func NewCommand() *cobra.Command { Short: "Calculating the image digest according to the GOST standard Streebog (GOST R 34.11-2012)", Long: `Calculating the image digest according to the GOST standard Streebog (GOST R 34.11-2012)`, Args: func(cmd *cobra.Command, args []string) error { - if len(args) != 1 { - return fmt.Errorf("this command requires exactly 1 argument (image reference), got %d", len(args)) + if err := cobra.MinimumNArgs(1)(cmd, args); err != nil { + return err } return nil }, @@ -56,12 +57,12 @@ func runCalculate(cmd *cobra.Command, args []string) error { opts = append(opts, crane.Insecure) } - digest, err := imagedigest.PullAndCalculate(imageName, opts...) + gostImageDigest, err := imagedigest.PullAndCalculate(imageName, opts...) if err != nil { - return fmt.Errorf("failed to calculate GOST digest: %w", err) + log.Fatal().Err(err).Msg("CalculateGostImageDigest") } - fmt.Println(hex.EncodeToString(digest)) + log.Info().Msgf("GOST Image Digest: %s", hex.EncodeToString(gostImageDigest)) return nil } diff --git a/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go b/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go index c5780e3e..dc3745f4 100644 --- a/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go +++ b/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go @@ -21,6 +21,7 @@ import ( "fmt" "os" + "github.com/rs/zerolog/log" "github.com/spf13/cobra" "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest" @@ -32,8 +33,8 @@ func NewCommand() *cobra.Command { Short: "Calculating the file digest according to the GOST standard Streebog (GOST R 34.11-2012). For stdin use '-'", Long: `Calculating the file digest according to the GOST standard Streebog (GOST R 34.11-2012)`, Args: func(cmd *cobra.Command, args []string) error { - if len(args) != 1 { - return fmt.Errorf("this command requires exactly 1 argument (file path or '-' for stdin), got %d", len(args)) + if err := cobra.MinimumNArgs(1)(cmd, args); err != nil { + return err } return nil }, @@ -50,18 +51,20 @@ func runCalculateFromFile(cmd *cobra.Command, args []string) error { if filename != "-" { file, err := os.Open(filename) if err != nil { - return fmt.Errorf("failed to open file: %w", err) + log.Err(err).Msg("failed to open file") + os.Exit(1) } defer file.Close() reader = file } - digest, err := imagedigest.CalculateGostHashFromReader(reader) + sum, err := imagedigest.CalculateGostHashFromReader(reader) if err != nil { - return fmt.Errorf("failed to calculate GOST digest: %w", err) + log.Err(err).Msg("failed to calculate GOST digest") + os.Exit(2) } - fmt.Println(hex.EncodeToString(digest)) + fmt.Println(hex.EncodeToString(sum)) return nil } diff --git a/internal/tools/imagedigest/cmd/imagedigest.go b/internal/tools/imagedigest/cmd/imagedigest.go index d94742fb..ceab4ab0 100644 --- a/internal/tools/imagedigest/cmd/imagedigest.go +++ b/internal/tools/imagedigest/cmd/imagedigest.go @@ -17,6 +17,10 @@ limitations under the License. package cmd import ( + "os" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" "github.com/spf13/cobra" "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest/cmd/add" @@ -30,9 +34,23 @@ func NewCommand() *cobra.Command { Use: "imagedigest", Short: "", Long: "", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + ojson, _ := cmd.Flags().GetBool("json") + debug, _ := cmd.Flags().GetBool("debug") + + zerolog.SetGlobalLevel(zerolog.InfoLevel) + if !ojson { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout}) + } + if debug { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + } + }, } imagedigestCmd.PersistentFlags().BoolP("insecure", "i", false, "Allow insecure connections to registries (skip TLS verification)") + imagedigestCmd.PersistentFlags().BoolP("json", "", false, "Use JSON formatter for output logs") + imagedigestCmd.PersistentFlags().BoolP("debug", "d", false, "Enable debug logging") imagedigestCmd.AddCommand( calculate.NewCommand(), diff --git a/internal/tools/imagedigest/cmd/validate/validate.go b/internal/tools/imagedigest/cmd/validate/validate.go index 3a6a821c..92c6d998 100644 --- a/internal/tools/imagedigest/cmd/validate/validate.go +++ b/internal/tools/imagedigest/cmd/validate/validate.go @@ -20,6 +20,7 @@ import ( "fmt" "github.com/google/go-containerregistry/pkg/crane" + "github.com/rs/zerolog/log" "github.com/spf13/cobra" "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest" @@ -27,19 +28,19 @@ import ( func NewCommand() *cobra.Command { validateCmd := &cobra.Command{ - Use: "validate ", - Short: "Validating the image digest in the image metadata calculated according to the GOST standard Streebog (GOST R 34.11-2012)", - Long: `Validating the image digest in the image metadata calculated according to the GOST standard Streebog (GOST R 34.11-2012)`, + Use: "validate ", + Short: "Validating the image digest in the image metadata calculated according to the GOST standard Streebog (GOST R 34.11-2012)", + Long: `Validating the image digest in the image metadata calculated according to the GOST standard Streebog (GOST R 34.11-2012)`, Args: func(cmd *cobra.Command, args []string) error { - if len(args) != 1 { - return fmt.Errorf("this command requires exactly 1 argument (image reference), got %d", len(args)) + if err := cobra.MinimumNArgs(1)(cmd, args); err != nil { + return err } return nil }, RunE: runValidate, } - validateCmd.Flags().Bool("fix", false, "Automatically fix GOST digest if validation fails") + validateCmd.Flags().Bool("fix", false, "Fix GOST Image Digest if it is incorrect") return validateCmd } @@ -62,32 +63,27 @@ func runValidate(cmd *cobra.Command, args []string) error { opts = append(opts, crane.Insecure) } - fmt.Printf("Validating GOST digest for image: %s\n", imageName) - result, err := imagedigest.PullAndValidate(imageName, opts...) if err != nil { - if result != nil { - fmt.Printf("Stored GOST digest: %s\n", result.StoredDigest) - fmt.Printf("Calculated GOST digest: %s\n", result.CalculatedDigest) - } - fmt.Printf("Validation failed: %v\n", err) + log.Error().Err(err).Msg("ValidateGostImageDigest") if fix { - fmt.Println("Attempting to fix GOST digest...") + log.Info().Msg("Fix GOST Image Digest") newDigest, fixErr := imagedigest.PullAnnotatePush(imageName, opts...) if fixErr != nil { - return fmt.Errorf("failed to fix GOST digest: %w", fixErr) + log.Fatal().Err(fixErr).Msg("AddGostImageDigest") } - fmt.Printf("New GOST digest: %s\n", newDigest) - fmt.Println("Digest fixed successfully") + log.Info().Msgf("GOST Image Digest: %s", newDigest) + log.Info().Msg("Added successfully") return nil } - return err + return nil } - fmt.Printf("GOST digest: %s\n", result.StoredDigest) - fmt.Println("Validation successful") + log.Info().Msgf("GOST Image Digest from image: %s", result.StoredDigest) + log.Info().Msgf("Calculated GOST Image Digest: %s", result.CalculatedDigest) + log.Info().Msg("Validate successfully") return nil } diff --git a/internal/tools/imagedigest/imagedigest.go b/internal/tools/imagedigest/imagedigest.go index 7c9c3a95..09a22ded 100644 --- a/internal/tools/imagedigest/imagedigest.go +++ b/internal/tools/imagedigest/imagedigest.go @@ -27,6 +27,7 @@ import ( "github.com/google/go-containerregistry/pkg/crane" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/rs/zerolog/log" "go.cypherpunks.ru/gogost/v5/gost34112012256" ) @@ -101,6 +102,9 @@ func ReadGostAnnotation(image v1.Image) (string, bool, error) { } digest, ok := manifest.Annotations[GostDigestAnnotationKey] + if !ok { + log.Debug().Msg("the image does not contain gost digest") + } return digest, ok, nil } @@ -248,6 +252,8 @@ func GetImageInfo(name string, image v1.Image) (*ImageInfo, error) { } info.LayerDigests = layerDigests + log.Debug().Interface("imageInfo", info).Msg("ImageMetadata") + return info, nil } From ca55fed69635a1dc1751f9d7d774f9b3f9aee06e Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Wed, 21 Jan 2026 23:48:50 +0300 Subject: [PATCH 9/9] Fix defer file close Signed-off-by: Roman Berezkin --- .../imagedigest/cmd/calculatefromfile/calculatefromfile.go | 7 ++----- internal/tools/imagedigest/imagedigest.go | 1 - 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go b/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go index dc3745f4..a6d90ac7 100644 --- a/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go +++ b/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go @@ -21,7 +21,6 @@ import ( "fmt" "os" - "github.com/rs/zerolog/log" "github.com/spf13/cobra" "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest" @@ -51,8 +50,7 @@ func runCalculateFromFile(cmd *cobra.Command, args []string) error { if filename != "-" { file, err := os.Open(filename) if err != nil { - log.Err(err).Msg("failed to open file") - os.Exit(1) + return fmt.Errorf("failed to open file: %w", err) } defer file.Close() reader = file @@ -60,8 +58,7 @@ func runCalculateFromFile(cmd *cobra.Command, args []string) error { sum, err := imagedigest.CalculateGostHashFromReader(reader) if err != nil { - log.Err(err).Msg("failed to calculate GOST digest") - os.Exit(2) + return fmt.Errorf("failed to calculate GOST digest: %w", err) } fmt.Println(hex.EncodeToString(sum)) diff --git a/internal/tools/imagedigest/imagedigest.go b/internal/tools/imagedigest/imagedigest.go index 09a22ded..321d2a74 100644 --- a/internal/tools/imagedigest/imagedigest.go +++ b/internal/tools/imagedigest/imagedigest.go @@ -256,4 +256,3 @@ func GetImageInfo(name string, image v1.Image) (*ImageInfo, error) { return info, nil } -