Skip to content
Closed
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
70 changes: 64 additions & 6 deletions CubeAPI/src/services/sandboxes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ use uuid::Uuid;
use crate::{
constants::ENVD_VERSION,
cubemaster::{
datetime_from_unix_nanos, extract_template_id, CreateSandboxRequest, CubeMasterClient,
CubeMasterError, CubeVSContext, DeleteSandboxRequest, ListSandboxRequest, SandboxInfo,
SandboxLogsRequest, SandboxRefreshRequest, SandboxStatus, SandboxTimeoutRequest,
SandboxUpdateRequest,
datetime_from_unix_nanos, extract_template_id, ContainerSpec, CreateSandboxRequest,
CubeMasterClient, CubeMasterError, CubeVSContext, DeleteSandboxRequest, EnvVar, ImageSpec,
ListSandboxRequest, SandboxInfo, SandboxLogsRequest, SandboxRefreshRequest, SandboxStatus,
SandboxTimeoutRequest, SandboxUpdateRequest,
},
error::{AppError, AppResult},
models::{
Expand Down Expand Up @@ -140,7 +140,7 @@ impl SandboxService {
annotations,
labels,
volumes: None,
containers: vec![],
containers: build_containers_from_env_vars(body.env_vars.as_ref()),
exposed_ports: vec![],
network_type: Some("tap".to_string()),
cubevs_context: build_cubevs_context(body.allow_internet_access, body.network.as_ref()),
Expand Down Expand Up @@ -608,6 +608,41 @@ fn new_request_id() -> String {
Uuid::new_v4().to_string()
}

/// Map SDK `envVars` into a placeholder container so CubeMaster merges them
/// with the template container via `applyTemplateToContainer`.
fn build_containers_from_env_vars(env_vars: Option<&HashMap<String, String>>) -> Vec<ContainerSpec> {
let Some(env_vars) = env_vars.filter(|vars| !vars.is_empty()) else {
return Vec::new();
};

let envs = env_vars
.iter()
.map(|(key, value)| EnvVar {
key: key.clone(),
value: value.clone(),
})
.collect();

vec![ContainerSpec {
name: None,
image: ImageSpec {
image: String::new(),
storage_media: None,
},
command: None,
args: None,
working_dir: None,
resources: None,
envs: Some(envs),
volume_mounts: None,
dns_config: None,
r_limit: None,
security_context: None,
probe: None,
annotations: None,
}]
}

pub(crate) fn build_cubevs_context(
allow_internet_access: Option<bool>,
network: Option<&SandboxNetworkConfig>,
Expand Down Expand Up @@ -635,7 +670,7 @@ pub(crate) fn build_cubevs_context(
mod tests {
use std::collections::HashMap;

use super::{build_cubevs_context, filter_by_metadata, from_cubemaster_info};
use super::{build_containers_from_env_vars, build_cubevs_context, filter_by_metadata, from_cubemaster_info};
use crate::cubemaster::{ListSandboxResponse, SandboxInfo};
use crate::models::{SandboxNetworkConfig, SandboxState};

Expand Down Expand Up @@ -680,6 +715,7 @@ mod tests {
status: "running".to_string(),
started_at: None,
end_at: None,
create_at: 0,
cpu_count: 2,
memory_mb: 2048,
template_id: "tpl-1".to_string(),
Expand All @@ -692,6 +728,28 @@ mod tests {
assert_eq!(listed.template_id, "tpl-1");
}

#[test]
fn build_containers_from_env_vars_maps_sdk_payload() {
let mut env_vars = HashMap::from([
("CUBE_CREATE_ENV_TEST".to_string(), "injected-at-create".to_string()),
("MY_APP_TOKEN".to_string(), "token-abc-123".to_string()),
]);

let containers = build_containers_from_env_vars(Some(&env_vars));
assert_eq!(containers.len(), 1);
let envs = containers[0]
.envs
.as_ref()
.expect("create env vars should produce container envs");
assert_eq!(envs.len(), 2);
assert!(envs.iter().any(|e| e.key == "CUBE_CREATE_ENV_TEST" && e.value == "injected-at-create"));
assert!(envs.iter().any(|e| e.key == "MY_APP_TOKEN" && e.value == "token-abc-123"));

env_vars.clear();
assert!(build_containers_from_env_vars(Some(&env_vars)).is_empty());
assert!(build_containers_from_env_vars(None).is_empty());
}

#[test]
fn listed_sandbox_maps_paused_container_state_from_cubemaster_list() {
let payload = serde_json::json!({
Expand Down
11 changes: 10 additions & 1 deletion CubeMaster/pkg/service/httpservice/cube/cubeboxutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"maps"
"slices"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -123,9 +124,17 @@ func applyTemplateToContainer(ctr *types.Container, templateCtr *types.Container
ctr.Sysctls = templateCtr.Sysctls
ctr.SecurityContext = templateCtr.SecurityContext

ctr.Envs = append(ctr.Envs, templateCtr.Envs...)
ctr.Envs = append(slices.Clone(templateCtr.Envs), ctr.Envs...)
applyTemplateVolumeMounts(templateCtr, ctr)

// Create-time overrides may send env-only placeholder containers from CubeAPI.
if len(ctr.Command) == 0 {
ctr.Command = templateCtr.Command
}
if len(ctr.Args) == 0 {
ctr.Args = templateCtr.Args
}

if !isContainerReqWhiteTag("WorkingDir") {
ctr.WorkingDir = templateCtr.WorkingDir
}
Expand Down
60 changes: 60 additions & 0 deletions CubeMaster/pkg/service/httpservice/cube/cubeboxutil_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) 2024 Tencent Inc.
// SPDX-License-Identifier: Apache-2.0
//

package cube

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/service/sandbox/types"
)

func TestApplyTemplateToContainerCreateTimeEnvOverridesTemplateDefaults(t *testing.T) {
template := &types.Container{
Name: "main",
Command: []string{"/bin/sh", "-c", "sleep infinity"},
Args: []string{"ignored"},
Envs: []*types.KeyValue{
{Key: "BASE_ENV", Value: "from-template"},
{Key: "OVERRIDE_ME", Value: "template-value"},
},
Image: &types.ImageSpec{Image: "tpl-image"},
}

request := &types.Container{
Envs: []*types.KeyValue{
{Key: "OVERRIDE_ME", Value: "create-value"},
{Key: "CREATE_ONLY", Value: "create-only"},
},
}

require.NoError(t, applyTemplateToContainer(request, template, 0))

assert.Equal(t, []string{"/bin/sh", "-c", "sleep infinity"}, request.Command)
assert.Equal(t, []string{"ignored"}, request.Args)
require.Len(t, request.Envs, 4)
assert.Equal(t, &types.KeyValue{Key: "BASE_ENV", Value: "from-template"}, request.Envs[0])
assert.Equal(t, &types.KeyValue{Key: "OVERRIDE_ME", Value: "template-value"}, request.Envs[1])
assert.Equal(t, &types.KeyValue{Key: "OVERRIDE_ME", Value: "create-value"}, request.Envs[2])
assert.Equal(t, &types.KeyValue{Key: "CREATE_ONLY", Value: "create-only"}, request.Envs[3])
}

func TestApplyTemplateToContainerPreservesExplicitCommandAndArgs(t *testing.T) {
template := &types.Container{
Command: []string{"/entrypoint.sh"},
Args: []string{"template-arg"},
Image: &types.ImageSpec{Image: "tpl-image"},
}
request := &types.Container{
Command: []string{"/custom-entrypoint"},
Args: []string{"request-arg"},
}

require.NoError(t, applyTemplateToContainer(request, template, 0))

assert.Equal(t, []string{"/custom-entrypoint"}, request.Command)
assert.Equal(t, []string{"request-arg"}, request.Args)
}
140 changes: 135 additions & 5 deletions Cubelet/pkg/container/netfile/netfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"time"

"github.com/containerd/containerd/v2/pkg/oci"
jsoniter "github.com/json-iterator/go"
Expand All @@ -31,10 +33,14 @@ var (
)

const (
netfilePathResolv = "/etc/resolv.conf"
netfilePathHosts = "/etc/hosts"
hostnameFilePath = "/etc/hostname"
defaultDNSIP = "119.29.29.29"
netfilePathResolv = "/etc/resolv.conf"
netfilePathHosts = "/etc/hosts"
hostnameFilePath = "/etc/hostname"
createEnvProfilePath = "/etc/profile.d/99-cube-create-env.sh"
CreateEnvProfilePath = createEnvProfilePath
CreateEnvInternalVolumeName = "cube-internal-create-env"
CreateEnvHostBase = "/data/cubelet/create-env"
defaultDNSIP = "119.29.29.29"
)

func Init(oldDir string) error {
Expand All @@ -57,6 +63,7 @@ type CubeboxNetfile struct {
Hostname string

ContainerNetfiles map[string]ContainerNetfile
mu sync.Mutex
}

func (c *CubeboxNetfile) WriteToHost() error {
Expand Down Expand Up @@ -110,6 +117,12 @@ func (cn *CubeboxNetfile) CreateNetfiles(req *cubebox.RunCubeSandboxRequest) err
},
},
}
if envProfile := genCreateEnvProfile(c.GetEnvs()); len(envProfile) > 0 {
netfiles[c.Name].Files[createEnvProfilePath] = FileContent{
Path: createEnvProfilePath,
Content: envProfile,
}
}
}
cn.ContainerNetfiles = netfiles
return nil
Expand Down Expand Up @@ -159,7 +172,43 @@ func (cn *CubeboxNetfile) ContainerVirtiofsMounts(containerName string) []virtio
return mounts
}

func (cn *CubeboxNetfile) OciContainerNetfileSpec(ctx context.Context, containerName string) oci.SpecOpts {
// CreateEnvVolumeName returns the hostdir volume name for per-container create env injection.
func CreateEnvVolumeName(containerKey string) string {
if containerKey == "" {
return CreateEnvInternalVolumeName
}
return CreateEnvInternalVolumeName + "-" + containerKey
}

// EnsureCreateEnvProfile refreshes create-time env injection file from the
// latest container envs. Netfile Create runs before cubebox merges envs, so
// profile.d must be rebuilt when OCI annotations are generated.
func (cn *CubeboxNetfile) EnsureCreateEnvProfile(containerName string, envs []*cubebox.KeyValue) {
if cn == nil || cn.ContainerNetfiles == nil {
return
}
cn.mu.Lock()
defer cn.mu.Unlock()
cf, ok := cn.ContainerNetfiles[containerName]
if !ok {
return
}
envProfile := genCreateEnvProfile(envs)
if len(envProfile) == 0 {
delete(cf.Files, createEnvProfilePath)
} else {
if cf.Files == nil {
cf.Files = make(map[string]FileContent)
}
cf.Files[createEnvProfilePath] = FileContent{
Path: createEnvProfilePath,
Content: envProfile,
}
}
cn.ContainerNetfiles[containerName] = cf
}

func (cn *CubeboxNetfile) OciContainerNetfileSpec(ctx context.Context, containerName string, envs []*cubebox.KeyValue) oci.SpecOpts {
if cn.RootPath != "" {
return nil
}
Expand All @@ -172,6 +221,12 @@ func (cn *CubeboxNetfile) OciContainerNetfileSpec(ctx context.Context, container
for _, f := range cf.Files {
files = append(files, f)
}
if envProfile := genCreateEnvProfile(envs); len(envProfile) > 0 {
files = append(files, FileContent{
Path: createEnvProfilePath,
Content: envProfile,
})
}

d, err := jsoniter.MarshalToString(files)
if err != nil {
Expand Down Expand Up @@ -290,6 +345,81 @@ func defaultDNSServers() []string {
return append([]string(nil), cfg.Common.DefaultDNSServers...)
}

// GenCreateEnvProfile builds shell exports for create-time env injection.
func GenCreateEnvProfile(envs []*cubebox.KeyValue) []byte {
return genCreateEnvProfile(envs)
}

// shellSingleQuoted wraps s in POSIX single quotes so profile.d sourcing does not expand $, `, etc.
func shellSingleQuoted(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}

func genCreateEnvProfile(envs []*cubebox.KeyValue) []byte {
if len(envs) == 0 {
return nil
}
var b strings.Builder
b.WriteString("# Generated by Cubelet for create-time env injection\n")
wrote := false
for _, kv := range envs {
if kv == nil || kv.Key == "" {
continue
}
if strings.HasPrefix(kv.Key, "CUBE_CONTAINER_") || kv.Key == "__BRIEF_SUMMARY__" {
continue
}
b.WriteString("export ")
b.WriteString(kv.Key)
b.WriteString("=")
b.WriteString(shellSingleQuoted(kv.Value))
b.WriteString("\n")
wrote = true
}
if !wrote {
return nil
}
return []byte(b.String())
}

const rootfsEnvProfileMountTimeout = 30 * time.Second

// WriteCreateEnvProfileToBlockDevice writes create-time env exports into the
// sandbox writable rootfs ext4 device. AppSnapshot memory restore skips shim
// custom-file netfiles, so profile.d must be seeded on the rootfs directly.
func WriteCreateEnvProfileToBlockDevice(ctx context.Context, devicePath string, envs []*cubebox.KeyValue) error {
content := genCreateEnvProfile(envs)
if len(content) == 0 || strings.TrimSpace(devicePath) == "" {
return nil
}
mountDir, err := os.MkdirTemp("", "cube-create-env-mount-*")
if err != nil {
return fmt.Errorf("create mount dir for create env profile: %w", err)
}
defer os.RemoveAll(mountDir)

profilePath := filepath.Join(mountDir, "etc", "profile.d", filepath.Base(createEnvProfilePath))
cmds := [][]string{
{"mount", devicePath, mountDir},
{"mkdir", "-p", filepath.Dir(profilePath)},
}
for _, cmd := range cmds {
if _, stderr, execErr := utils.ExecV(cmd, rootfsEnvProfileMountTimeout); execErr != nil {
_, _, _ = utils.ExecV([]string{"umount", mountDir}, rootfsEnvProfileMountTimeout)
return fmt.Errorf("prepare rootfs for create env profile (%v): %s", cmd, stderr)
}
}
if err := os.WriteFile(profilePath, content, 0644); err != nil {
_, _, _ = utils.ExecV([]string{"umount", mountDir}, rootfsEnvProfileMountTimeout)
return fmt.Errorf("write create env profile to rootfs: %w", err)
}
if _, stderr, err := utils.ExecV([]string{"umount", mountDir}, rootfsEnvProfileMountTimeout); err != nil {
return fmt.Errorf("umount rootfs after create env profile: %s", stderr)
}
log.G(ctx).Infof("write create env profile to rootfs device %s", devicePath)
return nil
}

func Clean(ctx context.Context, containerID string) error {
// 1.2.1 路径穿越防护:校验 containerID 不含路径穿越字符
dir, err := utils.SafeJoinPath(oldNetfilePath, containerID)
Expand Down
Loading
Loading