diff --git a/CubeMaster/.gitignore b/CubeMaster/.gitignore index 52b3d8688..01e6369d6 100644 --- a/CubeMaster/.gitignore +++ b/CubeMaster/.gitignore @@ -1,2 +1,3 @@ /build/ /test/ +cmd/cubemastercli/internal/envd/assets/envd diff --git a/CubeMaster/Makefile b/CubeMaster/Makefile index 2f2bb5fd9..274cb5668 100644 --- a/CubeMaster/Makefile +++ b/CubeMaster/Makefile @@ -42,6 +42,9 @@ BUILD_FLAGS=-ldflags "-s -w \ -X '$(VERSION_PKG).Version=$(CUBE_VERSION)' \ -X '$(VERSION_PKG).Commit=$(CUBE_COMMIT)' \ -X '$(VERSION_PKG).BuildTime=$(CUBE_BUILD_TIME)'" +ENVD_LOCAL_PATH ?= +ENVD_EMBED_ASSET := $(ROOT_DIR)/cmd/cubemastercli/internal/envd/assets/envd +ENVD_BUILD_TAGS := $(if $(strip $(ENVD_LOCAL_PATH)),embed_envd,) DATE:=$(shell date +'%Y%m%d-%H%M%S') NAMESPACE?=cubemaster @@ -72,13 +75,39 @@ check-proto-tools: @command -v protoc-gen-go-grpc >/dev/null 2>&1 || { echo "error: protoc-gen-go-grpc not installed"; exit 1; } @if [ -z "$(PROTOBUF_INCLUDE_DIR)" ]; then echo "error: protobuf include directory not found"; exit 1; fi +.PHONY: prepare-cubemastercli-envd cleanup-cubemastercli-envd +prepare-cubemastercli-envd: +ifneq ($(strip $(ENVD_LOCAL_PATH)),) + @test -f "$(ENVD_LOCAL_PATH)" || { echo "error: ENVD_LOCAL_PATH must point to a regular file: $(ENVD_LOCAL_PATH)"; exit 1; } + @test -s "$(ENVD_LOCAL_PATH)" || { echo "error: ENVD_LOCAL_PATH must not be empty: $(ENVD_LOCAL_PATH)"; exit 1; } + @size=$$(wc -c < "$(ENVD_LOCAL_PATH)"); \ + if [ "$$size" -gt 16777216 ]; then \ + echo "error: ENVD_LOCAL_PATH exceeds 16MiB: $(ENVD_LOCAL_PATH)"; exit 1; \ + fi + @magic=$$(LC_ALL=C dd if="$(ENVD_LOCAL_PATH)" bs=4 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n'); \ + if [ "$$magic" != "7f454c46" ]; then \ + echo "error: ENVD_LOCAL_PATH must be an ELF binary: $(ENVD_LOCAL_PATH)"; exit 1; \ + fi + @mkdir -p "$(dir $(ENVD_EMBED_ASSET))" + @cp "$(ENVD_LOCAL_PATH)" "$(ENVD_EMBED_ASSET)" +endif + +cleanup-cubemastercli-envd: +ifneq ($(strip $(ENVD_LOCAL_PATH)),) + @rm -f "$(ENVD_EMBED_ASSET)" +endif + $(APPS): deps @echo "${GOOS}" @echo "${CUBE_VERSION}" $(call msg,Build app,$@) - $(Q)go build -o $(BUILD_DIR)/$@ \ - $(BUILD_FLAGS) \ - cmd/$@/*.go + $(Q)if [ "$@" = "cubemastercli" ] && [ -n "$(strip $(ENVD_LOCAL_PATH))" ]; then \ + $(MAKE) prepare-cubemastercli-envd; \ + trap '$(MAKE) cleanup-cubemastercli-envd' EXIT; \ + go build -tags "$(ENVD_BUILD_TAGS)" -o $(BUILD_DIR)/$@ $(BUILD_FLAGS) cmd/$@/*.go; \ + else \ + go build -o $(BUILD_DIR)/$@ $(BUILD_FLAGS) cmd/$@/*.go; \ + fi diff --git a/CubeMaster/cmd/cubemastercli/commands/cubebox/envd_upload.go b/CubeMaster/cmd/cubemastercli/commands/cubebox/envd_upload.go new file mode 100644 index 000000000..74c7b323a --- /dev/null +++ b/CubeMaster/cmd/cubemastercli/commands/cubebox/envd_upload.go @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +package cubebox + +import ( + "bytes" + "errors" + "fmt" + "mime/multipart" + "os" + "strings" + + jsoniter "github.com/json-iterator/go" + embeddedenvd "github.com/tencentcloud/CubeSandbox/CubeMaster/cmd/cubemastercli/internal/envd" + "github.com/urfave/cli" +) + +const maxEnvdUploadBytes = 16 * 1024 * 1024 + +type envdUploadPayload struct { + Data []byte + Source string +} + +func selectEnvdUploadPayload(c *cli.Context) (*envdUploadPayload, error) { + envdPath := strings.TrimSpace(c.String("envd-path")) + if envdPath != "" && !c.Bool("enable-inject-envd") { + return nil, errors.New("--envd-path requires --enable-inject-envd") + } + if !c.Bool("enable-inject-envd") { + return nil, nil + } + if envdPath != "" { + data, err := readLocalEnvdBinary(envdPath) + if err != nil { + return nil, err + } + return &envdUploadPayload{Data: data, Source: envdPath}, nil + } + if embeddedenvd.HasDefaultBinary() { + data := embeddedenvd.DefaultBinary() + if err := validateEnvdUploadBytes("embedded envd", data); err != nil { + return nil, err + } + return &envdUploadPayload{Data: data, Source: "embedded"}, nil + } + return nil, errors.New("envd injection is enabled, but no --envd-path was provided and this cubemastercli was built without an embedded envd binary") +} + +func readLocalEnvdBinary(path string) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("read local envd binary %q: %w", path, err) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("local envd binary %q must be a regular file", path) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read local envd binary %q: %w", path, err) + } + if err := validateEnvdUploadBytes(path, data); err != nil { + return nil, err + } + return data, nil +} + +func validateEnvdUploadBytes(source string, data []byte) error { + if len(data) == 0 { + return fmt.Errorf("envd binary %q must not be empty", source) + } + if len(data) > maxEnvdUploadBytes { + return fmt.Errorf("envd binary %q exceeds 16MiB", source) + } + if len(data) < 4 || data[0] != 0x7f || data[1] != 'E' || data[2] != 'L' || data[3] != 'F' { + return fmt.Errorf("envd binary %q must be an ELF binary", source) + } + return nil +} + +func buildCreateFromImageMultipartBody(req interface{}, payload *envdUploadPayload) (*bytes.Buffer, string, error) { + if payload == nil { + return nil, "", errors.New("envd upload payload is required") + } + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + requestPart, err := writer.CreateFormField("request") + if err != nil { + return nil, "", err + } + encoded, err := jsoniter.Marshal(req) + if err != nil { + return nil, "", err + } + if _, err := requestPart.Write(encoded); err != nil { + return nil, "", err + } + envdPart, err := writer.CreateFormFile("envd", "envd") + if err != nil { + return nil, "", err + } + if _, err := envdPart.Write(payload.Data); err != nil { + return nil, "", err + } + if err := writer.Close(); err != nil { + return nil, "", err + } + return body, writer.FormDataContentType(), nil +} diff --git a/CubeMaster/cmd/cubemastercli/commands/cubebox/envd_upload_test.go b/CubeMaster/cmd/cubemastercli/commands/cubebox/envd_upload_test.go new file mode 100644 index 000000000..506553cac --- /dev/null +++ b/CubeMaster/cmd/cubemastercli/commands/cubebox/envd_upload_test.go @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +package cubebox + +import ( + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/service/sandbox/types" +) + +func TestSelectEnvdUploadPayloadRejectsPathWithoutInjectFlag(t *testing.T) { + ctx := newCreateFromImageContext(t, []string{"--envd-path", "/tmp/envd"}) + payload, err := selectEnvdUploadPayload(ctx) + if err == nil { + t.Fatal("expected error") + } + if payload != nil { + t.Fatalf("payload=%+v, want nil", payload) + } + if !strings.Contains(err.Error(), "--envd-path requires --enable-inject-envd") { + t.Fatalf("error=%q", err.Error()) + } +} + +func TestSelectEnvdUploadPayloadUsesLocalEnvdPath(t *testing.T) { + path := writeCLIEnvdFixture(t, []byte{0x7f, 'E', 'L', 'F', 'o', 'k'}) + ctx := newCreateFromImageContext(t, []string{"--enable-inject-envd", "--envd-path", path}) + payload, err := selectEnvdUploadPayload(ctx) + if err != nil { + t.Fatalf("selectEnvdUploadPayload error=%v", err) + } + if payload == nil { + t.Fatal("expected payload") + } + if string(payload.Data) != string([]byte{0x7f, 'E', 'L', 'F', 'o', 'k'}) { + t.Fatalf("payload data=%v", payload.Data) + } + if payload.Source != path { + t.Fatalf("source=%q, want %q", payload.Source, path) + } +} + +func TestBuildCreateFromImageMultipartBodyIncludesRequestAndEnvd(t *testing.T) { + payload := &envdUploadPayload{Data: []byte{0x7f, 'E', 'L', 'F', 'x'}, Source: "test"} + req := map[string]string{"source_image_ref": "ubuntu:22.04"} + body, contentType, err := buildCreateFromImageMultipartBody(req, payload) + if err != nil { + t.Fatalf("buildCreateFromImageMultipartBody error=%v", err) + } + if !strings.HasPrefix(contentType, "multipart/form-data; boundary=") { + t.Fatalf("contentType=%q", contentType) + } + reader := multipart.NewReader(body, strings.TrimPrefix(contentType, "multipart/form-data; boundary=")) + form, err := reader.ReadForm(16 * 1024 * 1024) + if err != nil { + t.Fatalf("ReadForm error=%v", err) + } + if got := form.Value["request"]; len(got) != 1 || !strings.Contains(got[0], "ubuntu:22.04") { + t.Fatalf("request field=%v", got) + } + files := form.File["envd"] + if len(files) != 1 { + t.Fatalf("envd files=%d, want 1", len(files)) + } + f, err := files[0].Open() + if err != nil { + t.Fatalf("open envd part: %v", err) + } + defer f.Close() + data, err := io.ReadAll(f) + if err != nil { + t.Fatalf("read envd part: %v", err) + } + if string(data) != string(payload.Data) { + t.Fatalf("envd data=%v", data) + } +} + +func TestSelectEnvdUploadPayloadRejectsInvalidLocalEnvd(t *testing.T) { + path := writeCLIEnvdFixture(t, []byte("not-elf")) + ctx := newCreateFromImageContext(t, []string{"--enable-inject-envd", "--envd-path", path}) + payload, err := selectEnvdUploadPayload(ctx) + if err == nil { + t.Fatal("expected error") + } + if payload != nil { + t.Fatalf("payload=%+v, want nil", payload) + } + if !strings.Contains(err.Error(), "ELF") { + t.Fatalf("error=%q", err.Error()) + } +} + +func writeCLIEnvdFixture(t *testing.T, data []byte) string { + t.Helper() + path := t.TempDir() + "/envd" + if err := os.WriteFile(path, data, 0o755); err != nil { + t.Fatalf("write fixture: %v", err) + } + return path +} + +func TestDoHttpReqWithContentTypeUsesProvidedContentType(t *testing.T) { + var gotContentType string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotContentType = r.Header.Get("Content-Type") + _, _ = w.Write([]byte(`{"ret":{"retCode":200,"retMsg":"success"}}`)) + })) + defer server.Close() + + ctx := newCreateFromImageContext(t, nil) + var rsp struct { + Ret *types.Ret `json:"ret"` + } + err := doHttpReqWithContentType(ctx, server.URL, http.MethodPost, "req-1", strings.NewReader("body"), "multipart/form-data; boundary=test", &rsp) + if err != nil { + t.Fatalf("doHttpReqWithContentType error=%v", err) + } + if gotContentType != "multipart/form-data; boundary=test" { + t.Fatalf("Content-Type=%q", gotContentType) + } +} diff --git a/CubeMaster/cmd/cubemastercli/commands/cubebox/template.go b/CubeMaster/cmd/cubemastercli/commands/cubebox/template.go index addbe4fe2..577ee5257 100644 --- a/CubeMaster/cmd/cubemastercli/commands/cubebox/template.go +++ b/CubeMaster/cmd/cubemastercli/commands/cubebox/template.go @@ -797,6 +797,8 @@ var TemplateCreateFromImageCommand = cli.Command{ cli.IntFlag{Name: "cpu", Value: 2000, Usage: "CPU millicores for the template container (default: 2000, i.e. 2 cores)"}, cli.IntFlag{Name: "memory", Value: 2000, Usage: "Memory for the template container in MB (default: 2000 MB)"}, cli.BoolTFlag{Name: "with-cube-ca", Usage: "bake the CubeEgress root CA at /etc/cube/ca/cube-root-ca.crt into the template rootfs so sandboxes trust CubeEgress's MITM. Pass --with-cube-ca=false to skip (default: true)"}, + cli.BoolFlag{Name: "enable-inject-envd", Usage: "enable cubesandbox-envd injection for this template build"}, + cli.StringFlag{Name: "envd-path", Usage: "local envd binary path to upload when --enable-inject-envd is set; defaults to the CLI-embedded envd if available"}, cli.BoolFlag{Name: "detach, no-wait", Usage: "submit and exit immediately instead of watching the job to completion"}, cli.DurationFlag{Name: "interval", Value: defaultWatchInterval, Usage: "poll interval while watching the job"}, cli.BoolFlag{Name: "json", Usage: "print raw json response"}, @@ -845,14 +847,28 @@ var TemplateCreateFromImageCommand = cli.Command{ if err != nil { return err } - body, err := jsoniter.Marshal(req) + envdPayload, err := selectEnvdUploadPayload(c) if err != nil { return err } url := fmt.Sprintf("http://%s/cube/template/from-image", net.JoinHostPort(host, port)) rsp := &templateImageJobResponse{} - if err := doHttpReq(c, url, http.MethodPost, req.RequestID, bytes.NewBuffer(body), rsp); err != nil { - return err + if envdPayload != nil { + body, contentType, err := buildCreateFromImageMultipartBody(req, envdPayload) + if err != nil { + return err + } + if err := doHttpReqWithContentType(c, url, http.MethodPost, req.RequestID, body, contentType, rsp); err != nil { + return err + } + } else { + body, err := jsoniter.Marshal(req) + if err != nil { + return err + } + if err := doHttpReq(c, url, http.MethodPost, req.RequestID, bytes.NewBuffer(body), rsp); err != nil { + return err + } } if rsp.Ret == nil { return errors.New("empty response") @@ -1386,8 +1402,9 @@ func parseContainerOverrides(c *cli.Context) (*types.ContainerOverrides, error) probePort := c.Int("probe") cpuMillicores := c.Int("cpu") memoryMB := c.Int("memory") + enableInjectEnvd := c.Bool("enable-inject-envd") - if len(cmds) == 0 && len(args) == 0 && len(rawEnvs) == 0 && len(dnsServers) == 0 && probePort == 0 && !c.IsSet("cpu") && !c.IsSet("memory") { + if len(cmds) == 0 && len(args) == 0 && len(rawEnvs) == 0 && len(dnsServers) == 0 && probePort == 0 && !c.IsSet("cpu") && !c.IsSet("memory") && !enableInjectEnvd { return nil, nil } @@ -1442,5 +1459,11 @@ func parseContainerOverrides(c *cli.Context) (*types.ContainerOverrides, error) SuccessThreshold: 1, } } + if enableInjectEnvd { + if overrides.Annotations == nil { + overrides.Annotations = map[string]string{} + } + overrides.Annotations[constants.CubeAnnotationsInjectEnvd] = constants.CubeAnnotationsInjectEnvdOptIn + } return overrides, nil } diff --git a/CubeMaster/cmd/cubemastercli/commands/cubebox/template_test.go b/CubeMaster/cmd/cubemastercli/commands/cubebox/template_test.go index ed0d134b3..023bcf970 100644 --- a/CubeMaster/cmd/cubemastercli/commands/cubebox/template_test.go +++ b/CubeMaster/cmd/cubemastercli/commands/cubebox/template_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/constants" "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/service/sandbox/types" "github.com/urfave/cli" ) @@ -292,6 +293,20 @@ func TestParseContainerOverridesNoDNS(t *testing.T) { } } +func TestParseContainerOverridesEnableInjectEnvd(t *testing.T) { + ctx := newCreateFromImageContext(t, []string{"--enable-inject-envd"}) + overrides, err := parseContainerOverrides(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if overrides == nil || overrides.Annotations == nil { + t.Fatal("expected annotations to be set") + } + if got := overrides.Annotations[constants.CubeAnnotationsInjectEnvd]; got != constants.CubeAnnotationsInjectEnvdOptIn { + t.Fatalf("expected inject envd annotation=true, got %q", got) + } +} + func TestTemplateImageJobWatchPhaseLabel(t *testing.T) { tests := []struct { name string diff --git a/CubeMaster/cmd/cubemastercli/commands/cubebox/util.go b/CubeMaster/cmd/cubemastercli/commands/cubebox/util.go index a01ae2ffd..5631785ca 100644 --- a/CubeMaster/cmd/cubemastercli/commands/cubebox/util.go +++ b/CubeMaster/cmd/cubemastercli/commands/cubebox/util.go @@ -320,6 +320,10 @@ func checkDone(cancel context.CancelFunc) { } func doHttpReq(c *cli.Context, url, method, requestID string, body io.Reader, rsp interface{}) error { + return doHttpReqWithContentType(c, url, method, requestID, body, "", rsp) +} + +func doHttpReqWithContentType(c *cli.Context, url, method, requestID string, body io.Reader, contentType string, rsp interface{}) error { var ctx = context.Background() var cancel context.CancelFunc timeout := c.GlobalDuration("timeout") @@ -336,6 +340,9 @@ func doHttpReq(c *cli.Context, url, method, requestID string, body io.Reader, rs return err } httpReq.Header.Set(constants.Caller, "mastercli") + if contentType != "" { + httpReq.Header.Set("Content-Type", contentType) + } if callerHostIP := c.String("callerhostip"); callerHostIP != "" { httpReq.Header.Set(constants.CallerHostIP, callerHostIP) } diff --git a/CubeMaster/cmd/cubemastercli/internal/envd/assets/.gitkeep b/CubeMaster/cmd/cubemastercli/internal/envd/assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/CubeMaster/cmd/cubemastercli/internal/envd/embed_disabled.go b/CubeMaster/cmd/cubemastercli/internal/envd/embed_disabled.go new file mode 100644 index 000000000..453ac9ea3 --- /dev/null +++ b/CubeMaster/cmd/cubemastercli/internal/envd/embed_disabled.go @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +//go:build !embed_envd + +package envd + +func DefaultBinary() []byte { + return nil +} + +func HasDefaultBinary() bool { + return false +} diff --git a/CubeMaster/cmd/cubemastercli/internal/envd/embed_enabled.go b/CubeMaster/cmd/cubemastercli/internal/envd/embed_enabled.go new file mode 100644 index 000000000..03be69166 --- /dev/null +++ b/CubeMaster/cmd/cubemastercli/internal/envd/embed_enabled.go @@ -0,0 +1,20 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +//go:build embed_envd + +package envd + +import _ "embed" + +//go:embed assets/envd +var defaultBinary []byte + +func DefaultBinary() []byte { + return defaultBinary +} + +func HasDefaultBinary() bool { + return len(defaultBinary) > 0 +} diff --git a/CubeMaster/pkg/base/constants/constants.go b/CubeMaster/pkg/base/constants/constants.go index b29e6d29e..cf69bb262 100644 --- a/CubeMaster/pkg/base/constants/constants.go +++ b/CubeMaster/pkg/base/constants/constants.go @@ -86,6 +86,11 @@ const ( CubeAnnotationsVirtiofsCache = "cube.master.virtiofs.cache" + CubeAnnotationsInjectEnvd = "cube.master.inject_envd" + CubeAnnotationsInjectEnvdOptIn = "true" + CubeAnnotationsEnvdSidecarImage = "cube.master.envd_sidecar_image" + CubeEnvdInImagePath = "/usr/local/bin/envd" + // CubeAnnotationComponentsPrefix is the namespace for pre-installed // runtime-component metadata carried on templates/sandboxes. CubeAnnotationComponentsPrefix = "cube.master.components." diff --git a/CubeMaster/pkg/service/httpservice/cube/cubeboxutil.go b/CubeMaster/pkg/service/httpservice/cube/cubeboxutil.go index 8f5a2ce5f..da53a7f76 100644 --- a/CubeMaster/pkg/service/httpservice/cube/cubeboxutil.go +++ b/CubeMaster/pkg/service/httpservice/cube/cubeboxutil.go @@ -433,6 +433,7 @@ func dealCubeboxCreateReqWithTemplate(ctx context.Context, reqInOut *types.Creat if reqInOut.InstanceType != cubebox.InstanceType_cubebox.String() { return nil } + stripPerRequestEnvdAnnotations(ctx, reqInOut) constants.NormalizeAppSnapshotAnnotations(reqInOut.Annotations) templateID, hasTemplateID := reqInOut.Annotations[constants.CubeAnnotationAppSnapshotTemplateID] @@ -719,3 +720,22 @@ func getTemplateVolumes(sourceVolume interface{}, templateVolumes []*types.Volum } return nil } + +func stripPerRequestEnvdAnnotations(ctx context.Context, req *types.CreateCubeSandboxReq) { + if req == nil || len(req.Annotations) == 0 { + return + } + stripped := make([]string, 0, 2) + for _, key := range []string{ + constants.CubeAnnotationsInjectEnvd, + constants.CubeAnnotationsEnvdSidecarImage, + } { + if _, ok := req.Annotations[key]; ok { + delete(req.Annotations, key) + stripped = append(stripped, key) + } + } + if len(stripped) > 0 { + log.G(ctx).Infof("envd-sidecar: stripped per-request annotation(s) %v; envd injection is template-only", stripped) + } +} diff --git a/CubeMaster/pkg/service/httpservice/cube/cubeboxutil_envd_test.go b/CubeMaster/pkg/service/httpservice/cube/cubeboxutil_envd_test.go new file mode 100644 index 000000000..9fe5594f2 --- /dev/null +++ b/CubeMaster/pkg/service/httpservice/cube/cubeboxutil_envd_test.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +package cube + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/constants" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/service/sandbox/types" +) + +func TestStripPerRequestEnvdAnnotations(t *testing.T) { + req := &types.CreateCubeSandboxReq{Annotations: map[string]string{ + constants.CubeAnnotationsInjectEnvd: "true", + constants.CubeAnnotationsEnvdSidecarImage: "image", + "keep": "value", + }} + + stripPerRequestEnvdAnnotations(context.Background(), req) + + assert.NotContains(t, req.Annotations, constants.CubeAnnotationsInjectEnvd) + assert.NotContains(t, req.Annotations, constants.CubeAnnotationsEnvdSidecarImage) + assert.Equal(t, "value", req.Annotations["keep"]) +} diff --git a/CubeMaster/pkg/service/httpservice/cube/template_from_image.go b/CubeMaster/pkg/service/httpservice/cube/template_from_image.go index c2022a9df..24a20cbbe 100644 --- a/CubeMaster/pkg/service/httpservice/cube/template_from_image.go +++ b/CubeMaster/pkg/service/httpservice/cube/template_from_image.go @@ -75,8 +75,8 @@ func handleRedoTemplateAction(c *gin.Context) { } func createTemplateFromImage(r *http.Request, rt *CubeLog.RequestTrace) interface{} { - req := &types.CreateTemplateFromImageReq{} - if err := common.GetBodyReq(r, req); err != nil { + req, envdPayload, err := parseCreateTemplateFromImageRequest(r) + if err != nil { return &types.CreateTemplateFromImageRes{ Ret: &types.Ret{ RetCode: int(errorcode.ErrorCode_MasterParamsError), @@ -91,7 +91,7 @@ func createTemplateFromImage(r *http.Request, rt *CubeLog.RequestTrace) interfac "Action": "CreateTemplateFromImage", "TemplateID": req.TemplateID, })) - job, err := templatecenter.SubmitTemplateFromImage(ctx, req, requestBaseURL(r)) + job, err := templatecenter.SubmitTemplateFromImageWithEnvdPayload(ctx, req, requestBaseURL(r), envdPayload) if err != nil { return &types.CreateTemplateFromImageRes{ RequestID: req.RequestID, diff --git a/CubeMaster/pkg/service/httpservice/cube/template_from_image_multipart.go b/CubeMaster/pkg/service/httpservice/cube/template_from_image_multipart.go new file mode 100644 index 000000000..bf92937a7 --- /dev/null +++ b/CubeMaster/pkg/service/httpservice/cube/template_from_image_multipart.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +package cube + +import ( + "fmt" + "io" + "mime" + "net/http" + "strings" + + jsoniter "github.com/json-iterator/go" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/service/httpservice/common" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/service/sandbox/types" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/templatecenter" +) + +const maxMultipartEnvdPayloadBytes = 16 * 1024 * 1024 + +func parseCreateTemplateFromImageRequest(r *http.Request) (*types.CreateTemplateFromImageReq, *templatecenter.EnvdInjectionPayload, error) { + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || mediaType == "" { + mediaType = "application/json" + } + switch mediaType { + case "application/json": + req := &types.CreateTemplateFromImageReq{} + if err := common.GetBodyReq(r, req); err != nil { + return nil, nil, err + } + if templatecenter.ShouldInjectEnvdIntoTemplate(req) { + return nil, nil, fmt.Errorf("envd injection requires multipart create-from-image upload") + } + return req, nil, nil + case "multipart/form-data": + return parseMultipartCreateTemplateFromImageRequest(r) + default: + return nil, nil, fmt.Errorf("unsupported content type %q", mediaType) + } +} + +func parseMultipartCreateTemplateFromImageRequest(r *http.Request) (*types.CreateTemplateFromImageReq, *templatecenter.EnvdInjectionPayload, error) { + if err := r.ParseMultipartForm(maxMultipartEnvdPayloadBytes + 1*1024*1024); err != nil { + return nil, nil, err + } + values := r.MultipartForm.Value["request"] + if len(values) != 1 || strings.TrimSpace(values[0]) == "" { + return nil, nil, fmt.Errorf("multipart field %q is required", "request") + } + req := &types.CreateTemplateFromImageReq{} + if err := jsoniter.Unmarshal([]byte(values[0]), req); err != nil { + return nil, nil, fmt.Errorf("decode multipart request field: %w", err) + } + if !templatecenter.ShouldInjectEnvdIntoTemplate(req) { + return req, nil, nil + } + files := r.MultipartForm.File["envd"] + if len(files) != 1 { + return nil, nil, fmt.Errorf("multipart file field %q is required when envd injection is enabled", "envd") + } + file, err := files[0].Open() + if err != nil { + return nil, nil, err + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, maxMultipartEnvdPayloadBytes+1)) + if err != nil { + return nil, nil, err + } + payload, err := templatecenter.NewEnvdInjectionPayloadFromBytes(data) + if err != nil { + return nil, nil, err + } + return req, payload, nil +} diff --git a/CubeMaster/pkg/service/httpservice/cube/template_from_image_multipart_test.go b/CubeMaster/pkg/service/httpservice/cube/template_from_image_multipart_test.go new file mode 100644 index 000000000..36613f496 --- /dev/null +++ b/CubeMaster/pkg/service/httpservice/cube/template_from_image_multipart_test.go @@ -0,0 +1,140 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +package cube + +import ( + "bytes" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + jsoniter "github.com/json-iterator/go" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/constants" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/service/sandbox/types" +) + +func TestParseCreateTemplateFromImageRequestRejectsJSONEnvdInjection(t *testing.T) { + reqBody := &types.CreateTemplateFromImageReq{ + Request: &types.Request{RequestID: "req-1"}, + SourceImageRef: "ubuntu:22.04", + WritableLayerSize: "20Gi", + ContainerOverrides: &types.ContainerOverrides{Annotations: map[string]string{ + constants.CubeAnnotationsInjectEnvd: constants.CubeAnnotationsInjectEnvdOptIn, + }}, + } + raw, err := jsoniter.Marshal(reqBody) + if err != nil { + t.Fatalf("marshal: %v", err) + } + httpReq := httptest.NewRequest(http.MethodPost, "/cube/template/from-image", bytes.NewReader(raw)) + httpReq.Header.Set("Content-Type", "application/json") + + parsed, payload, err := parseCreateTemplateFromImageRequest(httpReq) + if err == nil { + t.Fatal("expected error") + } + if parsed != nil || payload != nil { + t.Fatalf("parsed=%+v payload=%+v, want nil", parsed, payload) + } + if !strings.Contains(err.Error(), "multipart") { + t.Fatalf("error=%q", err.Error()) + } +} + +func TestParseCreateTemplateFromImageRequestMultipartWithEnvd(t *testing.T) { + reqBody := &types.CreateTemplateFromImageReq{ + Request: &types.Request{RequestID: "req-1"}, + SourceImageRef: "ubuntu:22.04", + WritableLayerSize: "20Gi", + ContainerOverrides: &types.ContainerOverrides{Annotations: map[string]string{ + constants.CubeAnnotationsInjectEnvd: constants.CubeAnnotationsInjectEnvdOptIn, + }}, + } + httpReq := newMultipartTemplateFromImageRequest(t, reqBody, []byte{0x7f, 'E', 'L', 'F', 'o', 'k'}) + parsed, payload, err := parseCreateTemplateFromImageRequest(httpReq) + if err != nil { + t.Fatalf("parseCreateTemplateFromImageRequest error=%v", err) + } + if parsed == nil || parsed.SourceImageRef != "ubuntu:22.04" { + t.Fatalf("parsed=%+v", parsed) + } + if payload == nil || string(payload.Data) != string([]byte{0x7f, 'E', 'L', 'F', 'o', 'k'}) { + t.Fatalf("payload=%+v", payload) + } +} + +func TestParseCreateTemplateFromImageRequestMultipartMissingEnvd(t *testing.T) { + reqBody := &types.CreateTemplateFromImageReq{ + Request: &types.Request{RequestID: "req-1"}, + SourceImageRef: "ubuntu:22.04", + WritableLayerSize: "20Gi", + ContainerOverrides: &types.ContainerOverrides{Annotations: map[string]string{ + constants.CubeAnnotationsInjectEnvd: constants.CubeAnnotationsInjectEnvdOptIn, + }}, + } + httpReq := newMultipartTemplateFromImageRequest(t, reqBody, nil) + parsed, payload, err := parseCreateTemplateFromImageRequest(httpReq) + if err == nil { + t.Fatal("expected error") + } + if parsed != nil || payload != nil { + t.Fatalf("parsed=%+v payload=%+v, want nil", parsed, payload) + } + if !strings.Contains(err.Error(), "envd") { + t.Fatalf("error=%q", err.Error()) + } +} + +func TestParseCreateTemplateFromImageRequestMultipartRejectsInvalidEnvd(t *testing.T) { + reqBody := &types.CreateTemplateFromImageReq{ + Request: &types.Request{RequestID: "req-1"}, + SourceImageRef: "ubuntu:22.04", + WritableLayerSize: "20Gi", + ContainerOverrides: &types.ContainerOverrides{Annotations: map[string]string{ + constants.CubeAnnotationsInjectEnvd: constants.CubeAnnotationsInjectEnvdOptIn, + }}, + } + httpReq := newMultipartTemplateFromImageRequest(t, reqBody, []byte("not-elf")) + parsed, payload, err := parseCreateTemplateFromImageRequest(httpReq) + if err == nil { + t.Fatal("expected error") + } + if parsed != nil || payload != nil { + t.Fatalf("parsed=%+v payload=%+v, want nil", parsed, payload) + } + if !strings.Contains(err.Error(), "ELF") { + t.Fatalf("error=%q", err.Error()) + } +} + +func newMultipartTemplateFromImageRequest(t *testing.T, reqBody *types.CreateTemplateFromImageReq, envd []byte) *http.Request { + t.Helper() + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + raw, err := jsoniter.Marshal(reqBody) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := writer.WriteField("request", string(raw)); err != nil { + t.Fatalf("write request field: %v", err) + } + if envd != nil { + part, err := writer.CreateFormFile("envd", "envd") + if err != nil { + t.Fatalf("create envd part: %v", err) + } + if _, err := part.Write(envd); err != nil { + t.Fatalf("write envd part: %v", err) + } + } + if err := writer.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + httpReq := httptest.NewRequest(http.MethodPost, "/cube/template/from-image", body) + httpReq.Header.Set("Content-Type", writer.FormDataContentType()) + return httpReq +} diff --git a/CubeMaster/pkg/service/sandbox/envd_sidecar.go b/CubeMaster/pkg/service/sandbox/envd_sidecar.go new file mode 100644 index 000000000..f46722bf6 --- /dev/null +++ b/CubeMaster/pkg/service/sandbox/envd_sidecar.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +package sandbox + +import ( + "context" + "fmt" + "strings" + + "github.com/tencentcloud/CubeSandbox/CubeMaster/api/services/cubebox/v1" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/constants" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/log" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/service/sandbox/types" +) + +const envdDefaultPort = 49983 + +func injectEnvdSidecar(ctx context.Context, req *types.CreateCubeSandboxReq, out *cubebox.RunCubeSandboxRequest) { + val, ok := req.Annotations[constants.CubeAnnotationsInjectEnvd] + if !ok || val != constants.CubeAnnotationsInjectEnvdOptIn { + return + } + if req.InstanceType != "" && req.InstanceType != cubebox.InstanceType_cubebox.String() { + log.G(ctx).Infof("envd: skipping injection for instance_type=%q (only cubebox is supported)", req.InstanceType) + return + } + if len(req.Containers) == 0 { + return + } + mainContainer := req.Containers[0] + if isEnvdEntrypointWrapped(mainContainer) { + log.G(ctx).Infof("envd: container %q entrypoint already wrapped, skipping", mainContainer.Name) + return + } + wrapEntrypointForEnvd(mainContainer) + appendExposedPortIfMissing(out, envdDefaultPort) + log.G(ctx).Infof("envd: wrapped entrypoint of container %q (envd path=%s, pre-baked at build time)", mainContainer.Name, constants.CubeEnvdInImagePath) + return +} + +func wrapEntrypointForEnvd(c *types.Container) { + originalCmd := buildOriginalCommand(c) + c.Command = []string{"/bin/sh", "-c"} + c.Args = []string{ + fmt.Sprintf(`%s >/var/log/envd.log 2>&1 & exec "$@"`, constants.CubeEnvdInImagePath), + "--", + } + c.Args = append(c.Args, originalCmd...) +} + +func isEnvdEntrypointWrapped(c *types.Container) bool { + if c == nil || len(c.Command) < 2 || len(c.Args) < 1 { + return false + } + if c.Command[0] != "/bin/sh" || c.Command[1] != "-c" { + return false + } + return strings.Contains(c.Args[0], constants.CubeEnvdInImagePath) +} + +func buildOriginalCommand(c *types.Container) []string { + var cmd []string + cmd = append(cmd, c.Command...) + cmd = append(cmd, c.Args...) + if len(cmd) == 0 { + return []string{"sleep", "infinity"} + } + return cmd +} diff --git a/CubeMaster/pkg/service/sandbox/envd_sidecar_test.go b/CubeMaster/pkg/service/sandbox/envd_sidecar_test.go new file mode 100644 index 000000000..8d5e72ba9 --- /dev/null +++ b/CubeMaster/pkg/service/sandbox/envd_sidecar_test.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +package sandbox + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + cubebox "github.com/tencentcloud/CubeSandbox/CubeMaster/api/services/cubebox/v1" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/constants" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/service/sandbox/types" +) + +func TestInjectEnvdSidecarWrapsEntrypoint(t *testing.T) { + req := &types.CreateCubeSandboxReq{ + InstanceType: cubebox.InstanceType_cubebox.String(), + Annotations: map[string]string{constants.CubeAnnotationsInjectEnvd: constants.CubeAnnotationsInjectEnvdOptIn}, + Containers: []*types.Container{{ + Name: "main", + Command: []string{"python3"}, + Args: []string{"app.py"}, + }}, + } + out := &cubebox.RunCubeSandboxRequest{} + + injectEnvdSidecar(context.Background(), req, out) + assert.Equal(t, []string{"/bin/sh", "-c"}, req.Containers[0].Command) + assert.Contains(t, req.Containers[0].Args[0], constants.CubeEnvdInImagePath) + assert.Equal(t, []string{"--", "python3", "app.py"}, req.Containers[0].Args[1:]) + assert.Contains(t, out.ExposedPorts, int64(envdDefaultPort)) +} + +func TestInjectEnvdSidecarDoesNotDuplicateEnvdPort(t *testing.T) { + req := &types.CreateCubeSandboxReq{ + InstanceType: cubebox.InstanceType_cubebox.String(), + Annotations: map[string]string{constants.CubeAnnotationsInjectEnvd: constants.CubeAnnotationsInjectEnvdOptIn}, + Containers: []*types.Container{{ + Name: "main", + Command: []string{"python3"}, + Args: []string{"app.py"}, + }}, + } + out := &cubebox.RunCubeSandboxRequest{ExposedPorts: []int64{envdDefaultPort}} + + injectEnvdSidecar(context.Background(), req, out) + assert.Equal(t, []int64{envdDefaultPort}, out.ExposedPorts) +} + +func TestInjectEnvdSidecarIsIdempotent(t *testing.T) { + container := &types.Container{Name: "main", Command: []string{"sleep"}, Args: []string{"infinity"}} + wrapEntrypointForEnvd(container) + originalArgs := append([]string(nil), container.Args...) + req := &types.CreateCubeSandboxReq{ + InstanceType: cubebox.InstanceType_cubebox.String(), + Annotations: map[string]string{constants.CubeAnnotationsInjectEnvd: constants.CubeAnnotationsInjectEnvdOptIn}, + Containers: []*types.Container{container}, + } + out := &cubebox.RunCubeSandboxRequest{} + + injectEnvdSidecar(context.Background(), req, out) + assert.Equal(t, originalArgs, container.Args) + assert.Empty(t, out.ExposedPorts) +} + +func TestInjectEnvdSidecarSkipsWhenAnnotationMissing(t *testing.T) { + container := &types.Container{Name: "main", Command: []string{"sleep"}, Args: []string{"infinity"}} + req := &types.CreateCubeSandboxReq{Annotations: map[string]string{}, Containers: []*types.Container{container}} + out := &cubebox.RunCubeSandboxRequest{} + + injectEnvdSidecar(context.Background(), req, out) + assert.Equal(t, []string{"sleep"}, container.Command) + assert.Equal(t, []string{"infinity"}, container.Args) + assert.Empty(t, out.ExposedPorts) +} diff --git a/CubeMaster/pkg/service/sandbox/util.go b/CubeMaster/pkg/service/sandbox/util.go index 077c5f4ca..52b6db0f9 100644 --- a/CubeMaster/pkg/service/sandbox/util.go +++ b/CubeMaster/pkg/service/sandbox/util.go @@ -185,6 +185,8 @@ func ConstructCubeletReq(ctx context.Context, req *types.CreateCubeSandboxReq) ( return nil, ret.Err(errorcode.ErrorCode_MasterParamsError, err.Error()) } + injectEnvdSidecar(ctx, req, out) + if err = checkAndGetVolumes(req, out); err != nil { return nil, ret.Err(errorcode.ErrorCode_MasterParamsError, err.Error()) } @@ -289,6 +291,15 @@ func formatConstructCubeNetworkConfig(in *cubebox.CubeNetworkConfig) string { return fmt.Sprintf("allow_internet_access=%s allow_out=%v deny_out=%v rules=%d", allowInternetAccess, in.GetAllowOut(), in.GetDenyOut(), len(in.GetRules())) } +func appendExposedPortIfMissing(out *cubebox.RunCubeSandboxRequest, port int64) { + for _, existing := range out.ExposedPorts { + if existing == port { + return + } + } + out.ExposedPorts = append(out.ExposedPorts, port) +} + func getExposedPorts(req *types.CreateCubeSandboxReq, out *cubebox.RunCubeSandboxRequest) error { if config.GetConfig().CubeletConf.EnableExposedPort { @@ -306,7 +317,7 @@ func getExposedPorts(req *types.CreateCubeSandboxReq, out *cubebox.RunCubeSandbo if err != nil { return ret.Errorf(errorcode.ErrorCode_MasterParamsError, "com.exposed_ports:%s,invalid:%v", p, err.Error()) } - out.ExposedPorts = append(out.ExposedPorts, v) + appendExposedPortIfMissing(out, v) } if len(out.GetExposedPorts()) <= 0 { return ret.Errorf(errorcode.ErrorCode_MasterParamsError, "com.exposed_ports is empty") diff --git a/CubeMaster/pkg/templatecenter/artifact_build.go b/CubeMaster/pkg/templatecenter/artifact_build.go index f2840b801..ee7c853d4 100644 --- a/CubeMaster/pkg/templatecenter/artifact_build.go +++ b/CubeMaster/pkg/templatecenter/artifact_build.go @@ -39,14 +39,24 @@ import ( // build in image.BuildExt4. var artifactBuildLocks sync.Map // map[string]*sync.Mutex -func ensureRootfsArtifact(ctx context.Context, req *types.CreateTemplateFromImageReq, source *image.PreparedSource, downloadBaseURL string) (*models.RootfsArtifact, *types.CreateCubeSandboxReq, bool, error) { +func ensureRootfsArtifact(ctx context.Context, req *types.CreateTemplateFromImageReq, source *image.PreparedSource, downloadBaseURL string, envdPayload *EnvdInjectionPayload) (*models.RootfsArtifact, *types.CreateCubeSandboxReq, bool, error) { var generatedReq *types.CreateCubeSandboxReq withCubeCA := resolveWithCubeCA(req.WithCubeCA) caPEM, caFingerprint, err := loadCubeEgressCA(ctx, withCubeCA) if err != nil { return nil, nil, false, err } - fingerprint := buildTemplateSpecFingerprintWithCA(req, source.Digest, caFingerprint) + if envdPayload == nil { + envdPayload, err = prepareEnvdInjectionPayload(req) + if err != nil { + return nil, nil, false, err + } + } + envdSHA := "" + if envdPayload != nil { + envdSHA = envdPayload.SHA256 + } + fingerprint := buildTemplateSpecFingerprintWithEnvdSHA(req, source.Digest, caFingerprint, envdSHA) artifactID := buildArtifactID(fingerprint) // Serialize concurrent builds of the same artifactID. Without this, two // submits of the same image spec race on workDir/storeDir/ext4Path; the @@ -118,7 +128,7 @@ func ensureRootfsArtifact(ctx context.Context, req *types.CreateTemplateFromImag if claimed != nil { record = claimed } - record, generatedReq, err = buildRootfsArtifact(ctx, record, req, source, downloadBaseURL, caPEM, caFingerprint) + record, generatedReq, err = buildRootfsArtifact(ctx, record, req, source, downloadBaseURL, caPEM, caFingerprint, envdPayload) if err != nil { _ = updateRootfsArtifact(ctx, artifactID, map[string]any{ "status": ArtifactStatusFailed, @@ -251,7 +261,7 @@ func restoreRootfsArtifact(ctx context.Context, artifactID string) error { return nil } -func buildRootfsArtifact(ctx context.Context, record *models.RootfsArtifact, req *types.CreateTemplateFromImageReq, source *image.PreparedSource, downloadBaseURL string, caPEM []byte, caFingerprint string) (*models.RootfsArtifact, *types.CreateCubeSandboxReq, error) { +func buildRootfsArtifact(ctx context.Context, record *models.RootfsArtifact, req *types.CreateTemplateFromImageReq, source *image.PreparedSource, downloadBaseURL string, caPEM []byte, caFingerprint string, envdPayload *EnvdInjectionPayload) (*models.RootfsArtifact, *types.CreateCubeSandboxReq, error) { var caBakeResult cube_egress_ca.Result opts := image.BuildOptions{ArtifactID: record.ArtifactID} // Bake the CubeEgress root CA into the rootfs while it's still a mutable @@ -259,6 +269,9 @@ func buildRootfsArtifact(ctx context.Context, record *models.RootfsArtifact, req // design/cube-egress-ca-bake.md for the contract. opts.PostRootfsExport = func(ctx context.Context, rootfsDir string) error { var err error + if _, err = injectEnvdPayloadIntoRootfs(ctx, rootfsDir, envdPayload); err != nil { + return err + } caBakeResult, err = applyCubeEgressCAToRootfs(ctx, rootfsDir, caPEM, caFingerprint) return err } diff --git a/CubeMaster/pkg/templatecenter/envd_inject.go b/CubeMaster/pkg/templatecenter/envd_inject.go new file mode 100644 index 000000000..b355a4cc1 --- /dev/null +++ b/CubeMaster/pkg/templatecenter/envd_inject.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +package templatecenter + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/constants" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/log" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/service/sandbox/types" +) + +const ( + envdInjectionFileMode = 0o755 + envdInjectionDirMode = 0o755 + maxEnvdPayloadBytes = 16 * 1024 * 1024 +) + +func ShouldInjectEnvdIntoTemplate(req *types.CreateTemplateFromImageReq) bool { + if req == nil || req.ContainerOverrides == nil || req.ContainerOverrides.Annotations == nil { + return false + } + return req.ContainerOverrides.Annotations[constants.CubeAnnotationsInjectEnvd] == constants.CubeAnnotationsInjectEnvdOptIn +} + +type EnvdInjectionPayload struct { + SHA256 string + Data []byte +} + +func NewEnvdInjectionPayloadFromBytes(data []byte) (*EnvdInjectionPayload, error) { + if err := validateEnvdPayloadBytes(data); err != nil { + return nil, err + } + sum := sha256.Sum256(data) + return &EnvdInjectionPayload{ + SHA256: hex.EncodeToString(sum[:]), + Data: data, + }, nil +} + +func validateEnvdPayloadBytes(data []byte) error { + if len(data) == 0 { + return fmt.Errorf("envd-inject: uploaded envd must not be empty") + } + if len(data) > maxEnvdPayloadBytes { + return fmt.Errorf("envd-inject: uploaded envd exceeds 16MiB") + } + if len(data) < 4 || data[0] != 0x7f || data[1] != 'E' || data[2] != 'L' || data[3] != 'F' { + return fmt.Errorf("envd-inject: uploaded envd must be an ELF binary") + } + return nil +} + +func prepareEnvdInjectionPayload(req *types.CreateTemplateFromImageReq) (*EnvdInjectionPayload, error) { + if !ShouldInjectEnvdIntoTemplate(req) { + return nil, nil + } + return nil, fmt.Errorf("envd-inject: uploaded envd payload is required; use multipart create-from-image upload") +} + +func injectEnvdIntoRootfs(ctx context.Context, rootfsDir string, req *types.CreateTemplateFromImageReq) (string, error) { + payload, err := prepareEnvdInjectionPayload(req) + if err != nil { + return "", err + } + return injectEnvdPayloadIntoRootfs(ctx, rootfsDir, payload) +} + +func injectEnvdPayloadIntoRootfs(ctx context.Context, rootfsDir string, payload *EnvdInjectionPayload) (string, error) { + if payload == nil { + return "", nil + } + dstDir := filepath.Join(rootfsDir, filepath.Dir(constants.CubeEnvdInImagePath)) + if err := os.MkdirAll(dstDir, envdInjectionDirMode); err != nil { + return "", fmt.Errorf("envd-inject: mkdir %q: %w", dstDir, err) + } + dstPath := filepath.Join(rootfsDir, constants.CubeEnvdInImagePath) + if err := os.WriteFile(dstPath, payload.Data, envdInjectionFileMode); err != nil { + _ = os.Remove(dstPath) + return "", fmt.Errorf("envd-inject: write %q: %w", dstPath, err) + } + log.G(ctx).Infof("envd-inject: wrote uploaded envd -> rootfs%s sha256=%s", constants.CubeEnvdInImagePath, payload.SHA256) + return payload.SHA256, nil +} diff --git a/CubeMaster/pkg/templatecenter/envd_inject_test.go b/CubeMaster/pkg/templatecenter/envd_inject_test.go new file mode 100644 index 000000000..0d9f1e852 --- /dev/null +++ b/CubeMaster/pkg/templatecenter/envd_inject_test.go @@ -0,0 +1,119 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +package templatecenter + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/constants" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/service/sandbox/types" +) + +func fakeEnvdBinary(s string) []byte { + return append([]byte{0x7f, 'E', 'L', 'F'}, []byte(s)...) +} + +func reqWithEnvdAnnotation(value string) *types.CreateTemplateFromImageReq { + return &types.CreateTemplateFromImageReq{ + ContainerOverrides: &types.ContainerOverrides{ + Annotations: map[string]string{ + constants.CubeAnnotationsInjectEnvd: value, + }, + }, + } +} + +func TestNewEnvdInjectionPayloadFromBytesComputesSHA(t *testing.T) { + data := fakeEnvdBinary("uploaded") + payload, err := NewEnvdInjectionPayloadFromBytes(data) + assert.NoError(t, err) + assert.Equal(t, data, payload.Data) + sum := sha256.Sum256(data) + assert.Equal(t, hex.EncodeToString(sum[:]), payload.SHA256) +} + +func TestNewEnvdInjectionPayloadFromBytesRejectsInvalidPayload(t *testing.T) { + tests := []struct { + name string + data []byte + wantErr string + }{ + {name: "empty", data: nil, wantErr: "must not be empty"}, + {name: "not_elf", data: []byte("#!/bin/sh"), wantErr: "ELF"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload, err := NewEnvdInjectionPayloadFromBytes(tt.data) + assert.Error(t, err) + assert.Nil(t, payload) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestInjectEnvdPayloadIntoRootfsCopiesUploadedBinary(t *testing.T) { + want := fakeEnvdBinary("ENVD-PAYLOAD-CONTENT") + payload, err := NewEnvdInjectionPayloadFromBytes(want) + assert.NoError(t, err) + rootfs := t.TempDir() + + gotSHA, err := injectEnvdPayloadIntoRootfs(context.Background(), rootfs, payload) + assert.NoError(t, err) + assert.Equal(t, payload.SHA256, gotSHA) + + dst := filepath.Join(rootfs, constants.CubeEnvdInImagePath) + got, err := os.ReadFile(dst) + assert.NoError(t, err) + assert.Equal(t, want, got) + + st, err := os.Stat(dst) + assert.NoError(t, err) + assert.Equal(t, os.FileMode(envdInjectionFileMode), st.Mode().Perm()) +} + +func TestInjectEnvdPayloadIntoRootfsNilPayloadLeavesRootfsUntouched(t *testing.T) { + rootfs := t.TempDir() + sha, err := injectEnvdPayloadIntoRootfs(context.Background(), rootfs, nil) + assert.NoError(t, err) + assert.Empty(t, sha) + _, err = os.Stat(filepath.Join(rootfs, constants.CubeEnvdInImagePath)) + assert.True(t, os.IsNotExist(err)) +} + +func TestPrepareEnvdInjectionPayloadRequiresUploadedPayload(t *testing.T) { + payload, err := prepareEnvdInjectionPayload(reqWithEnvdAnnotation(constants.CubeAnnotationsInjectEnvdOptIn)) + assert.Error(t, err) + assert.Nil(t, payload) + assert.Contains(t, err.Error(), "uploaded envd payload is required") +} + +func TestBuildTemplateSpecFingerprintEnvdInfluence(t *testing.T) { + envdReq := reqWithEnvdAnnotation("true") + envdReq.SourceImageRef = "ubuntu:22.04" + payloadV1, err := NewEnvdInjectionPayloadFromBytes(fakeEnvdBinary("envd-v1")) + assert.NoError(t, err) + plainReq := &types.CreateTemplateFromImageReq{SourceImageRef: "ubuntu:22.04"} + + fpEnvdV1 := buildTemplateSpecFingerprintWithEnvdSHA(envdReq, "sha256:src", "", payloadV1.SHA256) + fpPlain := buildTemplateSpecFingerprint(plainReq, "sha256:src") + assert.NotEqual(t, fpEnvdV1, fpPlain) + + payloadV2, err := NewEnvdInjectionPayloadFromBytes(fakeEnvdBinary("envd-v2")) + assert.NoError(t, err) + fpEnvdV2 := buildTemplateSpecFingerprintWithEnvdSHA(envdReq, "sha256:src", "", payloadV2.SHA256) + assert.NotEqual(t, fpEnvdV1, fpEnvdV2) + + disableReq := reqWithEnvdAnnotation("false") + disableReq.SourceImageRef = "ubuntu:22.04" + fpDisable := buildTemplateSpecFingerprint(disableReq, "sha256:src") + fpDisableAgain := buildTemplateSpecFingerprint(disableReq, "sha256:src") + assert.Equal(t, fpDisable, fpDisableAgain) +} diff --git a/CubeMaster/pkg/templatecenter/fingerprint.go b/CubeMaster/pkg/templatecenter/fingerprint.go index f4f77e9b0..62a88a123 100644 --- a/CubeMaster/pkg/templatecenter/fingerprint.go +++ b/CubeMaster/pkg/templatecenter/fingerprint.go @@ -36,6 +36,10 @@ func buildTemplateSpecFingerprint(req *types.CreateTemplateFromImageReq, sourceI // reuse automatically. cubeEgressCAFingerprint is empty when CA baking is // disabled for this request. func buildTemplateSpecFingerprintWithCA(req *types.CreateTemplateFromImageReq, sourceImageDigest, cubeEgressCAFingerprint string) string { + return buildTemplateSpecFingerprintWithEnvdSHA(req, sourceImageDigest, cubeEgressCAFingerprint, "") +} + +func buildTemplateSpecFingerprintWithEnvdSHA(req *types.CreateTemplateFromImageReq, sourceImageDigest, cubeEgressCAFingerprint, envdSHA string) string { type fingerprintPayload struct { SourceImageDigest string `json:"source_image_digest"` WritableLayerSize string `json:"writable_layer_size"` @@ -44,6 +48,7 @@ func buildTemplateSpecFingerprintWithCA(req *types.CreateTemplateFromImageReq, s NetworkType string `json:"network_type"` ContainerOverrides *types.ContainerOverrides `json:"container_overrides,omitempty"` CubeEgressCAFingerprint string `json:"cube_egress_ca_fingerprint,omitempty"` + EnvdBinarySHA256 string `json:"envd_binary_sha256,omitempty"` } payload, _ := json.Marshal(fingerprintPayload{ SourceImageDigest: sourceImageDigest, @@ -53,6 +58,7 @@ func buildTemplateSpecFingerprintWithCA(req *types.CreateTemplateFromImageReq, s NetworkType: req.NetworkType, ContainerOverrides: req.ContainerOverrides, CubeEgressCAFingerprint: cubeEgressCAFingerprint, + EnvdBinarySHA256: envdSHA, }) sum := sha256.Sum256(payload) return hex.EncodeToString(sum[:]) diff --git a/CubeMaster/pkg/templatecenter/image_job_runner.go b/CubeMaster/pkg/templatecenter/image_job_runner.go index 3e9271f57..f4003f722 100644 --- a/CubeMaster/pkg/templatecenter/image_job_runner.go +++ b/CubeMaster/pkg/templatecenter/image_job_runner.go @@ -18,7 +18,7 @@ import ( CubeLog "github.com/tencentcloud/CubeSandbox/cubelog" ) -func runTemplateImageJob(ctx context.Context, jobID string, req *types.CreateTemplateFromImageReq, downloadBaseURL string) { +func runTemplateImageJob(ctx context.Context, jobID string, req *types.CreateTemplateFromImageReq, downloadBaseURL string, envdPayload *EnvdInjectionPayload) { logger := log.G(ctx).WithFields(map[string]any{ "job_id": jobID, "template_id": req.TemplateID, @@ -83,7 +83,11 @@ func runTemplateImageJob(ctx context.Context, jobID string, req *types.CreateTem }) return } - fingerprint := buildTemplateSpecFingerprintWithCA(req, source.Digest, caFingerprint) + envdSHA := "" + if envdPayload != nil { + envdSHA = envdPayload.SHA256 + } + fingerprint := buildTemplateSpecFingerprintWithEnvdSHA(req, source.Digest, caFingerprint, envdSHA) artifactID := buildArtifactID(fingerprint) if err := updateTemplateImageJob(ctx, jobID, map[string]any{ "artifact_id": artifactID, @@ -94,7 +98,7 @@ func runTemplateImageJob(ctx context.Context, jobID string, req *types.CreateTem }); err != nil { logger.Errorf("update job source metadata fail: %v", err) } - artifact, generatedReq, builtFreshArtifact, err := ensureRootfsArtifact(ctx, req, source, downloadBaseURL) + artifact, generatedReq, builtFreshArtifact, err := ensureRootfsArtifact(ctx, req, source, downloadBaseURL, envdPayload) if err != nil { if !pullProgressFlushed { pullProgress.flush(false) diff --git a/CubeMaster/pkg/templatecenter/redo.go b/CubeMaster/pkg/templatecenter/redo.go index a3063b7e8..afbba525d 100644 --- a/CubeMaster/pkg/templatecenter/redo.go +++ b/CubeMaster/pkg/templatecenter/redo.go @@ -248,7 +248,7 @@ func runRedoTemplateImageJob(ctx context.Context, jobID string, req *types.RedoT if source.Cleanup != nil { defer source.Cleanup(ctx) } - artifact, _, _, err = ensureRootfsArtifact(ctx, &workingReq, source, downloadBaseURL) + artifact, _, _, err = ensureRootfsArtifact(ctx, &workingReq, source, downloadBaseURL, nil) if err != nil { _ = updateTemplateImageJob(ctx, jobID, map[string]any{ "status": JobStatusFailed, diff --git a/CubeMaster/pkg/templatecenter/template_image.go b/CubeMaster/pkg/templatecenter/template_image.go index 511c78ac9..3f3bb82df 100644 --- a/CubeMaster/pkg/templatecenter/template_image.go +++ b/CubeMaster/pkg/templatecenter/template_image.go @@ -93,6 +93,10 @@ func newRedoTemplateImageJobRecord(jobID string, normalized *types.RedoTemplateF } func SubmitTemplateFromImage(ctx context.Context, req *types.CreateTemplateFromImageReq, downloadBaseURL string) (*types.TemplateImageJobInfo, error) { + return SubmitTemplateFromImageWithEnvdPayload(ctx, req, downloadBaseURL, nil) +} + +func SubmitTemplateFromImageWithEnvdPayload(ctx context.Context, req *types.CreateTemplateFromImageReq, downloadBaseURL string, envdPayload *EnvdInjectionPayload) (*types.TemplateImageJobInfo, error) { if !isReady() { return nil, ErrTemplateStoreNotInitialized } @@ -172,7 +176,7 @@ func SubmitTemplateFromImage(ctx context.Context, req *types.CreateTemplateFromI "attempt_no": attemptNo, "retry_of_job_id": retryOfJobID, "image": normalized.SourceImageRef, - }), jobID, normalized, downloadBaseURL) + }), jobID, normalized, downloadBaseURL, envdPayload) return GetTemplateImageJobInfo(ctx, jobID) } diff --git a/CubeMaster/pkg/templatecenter/template_image_test.go b/CubeMaster/pkg/templatecenter/template_image_test.go index b46c84ba9..0a65732c3 100644 --- a/CubeMaster/pkg/templatecenter/template_image_test.go +++ b/CubeMaster/pkg/templatecenter/template_image_test.go @@ -1178,7 +1178,7 @@ func TestBuildRootfsArtifactFinalizesBuildResult(t *testing.T) { return &latest, nil }) - got, generatedReq, err := buildRootfsArtifact(context.Background(), artifact, req, source, "http://master.example", nil, "") + got, generatedReq, err := buildRootfsArtifact(context.Background(), artifact, req, source, "http://master.example", nil, "", nil) if err != nil { t.Fatalf("buildRootfsArtifact failed: %v", err) } diff --git a/CubeMaster/pkg/templatecenter/template_request.go b/CubeMaster/pkg/templatecenter/template_request.go index 06b062e3a..bf815132f 100644 --- a/CubeMaster/pkg/templatecenter/template_request.go +++ b/CubeMaster/pkg/templatecenter/template_request.go @@ -30,6 +30,9 @@ func generateTemplateCreateRequest(req *types.CreateTemplateFromImageReq, artifa constants.CubeAnnotationWritableLayerSize: req.WritableLayerSize, constants.CubeAnnotationTemplateSpecFingerprint: artifact.TemplateSpecFingerprint, } + if ShouldInjectEnvdIntoTemplate(req) { + annotations[constants.CubeAnnotationsInjectEnvd] = constants.CubeAnnotationsInjectEnvdOptIn + } sizeGi, err := quantityToGi(req.WritableLayerSize) if err == nil && sizeGi > 0 { annotations[constants.CubeAnnotationsSystemDiskSize] = strconv.FormatInt(sizeGi, 10) diff --git a/deploy/one-click/README.md b/deploy/one-click/README.md index 8967b9f69..409a1348e 100644 --- a/deploy/one-click/README.md +++ b/deploy/one-click/README.md @@ -69,6 +69,15 @@ Run the following from the repository root on the host machine (recommended): ./deploy/one-click/build-release-bundle-builder.sh ``` +To embed a default `envd` binary into the packaged `cubemastercli`, prepare the binary on the build host and pass `ENVD_LOCAL_PATH` to the recommended builder entry point: + +```bash +ENVD_LOCAL_PATH=/abs/path/to/envd \ +./deploy/one-click/build-release-bundle-builder.sh +``` + +When this variable is set, the host wrapper copies the file into `deploy/one-click/.work/envd`; the builder container then builds `cubemastercli` with that file embedded. If `ENVD_LOCAL_PATH` is omitted, the packaged `cubemastercli` does not include a default `envd`, and template builds that opt in to envd injection must pass `--envd-path` at runtime. + This entry point will: - Compile `cubemaster`, `cubemastercli`, `cubelet`, `cubecli`, `cube-api`, `network-agent`, `cube-agent`, `containerd-shim-cube-rs`, and `cube-runtime` inside a container using the root-level builder image. diff --git a/deploy/one-click/README_zh.md b/deploy/one-click/README_zh.md index 78bc95a07..2aeea79dd 100644 --- a/deploy/one-click/README_zh.md +++ b/deploy/one-click/README_zh.md @@ -69,6 +69,15 @@ cp env.example .env ./deploy/one-click/build-release-bundle-builder.sh ``` +如果希望发布包里的 `cubemastercli` 内嵌默认 `envd`,请先在构建机准备好 `envd` 二进制文件,然后在推荐的 builder 入口中传入 `ENVD_LOCAL_PATH`: + +```bash +ENVD_LOCAL_PATH=/abs/path/to/envd \ +./deploy/one-click/build-release-bundle-builder.sh +``` + +设置该变量后,宿主机 wrapper 会先把文件复制到 `deploy/one-click/.work/envd`,builder 容器再用这个文件构建内嵌 `envd` 的 `cubemastercli`。如果不设置 `ENVD_LOCAL_PATH`,发布包中的 `cubemastercli` 不包含默认 `envd`;运行时若模板构建启用 envd 注入,需要显式传入 `--envd-path`。 + 这个入口会先: - 通过根目录 builder 镜像在容器内编译 `cubemaster`、`cubemastercli`、`cubelet`、`cubecli`、`cube-api`、`network-agent`、`cube-agent`、`containerd-shim-cube-rs`、`cube-runtime` diff --git a/deploy/one-click/build-release-bundle-builder.sh b/deploy/one-click/build-release-bundle-builder.sh index 99c6cd301..7830d49fd 100755 --- a/deploy/one-click/build-release-bundle-builder.sh +++ b/deploy/one-click/build-release-bundle-builder.sh @@ -13,6 +13,7 @@ fi PREBUILT_DIR="${SCRIPT_DIR}/.work/prebuilt" HELPER_SCRIPT="${SCRIPT_DIR}/.work/build-prebuilt-in-builder.sh" +ENVD_WORK_PATH="${SCRIPT_DIR}/.work/envd" BUILDER_IMAGE_REF="${BUILDER_IMAGE:-cube-sandbox-builder:ubuntu2004}" CUBE_VERSION_FROM_ENV="${CUBE_VERSION:-}" @@ -28,6 +29,12 @@ require_cmd make rm -rf "${PREBUILT_DIR}" mkdir -p "${PREBUILT_DIR}" "$(dirname "${HELPER_SCRIPT}")" +rm -f "${ENVD_WORK_PATH}" +if [[ -n "${ENVD_LOCAL_PATH:-}" ]]; then + ensure_file "${ENVD_LOCAL_PATH}" + install -m 0755 "${ENVD_LOCAL_PATH}" "${ENVD_WORK_PATH}" + log "embedding envd into cubemastercli from ${ENVD_LOCAL_PATH}" +fi cat > "${HELPER_SCRIPT}" <<'SCRIPT_EOF' #!/usr/bin/env bash @@ -69,7 +76,12 @@ echo "[one-click] building cubemaster in builder" >&2 (cd /workspace/CubeMaster && go mod download && go build -ldflags "${CUBEMASTER_LDFLAGS}" -o "${PREBUILT_DIR}/cubemaster" ./cmd/cubemaster) echo "[one-click] building cubemastercli in builder" >&2 -(cd /workspace/CubeMaster && go build -ldflags "${CUBEMASTER_LDFLAGS}" -o "${PREBUILT_DIR}/cubemastercli" ./cmd/cubemastercli) +if [[ -f /workspace/deploy/one-click/.work/envd ]]; then + (cd /workspace/CubeMaster && make cubemastercli ENVD_LOCAL_PATH="/workspace/deploy/one-click/.work/envd") +else + (cd /workspace/CubeMaster && make cubemastercli) +fi +install -m 0755 /workspace/CubeMaster/build/cubemastercli "${PREBUILT_DIR}/cubemastercli" echo "[one-click] building cubelet and cubecli in builder" >&2 mkdir -p /workspace/_output/bin diff --git a/deploy/one-click/env.example b/deploy/one-click/env.example index 4c063ed5d..b2f441fbd 100644 --- a/deploy/one-click/env.example +++ b/deploy/one-click/env.example @@ -20,6 +20,10 @@ ONE_CLICK_CUBE_SHIM_BUILD_MODE=local # Optional overrides for prebuilt binaries or fixed artifacts. # ONE_CLICK_CUBEMASTER_BIN=/abs/path/to/cubemaster # ONE_CLICK_CUBEMASTERCLI_BIN=/abs/path/to/cubemastercli +# Optional: embed a default envd binary into cubemastercli when using +# build-release-bundle-builder.sh. Prepare/download envd before building; +# the path is read on the build host and copied into the builder workspace. +# ENVD_LOCAL_PATH=/abs/path/to/envd # ONE_CLICK_CUBELET_BIN=/abs/path/to/cubelet # ONE_CLICK_CUBECLI_BIN=/abs/path/to/cubecli # ONE_CLICK_CUBE_API_BIN=/abs/path/to/cube-api diff --git a/docs/guide/tutorials/template-from-image.md b/docs/guide/tutorials/template-from-image.md index 9d92ffd09..1c91cdcfa 100644 --- a/docs/guide/tutorials/template-from-image.md +++ b/docs/guide/tutorials/template-from-image.md @@ -92,6 +92,38 @@ cubemastercli tpl create-from-image \ > **Image registry:** Use `cube-sandbox-int.tencentcloudcr.com/cube-sandbox/sandbox-code:latest` (recommended for international access). If you are in mainland China, use `cube-sandbox-cn.tencentcloudcr.com/cube-sandbox/sandbox-code:latest` instead. +#### Optional — inject envd at template build time + +By default, Cube uses the image as-is and does not inject `envd`. For dev or code-sandbox images that do not already include `envd`, or that need a controlled `envd` version, opt in explicitly: + +```bash +cubemastercli tpl create-from-image \ + --image \ + --writable-layer-size 1G \ + --expose-port 49983 \ + --probe 49983 \ + --probe-path /health \ + --enable-inject-envd +``` + +| Flag | Description | +|------|-------------| +| `--enable-inject-envd` | Upload an `envd` binary from `cubemastercli` and bake it into the template rootfs. | +| `--envd-path` | Local path on the machine running `cubemastercli`; used only when `--enable-inject-envd` is set. If omitted, `cubemastercli` uses its embedded default `envd` binary when available. | + +When `--enable-inject-envd` is set, `cubemastercli` uploads the selected `envd` binary with the create-from-image request. `--envd-path` is local to `cubemastercli`; it is not a CubeMaster host path and is never sent to CubeMaster as a path. CubeMaster validates the uploaded bytes, writes the binary to `/usr/local/bin/envd` inside the template rootfs, and includes `sha256(uploaded bytes)` in the rootfs artifact fingerprint. + +The uploaded `envd` must be a non-empty ELF binary no larger than 16 MiB, and it must be compatible with the target template rootfs operating system and CPU architecture. For example, a Linux x86_64 template image should receive a Linux x86_64 `envd` ELF; templates for other architectures need a matching `envd` binary. + +If `cubemastercli` was built without an embedded `envd` binary, `--enable-inject-envd` requires `--envd-path`. + +To build a `cubemastercli` binary with an embedded default `envd`, prepare the binary before building and pass `ENVD_LOCAL_PATH`: + +```bash +# Use the official envd artifact once available, or a locally built envd binary for development builds. +make cubemastercli ENVD_LOCAL_PATH=/path/to/envd +``` + --- ## Step 2 — Monitor Progress diff --git a/docs/zh/guide/tutorials/template-from-image.md b/docs/zh/guide/tutorials/template-from-image.md index 32a1f6a1c..8e2398380 100644 --- a/docs/zh/guide/tutorials/template-from-image.md +++ b/docs/zh/guide/tutorials/template-from-image.md @@ -77,6 +77,38 @@ cubemastercli tpl create-from-image \ > **镜像仓库说明:** 国内优先使用 `cube-sandbox-cn.tencentcloudcr.com/cube-sandbox/sandbox-code:latest`;境外访问推荐使用 `cube-sandbox-int.tencentcloudcr.com/cube-sandbox/sandbox-code:latest`。 +#### 可选 — 在模板构建阶段注入 envd + +默认情况下,Cube 会按原样使用镜像,不会注入 `envd`。如果你的开发环境或代码沙箱镜像尚未包含 `envd`,或需要使用受控版本的 `envd`,可以显式开启: + +```bash +cubemastercli tpl create-from-image \ + --image \ + --writable-layer-size 1G \ + --expose-port 49983 \ + --probe 49983 \ + --probe-path /health \ + --enable-inject-envd +``` + +| 参数 | 说明 | +|------|------| +| `--enable-inject-envd` | 从 `cubemastercli` 上传一个 `envd` 二进制并写入模板 rootfs。 | +| `--envd-path` | 运行 `cubemastercli` 的机器上的本地路径;仅在设置 `--enable-inject-envd` 时生效。若省略该参数,`cubemastercli` 会在可用时使用构建期内嵌的默认 `envd`。 | + +设置 `--enable-inject-envd` 后,`cubemastercli` 会选择 `envd` 二进制并通过 create-from-image 的 multipart 请求上传给 CubeMaster。`--envd-path` 是 `cubemastercli` 本地路径,不是 CubeMaster 宿主机路径,也不会作为路径发送给 CubeMaster。CubeMaster 只校验并注入本次请求上传的二进制,将其写入模板 rootfs 内的 `/usr/local/bin/envd`,并把 `sha256(uploaded bytes)` 纳入 rootfs artifact 指纹,避免复用由不同 `envd` 构建出的 artifact。 + +上传的 `envd` 必须是非空 ELF 二进制,大小不能超过 16 MiB,并且需要与目标模板 rootfs 的操作系统和 CPU 架构兼容。例如,为 Linux x86_64 镜像注入的 `envd` 应该是可在该环境中运行的 Linux x86_64 ELF;如果模板镜像是其他架构,需要提供对应架构的 `envd`。 + +如果 `cubemastercli` 构建时没有内嵌默认 `envd`,则使用 `--enable-inject-envd` 时必须显式传入 `--envd-path`。 + +构建带内嵌默认 `envd` 的 `cubemastercli` 时,请先准备好 `envd` 二进制,然后在构建时传入 `ENVD_LOCAL_PATH`: + +```bash +# 可使用官方 envd artifact(发布后)或开发环境中本地构建的 envd。 +make cubemastercli ENVD_LOCAL_PATH=/path/to/envd +``` + --- ## 第二步 — 监控进度