Merge pull request #134414 from HirazawaUi/promote-3721-to-beta

KEP-3721: Promote EnvFiles feature gate to Beta
This commit is contained in:
Kubernetes Prow Robot 2025-11-05 21:56:50 -08:00 committed by GitHub
commit 0decbf4405
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 803 additions and 295 deletions

View file

@ -1242,6 +1242,7 @@ var defaultVersionedKubernetesFeatureGates = map[featuregate.Feature]featuregate
},
EnvFiles: {
{Version: version.MustParse("1.34"), Default: false, PreRelease: featuregate.Alpha},
{Version: version.MustParse("1.35"), Default: true, PreRelease: featuregate.Beta},
},
EventedPLEG: {
{Version: version.MustParse("1.26"), Default: false, PreRelease: featuregate.Alpha},

View file

@ -932,12 +932,6 @@ func (kl *Kubelet) makeEnvironmentVariables(pod *v1.Pod, container *v1.Container
return result, fmt.Errorf("failed to get host path for volume %q: %w", volume, err)
}
// Validate key length, must not exceed 128 characters.
// TODO: @HirazawaUi This limit will be relaxed after the EnvFiles feature gate beta stage.
if len(key) > 128 {
return result, fmt.Errorf("environment variable key %q exceeds maximum length of 128 characters", key)
}
// Construct the full path to the environment variable file
// by combining hostPath with the specified path in FileKeyRef
envFilePath, err := securejoin.SecureJoin(hostPath, f.Path)
@ -951,12 +945,6 @@ func (kl *Kubelet) makeEnvironmentVariables(pod *v1.Pod, container *v1.Container
return result, fmt.Errorf("couldn't parse env file")
}
// Validate value size, must not exceed 32KB.
// TODO: @HirazawaUi This limit will be relaxed after the EnvFiles feature gate beta stage.
if len(runtimeVal) > 32*1024 {
return result, fmt.Errorf("environment variable value for key %q exceeds maximum size of 32KB", key)
}
// If the key was not found, and it's not optional, return an error
if runtimeVal == "" {
if optional {

View file

@ -7023,7 +7023,7 @@ func TestMakeEnvironmentVariablesWithFileKeyRef(t *testing.T) {
{Name: "DATABASE", Value: "mydb"},
},
setupFiles: func() []string {
content := "DATABASE=mydb\nAPI_KEY=secret123\n"
content := "DATABASE='mydb'\nAPI_KEY='secret123'\n"
return []string{createEnvFile("database.env", content)}
},
},
@ -7052,7 +7052,7 @@ func TestMakeEnvironmentVariablesWithFileKeyRef(t *testing.T) {
{Name: "API_KEY", Value: "secret123"},
},
setupFiles: func() []string {
content := "# This is a comment\n\nDATABASE=mydb\nAPI_KEY=secret123\n\n# Another comment\n"
content := "# This is a comment\n\nDATABASE='mydb'\nAPI_KEY='secret123'\n\n# Another comment\n"
return []string{createEnvFile("config.env", content)}
},
},
@ -7080,8 +7080,10 @@ func TestMakeEnvironmentVariablesWithFileKeyRef(t *testing.T) {
expectedEnvs: []kubecontainer.EnvVar{
{Name: "DEBUG_MODE", Value: "true"},
},
expectedError: true,
errorContains: "couldn't parse env file",
setupFiles: func() []string {
content := "DEBUG_MODE = true\nLOG_LEVEL = info\n"
content := "DEBUG_MODE = 'true'\nLOG_LEVEL = 'info'\n"
return []string{createEnvFile("debug.env", content)}
},
},
@ -7109,7 +7111,7 @@ func TestMakeEnvironmentVariablesWithFileKeyRef(t *testing.T) {
expectedError: true,
errorContains: "environment variable key \"MISSING_KEY\" not found in file",
setupFiles: func() []string {
content := "EXISTING_KEY=value\n"
content := "EXISTING_KEY='value'\n"
return []string{createEnvFile("config.env", content)}
},
},
@ -7137,7 +7139,7 @@ func TestMakeEnvironmentVariablesWithFileKeyRef(t *testing.T) {
},
expectedEnvs: nil,
setupFiles: func() []string {
content := "EXISTING_KEY=value\n"
content := "EXISTING_KEY='value'\n"
return []string{createEnvFile("config.env", content)}
},
},
@ -7168,63 +7170,6 @@ func TestMakeEnvironmentVariablesWithFileKeyRef(t *testing.T) {
return []string{} // No files created
},
},
{
name: "key length exceeds 128 characters",
container: &v1.Container{
Env: []v1.EnvVar{
{
Name: "LONG_KEY",
ValueFrom: &v1.EnvVarSource{
FileKeyRef: &v1.FileKeySelector{
VolumeName: "config-volume",
Path: "config.env",
Key: strings.Repeat("A", 129),
},
},
},
},
},
podVolumes: map[string]kubecontainer.VolumeInfo{
"config-volume": {
Mounter: &testVolumeMounter{path: tmpDir},
},
},
expectedError: true,
errorContains: "exceeds maximum length of 128 characters",
setupFiles: func() []string {
content := "EXISTING_KEY=value\n"
return []string{createEnvFile("config.env", content)}
},
},
{
name: "value size exceeds 32KB",
container: &v1.Container{
Env: []v1.EnvVar{
{
Name: "LARGE_VALUE",
ValueFrom: &v1.EnvVarSource{
FileKeyRef: &v1.FileKeySelector{
VolumeName: "config-volume",
Path: "large.env",
Key: "LARGE_VALUE",
},
},
},
},
},
podVolumes: map[string]kubecontainer.VolumeInfo{
"config-volume": {
Mounter: &testVolumeMounter{path: tmpDir},
},
},
expectedError: true,
errorContains: "environment variable value for key \"LARGE_VALUE\" exceeds maximum size of 32KB",
setupFiles: func() []string {
largeValue := strings.Repeat("A", 33*1024) // 33KB
content := fmt.Sprintf("LARGE_VALUE=%s\n", largeValue)
return []string{createEnvFile("large.env", content)}
},
},
{
name: "volume not found",
container: &v1.Container{
@ -7315,8 +7260,8 @@ func TestMakeEnvironmentVariablesWithFileKeyRef(t *testing.T) {
{Name: "API_KEY", Value: "secret123"},
},
setupFiles: func() []string {
dbContent := "DATABASE=mydb\n"
apiContent := "API_KEY=secret123\n"
dbContent := "DATABASE='mydb'\n"
apiContent := "API_KEY='secret123'\n"
return []string{
createEnvFile("database.env", dbContent),
createEnvFile("api.env", apiContent),
@ -7353,7 +7298,7 @@ func TestMakeEnvironmentVariablesWithFileKeyRef(t *testing.T) {
{Name: "FILE_VAR", Value: "file_value"},
},
setupFiles: func() []string {
content := "FILE_VAR=file_value\n"
content := "FILE_VAR='file_value'\n"
return []string{createEnvFile("config.env", content)}
},
},

View file

@ -23,16 +23,19 @@ import (
"strings"
)
// ParseEnv implements a strict parser for .env environment files,
// adhering to the format defined in the RFC documentation at https://smartmob-rfc.readthedocs.io/en/latest/2-dotenv.html.
// ParseEnv implements a strict parser for environment files using a subset of POSIX shell syntax.
// The parser enforces that all variable values must be enclosed in single quotes.
//
// This function implements a strict parser for environment files similar to the requirements in the OCI and Docker env file RFCs:
// - Leading whitespace is ignored for all lines.
// - Blank lines (including those with only whitespace) are ignored.
// - Lines starting with '#' are treated as comments and ignored.
// - Each variable must be declared as VAR=VAL. Whitespace around '=' and at the end of the line is ignored.
// - A backslash ('\') at the end of a variable declaration line indicates the value continues on the next line. The lines are joined with a single space, and the backslash is not included.
// - If a continuation line is interrupted by a blank line or comment, it is considered invalid and an error is returned.
// Supported format:
// - VAR='value' - Values must be enclosed in single quotes
// - Content within single quotes is preserved literally (no escape sequences or expansions)
// - Multi-line values are supported (newlines within single quotes are preserved)
// - Inline comments after the closing quote are supported (e.g., VAR='value' # comment)
// - Leading whitespace before the variable name is ignored
// - Blank lines (including those with only whitespace) are ignored when not within quotes
// - Lines starting with '#' are treated as comments and ignored
// - Whitespace before '=' is invalid (e.g., VAR = 'value' is rejected)
// - Whitespace after '=' but before the quote results in empty assignment (e.g., VAR= 'value' assigns empty string)
func ParseEnv(envFilePath, key string) (string, error) {
file, err := os.Open(envFilePath)
if err != nil {
@ -41,67 +44,85 @@ func ParseEnv(envFilePath, key string) (string, error) {
defer func() { _ = file.Close() }()
scanner := bufio.NewScanner(file)
var (
currentLine string
inContinuation bool
lineNum int
)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
line = strings.TrimLeft(line, " \t")
// Skip blank lines
if line == "" {
if inContinuation {
return "", fmt.Errorf("invalid environment variable format at line %d: blank line in continuation", lineNum)
}
continue
}
if strings.HasPrefix(line, "#") {
if inContinuation {
return "", fmt.Errorf("invalid environment variable format at line %d: comment in continuation", lineNum)
}
continue
}
if inContinuation {
trimmed := strings.TrimRight(line, " \t")
if strings.HasSuffix(trimmed, "\\") {
currentLine += " " + strings.TrimRight(trimmed[:len(trimmed)-1], " \t")
continue
} else {
currentLine += " " + trimmed
line = currentLine
inContinuation = false
currentLine = ""
}
} else {
trimmed := strings.TrimRight(line, " \t")
if strings.HasSuffix(trimmed, "\\") {
currentLine = strings.TrimRight(trimmed[:len(trimmed)-1], " \t")
inContinuation = true
continue
}
// Skip comments
if strings.HasPrefix(line, "#") {
continue
}
eqIdx := strings.Index(line, "=")
if eqIdx == -1 {
return "", fmt.Errorf("invalid environment variable format at line %d", lineNum)
return "", fmt.Errorf("invalid environment variable format at line %d: missing '='", lineNum)
}
varName := strings.TrimSpace(line[:eqIdx])
varValue := strings.TrimRight(strings.TrimSpace(line[eqIdx+1:]), " \t")
// Variable name must not contain whitespace or trailing whitespace before '='
varNamePart := line[:eqIdx]
varName := strings.TrimRight(varNamePart, " \t")
if varName == "" {
return "", fmt.Errorf("invalid environment variable format at line %d", lineNum)
return "", fmt.Errorf("invalid environment variable format at line %d: empty variable name", lineNum)
}
if varName == key {
return varValue, nil
}
}
if inContinuation {
return "", fmt.Errorf("unexpected end of file: unfinished line continuation")
// If trimming removed whitespace, it means there was whitespace before '='
if varNamePart != varName {
return "", fmt.Errorf("invalid environment variable format at line %d: whitespace before '=' is not allowed", lineNum)
}
valuePart := line[eqIdx+1:]
// Check if there's whitespace before any non-whitespace character
trimmedValue := strings.TrimLeft(valuePart, " \t")
if valuePart != trimmedValue {
// There is whitespace between '=' and the value
// This matches bash behavior: KEY= 'val1' results in KEY being empty
if varName == key {
return "", nil
}
continue
}
// Value must start with single quote
if !strings.HasPrefix(trimmedValue, "'") {
return "", fmt.Errorf("invalid environment variable format at line %d: value must be enclosed in single quotes", lineNum)
}
// Find the closing single quote (may span multiple lines)
var valueBuilder strings.Builder
rest := trimmedValue[1:]
startLineNum := lineNum
for {
closingIdx := strings.Index(rest, "'")
if closingIdx != -1 {
valueBuilder.WriteString(rest[:closingIdx])
afterQuote := strings.TrimLeft(rest[closingIdx+1:], " \t")
if afterQuote != "" && !strings.HasPrefix(afterQuote, "#") {
return "", fmt.Errorf("invalid environment variable format at line %d: unexpected content after closing quote", lineNum)
}
if varName == key {
return valueBuilder.String(), nil
}
break
}
valueBuilder.WriteString(rest)
valueBuilder.WriteString("\n")
if !scanner.Scan() {
return "", fmt.Errorf("invalid environment variable format starting at line %d: unclosed single quote", startLineNum)
}
lineNum++
rest = scanner.Text()
}
}
if err := scanner.Err(); err != nil {

View file

@ -18,10 +18,42 @@ package env
import (
"os"
"os/exec"
"runtime"
"strings"
"testing"
)
// testShellBehavior tests how a POSIX shell would interpret the given environment file content
// and returns the value that would be assigned to the given key
// This test will not run on Windows.
func testShellBehavior(t *testing.T, envContent, key string) (string, error) {
tmpFile, err := os.CreateTemp("", "shell_test_*")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer func() { _ = os.Remove(tmpFile.Name()) }()
if _, err := tmpFile.Write([]byte(envContent)); err != nil {
t.Fatalf("failed to write to temp file: %v", err)
}
if err := tmpFile.Close(); err != nil {
t.Fatalf("failed to close temp file: %v", err)
}
// Use bash with POSIX mode to source the file and print the variable
// We use . instead of source for POSIX compliance
cmd := exec.Command("bash", "--posix", "-c",
"set -a && . \""+tmpFile.Name()+"\" && printf '%s' \"${"+key+"}\"")
output, err := cmd.CombinedOutput()
if err != nil {
return "", err
}
return string(output), nil
}
func TestParseEnv(t *testing.T) {
tempDir := t.TempDir()
@ -36,245 +68,498 @@ func TestParseEnv(t *testing.T) {
tests := []testCase{
{
name: "ignore leading whitespace",
envContent: ` KEY1=val1
KEY2=val2
KEY3=val3
envContent: ` KEY1='val1'
KEY2='val2'
KEY3='val3'
`,
key: "KEY2",
wantValue: "val2",
wantValue: `val2`,
},
{
name: "ignore blank and comment lines",
envContent: `# comment
KEY1=foo
KEY1='foo'
# another comment
KEY2=bar
KEY2='bar'
`,
key: "KEY2",
wantValue: "bar",
wantValue: `bar`,
},
{
name: "whitespace around = and trailing",
envContent: `KEY1 = val1
KEY2= val2
KEY3=val3
`,
key: "KEY2",
wantValue: "val2",
},
{
name: "continuation line with \\",
envContent: `KEY1=foo \
bar \
baz
KEY2=val2
`,
key: "KEY1",
wantValue: "foo bar baz",
},
{
name: "continuation with whitespace and comment",
envContent: `KEY1=foo \
bar
# comment
KEY2=val2
`,
key: "KEY1",
wantValue: "foo bar",
},
{
name: "invalid line triggers error with line number",
envContent: `KEY1=foo
INVALID_LINE
KEY2=bar
name: "whitespace around = and not allowed",
envContent: `KEY1 = 'val1'
KEY2= 'val2'
KEY3='val3'
`,
key: "KEY2",
wantErr: true,
errContains: "at line 2",
},
{
name: "unfinished continuation triggers error",
envContent: `KEY1=foo \
bar \
`,
key: "KEY1",
wantErr: true,
errContains: "unfinished line continuation",
errContains: `whitespace before '=' is not allowed`,
},
{
name: "key not found returns empty",
envContent: `KEY1=foo
KEY2=bar
envContent: `KEY1='foo'
KEY2='bar'
`,
key: "KEY3",
wantValue: "",
wantValue: ``,
},
{
name: "value with embedded #",
envContent: `KEY1=foo#notcomment
KEY2=bar
envContent: `KEY1='foo#notcomment'
KEY2='bar'
`,
key: "KEY1",
wantValue: "foo#notcomment",
wantValue: `foo#notcomment`,
},
{
name: "key with trailing whitespace",
envContent: `KEY1 =foo
KEY2=bar
name: "key with whitespace (should error)",
envContent: `KEY1 ='foo'
KEY2='bar'
`,
key: "KEY1",
wantValue: "foo",
key: "KEY1",
wantErr: true,
errContains: "whitespace before '=' is not allowed",
},
{
name: "value with leading and trailing whitespace",
envContent: `KEY1= foo bar
KEY2=bar
envContent: `KEY1=' foo bar '
KEY2='bar'
`,
key: "KEY1",
wantValue: "foo bar",
wantValue: ` foo bar `,
},
{
name: "multiple comments and blank lines",
envContent: `# first comment
# second comment
KEY1=foo
KEY1='foo'
# third comment
KEY2=bar
KEY2='bar'
`,
key: "KEY2",
wantValue: "bar",
},
{
name: "continuation with blank line in between (should error)",
envContent: `KEY1=foo \
bar
`,
key: "KEY1",
wantErr: true,
errContains: "invalid environment variable format",
},
{
name: "continuation with comment in between (should error)",
envContent: `KEY1=foo \
# comment
bar
`,
key: "KEY1",
wantErr: true,
errContains: "invalid environment variable format",
wantValue: `bar`,
},
{
name: "empty value",
envContent: `KEY1=foo
KEY2=
KEY3=bar
envContent: `KEY1='foo'
KEY2=''
KEY3='bar'
`,
key: "KEY2",
wantValue: "",
},
{
name: "value with only spaces",
envContent: `KEY1=foo
KEY2=
KEY3=bar
`,
key: "KEY2",
wantValue: "",
wantValue: ``,
},
{
name: "key is empty (should error)",
envContent: `=foo
KEY2=bar
envContent: `='foo'
KEY2='bar'
`,
key: "KEY2",
wantValue: "bar",
wantErr: true,
errContains: "invalid environment variable format",
},
{
name: "multiple = in value",
envContent: `KEY1=foo=bar=baz
KEY2=bar
envContent: `KEY1='foo=bar=baz'
KEY2='bar'
`,
key: "KEY1",
wantValue: "foo=bar=baz",
},
{
name: "continuation with trailing spaces",
envContent: `KEY1=foo \
bar \
baz
KEY2=bar
`,
key: "KEY1",
wantValue: "foo bar baz",
wantValue: `foo=bar=baz`,
},
{
name: "comment after key value pair",
envContent: `KEY1=foo
KEY2=bar # comment
envContent: `KEY1='foo'
KEY2='bar' # comment
`,
key: "KEY2",
wantValue: "bar # comment",
},
{
name: "blank line in continuation triggers error with line number",
envContent: `KEY1=foo \
bar=val`,
key: "bar",
wantErr: true,
errContains: "at line 2",
},
{
name: "comment in continuation triggers error with line number",
envContent: `KEY1=foo \
# comment
bar=val`,
key: "bar",
wantErr: true,
errContains: "at line 2",
wantValue: `bar`,
},
{
name: "missing key triggers error with line number",
envContent: `=foo
KEY2=bar`,
envContent: `='foo'
KEY2='bar'`,
key: "KEY2",
wantErr: true,
errContains: "at line 1",
},
{
name: "value contains $VAR, should not expand",
envContent: `KEY1=$VAR
KEY2=bar`,
envContent: `KEY1='$VAR'
KEY2='bar'`,
key: "KEY1",
wantValue: "$VAR",
wantValue: `$VAR`,
},
{
name: "value contains ${VAR}, should not expand",
envContent: `KEY1=${VAR}
KEY2=bar`,
envContent: `KEY1='${VAR}'
KEY2='bar'`,
key: "KEY1",
wantValue: "${VAR}",
wantValue: `${VAR}`,
},
{
name: "value contains $HOME, should not expand",
envContent: `KEY1=$HOME
KEY2=bar`,
envContent: `KEY1='$HOME'
KEY2='bar'`,
key: "KEY1",
wantValue: "$HOME",
wantValue: `$HOME`,
},
{
name: "value contains mixed shell variable syntax, should not expand",
envContent: `KEY1=foo$BAR-${HOME}_$
KEY2=bar`,
envContent: `KEY1='foo$BAR-${HOME}_$'
KEY2='bar'`,
key: "KEY1",
wantValue: "foo$BAR-${HOME}_$",
wantValue: `foo$BAR-${HOME}_$`,
},
{
name: "invalid line triggers error with line number",
envContent: `KEY1='foo'
INVALID_LINE
KEY2='bar'
`,
key: "KEY2",
wantErr: true,
errContains: "at line 2",
},
{
name: "unquoted value triggers error",
envContent: `KEY1='foo'
KEY2=bar
KEY3='baz'
`,
key: "KEY2",
wantErr: true,
errContains: "value must be enclosed in single quotes",
},
{
name: "double quoted value triggers error",
envContent: `KEY1='foo'
KEY2="bar"
KEY3='baz'
`,
key: "KEY2",
wantErr: true,
errContains: "value must be enclosed in single quotes",
},
{
name: "value with multiple adjacent quoted strings",
envContent: `KEY1='foo''bar'`,
key: "KEY1",
wantErr: true,
errContains: `unexpected content after closing quote`,
},
{
name: "unclosed single quote triggers error",
envContent: `KEY1='foo'
KEY2='bar
KEY3='baz'
`,
key: "KEY2",
wantErr: true,
errContains: "unexpected content after closing quote",
},
{
name: "content after closing quote triggers error",
envContent: `KEY1='foo'
KEY2='bar'baz
KEY3='baz'
`,
key: "KEY2",
wantErr: true,
errContains: "unexpected content after closing quote",
},
{
name: "empty quotes preserve literal content",
envContent: `KEY1=''
KEY2=' '
KEY3='bar'
`,
key: "KEY1",
wantValue: ``,
},
{
name: "quotes preserve all literal content including spaces",
envContent: `KEY1=' foo bar '
KEY2='bar'
`,
key: "KEY1",
wantValue: ` foo bar `,
},
{
name: "special characters in value",
envContent: `KEY1='!@#$%^&*()_+-=[]{}|;:,.<>?/'
KEY2='bar'
`,
key: "KEY1",
wantValue: `!@#$%^&*()_+-=[]{}|;:,.<>?/`,
},
{
name: "newlines and tabs in value",
envContent: `KEY1='line1\nline2\tline3'
KEY2='bar'
`,
key: "KEY1",
wantValue: `line1\nline2\tline3`,
},
{
name: "unicode characters in value",
envContent: `KEY1='中文 Español Français 🌍'
KEY2='bar'
`,
key: "KEY1",
wantValue: `中文 Español Français 🌍`,
},
{
name: "backslashes in value",
envContent: `KEY1='path\\to\\file'
KEY2='bar'
`,
key: "KEY1",
wantValue: `path\\to\\file`,
},
{
name: "single quotes within value quotes",
envContent: `KEY1='value with \'nested\' quotes'
KEY2='bar'
`,
key: "KEY1",
wantErr: true,
errContains: "unexpected content after closing quote",
},
{
name: "double quotes within value quotes",
envContent: `KEY1='value with "nested" quotes'
KEY2='bar'
`,
key: "KEY1",
wantErr: false,
wantValue: `value with "nested" quotes`,
},
{
name: "empty single quotes",
envContent: `KEY1=''
KEY2=''
KEY3='bar'
`,
key: "KEY2",
wantValue: ``,
},
{
name: "only whitespace in quotes",
envContent: `KEY1=' '
KEY2='\t\n'
KEY3='bar'
`,
key: "KEY1",
wantValue: " ",
},
{
name: "complex JSON-like value",
envContent: `KEY1='{"name": "test", "value": 123, "nested": {"key": "val"}}'
KEY2='bar'
`,
key: "KEY1",
wantValue: `{"name": "test", "value": 123, "nested": {"key": "val"}}`,
},
{
name: "URL with special characters",
envContent: `KEY1='https://example.com/path?query=value&another=param'
KEY2='bar'
`,
key: "KEY1",
wantValue: `https://example.com/path?query=value&another=param`,
},
{
name: "XML-like content",
envContent: `KEY1='<root><element attr="value">content</element></root>'
KEY2='bar'
`,
key: "KEY1",
wantValue: `<root><element attr="value">content</element></root>`,
},
{
name: "base64 encoded data",
envContent: `KEY1='SGVsbG8gV29ybGQh'
KEY2='bar'
`,
key: "KEY1",
wantValue: `SGVsbG8gV29ybGQh`,
},
{
name: "regex pattern",
envContent: `KEY1='^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$'
KEY2='bar'
`,
key: "KEY1",
wantValue: `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$`,
},
{
name: "multi-line value with spaces",
envContent: `KEY1='line one
line two with spaces
line three with more spaces'
KEY2='bar'
`,
key: "KEY1",
wantValue: `line one
line two with spaces
line three with more spaces`,
},
{
name: "multi-line value with special characters",
envContent: `KEY1='line1: !@#$%^&*()
line2: []{}|;:,.<>?/
line3: \\\\tabs\\nnewlines'
KEY2='bar'
`,
key: "KEY1",
wantValue: `line1: !@#$%^&*()
line2: []{}|;:,.<>?/
line3: \\\\tabs\\nnewlines`,
},
{
name: "multi-line value with mixed content",
envContent: `KEY1='First line
Second line with $VAR and ${HOME}
Third line with spaces and tabs\\t
Fourth line with special chars: !@#$%^&*()_+'
KEY2='bar'
`,
key: "KEY1",
wantValue: `First line
Second line with $VAR and ${HOME}
Third line with spaces and tabs\\t
Fourth line with special chars: !@#$%^&*()_+`,
},
{
name: "multi-line value with empty lines",
envContent: `KEY1='line1
line3 after empty line
line5 after another empty'
KEY2='bar'
`,
key: "KEY1",
wantValue: `line1
line3 after empty line
line5 after another empty`,
},
{
name: "multi-line value with trailing spaces",
envContent: `KEY1='line1
line2 with trailing spaces
line3 '
KEY2='bar'
`,
key: "KEY1",
wantValue: `line1
line2 with trailing spaces
line3 `,
},
{
name: "multi-line value with unicode characters",
envContent: `KEY1='中文 第一行
English second line
Français troisième ligne 🌍
Русский четвертая строка'
KEY2='bar'
`,
key: "KEY1",
wantValue: `中文 第一行
English second line
Français troisième ligne 🌍
Русский четвертая строка`,
},
{
name: "multi-line value with code-like content",
envContent: `KEY1='func main() {
fmt.Println(\"Hello, World!\")
for i := 0; i < 10; i++ {
fmt.Printf(\"i=%d\\n\", i)
}
}'
KEY2='bar'
`,
key: "KEY1",
wantValue: `func main() {
fmt.Println(\"Hello, World!\")
for i := 0; i < 10; i++ {
fmt.Printf(\"i=%d\\n\", i)
}
}`,
},
{
name: "multi-line value with JSON content",
envContent: `KEY1='{
"name": "test",
"value": 123,
"nested": {
"key": "val",
"array": [1, 2, 3]
}
}'
KEY2='bar'
`,
key: "KEY1",
wantValue: `{
"name": "test",
"value": 123,
"nested": {
"key": "val",
"array": [1, 2, 3]
}
}`,
},
{
name: "multi-line value with YAML content",
envContent: `KEY1='name: test
value: 123
nested:
key: val
array:
- 1
- 2
- 3'
KEY2='bar'
`,
key: "KEY1",
wantValue: `name: test
value: 123
nested:
key: val
array:
- 1
- 2
- 3`,
},
{
name: "multi-line value with Cert",
envContent: `KEY1='-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQSSSSSSSSSSSSSBlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEAyUgx65kBQf/Nl+EcOFkP8q7n7FDetNrH7WYAnpYnaWG8w4rA9ht9
upttTNyRbCVpb32f5m8DFvVug0vAa/ksX8iSKzD53bWTuTQiN/i9Q/iG0eCNPR0KoW/gLV
SS9pIWWVYNav6dBjknR85202+YryJkn8THAf8Kg5Lwl0dom41dZvN1DirbcqWOU1BKG2sl
pFIq8BdXedRjwoNYngMvLBOb4CAfvQyKr1+ARQecqzPn4pTovYtIZRasIgrBOpSpHGZa70
pTAVP5+KGXN5DigjQUWaje1AiEx5zk8J3T5AA0abSaNn0uE53tjgalTzcY9kxHdo2rgHJC
huHhWsWslkcntkKp0V1Jc8oGv86Dp5mPhpfpMOK+vCe2TrS/saes9fNVxjorSpLl4xTU/V
-----END OPENSSH PRIVATE KEY-----'
KEY2='bar'
`,
key: "KEY1",
wantValue: `-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQSSSSSSSSSSSSSBlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEAyUgx65kBQf/Nl+EcOFkP8q7n7FDetNrH7WYAnpYnaWG8w4rA9ht9
upttTNyRbCVpb32f5m8DFvVug0vAa/ksX8iSKzD53bWTuTQiN/i9Q/iG0eCNPR0KoW/gLV
SS9pIWWVYNav6dBjknR85202+YryJkn8THAf8Kg5Lwl0dom41dZvN1DirbcqWOU1BKG2sl
pFIq8BdXedRjwoNYngMvLBOb4CAfvQyKr1+ARQecqzPn4pTovYtIZRasIgrBOpSpHGZa70
pTAVP5+KGXN5DigjQUWaje1AiEx5zk8J3T5AA0abSaNn0uE53tjgalTzcY9kxHdo2rgHJC
huHhWsWslkcntkKp0V1Jc8oGv86Dp5mPhpfpMOK+vCe2TrS/saes9fNVxjorSpLl4xTU/V
-----END OPENSSH PRIVATE KEY-----`,
},
}
@ -310,6 +595,18 @@ KEY2=bar`,
if gotValue != tt.wantValue {
t.Errorf("got %q, want %q", gotValue, tt.wantValue)
}
// Verify shell behavior matches our parser
if runtime.GOOS != "windows" {
shellValue, shellErr := testShellBehavior(t, tt.envContent, tt.key)
if shellErr != nil {
t.Errorf("shell failed to parse valid syntax: %v", shellErr)
return
}
if gotValue != shellValue {
t.Errorf("shell behavior mismatch: ParseEnv=%q, shell=%q", gotValue, shellValue)
}
}
})
}
}

View file

@ -569,6 +569,10 @@
lockToDefault: false
preRelease: Alpha
version: "1.34"
- default: true
lockToDefault: false
preRelease: Beta
version: "1.35"
- name: EventedPLEG
versionedSpecs:
- default: false

View file

@ -61,7 +61,7 @@ var _ = SIGDescribe("FileKeyRef", framework.WithFeatureGate(features.EnvFiles),
{
Name: "setup-envfile",
Image: imageutils.GetE2EImage(imageutils.BusyBox),
Command: []string{"sh", "-c", "echo 'CONFIG_1=value1' > /data/config.env && echo 'CONFIG_2=value2' >> /data/config.env"},
Command: []string{"sh", "-c", `echo CONFIG_1=\'value1\' > /data/config.env && echo CONFIG_2=\'value2\' >> /data/config.env`},
VolumeMounts: []v1.VolumeMount{
{
Name: "config",
@ -136,7 +136,7 @@ var _ = SIGDescribe("FileKeyRef", framework.WithFeatureGate(features.EnvFiles),
{
Name: "setup-envfile",
Image: imageutils.GetE2EImage(imageutils.BusyBox),
Command: []string{"sh", "-c", "echo 'CONFIG_1=value1' > /data/config.env && echo 'CONFIG_2=value2' >> /data/config.env && echo 'CONFIG_3=value3' >> /data/config.env"},
Command: []string{"sh", "-c", `echo CONFIG_1=\'value1\' > /data/config.env && echo CONFIG_2=\'value2\' >> /data/config.env && echo CONFIG_3=\'value3\' >> /data/config.env`},
VolumeMounts: []v1.VolumeMount{
{
Name: "config",
@ -239,7 +239,7 @@ var _ = SIGDescribe("FileKeyRef", framework.WithFeatureGate(features.EnvFiles),
{
Name: "setup-envfile",
Image: imageutils.GetE2EImage(imageutils.BusyBox),
Command: []string{"sh", "-c", "echo 'EXISTING_KEY=existing_value' > /data/config.env"},
Command: []string{"sh", "-c", `echo EXISTING_KEY=\'existing_value\' > /data/config.env`},
VolumeMounts: []v1.VolumeMount{
{
Name: "config",
@ -313,7 +313,7 @@ var _ = SIGDescribe("FileKeyRef", framework.WithFeatureGate(features.EnvFiles),
{
Name: "setup-envfile",
Image: imageutils.GetE2EImage(imageutils.BusyBox),
Command: []string{"sh", "-c", "echo 'HOOK_CONFIG=hook_value' > /data/config.env"},
Command: []string{"sh", "-c", `echo HOOK_CONFIG=\'hook_value\' > /data/config.env`},
VolumeMounts: []v1.VolumeMount{
{
Name: "config",
@ -394,7 +394,7 @@ var _ = SIGDescribe("FileKeyRef", framework.WithFeatureGate(features.EnvFiles),
{
Name: "setup-envfile",
Image: imageutils.GetE2EImage(imageutils.BusyBox),
Command: []string{"sh", "-c", "echo 'HOOK_CONFIG=hook_value' > /data/config.env"},
Command: []string{"sh", "-c", `echo HOOK_CONFIG=\'hook_value\' > /data/config.env`},
VolumeMounts: []v1.VolumeMount{
{
Name: "config",
@ -475,7 +475,7 @@ var _ = SIGDescribe("FileKeyRef", framework.WithFeatureGate(features.EnvFiles),
{
Name: "setup-envfile",
Image: imageutils.GetE2EImage(imageutils.BusyBox),
Command: []string{"sh", "-c", "echo 'CONFIG_1=value1' > /data/config.env"},
Command: []string{"sh", "-c", `echo CONFIG_1=\'value1\' > /data/config.env`},
VolumeMounts: []v1.VolumeMount{
{
Name: "config",
@ -545,7 +545,7 @@ var _ = SIGDescribe("FileKeyRef", framework.WithFeatureGate(features.EnvFiles),
{
Name: "setup-envfile",
Image: imageutils.GetE2EImage(imageutils.BusyBox),
Command: []string{"sh", "-c", "echo 'CONFIG_1=value1' > /data/config.env"},
Command: []string{"sh", "-c", `echo CONFIG_1=\'value1\' > /data/config.env`},
VolumeMounts: []v1.VolumeMount{
{
Name: "config",
@ -607,7 +607,7 @@ var _ = SIGDescribe("FileKeyRef", framework.WithFeatureGate(features.EnvFiles),
{
Name: "setup-envfile",
Image: imageutils.GetE2EImage(imageutils.BusyBox),
Command: []string{"sh", "-c", "echo 'EXISTING_KEY=value' > /data/config.env"},
Command: []string{"sh", "-c", `echo EXISTING_KEY=\'value\' > /data/config.env`},
VolumeMounts: []v1.VolumeMount{
{
Name: "config",
@ -679,7 +679,7 @@ var _ = SIGDescribe("FileKeyRef", framework.WithFeatureGate(features.EnvFiles),
{
Name: "setup-envfile",
Image: imageutils.GetE2EImage(imageutils.BusyBox),
Command: []string{"sh", "-c", "echo 'EXISTING_KEY=value' > /data/config.env"},
Command: []string{"sh", "-c", `echo EXISTING_KEY=\'value\' > /data/config.env`},
VolumeMounts: []v1.VolumeMount{
{
Name: "config",
@ -749,7 +749,7 @@ var _ = SIGDescribe("FileKeyRef", framework.WithFeatureGate(features.EnvFiles),
{
Name: "setup-envfile",
Image: imageutils.GetE2EImage(imageutils.BusyBox),
Command: []string{"sh", "-c", "echo 'CONFIG_1=value1' > /data/config.env"},
Command: []string{"sh", "-c", `echo CONFIG_1=\'value1\' > /data/config.env`},
VolumeMounts: []v1.VolumeMount{{Name: "config", MountPath: "/data"}},
},
{
@ -807,7 +807,7 @@ var _ = SIGDescribe("FileKeyRef", framework.WithFeatureGate(features.EnvFiles),
{
Name: "setup-envfile",
Image: imageutils.GetE2EImage(imageutils.BusyBox),
Command: []string{"sh", "-c", "echo 'CONFIG_INIT=fail' > /data/config.env"},
Command: []string{"sh", "-c", `echo CONFIG_INIT=\'fail\' > /data/config.env`},
VolumeMounts: []v1.VolumeMount{{Name: "config", MountPath: "/data"}},
},
{
@ -856,7 +856,7 @@ var _ = SIGDescribe("FileKeyRef", framework.WithFeatureGate(features.EnvFiles),
InitContainers: []v1.Container{{
Name: "setup-envfile",
Image: imageutils.GetE2EImage(imageutils.BusyBox),
Command: []string{"sh", "-c", "echo 'CONFIG_EPH=ephemeral' > /data/config.env"},
Command: []string{"sh", "-c", `echo CONFIG_EPH=\'ephemeral\' > /data/config.env`},
VolumeMounts: []v1.VolumeMount{{Name: "config", MountPath: "/data"}},
}},
Containers: []v1.Container{{

View file

@ -32,8 +32,10 @@ import (
"k8s.io/apimachinery/pkg/util/uuid"
"k8s.io/apimachinery/pkg/util/wait"
clientset "k8s.io/client-go/kubernetes"
"k8s.io/kubernetes/pkg/features"
kubetypes "k8s.io/kubernetes/pkg/kubelet/types"
"k8s.io/kubernetes/test/e2e/framework"
e2epod "k8s.io/kubernetes/test/e2e/framework/pod"
imageutils "k8s.io/kubernetes/test/utils/image"
admissionapi "k8s.io/pod-security-admission/api"
"sigs.k8s.io/yaml"
@ -43,7 +45,6 @@ import (
"github.com/onsi/gomega"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/cli-runtime/pkg/printers"
e2epod "k8s.io/kubernetes/test/e2e/framework/pod"
e2evolume "k8s.io/kubernetes/test/e2e/framework/volume"
)
@ -384,6 +385,103 @@ var _ = SIGDescribe("MirrorPod (Pod Generation)", func() {
})
})
var _ = SIGDescribe("MirrorPod with EnvFiles", framework.WithFeatureGate(features.EnvFiles), func() {
f := framework.NewDefaultFramework("mirror-pod-envfiles")
f.NamespacePodSecurityLevel = admissionapi.LevelPrivileged
var ns, podPath, staticPodName, mirrorPodName string
ginkgo.Context("when creating a static pod with EnvFiles", func() {
ginkgo.BeforeEach(func() {
ns = f.Namespace.Name
staticPodName = "static-pod-envfiles-" + string(uuid.NewUUID())
mirrorPodName = staticPodName + "-" + framework.TestContext.NodeName
podPath = kubeletCfg.StaticPodPath
})
ginkgo.AfterEach(func(ctx context.Context) {
ginkgo.By("delete the static pod")
err := deleteStaticPod(podPath, staticPodName, ns)
framework.ExpectNoError(err)
ginkgo.By("wait for the mirror pod to disappear")
gomega.Eventually(ctx, func(ctx context.Context) error {
return checkMirrorPodDisappear(ctx, f.ClientSet, mirrorPodName, ns)
}, 2*time.Minute, time.Second*4).Should(gomega.Succeed())
})
ginkgo.It("should be able to consume variables from a file", func(ctx context.Context) {
podSpec := v1.PodSpec{
InitContainers: []v1.Container{
{
Name: "setup-envfile",
Image: imageutils.GetE2EImage(imageutils.BusyBox),
Command: []string{"sh", "-c", `echo CONFIG_1='value1' > /data/config.env && echo CONFIG_2=\'value2\' >> /data/config.env`},
VolumeMounts: []v1.VolumeMount{
{
Name: "config",
MountPath: "/data",
},
},
},
},
Containers: []v1.Container{
{
Name: "use-envfile",
Image: imageutils.GetE2EImage(imageutils.BusyBox),
Command: []string{"sh", "-c", "env | grep -E '(CONFIG_1|CONFIG_2)' | sort"},
Env: []v1.EnvVar{
{
Name: "CONFIG_1",
ValueFrom: &v1.EnvVarSource{
FileKeyRef: &v1.FileKeySelector{
VolumeName: "config",
Path: "config.env",
Key: "CONFIG_1",
},
},
},
{
Name: "CONFIG_2",
ValueFrom: &v1.EnvVarSource{
FileKeyRef: &v1.FileKeySelector{
VolumeName: "config",
Path: "config.env",
Key: "CONFIG_2",
},
},
},
},
},
},
RestartPolicy: v1.RestartPolicyNever,
Volumes: []v1.Volume{
{
Name: "config",
VolumeSource: v1.VolumeSource{
EmptyDir: &v1.EmptyDirVolumeSource{},
},
},
},
}
ginkgo.By("create the static pod with envfiles")
err := createStaticPodWithSpec(podPath, staticPodName, ns, podSpec)
framework.ExpectNoError(err)
ginkgo.By("wait for the mirror pod to succeed")
err = e2epod.WaitForPodSuccessInNamespace(ctx, f.ClientSet, mirrorPodName, ns)
framework.ExpectNoError(err)
ginkgo.By("checking the logs of the mirror pod")
logs, err := e2epod.GetPodLogs(ctx, f.ClientSet, ns, mirrorPodName, "use-envfile")
framework.ExpectNoError(err)
gomega.Expect(logs).To(gomega.ContainSubstring("CONFIG_1=value1"))
gomega.Expect(logs).To(gomega.ContainSubstring("CONFIG_2=value2"))
})
})
})
func podVolumeDirectoryExists(uid types.UID) bool {
podVolumePath := fmt.Sprintf("/var/lib/kubelet/pods/%s/volumes/", uid)
var podVolumeDirectoryExists bool

View file

@ -24,27 +24,151 @@ import (
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/uuid"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/cli-runtime/pkg/printers"
"k8s.io/kubernetes/pkg/cluster/ports"
"k8s.io/kubernetes/pkg/features"
"k8s.io/kubernetes/test/e2e/feature"
"k8s.io/kubernetes/test/e2e/framework"
testutils "k8s.io/kubernetes/test/utils"
imageutils "k8s.io/kubernetes/test/utils/image"
admissionapi "k8s.io/pod-security-admission/api"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
apierrors "k8s.io/apimachinery/pkg/api/errors"
testutils "k8s.io/kubernetes/test/utils"
)
var _ = SIGDescribe(feature.StandaloneMode, framework.WithFeatureGate(features.EnvFiles), func() {
f := framework.NewDefaultFramework("static-pod-envfiles")
f.NamespacePodSecurityLevel = admissionapi.LevelBaseline
ginkgo.Context("when creating a static pod with EnvFiles", func() {
var ns, podPath, staticPodName string
ginkgo.It("the pod should be running and consume variables", func(ctx context.Context) {
ns = f.Namespace.Name
staticPodName = "static-pod-envfiles-" + string(uuid.NewUUID())
podPath = kubeletCfg.StaticPodPath
podSpec := &v1.Pod{
TypeMeta: metav1.TypeMeta{
Kind: "Pod",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: staticPodName,
Namespace: ns,
},
Spec: v1.PodSpec{
InitContainers: []v1.Container{
{
Name: "setup-envfile",
Image: imageutils.GetE2EImage(imageutils.BusyBox),
Command: []string{"sh", "-c", `echo CONFIG_1=\'value1\' > /data/config.env && echo CONFIG_2=\'value2\' >> /data/config.env`},
VolumeMounts: []v1.VolumeMount{
{
Name: "config",
MountPath: "/data",
},
},
},
},
Containers: []v1.Container{
{
Name: "use-envfile",
Image: imageutils.GetE2EImage(imageutils.BusyBox),
Command: []string{"sh", "-c", "env | grep -E '(CONFIG_1|CONFIG_2)' | sort"},
Env: []v1.EnvVar{
{
Name: "CONFIG_1",
ValueFrom: &v1.EnvVarSource{
FileKeyRef: &v1.FileKeySelector{
VolumeName: "config",
Path: "config.env",
Key: "CONFIG_1",
},
},
},
{
Name: "CONFIG_2",
ValueFrom: &v1.EnvVarSource{
FileKeyRef: &v1.FileKeySelector{
VolumeName: "config",
Path: "config.env",
Key: "CONFIG_2",
},
},
},
},
},
},
RestartPolicy: v1.RestartPolicyNever,
Volumes: []v1.Volume{
{
Name: "config",
VolumeSource: v1.VolumeSource{
EmptyDir: &v1.EmptyDirVolumeSource{},
},
},
},
},
}
err := scheduleStaticPod(podPath, staticPodName, ns, podSpec)
framework.ExpectNoError(err)
gomega.Eventually(ctx, func(ctx context.Context) error {
pod, err := getPodFromStandaloneKubelet(ctx, ns, staticPodName)
if err != nil {
return fmt.Errorf("error getting pod(%v/%v) from standalone kubelet: %w", ns, staticPodName, err)
}
if pod.Status.Phase == v1.PodSucceeded {
return nil
}
if pod.Status.Phase == v1.PodFailed {
logs, err := getPodLogsFromStandaloneKubelet(ctx, ns, staticPodName, "use-envfile")
if err != nil {
framework.Logf("failed to get logs on pod failure: %v", err)
}
return fmt.Errorf("pod (%v/%v) failed, logs: %s", ns, staticPodName, logs)
}
return fmt.Errorf("pod (%v/%v) is not succeeded, phase: %s", ns, staticPodName, pod.Status.Phase)
}, f.Timeouts.PodStart, time.Second*5).Should(gomega.Succeed())
logs, err := getPodLogsFromStandaloneKubelet(ctx, ns, staticPodName, "use-envfile")
framework.ExpectNoError(err)
gomega.Expect(logs).To(gomega.ContainSubstring("CONFIG_1=value1"))
gomega.Expect(logs).To(gomega.ContainSubstring("CONFIG_2=value2"))
})
ginkgo.AfterEach(func(ctx context.Context) {
ginkgo.By(fmt.Sprintf("delete the static pod (%v/%v)", ns, staticPodName))
err := deleteStaticPod(podPath, staticPodName, ns)
framework.ExpectNoError(err)
ginkgo.By(fmt.Sprintf("wait for pod to disappear (%v/%v)", ns, staticPodName))
gomega.Eventually(ctx, func(ctx context.Context) error {
_, err := getPodFromStandaloneKubelet(ctx, ns, staticPodName)
if apierrors.IsNotFound(err) {
return nil
}
return fmt.Errorf("pod (%v/%v) still exists", ns, staticPodName)
}).Should(gomega.Succeed())
})
})
})
var _ = SIGDescribe(feature.StandaloneMode, func() {
f := framework.NewDefaultFramework("static-pod")
f.NamespacePodSecurityLevel = admissionapi.LevelBaseline
@ -315,6 +439,36 @@ func getPodFromStandaloneKubelet(ctx context.Context, podNamespace string, podNa
return nil, apierrors.NewNotFound(schema.GroupResource{Resource: "pods"}, podName)
}
func getPodLogsFromStandaloneKubelet(ctx context.Context, podNamespace string, podName string, containerName string) (string, error) {
pod, err := getPodFromStandaloneKubelet(ctx, podNamespace, podName)
if err != nil {
return "", fmt.Errorf("failed to get pod %s/%s: %w", podNamespace, podName, err)
}
logCRIDir := "/var/log/pods"
podLogDir := filepath.Join(logCRIDir, fmt.Sprintf("%s_%s_%s", pod.Namespace, pod.Name, pod.UID))
logFile := filepath.Join(podLogDir, containerName, "0.log")
var content []byte
err = wait.PollUntilContextTimeout(ctx, time.Second, time.Minute, true, func(ctx context.Context) (bool, error) {
var errRead error
content, errRead = os.ReadFile(logFile)
if errRead != nil {
if os.IsNotExist(errRead) {
return false, nil
}
return false, errRead
}
return true, nil
})
if err != nil {
return "", fmt.Errorf("could not read log file %s: %w", logFile, err)
}
return string(content), nil
}
// Decodes the http response from /configz and returns a kubeletconfig.KubeletConfiguration (internal type).
func decodePods(respBody []byte) (*v1.PodList, error) {
// This hack because /pods reports the following structure: