Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 82 additions & 8 deletions CubeMaster/pkg/templatecenter/artifact_build.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,25 @@ package templatecenter

import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"

"github.com/google/uuid"
"github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/constants"
"github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/db/models"
"github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/log"
"github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/service/sandbox/types"
"github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/templatecenter/cube_egress_ca"
"github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/templatecenter/image"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"strings"
"time"
)

func ensureRootfsArtifact(ctx context.Context, req *types.CreateTemplateFromImageReq, source *image.PreparedSource, downloadBaseURL string) (*models.RootfsArtifact, *types.CreateCubeSandboxReq, bool, error) {
Expand All @@ -38,9 +44,13 @@ func ensureRootfsArtifact(ctx context.Context, req *types.CreateTemplateFromImag
record.DeletedAt = gorm.DeletedAt{}
}
if err == nil && record.Status == ArtifactStatusReady && record.GeneratedRequestJSON != "" {
generatedReq, err = generateTemplateCreateRequest(req, record, source.Config, downloadBaseURL)
if err == nil {
return record, generatedReq, false, nil
if validationErr := validateReusableRootfsArtifactFile(record); validationErr != nil {
log.G(ctx).Warnf("rootfs artifact %s is not reusable: %v; rebuilding", record.ArtifactID, validationErr)
} else {
generatedReq, err = generateTemplateCreateRequest(req, record, source.Config, downloadBaseURL)
if err == nil {
return record, generatedReq, false, nil
}
}
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
Expand Down Expand Up @@ -72,9 +82,13 @@ func ensureRootfsArtifact(ctx context.Context, req *types.CreateTemplateFromImag
record.DeletedAt = gorm.DeletedAt{}
}
if record.Status == ArtifactStatusReady && record.GeneratedRequestJSON != "" {
generatedReq, err = generateTemplateCreateRequest(req, record, source.Config, downloadBaseURL)
if err == nil {
return record, generatedReq, false, nil
if validationErr := validateReusableRootfsArtifactFile(record); validationErr != nil {
log.G(ctx).Warnf("rootfs artifact %s is not reusable: %v; rebuilding", record.ArtifactID, validationErr)
} else {
generatedReq, err = generateTemplateCreateRequest(req, record, source.Config, downloadBaseURL)
if err == nil {
return record, generatedReq, false, nil
}
}
}
}
Expand Down Expand Up @@ -204,6 +218,66 @@ func validateReusableRootfsArtifact(record *models.RootfsArtifact, fingerprint,
return record, nil
}

func validateReusableRootfsArtifactFile(record *models.RootfsArtifact) error {
if record == nil {
return gorm.ErrRecordNotFound
}
if strings.TrimSpace(record.Ext4Path) == "" {
return fmt.Errorf("rootfs artifact %s ext4 path is empty", record.ArtifactID)
}
if record.Ext4SizeBytes <= 0 {
return fmt.Errorf("rootfs artifact %s ext4 size is invalid: %d", record.ArtifactID, record.Ext4SizeBytes)
}
expectedSHA := normalizeRootfsArtifactSHA256(record.Ext4SHA256)
if expectedSHA == "" {
return fmt.Errorf("rootfs artifact %s ext4 sha256 is empty", record.ArtifactID)
}
info, err := os.Stat(record.Ext4Path) // NOCC:Path Traversal()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("rootfs artifact %s ext4 file %q is missing: %w", record.ArtifactID, record.Ext4Path, err)
}
return fmt.Errorf("stat rootfs artifact %s ext4 file %q: %w", record.ArtifactID, record.Ext4Path, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("rootfs artifact %s ext4 path %q is not a regular file", record.ArtifactID, record.Ext4Path)
}
if info.Size() != record.Ext4SizeBytes {
return fmt.Errorf("rootfs artifact %s ext4 size mismatch: got %d want %d", record.ArtifactID, info.Size(), record.Ext4SizeBytes)
}
shaValue, sizeBytes, err := computeRootfsArtifactFileSHA256(record.Ext4Path)
if err != nil {
return fmt.Errorf("hash rootfs artifact %s ext4 file %q: %w", record.ArtifactID, record.Ext4Path, err)
}
if sizeBytes != record.Ext4SizeBytes {
return fmt.Errorf("rootfs artifact %s ext4 size changed while hashing: got %d want %d", record.ArtifactID, sizeBytes, record.Ext4SizeBytes)
}
if normalizeRootfsArtifactSHA256(shaValue) != expectedSHA {
return fmt.Errorf("rootfs artifact %s ext4 sha256 mismatch: got %s want %s", record.ArtifactID, shaValue, expectedSHA)
}
return nil
}

func normalizeRootfsArtifactSHA256(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
return strings.TrimPrefix(value, "sha256:")
}

func computeRootfsArtifactFileSHA256(path string) (string, int64, error) {
f, err := os.Open(path) // NOCC:Path Traversal()
if err != nil {
return "", 0, err
}
defer f.Close()
hasher := sha256.New()
buf := make([]byte, 4*1024*1024)
size, err := io.CopyBuffer(hasher, f, buf)
if err != nil {
return "", 0, err
}
return hex.EncodeToString(hasher.Sum(nil)), size, nil
}

func rootfsArtifactSoftDeleted(record *models.RootfsArtifact) bool {
return record != nil && record.DeletedAt.Valid
}
Expand Down
140 changes: 140 additions & 0 deletions CubeMaster/pkg/templatecenter/template_image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ package templatecenter

import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"os"
Expand Down Expand Up @@ -926,6 +928,134 @@ func TestValidateReusableRootfsArtifactHandlesMissingRecord(t *testing.T) {
}
}

func TestValidateReusableRootfsArtifactFileAcceptsMatchingFile(t *testing.T) {
data := []byte("artifact-data")
path, shaValue := writeRootfsArtifactTestFile(t, data)

err := validateReusableRootfsArtifactFile(&models.RootfsArtifact{
ArtifactID: "rfs-1",
Ext4Path: path,
Ext4SHA256: "sha256:" + shaValue,
Ext4SizeBytes: int64(len(data)),
})
if err != nil {
t.Fatalf("validateReusableRootfsArtifactFile failed: %v", err)
}
}

func TestValidateReusableRootfsArtifactFileRejectsMissingFile(t *testing.T) {
err := validateReusableRootfsArtifactFile(&models.RootfsArtifact{
ArtifactID: "rfs-1",
Ext4Path: filepath.Join(t.TempDir(), "missing.ext4"),
Ext4SHA256: strings.Repeat("a", 64),
Ext4SizeBytes: 1024,
})
if err == nil || !strings.Contains(err.Error(), "missing") {
t.Fatalf("expected missing file error, got %v", err)
}
}

func TestValidateReusableRootfsArtifactFileRejectsSizeMismatch(t *testing.T) {
path, shaValue := writeRootfsArtifactTestFile(t, []byte("artifact-data"))

err := validateReusableRootfsArtifactFile(&models.RootfsArtifact{
ArtifactID: "rfs-1",
Ext4Path: path,
Ext4SHA256: shaValue,
Ext4SizeBytes: 1,
})
if err == nil || !strings.Contains(err.Error(), "size mismatch") {
t.Fatalf("expected size mismatch error, got %v", err)
}
}

func TestValidateReusableRootfsArtifactFileRejectsSHA256Mismatch(t *testing.T) {
path, _ := writeRootfsArtifactTestFile(t, []byte("artifact-data"))

err := validateReusableRootfsArtifactFile(&models.RootfsArtifact{
ArtifactID: "rfs-1",
Ext4Path: path,
Ext4SHA256: strings.Repeat("b", 64),
Ext4SizeBytes: int64(len("artifact-data")),
})
if err == nil || !strings.Contains(err.Error(), "sha256 mismatch") {
t.Fatalf("expected sha256 mismatch error, got %v", err)
}
}

func TestEnsureRootfsArtifactRebuildsMissingReadyArtifact(t *testing.T) {
patches := gomonkey.NewPatches()
defer patches.Reset()

source := &image.PreparedSource{
Digest: "sha256:digest",
ConfigJSON: `{}`,
}
req := &types.CreateTemplateFromImageReq{
Request: &types.Request{RequestID: "req-1"},
TemplateID: "tpl-1",
SourceImageRef: "docker.io/library/nginx:latest",
WritableLayerSize: "20Gi",
InstanceType: cubeboxv1.InstanceType_cubebox.String(),
}
stale := &models.RootfsArtifact{
ArtifactID: "rfs-stale",
TemplateSpecFingerprint: "fingerprint-1",
Status: ArtifactStatusReady,
GeneratedRequestJSON: `{"annotations":{"cube.master.template.id":"tpl-old"}}`,
Ext4Path: filepath.Join(t.TempDir(), "missing.ext4"),
Ext4SHA256: strings.Repeat("a", 64),
Ext4SizeBytes: 1024,
}
rebuiltReq := &types.CreateCubeSandboxReq{
Annotations: map[string]string{constants.CubeAnnotationAppSnapshotTemplateID: "tpl-1"},
}

patches.ApplyFunc(loadCubeEgressCA, func(ctx context.Context, withCubeCA bool) ([]byte, string, error) {
return nil, "", nil
})
patches.ApplyFunc(buildTemplateSpecFingerprintWithCA, func(req *types.CreateTemplateFromImageReq, sourceImageDigest, cubeEgressCAFingerprint string) string {
return "fingerprint-1"
})
patches.ApplyFunc(buildArtifactID, func(fingerprint string) string {
return "rfs-stale"
})
patches.ApplyFunc(findReusableRootfsArtifact, func(ctx context.Context, fingerprint, artifactID string) (*models.RootfsArtifact, bool, error) {
return stale, false, nil
})
patches.ApplyFunc(claimRootfsArtifactForBuild, func(ctx context.Context, artifactID, fingerprint string, req *types.CreateTemplateFromImageReq, sourceDigest string) (*models.RootfsArtifact, error) {
if artifactID != stale.ArtifactID {
t.Fatalf("artifactID=%q, want %q", artifactID, stale.ArtifactID)
}
claimed := *stale
claimed.Status = ArtifactStatusBuilding
return &claimed, nil
})

buildCalled := false
patches.ApplyFunc(buildRootfsArtifact, func(ctx context.Context, record *models.RootfsArtifact, req *types.CreateTemplateFromImageReq, source *image.PreparedSource, downloadBaseURL string, caPEM []byte, caFingerprint string) (*models.RootfsArtifact, *types.CreateCubeSandboxReq, error) {
buildCalled = true
rebuilt := *record
rebuilt.Status = ArtifactStatusReady
rebuilt.Ext4Path = "/data/CubeMaster/storage/rfs-stale/rfs-stale.ext4"
return &rebuilt, rebuiltReq, nil
})

gotArtifact, gotReq, builtFresh, err := ensureRootfsArtifact(context.Background(), req, source, "http://master.example")
if err != nil {
t.Fatalf("ensureRootfsArtifact failed: %v", err)
}
if !buildCalled || !builtFresh {
t.Fatalf("expected missing reusable artifact to trigger rebuild, buildCalled=%v builtFresh=%v", buildCalled, builtFresh)
}
if gotArtifact == nil || gotArtifact.Ext4Path == stale.Ext4Path {
t.Fatalf("expected rebuilt artifact, got %#v", gotArtifact)
}
if gotReq != rebuiltReq {
t.Fatalf("expected rebuilt request, got %#v", gotReq)
}
}

func TestRootfsArtifactSoftDeleted(t *testing.T) {
if rootfsArtifactSoftDeleted(nil) {
t.Fatal("nil record should not be treated as deleted")
Expand All @@ -940,6 +1070,16 @@ func TestRootfsArtifactSoftDeleted(t *testing.T) {
}
}

func writeRootfsArtifactTestFile(t *testing.T, data []byte) (string, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "artifact.ext4")
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
sum := sha256.Sum256(data)
return path, hex.EncodeToString(sum[:])
}

func TestManagedArtifactDirRecognizesWorkAndStoreRoots(t *testing.T) {
workRoot := filepath.Join(t.TempDir(), "work")
storeRoot := filepath.Join(t.TempDir(), "store")
Expand Down
Loading