diff --git a/images/dvcr-artifact/cmd/dvcr-importer/main.go b/images/dvcr-artifact/cmd/dvcr-importer/main.go index d60fef617c..0eadfb0ffd 100644 --- a/images/dvcr-artifact/cmd/dvcr-importer/main.go +++ b/images/dvcr-artifact/cmd/dvcr-importer/main.go @@ -24,6 +24,8 @@ import ( "github.com/google/go-containerregistry/pkg/logs" "k8s.io/klog/v2" + // Prefer AES-GCM over GOST for TLS 1.3 when built with -tags=dvcr_no_gost_tls. + _ "github.com/deckhouse/virtualization-controller/dvcr-importers/pkg/gosttls" "github.com/deckhouse/virtualization-controller/dvcr-importers/pkg/importer" ) diff --git a/images/dvcr-artifact/cmd/dvcr-uploader/main.go b/images/dvcr-artifact/cmd/dvcr-uploader/main.go index 419aaeeb91..c374317581 100644 --- a/images/dvcr-artifact/cmd/dvcr-uploader/main.go +++ b/images/dvcr-artifact/cmd/dvcr-uploader/main.go @@ -27,6 +27,8 @@ import ( "kubevirt.io/containerized-data-importer/pkg/common" cryptowatch "kubevirt.io/containerized-data-importer/pkg/util/tls-crypto-watch" + // Prefer AES-GCM over GOST for TLS 1.3 when built with -tags=dvcr_no_gost_tls. + _ "github.com/deckhouse/virtualization-controller/dvcr-importers/pkg/gosttls" "github.com/deckhouse/virtualization-controller/dvcr-importers/pkg/uploader" ) diff --git a/images/dvcr-artifact/pkg/gosttls/disable_gost.go b/images/dvcr-artifact/pkg/gosttls/disable_gost.go new file mode 100644 index 0000000000..91539ae709 --- /dev/null +++ b/images/dvcr-artifact/pkg/gosttls/disable_gost.go @@ -0,0 +1,32 @@ +//go:build dvcr_no_gost_tls + +/* +Copyright 2026 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 gosttls + +import "crypto/tls" + +// SetAllowedTLS13CipherSuites is a deckhouse-toolchain-only API (absent from +// upstream Go), so this call lives behind the dvcr_no_gost_tls build tag: +// builds without the tag exclude this file and keep upstream behaviour. +func init() { + tls.SetAllowedTLS13CipherSuites([]uint16{ + tls.TLS_AES_128_GCM_SHA256, + tls.TLS_AES_256_GCM_SHA384, + tls.TLS_CHACHA20_POLY1305_SHA256, + }) +} diff --git a/images/dvcr-artifact/pkg/gosttls/gosttls.go b/images/dvcr-artifact/pkg/gosttls/gosttls.go new file mode 100644 index 0000000000..772b651fb9 --- /dev/null +++ b/images/dvcr-artifact/pkg/gosttls/gosttls.go @@ -0,0 +1,30 @@ +/* +Copyright 2026 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 gosttls optionally narrows the process-wide TLS 1.3 cipher suites to +// the hardware-accelerated AES-GCM / ChaCha20 set, removing the GOST +// (Kuznyechik/MGM) suites that the deckhouse GOST Go toolchain installs by +// default. +// +// The deckhouse toolchain advertises software GOST TLS 1.3 suites in every Go +// binary; when DVCR (which prefers GOST) is the peer, the importer/uploader +// upload runs through a pure-software GOST cipher and is capped at a few MB/s on +// a single core. Blank-import this package from a binary that should prefer +// AES, and build it with `-tags=dvcr_no_gost_tls` to activate the override. +// +// Without the build tag this package is a no-op, so standard-Go builds (local +// dev, golangci-lint) compile unchanged. +package gosttls diff --git a/images/dvcr-artifact/pkg/registry/registry.go b/images/dvcr-artifact/pkg/registry/registry.go index 3f33fa406f..a8830bb7cf 100644 --- a/images/dvcr-artifact/pkg/registry/registry.go +++ b/images/dvcr-artifact/pkg/registry/registry.go @@ -36,7 +36,6 @@ import ( "github.com/google/go-containerregistry/pkg/v1/empty" "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/google/go-containerregistry/pkg/v1/remote" - "github.com/google/go-containerregistry/pkg/v1/stream" "golang.org/x/sync/errgroup" "k8s.io/klog/v2" @@ -339,7 +338,12 @@ func (p DataProcessor) uploadLayersAndImage( return fmt.Errorf("error constructing new repository: %w", err) } - layer := stream.NewLayer(pipeReader) + // Upload the tar stream as an uncompressed layer. gzip compression + // (the default of stream.NewLayer) is single-threaded and CPU-bound, and + // caps the import speed of large disk images in the CPU-limited + // provisioning pod. Disk images barely compress, so skipping gzip removes + // the bottleneck without meaningfully growing the stored layer. + layer := newUncompressedLayer(pipeReader) klog.Infoln("Uploading layer to registry") if err := remote.WriteLayer(repo, layer, remoteOpts...); err != nil { diff --git a/images/dvcr-artifact/pkg/registry/uncompressed_layer.go b/images/dvcr-artifact/pkg/registry/uncompressed_layer.go new file mode 100644 index 0000000000..21f97885c2 --- /dev/null +++ b/images/dvcr-artifact/pkg/registry/uncompressed_layer.go @@ -0,0 +1,196 @@ +/* +Copyright 2026 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 registry + +import ( + "crypto" + "encoding/hex" + "errors" + "hash" + "io" + "os" + "sync" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/stream" + "github.com/google/go-containerregistry/pkg/v1/types" +) + +// uncompressedLayer is a single-pass streaming v1.Layer that uploads the raw +// (uncompressed) tar stream as an application/vnd.docker.image.rootfs.diff.tar +// layer. +// +// It mirrors go-containerregistry's stream.Layer but skips gzip compression. +// gzip (gzip.BestSpeed, single goroutine) is the CPU bottleneck of the importer +// upload path: the provisioning pod is CPU-limited, so compressing a multi-GB +// disk image caps the whole pipeline at a few MB/s. Disk images barely compress +// anyway, so an uncompressed layer is roughly the same size with near-zero CPU. +// +// For an uncompressed layer the on-disk blob equals the uncompressed content, +// so Digest and DiffID are identical (both the sha256 of the raw tar stream). +type uncompressedLayer struct { + blob io.ReadCloser + consumed bool + + mu sync.Mutex + digest *v1.Hash + size int64 +} + +var _ v1.Layer = (*uncompressedLayer)(nil) + +// newUncompressedLayer creates an uncompressed streaming Layer from rc. +func newUncompressedLayer(rc io.ReadCloser) *uncompressedLayer { + return &uncompressedLayer{blob: rc} +} + +// Digest implements v1.Layer. Until the stream is consumed it returns +// stream.ErrNotComputed: remote's pusher detects a streaming layer by +// errors.Is(err, stream.ErrNotComputed) and only then routes the upload through +// its lazy (chunked, digest-after-consume) path. Returning a different sentinel +// makes the pusher treat the error as fatal. +func (l *uncompressedLayer) Digest() (v1.Hash, error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.digest == nil { + return v1.Hash{}, stream.ErrNotComputed + } + return *l.digest, nil +} + +// DiffID implements v1.Layer. For an uncompressed layer it equals Digest. +func (l *uncompressedLayer) DiffID() (v1.Hash, error) { + return l.Digest() +} + +// Size implements v1.Layer. +func (l *uncompressedLayer) Size() (int64, error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.size == 0 { + return 0, stream.ErrNotComputed + } + return l.size, nil +} + +// MediaType implements v1.Layer. +func (l *uncompressedLayer) MediaType() (types.MediaType, error) { + return types.DockerUncompressedLayer, nil +} + +// Uncompressed implements v1.Layer. +func (l *uncompressedLayer) Uncompressed() (io.ReadCloser, error) { + return l.reader() +} + +// Compressed implements v1.Layer. The layer is not compressed, so this returns +// the raw tar stream unchanged. +func (l *uncompressedLayer) Compressed() (io.ReadCloser, error) { + return l.reader() +} + +func (l *uncompressedLayer) reader() (io.ReadCloser, error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.consumed { + return nil, stream.ErrConsumed + } + return newUncompressedReader(l), nil +} + +// finalize sets the layer to consumed and records the digest and size computed +// while streaming. +func (l *uncompressedLayer) finalize(h hash.Hash, size int64) error { + l.mu.Lock() + defer l.mu.Unlock() + + digest, err := v1.NewHash("sha256:" + hex.EncodeToString(h.Sum(nil))) + if err != nil { + return err + } + + l.digest = &digest + l.size = size + l.consumed = true + return nil +} + +type uncompressedReader struct { + pr io.Reader + closer func() error +} + +func newUncompressedReader(l *uncompressedLayer) *uncompressedReader { + // Collect the digest and size of the raw stream as it is read. + h := crypto.SHA256.New() + count := &countWriter{} + + pr, pw := io.Pipe() + + // Tee the raw blob to the pipe reader (consumed by the uploader), the + // hasher (digest), and the counter (size). + mw := io.MultiWriter(pw, h, count) + + doneDigesting := make(chan struct{}) + + r := &uncompressedReader{ + pr: pr, + closer: func() error { + // NOTE: pw.Close never returns an error. + _ = pw.Close() + + // Close the inner ReadCloser. net/http may have already closed it + // on success, so ignore os.ErrClosed. + if err := l.blob.Close(); err != nil && !errors.Is(err, os.ErrClosed) { + return err + } + + <-doneDigesting + return l.finalize(h, count.n) + }, + } + + go func() { + _, copyErr := io.Copy(mw, l.blob) + if copyErr != nil { + close(doneDigesting) + pw.CloseWithError(copyErr) + return + } + + // Notify closer that digest/size are done being written. + close(doneDigesting) + + // Close the reader to finalize digest/size. This causes pr to return + // EOF so readers of the stream finish. + pw.CloseWithError(r.Close()) + }() + + return r +} + +func (r *uncompressedReader) Read(b []byte) (int, error) { return r.pr.Read(b) } + +func (r *uncompressedReader) Close() error { return r.closer() } + +// countWriter counts bytes written to it. +type countWriter struct{ n int64 } + +func (c *countWriter) Write(p []byte) (int, error) { + c.n += int64(len(p)) + return len(p), nil +} diff --git a/images/dvcr-artifact/pkg/registry/uncompressed_layer_test.go b/images/dvcr-artifact/pkg/registry/uncompressed_layer_test.go new file mode 100644 index 0000000000..f49b86b041 --- /dev/null +++ b/images/dvcr-artifact/pkg/registry/uncompressed_layer_test.go @@ -0,0 +1,134 @@ +/* +Copyright 2026 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 registry + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "testing" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/stream" + "github.com/google/go-containerregistry/pkg/v1/types" +) + +func Test_UncompressedLayer_MediaType(t *testing.T) { + l := newUncompressedLayer(io.NopCloser(bytes.NewReader(nil))) + mt, err := l.MediaType() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mt != types.DockerUncompressedLayer { + t.Fatalf("media type: got %q, want %q", mt, types.DockerUncompressedLayer) + } +} + +func Test_UncompressedLayer_NotComputedBeforeConsumed(t *testing.T) { + l := newUncompressedLayer(io.NopCloser(bytes.NewReader([]byte("data")))) + + if _, err := l.Digest(); !errors.Is(err, stream.ErrNotComputed) { + t.Fatalf("Digest before consume: got %v, want stream.ErrNotComputed", err) + } + if _, err := l.DiffID(); !errors.Is(err, stream.ErrNotComputed) { + t.Fatalf("DiffID before consume: got %v, want stream.ErrNotComputed", err) + } + if _, err := l.Size(); !errors.Is(err, stream.ErrNotComputed) { + t.Fatalf("Size before consume: got %v, want stream.ErrNotComputed", err) + } +} + +func Test_UncompressedLayer_StreamsRawBytesAndComputesDigest(t *testing.T) { + payload := bytes.Repeat([]byte("virtualization-disk-image"), 4096) + + l := newUncompressedLayer(io.NopCloser(bytes.NewReader(payload))) + + rc, err := l.Compressed() + if err != nil { + t.Fatalf("Compressed: %v", err) + } + + got, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("ReadAll: %v", err) + } + if err := rc.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + // The uploaded bytes must equal the input: no compression is applied. + if !bytes.Equal(got, payload) { + t.Fatalf("streamed bytes differ from input: got %d bytes, want %d bytes", len(got), len(payload)) + } + + wantDigest := "sha256:" + hex.EncodeToString(sum256(payload)) + + digest, err := l.Digest() + if err != nil { + t.Fatalf("Digest after consume: %v", err) + } + if digest.String() != wantDigest { + t.Fatalf("digest: got %q, want %q", digest.String(), wantDigest) + } + + // For an uncompressed layer DiffID equals Digest. + diffID, err := l.DiffID() + if err != nil { + t.Fatalf("DiffID after consume: %v", err) + } + if diffID != digest { + t.Fatalf("diffID %q != digest %q", diffID, digest) + } + + size, err := l.Size() + if err != nil { + t.Fatalf("Size after consume: %v", err) + } + if size != int64(len(payload)) { + t.Fatalf("size: got %d, want %d", size, len(payload)) + } +} + +func Test_UncompressedLayer_SecondReadFailsAfterConsumed(t *testing.T) { + l := newUncompressedLayer(io.NopCloser(bytes.NewReader([]byte("payload")))) + + rc, err := l.Compressed() + if err != nil { + t.Fatalf("first Compressed: %v", err) + } + if _, err := io.ReadAll(rc); err != nil { + t.Fatalf("ReadAll: %v", err) + } + if err := rc.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + if _, err := l.Compressed(); !errors.Is(err, stream.ErrConsumed) { + t.Fatalf("second Compressed: got %v, want stream.ErrConsumed", err) + } +} + +// satisfy the v1.Layer interface at compile time in the test too. +var _ v1.Layer = (*uncompressedLayer)(nil) + +func sum256(b []byte) []byte { + h := sha256.New() + h.Write(b) + return h.Sum(nil) +} diff --git a/images/dvcr-artifact/werf.inc.yaml b/images/dvcr-artifact/werf.inc.yaml index c179c240a9..d667722980 100644 --- a/images/dvcr-artifact/werf.inc.yaml +++ b/images/dvcr-artifact/werf.inc.yaml @@ -55,9 +55,9 @@ shell: export GOARCH=amd64 - | {{- $_ := set $ "ProjectName" (list .ImageName "dvcr-importer" | join "/") }} - {{- include "image-build.build" (set $ "BuildCommand" `go build -ldflags="-s -w" -o /out/dvcr-importer ./cmd/dvcr-importer`) | nindent 6 }} + {{- include "image-build.build" (set $ "BuildCommand" `go build -tags=dvcr_no_gost_tls -ldflags="-s -w" -o /out/dvcr-importer ./cmd/dvcr-importer`) | nindent 6 }} {{- $_ := set $ "ProjectName" (list .ImageName "dvcr-uploader" | join "/") }} - {{- include "image-build.build" (set $ "BuildCommand" `go build -ldflags="-s -w" -o /out/dvcr-uploader ./cmd/dvcr-uploader`) | nindent 6 }} + {{- include "image-build.build" (set $ "BuildCommand" `go build -tags=dvcr_no_gost_tls -ldflags="-s -w" -o /out/dvcr-uploader ./cmd/dvcr-uploader`) | nindent 6 }} - | export CGO_ENABLED=0 {{- $_ := set $ "ProjectName" (list .ImageName "dvcr-cleaner" | join "/") }}