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 b3a96045..89f79bf5 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= @@ -1290,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= @@ -1643,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 new file mode 100644 index 00000000..162d8180 --- /dev/null +++ b/internal/tools/imagedigest/cmd/add/add.go @@ -0,0 +1,68 @@ +/* +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/rs/zerolog/log" + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest" +) + +func NewCommand() *cobra.Command { + addCmd := &cobra.Command{ + 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 err := cobra.MinimumNArgs(1)(cmd, args); err != nil { + return err + } + 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) + } + + digest, err := imagedigest.PullAnnotatePush(imageName, opts...) + if err != nil { + log.Fatal().Err(err).Msg("AddGostImageDigest") + } + + 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 new file mode 100644 index 00000000..8d62f5b2 --- /dev/null +++ b/internal/tools/imagedigest/cmd/calculate/calculate.go @@ -0,0 +1,68 @@ +/* +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/rs/zerolog/log" + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest" +) + +func NewCommand() *cobra.Command { + calculateCmd := &cobra.Command{ + 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 err := cobra.MinimumNArgs(1)(cmd, args); err != nil { + return err + } + 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) + } + + gostImageDigest, err := imagedigest.PullAndCalculate(imageName, opts...) + if err != nil { + log.Fatal().Err(err).Msg("CalculateGostImageDigest") + } + + 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 new file mode 100644 index 00000000..a6d90ac7 --- /dev/null +++ b/internal/tools/imagedigest/cmd/calculatefromfile/calculatefromfile.go @@ -0,0 +1,67 @@ +/* +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" + + "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest" +) + +func NewCommand() *cobra.Command { + calculateFromFileCmd := &cobra.Command{ + Use: "calculate-from-file ", + 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 err := cobra.MinimumNArgs(1)(cmd, args); err != nil { + return err + } + 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 + } + + sum, err := imagedigest.CalculateGostHashFromReader(reader) + if err != nil { + return fmt.Errorf("failed to calculate GOST digest: %w", err) + } + + fmt.Println(hex.EncodeToString(sum)) + + return nil +} diff --git a/internal/tools/imagedigest/cmd/imagedigest.go b/internal/tools/imagedigest/cmd/imagedigest.go new file mode 100644 index 00000000..ceab4ab0 --- /dev/null +++ b/internal/tools/imagedigest/cmd/imagedigest.go @@ -0,0 +1,63 @@ +/* +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 ( + "os" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + + "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" +) + +func NewCommand() *cobra.Command { + imagedigestCmd := &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(), + 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..92c6d998 --- /dev/null +++ b/internal/tools/imagedigest/cmd/validate/validate.go @@ -0,0 +1,89 @@ +/* +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/rs/zerolog/log" + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest" +) + +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)`, + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.MinimumNArgs(1)(cmd, args); err != nil { + return err + } + return nil + }, + RunE: runValidate, + } + + validateCmd.Flags().Bool("fix", false, "Fix GOST Image Digest if it is incorrect") + + 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) + } + + result, err := imagedigest.PullAndValidate(imageName, opts...) + if err != nil { + log.Error().Err(err).Msg("ValidateGostImageDigest") + + if fix { + log.Info().Msg("Fix GOST Image Digest") + newDigest, fixErr := imagedigest.PullAnnotatePush(imageName, opts...) + if fixErr != nil { + log.Fatal().Err(fixErr).Msg("AddGostImageDigest") + } + log.Info().Msgf("GOST Image Digest: %s", newDigest) + log.Info().Msg("Added successfully") + return nil + } + + return nil + } + + 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 new file mode 100644 index 00000000..321d2a74 --- /dev/null +++ b/internal/tools/imagedigest/imagedigest.go @@ -0,0 +1,258 @@ +/* +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" + "slices" + "strings" + + "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" +) + +const ( + GostDigestAnnotationKey = "deckhouse.io/gost-digest" +) + +// 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 +} + +// 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) +} + +// 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 CalculateGostHash(data), nil +} + +// ============================================================================= +// Layer 2: Layer Digest Extraction (depends only on v1.Image) +// ============================================================================= + +// ExtractSortedLayerDigests returns sorted layer digests from an image. +func ExtractSortedLayerDigests(image v1.Image) ([]string, error) { + layers, err := image.Layers() + if err != nil { + return nil, 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()) + } + + slices.Sort(digests) + return digests, nil +} + +// 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 "", false, err + } + + digest, ok := manifest.Annotations[GostDigestAnnotationKey] + if !ok { + log.Debug().Msg("the image does not contain gost digest") + } + return digest, ok, nil +} + +// 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) +} + +// ============================================================================= +// 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 + } + 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 +} + +// 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) + } + + calculated, err := CalculateImageGostDigest(image) + if err != nil { + return &ValidationResult{StoredDigest: stored}, err + } + + result := &ValidationResult{ + StoredDigest: stored, + CalculatedDigest: hex.EncodeToString(calculated), + } + + if err := compareDigests(stored, calculated); err != nil { + return result, err + } + return result, nil +} + +// 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 fmt.Errorf("invalid stored GOST digest format: %w", err) + } + + if subtle.ConstantTimeCompare(storedBytes, calculatedHash) == 0 { + return fmt.Errorf("GOST digest mismatch: stored=%s, calculated=%s", + storedHex, hex.EncodeToString(calculatedHash)) + } + return nil +} + +// ============================================================================= +// Layer 4: Registry Operations (high-level convenience with network I/O) +// ============================================================================= + +// 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 + } + return CalculateImageGostDigest(image) +} + +// 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 "", err + } + + annotated, digestHex, err := AnnotateWithGostDigest(image) + if err != nil { + return "", err + } + + if err := crane.Push(annotated, imageName, opts...); err != nil { + return "", err + } + return digestHex, nil +} + +// 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 ValidateGostDigest(image) +} + +// ============================================================================= +// Utility: ImageInfo for display purposes +// ============================================================================= + +// 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 + } + info.Digest = imageDigest.String() + + gostDigest, ok, err := ReadGostAnnotation(image) + if err != nil { + return nil, err + } + if ok { + info.GostDigest = gostDigest + } + + layerDigests, err := ExtractSortedLayerDigests(image) + if err != nil { + return nil, err + } + info.LayerDigests = layerDigests + + log.Debug().Interface("imageInfo", info).Msg("ImageMetadata") + + return info, nil +} diff --git a/internal/tools/imagedigest/imagedigest_test.go b/internal/tools/imagedigest/imagedigest_test.go new file mode 100644 index 00000000..f9f1d480 --- /dev/null +++ b/internal/tools/imagedigest/imagedigest_test.go @@ -0,0 +1,286 @@ +/* +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 ( + "bytes" + "encoding/hex" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/v1/random" +) + +// ============================================================================= +// 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)) + } + }) + + 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("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") + } + }) +} + +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("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") + } + }) +} + +// ============================================================================= +// 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 correct count", func(t *testing.T) { + digests, err := ExtractSortedLayerDigests(img) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + 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") + } + } + }) +} + +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 digest != "" { + t.Error("expected empty digest") + } + }) + + 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 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) { + img, _ := random.Image(256, 2) + digest, err := CalculateImageGostDigest(img) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(digest) != 32 { + t.Errorf("expected 32-byte hash, got %d", len(digest)) + } + }) + + t.Run("deterministic", func(t *testing.T) { + 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 TestAnnotateWithGostDigest(t *testing.T) { + img, _ := random.Image(256, 2) + + t.Run("success", func(t *testing.T) { + annotated, digestHex, err := AnnotateWithGostDigest(img) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(digestHex) != 64 { + t.Errorf("expected 64-char hex, got %d", len(digestHex)) + } + if annotated == nil { + t.Fatal("expected annotated image") + } + + // Verify annotation was added + manifest, _ := annotated.Manifest() + if manifest.Annotations[GostDigestAnnotationKey] != digestHex { + t.Error("annotation not set correctly") + } + }) + + t.Run("digest matches recalculation", func(t *testing.T) { + _, digestHex, _ := AnnotateWithGostDigest(img) + recalculated, _ := CalculateImageGostDigest(img) + if digestHex != hex.EncodeToString(recalculated) { + t.Error("digest doesn't match recalculation") + } + }) +} + +func TestValidateGostDigest(t *testing.T) { + t.Run("no annotation error", func(t *testing.T) { + img, _ := random.Image(256, 1) + _, err := ValidateGostDigest(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) + annotated, expectedDigest, _ := AnnotateWithGostDigest(img) + + result, err := ValidateGostDigest(annotated) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.StoredDigest != expectedDigest { + t.Errorf("StoredDigest = %s, want %s", result.StoredDigest, expectedDigest) + } + 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) + + result, err := ValidateGostDigest(annotated) + 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") + } + }) +} + +// ============================================================================= +// 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) + } + }) +} 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(), )