Skip to content
Merged
2 changes: 2 additions & 0 deletions images/dvcr-artifact/cmd/dvcr-importer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down
2 changes: 2 additions & 0 deletions images/dvcr-artifact/cmd/dvcr-uploader/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down
32 changes: 32 additions & 0 deletions images/dvcr-artifact/pkg/gosttls/disable_gost.go
Original file line number Diff line number Diff line change
@@ -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,
})
}
30 changes: 30 additions & 0 deletions images/dvcr-artifact/pkg/gosttls/gosttls.go
Original file line number Diff line number Diff line change
@@ -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
8 changes: 6 additions & 2 deletions images/dvcr-artifact/pkg/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 {
Expand Down
196 changes: 196 additions & 0 deletions images/dvcr-artifact/pkg/registry/uncompressed_layer.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading