mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(managed-agent): look for the configuration files Spring looks for, however a location is spelled
The check re-derived Spring's config-data location grammar from strings, so a spelling it did not classify was dropped: optional:File:/x/, with the scheme in another case, or the extension hint of file:app.conf[.properties] gave "no configuration file" over a file the server reads. prepare now gives Spring a resource loader that records every file Spring's own resolver looks a configuration file up at, there or not. The candidates are then Spring's: any spelling it reads, resource: prefixes, extension hints, bare names with a colon, imports, the variant of each profile Spring activates, and none at all for an empty spring.config.location or spring.config.name. Config trees and wildcards, which Spring opens or lists without such a lookup, still come from the location values, read with Spring's ConfigDataLocation and resource rules. A file's wildcard (.../*/application.yml) is covered as a directory's is, its file named as Spring names it, a directory whose name starts with ".." is skipped as Spring skips it, and a wildcard's directory this process may list but not enter is named as one it cannot enter. The secret scan drops a leading UTF-8 byte order mark, as YAML does, so a commented-out key on the first line is seen. A root service account reads and enters anything, as the kernel lets it, rather than being judged by permission bits, and the advice no longer says "run --check as root or as root".
This commit is contained in:
1 parent
25f4836539
commit
9b2e2b4910
7 files changed
+512
-138
No files matched your search
@@ -91,10 +91,20 @@ environment post-processor on the class path through
|
||||
no context, and `ResolvedConfiguration`, which the upgrade command reads its
|
||||
settings through, prepares the same environment. The `config` rows name the
|
||||
files the prepared property sources came from, then the files Spring read at a
|
||||
location without finding a setting. When the preparation throws, the row
|
||||
names what Spring refused: the file, read as missing or unreadable, or the
|
||||
YAML error's file and line, found among the files Spring looks for, since
|
||||
Spring's error names neither: each file whose own error is Spring's, the same
|
||||
location without finding a setting. The check does not read a location's
|
||||
spelling itself: it records each file Spring's own resolver looks a
|
||||
configuration file up at, there or not, so a scheme in another case, a
|
||||
`resource:` prefix, an extension hint, a bare name with a colon, an empty
|
||||
`spring.config.location` or `spring.config.name`, and each variant of a profile
|
||||
Spring activates come out as Spring reads them. Spring opens a config tree and
|
||||
lists a wildcard's directories without such a lookup, so those two come from the
|
||||
location values, read with Spring's location rules: a wildcard's directories,
|
||||
but none whose name starts with `..`, as Spring skips those, and in each, the
|
||||
files of a directory location or the one name of a file's wildcard. A directory
|
||||
there that this process cannot list or enter is named as one it cannot look in.
|
||||
When the preparation throws, the row names what Spring refused: the file, read
|
||||
as missing or unreadable, or the YAML error's file and line, found among the
|
||||
files Spring looked for before it refused, since Spring's error names neither: each file whose own error is Spring's, the same
|
||||
problem at the same place, unless a profile the arguments or the environment
|
||||
activate tells which one Spring read; a file this process cannot read names the
|
||||
service account when that cannot read it either, and otherwise says to run the
|
||||
@@ -106,11 +116,13 @@ no default, that row fails with Spring's reason and the others still print.
|
||||
Spring skips an optional file it cannot see, so where this process cannot enter
|
||||
a directory the server looks in for a configuration file or a config tree, or
|
||||
list one it lists, as it lists `config/` for `config/*/`, a `config` row names
|
||||
that directory and says to run the check as the service account or as root. When
|
||||
that account cannot look in it either, Spring reads nothing there, and the
|
||||
directory gets no row; nor does it without a service account, or when the check
|
||||
runs as that account, as this process then stands in for the server's, as it
|
||||
does for every file row.
|
||||
that directory and says to run the check as the service account or as root, or
|
||||
as root alone when that is the service account. When that account cannot look
|
||||
in it either, Spring reads nothing there, and the directory gets no row; nor
|
||||
does it without a service account, or when the check runs as that account, as
|
||||
this process then stands in for the server's, as it does for every file row. A
|
||||
service account of root reads and enters whatever the modes say, as the kernel
|
||||
lets it.
|
||||
|
||||
A file holds a secret by one rule, the configuration reference's: a key named as
|
||||
a password, token, key or secret, or a map of keys, in any spelling Spring
|
||||
@@ -119,14 +131,15 @@ key a file holds: those of each document, including one no profile activates,
|
||||
those in a comment, a commented-out line or, in a file that may be YAML, the
|
||||
comment after a value, the property a config tree value file holds, and a
|
||||
parameter of a value, such as a JDBC URL's `?password=`, or the password of a
|
||||
URL's user information. A path can be a configuration file and a config tree
|
||||
value file at once, as `config/application.yml` is under a config tree imported
|
||||
over `config/`, and so is a file whose extension a location hints, as
|
||||
`file:app.conf[.properties]` does, under one imported over its directory; it is
|
||||
then judged both ways. A value file Spring reads as no configuration file holds
|
||||
its value alone, whatever its name. `Binder` produces `ManagedAgentProperties`,
|
||||
the Harness probe uses the JDK HTTP client, and the database probe uses JDBC
|
||||
with a five-second connect and socket timeout.
|
||||
URL's user information. A UTF-8 byte order mark, which YAML drops, does not hide
|
||||
a commented-out key on the first line. A path can be a configuration file and a
|
||||
config tree value file at once, as `config/application.yml` is under a config
|
||||
tree imported over `config/`, and so is a file whose extension a location hints,
|
||||
as `file:app.conf[.properties]` does, under one imported over its directory; it
|
||||
is then judged both ways. A value file Spring reads as no configuration file
|
||||
holds its value alone, whatever its name. `Binder` produces
|
||||
`ManagedAgentProperties`, the Harness probe uses the JDK HTTP client, and the
|
||||
database probe uses JDBC with a five-second connect and socket timeout.
|
||||
|
||||
## Acceptance
|
||||
|
||||
|
||||
@@ -49,11 +49,11 @@
|
||||
|
||||
## 实现
|
||||
|
||||
`ManagedAgentServerApplication` 在 `SpringApplication.run` 之前拦截 `--check`,因此不会启动监听、调度器、数据源连接池或 worker。`ServerEnvironment` 按 `SpringApplication` 的方式准备 servlet 环境:参数、进程环境,然后经 `EnvironmentPostProcessorApplicationListener` 运行 classpath 上的所有 environment post-processor,并启用宽松绑定。它不创建 context;升级命令读取设置所用的 `ResolvedConfiguration` 也准备同一个环境。`config` 行先列出准备好的属性源来自的文件,再列出 Spring 在某个位置读到但其中没有任何设置的文件。准备过程抛出异常时,该行写明 Spring 拒绝的原因:被当作不存在或不可读的文件,或 YAML 错误所在的文件和行号。由于 Spring 的错误信息不包含这两者,需要在 Spring 会查找的文件中定位:列出自身错误与 Spring 一致(问题相同、位置相同)的每个文件;若参数或环境变量激活的 profile 能确定 Spring 读的是哪一个,则只列那一个。本进程读不了的文件,若 service account 也读不了,就写 service account;否则提示以该账号运行检查。此时其余各行读取参数、进程环境和打包默认值。环境准备成功、但某一行读取的设置无法解析时(例如没有取值也没有默认值的占位符),该行报 FAIL 并给出 Spring 的原因,其余各行照常输出。
|
||||
`ManagedAgentServerApplication` 在 `SpringApplication.run` 之前拦截 `--check`,因此不会启动监听、调度器、数据源连接池或 worker。`ServerEnvironment` 按 `SpringApplication` 的方式准备 servlet 环境:参数、进程环境,然后经 `EnvironmentPostProcessorApplicationListener` 运行 classpath 上的所有 environment post-processor,并启用宽松绑定。它不创建 context;升级命令读取设置所用的 `ResolvedConfiguration` 也准备同一个环境。`config` 行先列出准备好的属性源来自的文件,再列出 Spring 在某个位置读到但其中没有任何设置的文件。检查不自行解读 location 的写法,而是记录 Spring 自己的解析器去查找配置文件的每个路径(无论文件是否存在)。因此,大小写不同的 scheme、`resource:` 前缀、扩展名提示、带冒号的裸文件名、为空的 `spring.config.location` 或 `spring.config.name`,以及 Spring 激活的每个 profile 的变体,结果都与 Spring 的读取一致。Spring 打开 config tree、列出通配符目录时不经过这种查找,这两种情况从 location 的取值得出,并按 Spring 的 location 规则读取:通配符下的各个目录(名称以 `..` 开头的除外,Spring 会跳过它们),以及每个目录中目录 location 对应的各个文件,或文件通配符给出的那一个文件名。其中本进程无法列出或进入的目录,会作为它看不进去的目录报出。准备过程抛出异常时,该行写明 Spring 拒绝的原因:被当作不存在或不可读的文件,或 YAML 错误所在的文件和行号。由于 Spring 的错误信息不包含这两者,需要在 Spring 拒绝之前查找过的文件中定位:列出自身错误与 Spring 一致(问题相同、位置相同)的每个文件;若参数或环境变量激活的 profile 能确定 Spring 读的是哪一个,则只列那一个。本进程读不了的文件,若 service account 也读不了,就写 service account;否则提示以该账号运行检查。此时其余各行读取参数、进程环境和打包默认值。环境准备成功、但某一行读取的设置无法解析时(例如没有取值也没有默认值的占位符),该行报 FAIL 并给出 Spring 的原因,其余各行照常输出。
|
||||
|
||||
Spring 会跳过它看不到的 optional 文件。因此,本进程进不去服务端查找配置文件或 config tree 的某个目录,或列不出服务端要列出的目录(例如为 `config/*/` 列出 `config/`)时,会有一条 `config` 行写明该目录,并提示以 service account 或 root 运行检查。若 service account 也进不去,Spring 在那里什么都不读,该目录就不出行。未配置 service account,或检查本身就以该账号运行时,也不出行:此时与每个文件行一样,由本进程代表服务端的账号。
|
||||
Spring 会跳过它看不到的 optional 文件。因此,本进程进不去服务端查找配置文件或 config tree 的某个目录,或列不出服务端要列出的目录(例如为 `config/*/` 列出 `config/`)时,会有一条 `config` 行写明该目录,并提示以 service account 或 root 运行检查;service account 就是 root 时,只提示以 root 运行。若 service account 也进不去,Spring 在那里什么都不读,该目录就不出行。未配置 service account,或检查本身就以该账号运行时,也不出行:此时与每个文件行一样,由本进程代表服务端的账号。service account 为 root 时,与内核的处理一致,不论权限位如何都可以读取和进入。
|
||||
|
||||
文件是否含 secret 只按一条规则判断,即配置参考的规则:键名表示 password、token、key 或 secret,或是一组 key 的映射(Spring 宽松绑定认可的任何写法都算),且不是 ID,也不是保存 secret 的文件的路径。这条规则作用于文件中的每个键:每个文档里的键(包括没有任何 profile 激活的文档)、注释里的键(注释掉的行,以及可能是 YAML 的文件中值后面的注释)、config tree 值文件对应的属性,以及取值中的参数(例如 JDBC URL 的 `?password=`)或 URL 用户信息中的密码。同一路径可以既是配置文件又是 config tree 值文件(例如 config tree 导入在 `config/` 之上时的 `config/application.yml`;又如由位置提示扩展名的文件,像 `file:app.conf[.properties]`,而 config tree 导入在它所在的目录之上),此时两种身份都要判断。Spring 不把它当配置文件读取的值文件,无论叫什么名字,都只按取值判断。`Binder` 生成 `ManagedAgentProperties`,Harness 探测使用 JDK HTTP client,数据库探测使用 JDBC,连接与 socket 超时均为 5 秒。
|
||||
文件是否含 secret 只按一条规则判断,即配置参考的规则:键名表示 password、token、key 或 secret,或是一组 key 的映射(Spring 宽松绑定认可的任何写法都算),且不是 ID,也不是保存 secret 的文件的路径。这条规则作用于文件中的每个键:每个文档里的键(包括没有任何 profile 激活的文档)、注释里的键(注释掉的行,以及可能是 YAML 的文件中值后面的注释)、config tree 值文件对应的属性,以及取值中的参数(例如 JDBC URL 的 `?password=`)或 URL 用户信息中的密码。YAML 会丢弃 UTF-8 字节序标记,因此第一行前的字节序标记不会让该行被注释掉的键漏判。同一路径可以既是配置文件又是 config tree 值文件(例如 config tree 导入在 `config/` 之上时的 `config/application.yml`;又如由位置提示扩展名的文件,像 `file:app.conf[.properties]`,而 config tree 导入在它所在的目录之上),此时两种身份都要判断。Spring 不把它当配置文件读取的值文件,无论叫什么名字,都只按取值判断。`Binder` 生成 `ManagedAgentProperties`,Harness 探测使用 JDK HTTP client,数据库探测使用 JDBC,连接与 socket 超时均为 5 秒。
|
||||
|
||||
## 验收
|
||||
|
||||
|
||||
+15
-4
@@ -318,7 +318,13 @@ public final class ManagedAgentPreflight {
|
||||
directory + " cannot be " + (hidden.listed() ? "listed" : "entered") + " by "
|
||||
+ runner + ", which runs this check, and the server may read a"
|
||||
+ " configuration file in it",
|
||||
"run --check as " + account.name() + " or as root");
|
||||
runAs(account));
|
||||
}
|
||||
|
||||
// The account the check sees the server's files as.
|
||||
private static String runAs(ServiceAccount account) {
|
||||
return "run --check as " + account.name()
|
||||
+ (account.superuser() ? "" : " or as root");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -344,7 +350,7 @@ public final class ManagedAgentPreflight {
|
||||
return serverReads
|
||||
? new Check("config", Status.FAIL,
|
||||
path + " is not readable by " + runner + ", which runs this check",
|
||||
"run --check as " + account.name() + " or as root")
|
||||
runAs(account))
|
||||
: new Check("config", Status.FAIL,
|
||||
path + " is not readable by " + account.name(),
|
||||
unreadableFix(account.name(), path));
|
||||
@@ -647,12 +653,17 @@ public final class ManagedAgentPreflight {
|
||||
|
||||
// Spring reads YAML as UTF-8, or UTF-16 after a byte order mark, and
|
||||
// .properties as ISO-8859-1. A comment's key is ASCII, which ISO-8859-1,
|
||||
// decoding any byte, reads in all but UTF-16.
|
||||
// decoding any byte, reads in all but UTF-16. YAML drops a UTF-8 byte
|
||||
// order mark, as an editor may write one, so its first line is read
|
||||
// without it.
|
||||
private static String text(byte[] bytes) {
|
||||
boolean utf16 = bytes.length >= 2
|
||||
&& ((bytes[0] & 0xff) == 0xfe && (bytes[1] & 0xff) == 0xff
|
||||
|| (bytes[0] & 0xff) == 0xff && (bytes[1] & 0xff) == 0xfe);
|
||||
return new String(bytes, utf16 ? StandardCharsets.UTF_16 : StandardCharsets.ISO_8859_1);
|
||||
int start = !utf16 && bytes.length >= 3 && (bytes[0] & 0xff) == 0xef
|
||||
&& (bytes[1] & 0xff) == 0xbb && (bytes[2] & 0xff) == 0xbf ? 3 : 0;
|
||||
return new String(bytes, start, bytes.length - start,
|
||||
utf16 ? StandardCharsets.UTF_16 : StandardCharsets.ISO_8859_1);
|
||||
}
|
||||
|
||||
static String unreadableFix(String account, String path) {
|
||||
|
||||
+242
-96
@@ -3,6 +3,7 @@ package com.alibaba.qwen.code.managedagent.preflight;
|
||||
import org.springframework.boot.DefaultBootstrapContext;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.context.config.ConfigDataLocation;
|
||||
import org.springframework.boot.context.config.ConfigDataNotFoundException;
|
||||
import org.springframework.boot.context.config.ConfigDataResourceNotFoundException;
|
||||
import org.springframework.boot.context.config.StandardConfigDataResource;
|
||||
@@ -25,8 +26,12 @@ import org.springframework.core.env.EnumerablePropertySource;
|
||||
import org.springframework.core.env.PropertySource;
|
||||
import org.springframework.core.env.SimpleCommandLinePropertySource;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.util.ResourceUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.yaml.snakeyaml.error.Mark;
|
||||
import org.yaml.snakeyaml.error.MarkedYAMLException;
|
||||
|
||||
@@ -46,10 +51,12 @@ import java.util.Deque;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
@@ -64,14 +71,19 @@ import java.util.regex.Pattern;
|
||||
* the server's only when it runs with the unit's working directory.
|
||||
*/
|
||||
final class ServerEnvironment {
|
||||
// Where Spring Boot looks without being told, relative to the working
|
||||
// directory; config/*/ is a wildcard.
|
||||
private static final List<String> DEFAULT_DIRECTORIES = List.of("", "config", "config/*");
|
||||
// The one of Spring Boot's default locations it lists rather than looks
|
||||
// a file up in, relative to the working directory.
|
||||
private static final String DEFAULT_WILDCARD = "config/*";
|
||||
// Those of the property source loaders Spring Boot registers.
|
||||
private static final List<String> EXTENSIONS = List.of("yml", "yaml", "properties", "xml");
|
||||
// A location such as classpath: or configtree:, as Spring tells one from a path.
|
||||
private static final Pattern SCHEME = Pattern.compile("^[a-zA-Z][a-zA-Z0-9*]*?:");
|
||||
// A location Spring reads as written rather than beside the file that
|
||||
// imports it, as its StandardConfigDataLocationResolver tells one.
|
||||
private static final Pattern FIXED = Pattern.compile("^[a-zA-Z][a-zA-Z0-9*]*?:");
|
||||
// A file name with the extension it is read by, as Spring's
|
||||
// FileExtensionHint tells one: app.conf[.properties].
|
||||
private static final Pattern HINT = Pattern.compile("^(.*)\\[(\\.\\w+)](?!\\[)$");
|
||||
private static final String IMPORT = "spring.config.import";
|
||||
private static final String LOOKED_UP = "qwen-managed-agent-preflight-looked-up";
|
||||
|
||||
private ServerEnvironment() {
|
||||
}
|
||||
@@ -85,6 +97,10 @@ final class ServerEnvironment {
|
||||
ConfigurableEnvironment environment) {
|
||||
SpringApplication application = new SpringApplication();
|
||||
application.setWebApplicationType(WebApplicationType.SERVLET);
|
||||
// Kept before Spring reads a file, so a failure leaves what it looked for.
|
||||
LookedUp lookedUp = new LookedUp();
|
||||
environment.getPropertySources().addLast(lookedUp);
|
||||
application.setResourceLoader(lookedUp.loader());
|
||||
environment.setConversionService(new ApplicationConversionService());
|
||||
if (args.length > 0) {
|
||||
environment.getPropertySources().addFirst(
|
||||
@@ -99,6 +115,74 @@ final class ServerEnvironment {
|
||||
return environment;
|
||||
}
|
||||
|
||||
/**
|
||||
* The files Spring looked a configuration file up at while it prepared
|
||||
* an environment, whether one was there or not, as its own resolver
|
||||
* named them from a location's spelling: with the extension a location
|
||||
* hints, the variant of each profile it activated, and relative to the
|
||||
* working directory where the location is. It holds no property; it
|
||||
* keeps them with the environment for {@link #files} and the rest.
|
||||
*/
|
||||
private static final class LookedUp extends EnumerablePropertySource<Set<Path>> {
|
||||
LookedUp() {
|
||||
super(LOOKED_UP, new LinkedHashSet<>());
|
||||
}
|
||||
|
||||
// Enumerable and empty, so a binder skips it for every name.
|
||||
@Override
|
||||
public String[] getPropertyNames() {
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getProperty(String name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ResourceLoader loader() {
|
||||
return new DefaultResourceLoader() {
|
||||
@Override
|
||||
public Resource getResource(String location) {
|
||||
Resource resource = super.getResource(location);
|
||||
// A directory is one Spring lists for a wildcard, or
|
||||
// checks is there when it found no file in it.
|
||||
if (!location.endsWith("/")) {
|
||||
Path file = path(resource);
|
||||
if (file != null) {
|
||||
getSource().add(file);
|
||||
}
|
||||
}
|
||||
return resource;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** The files Spring looked up for this environment, in the working directory. */
|
||||
private static Set<Path> lookedUp(ConfigurableEnvironment environment,
|
||||
Path workingDirectory) {
|
||||
Set<Path> files = new LinkedHashSet<>();
|
||||
if (environment.getPropertySources().get(LOOKED_UP) instanceof LookedUp lookedUp) {
|
||||
for (Path file : lookedUp.getSource()) {
|
||||
files.add(workingDirectory.resolve(file).normalize());
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** The file a resource names, as relative as its location, or null for none. */
|
||||
private static Path path(Resource resource) {
|
||||
if (resource instanceof ClassPathResource || !resource.isFile()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return resource.getFile().toPath();
|
||||
} catch (IOException | RuntimeException error) {
|
||||
// Nor can Spring read a file there.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The configuration files the server reads from the file system, highest
|
||||
* precedence first, each once, absolute and normalized: those that hold
|
||||
@@ -298,25 +382,30 @@ final class ServerEnvironment {
|
||||
}
|
||||
|
||||
/**
|
||||
* A file system location, which names a directory when it ends in a
|
||||
* slash, or a config tree's directory.
|
||||
* A location Spring reads without looking a file up: a config tree's
|
||||
* directory, or a wildcard, whose last name is *, which Spring lists,
|
||||
* with the name of the file it looks for in each directory of it, or
|
||||
* null where it looks for each configuration file's.
|
||||
*/
|
||||
private record Location(Path path, boolean directory, boolean tree) {
|
||||
private record Location(Path path, boolean tree, String file) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The file system locations named at the top level, by the arguments or
|
||||
* the process environment, and those imported, at the top level or by a
|
||||
* file Spring read. Locations of another kind, such as classpath: or
|
||||
* configtree:, are left out.
|
||||
* The config trees and wildcards named at the top level, by the
|
||||
* arguments or the process environment, and those imported, at the top
|
||||
* level or by a file Spring read. Spring looks up the files of every
|
||||
* other location, which prepare records.
|
||||
*/
|
||||
private static List<Location> named(ConfigurableEnvironment environment,
|
||||
Path workingDirectory) {
|
||||
List<Location> named = new ArrayList<>();
|
||||
Binder binder = topLevel(environment);
|
||||
for (String property : List.of("spring.config.import",
|
||||
"spring.config.additional-location", "spring.config.location")) {
|
||||
locations(String.join(",", topLevel(environment, property)), workingDirectory,
|
||||
workingDirectory, named);
|
||||
for (String value : binder.bind(property, Bindable.listOf(String.class))
|
||||
.orElse(List.of())) {
|
||||
locations(value, workingDirectory, null, named);
|
||||
}
|
||||
}
|
||||
// Spring honours an import a file declares, and no other location a
|
||||
// file sets; it reads a bare relative one beside that file.
|
||||
@@ -332,8 +421,10 @@ final class ServerEnvironment {
|
||||
Path declaring = file(resource(tracked.getOrigin(key)));
|
||||
if (declaring != null) {
|
||||
// Spring resolves a placeholder in it as in any value.
|
||||
locations(environment.resolvePlaceholders(String.valueOf(entry.getValue())),
|
||||
workingDirectory, declaring.getParent(), named);
|
||||
for (String value : environment.resolvePlaceholders(
|
||||
String.valueOf(entry.getValue())).split(",")) {
|
||||
locations(value, workingDirectory, declaring.getParent(), named);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,39 +432,58 @@ final class ServerEnvironment {
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the file system locations of a value: a file: one, read in the
|
||||
* working directory when relative, and a bare one, read in base.
|
||||
* Adds the config trees and wildcards of one location value, which
|
||||
* Spring splits at semicolons, read with Spring's own rules: a
|
||||
* configtree: location, in the working directory when relative; and one
|
||||
* whose last directory is named *, opened as Spring opens a location,
|
||||
* beside the importing file in base when Spring reads it there.
|
||||
*/
|
||||
private static void locations(String value, Path workingDirectory, Path base,
|
||||
List<Location> named) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
for (String location : value.split("[,;]")) {
|
||||
String path = location.strip().replaceFirst("^optional:", "");
|
||||
Path relativeTo = base;
|
||||
boolean tree = path.startsWith("configtree:");
|
||||
if (tree || path.startsWith("file:")) {
|
||||
// A colon after it is part of the path; Spring reads a config
|
||||
// tree in the working directory too.
|
||||
path = path.substring(path.indexOf(':') + 1);
|
||||
relativeTo = workingDirectory;
|
||||
} else if (SCHEME.matcher(path).lookingAt()) {
|
||||
for (String each : value.split(";")) {
|
||||
ConfigDataLocation location = ConfigDataLocation.of(each.strip());
|
||||
if (location == null) {
|
||||
continue;
|
||||
}
|
||||
if (!path.isEmpty()) {
|
||||
named.add(new Location(relativeTo.resolve(path), tree || path.endsWith("/"),
|
||||
tree));
|
||||
if (location.hasPrefix("configtree:")) {
|
||||
named.add(new Location(workingDirectory.resolve(
|
||||
location.getNonPrefixedValue("configtree:")), true, null));
|
||||
continue;
|
||||
}
|
||||
// Spring takes a wildcard as the last directory, of a directory
|
||||
// location or of a file's.
|
||||
String resource = location.getNonPrefixedValue("resource:");
|
||||
int slash = resource.lastIndexOf('/');
|
||||
if (!resource.substring(0, slash + 1).endsWith("*/")) {
|
||||
continue;
|
||||
}
|
||||
String file = resource.substring(slash + 1);
|
||||
String parent = resource.substring(0, slash + 1 - "*/".length());
|
||||
if (base != null && !parent.startsWith("/") && !FIXED.matcher(parent).lookingAt()) {
|
||||
String directory = base.toUri().toString();
|
||||
parent = (directory.endsWith("/") ? directory : directory + "/") + parent;
|
||||
}
|
||||
Path directory = path(open(parent));
|
||||
if (directory != null) {
|
||||
named.add(new Location(workingDirectory.resolve(directory).resolve("*"), false,
|
||||
file.isEmpty() ? null : file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// As Spring's LocationResourceLoader opens a location.
|
||||
private static Resource open(String location) {
|
||||
String cleaned = StringUtils.cleanPath(location);
|
||||
return new DefaultResourceLoader().getResource(ResourceUtils.isUrl(cleaned) ? cleaned
|
||||
: ResourceUtils.FILE_URL_PREFIX + cleaned);
|
||||
}
|
||||
|
||||
/**
|
||||
* A property as Spring reads it before any file, as it reads the
|
||||
* locations and spring.config.name: from the arguments, the process
|
||||
* A binder over the sources Spring reads before any file, as it reads
|
||||
* the locations and spring.config.name: the arguments, the process
|
||||
* environment and the other sources no configuration file adds.
|
||||
*/
|
||||
private static List<String> topLevel(ConfigurableEnvironment environment, String property) {
|
||||
private static Binder topLevel(ConfigurableEnvironment environment) {
|
||||
List<PropertySource<?>> sources = new ArrayList<>();
|
||||
for (PropertySource<?> source : environment.getPropertySources()) {
|
||||
if (!(source instanceof OriginTrackedMapPropertySource)
|
||||
@@ -384,63 +494,73 @@ final class ServerEnvironment {
|
||||
// from() skips the attached view of every source, files' included;
|
||||
// a placeholder resolves against the same sources, as Spring's.
|
||||
return new Binder(ConfigurationPropertySources.from(sources),
|
||||
new PropertySourcesPlaceholdersResolver(sources))
|
||||
.bind(property, Bindable.listOf(String.class)).orElse(List.of());
|
||||
new PropertySourcesPlaceholdersResolver(sources));
|
||||
}
|
||||
|
||||
private static List<String> topLevel(ConfigurableEnvironment environment, String property) {
|
||||
return topLevel(environment).bind(property, Bindable.listOf(String.class))
|
||||
.orElse(List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* The files Spring looks for at the locations {@link #named} finds and,
|
||||
* unless spring.config.location replaces them, at its default ones in
|
||||
* the working directory: each location's files, then their variants for
|
||||
* these profiles, or for any profile when profiles is null. Where Spring
|
||||
* refused to start, the files it read are not known, so neither is a
|
||||
* location one of them imports. Adds to unlisted each directory a
|
||||
* wildcard names whose directories this process may not list.
|
||||
* The files Spring looks for: those prepare saw it look up, at a
|
||||
* location of any spelling and for each profile it activated; then, as
|
||||
* Spring lists a wildcard's directory itself, those in each directory a
|
||||
* wildcard names, the default config/* among them unless
|
||||
* spring.config.location is set: each name and extension, then its
|
||||
* variants for these profiles, or for any profile when profiles is null,
|
||||
* each with its profile. Adds to unlisted each directory a wildcard
|
||||
* names whose directories this process may not list.
|
||||
*/
|
||||
private static Map<Path, String> locationFiles(ConfigurableEnvironment environment,
|
||||
Path workingDirectory, List<String> profiles, Set<Path> unlisted) {
|
||||
List<String> names = new ArrayList<>();
|
||||
for (String name : topLevel(environment, "spring.config.name")) {
|
||||
if (!name.isBlank()) {
|
||||
names.add(name.strip());
|
||||
}
|
||||
Map<Path, String> located = new LinkedHashMap<>();
|
||||
for (Path file : lookedUp(environment, workingDirectory)) {
|
||||
located.put(file, null);
|
||||
}
|
||||
if (names.isEmpty()) {
|
||||
names.add("application");
|
||||
}
|
||||
List<Path> directories = new ArrayList<>();
|
||||
Set<Path> files = new LinkedHashSet<>();
|
||||
List<Location> wildcards = new ArrayList<>();
|
||||
for (Location named : named(environment, workingDirectory)) {
|
||||
if (named.tree()) {
|
||||
// Its files are values, which files() finds in their source.
|
||||
continue;
|
||||
}
|
||||
if (named.directory()) {
|
||||
directories.add(named.path());
|
||||
} else {
|
||||
files.add(named.path());
|
||||
if (!named.tree()) {
|
||||
wildcards.add(named);
|
||||
}
|
||||
}
|
||||
if (topLevel(environment, "spring.config.location").isEmpty()) {
|
||||
for (String directory : DEFAULT_DIRECTORIES) {
|
||||
directories.add(workingDirectory.resolve(directory));
|
||||
}
|
||||
Binder binder = topLevel(environment);
|
||||
// An empty spring.config.location replaces the defaults too.
|
||||
if (!binder.bind("spring.config.location", Bindable.listOf(String.class)).isBound()) {
|
||||
wildcards.add(new Location(workingDirectory.resolve(DEFAULT_WILDCARD), false, null));
|
||||
}
|
||||
for (Path location : directories) {
|
||||
for (Path directory : expand(location, unlisted)) {
|
||||
for (String name : names) {
|
||||
for (String extension : EXTENSIONS) {
|
||||
files.add(directory.resolve(name + "." + extension));
|
||||
String[] names = binder.bind("spring.config.name", String[].class)
|
||||
.orElse(new String[] {"application"});
|
||||
List<Named> files = new ArrayList<>();
|
||||
for (Location wildcard : wildcards) {
|
||||
for (Path directory : expand(wildcard.path(), unlisted)) {
|
||||
if (wildcard.file() == null) {
|
||||
for (String name : names) {
|
||||
for (String extension : EXTENSIONS) {
|
||||
files.add(new Named(directory, name, "." + extension));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// As Spring names it: by the first loader whose extension it
|
||||
// ends in, in any case, with that loader's extension, or with
|
||||
// none where a location hints it; with none, Spring reads nothing.
|
||||
Matcher hint = HINT.matcher(wildcard.file());
|
||||
String name = hint.matches() ? hint.group(1) + hint.group(2) : wildcard.file();
|
||||
for (String extension : EXTENSIONS) {
|
||||
if (name.toLowerCase(Locale.ROOT).endsWith(extension)) {
|
||||
files.add(new Named(directory,
|
||||
name.substring(0, name.length() - extension.length() - 1),
|
||||
hint.matches() ? "" : "." + extension));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Each file, then its variants, each with the profile it is for.
|
||||
Map<Path, String> located = new LinkedHashMap<>();
|
||||
for (Path file : files) {
|
||||
located.put(file, null);
|
||||
for (Named file : files) {
|
||||
located.putIfAbsent(file.directory().resolve(file.root() + file.extension()), null);
|
||||
}
|
||||
for (Path file : files) {
|
||||
for (Named file : files) {
|
||||
variants(file, profiles).forEach(located::putIfAbsent);
|
||||
}
|
||||
return located;
|
||||
@@ -457,7 +577,8 @@ final class ServerEnvironment {
|
||||
}
|
||||
List<Path> directories = new ArrayList<>();
|
||||
Path parent = location.getParent() == null ? Path.of("") : location.getParent();
|
||||
try (DirectoryStream<Path> nested = Files.newDirectoryStream(parent, Files::isDirectory)) {
|
||||
try (DirectoryStream<Path> nested =
|
||||
Files.newDirectoryStream(parent, ServerEnvironment::listedDirectory)) {
|
||||
nested.forEach(directories::add);
|
||||
} catch (AccessDeniedException denied) {
|
||||
unlisted.add(parent);
|
||||
@@ -469,18 +590,42 @@ final class ServerEnvironment {
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile variants of a file, as application-edge.yml is of
|
||||
* application.yml, each with its profile.
|
||||
* Whether Spring reads an entry of a wildcard's directory as a directory
|
||||
* to look in: one not named from "..", as Spring skips those. One this
|
||||
* process cannot look at, as in a directory it may list but not enter,
|
||||
* counts, since the service account may; hidden() then names the
|
||||
* directory.
|
||||
*/
|
||||
private static Map<Path, String> variants(Path file, List<String> profiles) {
|
||||
String name = file.getFileName().toString();
|
||||
int dot = name.lastIndexOf('.');
|
||||
if (dot <= 0) {
|
||||
return Map.of();
|
||||
private static boolean listedDirectory(Path entry) {
|
||||
if (entry.getFileName().toString().startsWith("..")) {
|
||||
return false;
|
||||
}
|
||||
String stem = name.substring(0, dot) + "-";
|
||||
String extension = name.substring(dot);
|
||||
Path directory = file.getParent() == null ? Path.of("") : file.getParent();
|
||||
try {
|
||||
return Files.readAttributes(entry, BasicFileAttributes.class).isDirectory();
|
||||
} catch (AccessDeniedException denied) {
|
||||
return true;
|
||||
} catch (IOException gone) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A file as Spring names it: its directory, its name without the
|
||||
* extension, which a variant's profile follows, and the extension, empty
|
||||
* where a location hints it.
|
||||
*/
|
||||
private record Named(Path directory, String root, String extension) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile variants of a file, as application-edge.yml is of
|
||||
* application.yml and app.conf-edge of app.conf[.properties], each with
|
||||
* its profile.
|
||||
*/
|
||||
private static Map<Path, String> variants(Named file, List<String> profiles) {
|
||||
Path directory = file.directory();
|
||||
String stem = file.root() + "-";
|
||||
String extension = file.extension();
|
||||
Map<Path, String> variants = new LinkedHashMap<>();
|
||||
if (profiles != null) {
|
||||
for (String profile : profiles) {
|
||||
@@ -594,14 +739,14 @@ final class ServerEnvironment {
|
||||
}
|
||||
MarkedYAMLException marked = cause(error, MarkedYAMLException.class);
|
||||
if (marked != null) {
|
||||
// The files with the same problem at the same place: those Spring
|
||||
// reads for certain, a base file or a variant of a profile the
|
||||
// arguments or the environment activate, else every one, since a
|
||||
// file may activate a profile too.
|
||||
// The files with the same problem at the same place, each once:
|
||||
// those Spring reads for certain, one it looked up or a variant of
|
||||
// a profile the arguments or the environment activate, else every
|
||||
// one, since a file may activate a profile too.
|
||||
Set<String> active = new LinkedHashSet<>(topLevel(environment, "spring.profiles.active"));
|
||||
active.addAll(topLevel(environment, "spring.profiles.include"));
|
||||
List<String> matches = new ArrayList<>();
|
||||
List<String> read = new ArrayList<>();
|
||||
Set<String> matches = new LinkedHashSet<>();
|
||||
Set<String> read = new LinkedHashSet<>();
|
||||
for (Map.Entry<Path, String> candidate : located.entrySet()) {
|
||||
Path file = candidate.getKey();
|
||||
if (Files.isRegularFile(file) && sameProblem(yamlError(file), marked)) {
|
||||
@@ -613,7 +758,8 @@ final class ServerEnvironment {
|
||||
}
|
||||
}
|
||||
if (matches.isEmpty()) {
|
||||
// In a file an import names from inside another.
|
||||
// In a file Spring found without a lookup, as an import beside
|
||||
// a file a wildcard found.
|
||||
return yamlFailure("a configuration file", marked);
|
||||
}
|
||||
return yamlFailure(String.join(" or ", read.isEmpty() ? matches : read), marked);
|
||||
|
||||
+27
-3
@@ -3,9 +3,11 @@ package com.alibaba.qwen.code.managedagent.preflight;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.nio.file.FileSystemLoopException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.nio.file.attribute.PosixFileAttributes;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.util.ArrayDeque;
|
||||
@@ -22,12 +24,16 @@ import java.util.stream.Collectors;
|
||||
/**
|
||||
* The account the server runs as. Preflight often runs as root, which can
|
||||
* read every file, so it checks file access for this account from the
|
||||
* owners and modes instead. ACLs are not consulted.
|
||||
* owners and modes instead, unless the account is root too. ACLs are not
|
||||
* consulted.
|
||||
*/
|
||||
final class ServiceAccount {
|
||||
private static final long LOOKUP_TIMEOUT_SECONDS = 5;
|
||||
// Linux's limit on symbolic links followed in one path lookup.
|
||||
static final int MAX_SYMBOLIC_LINKS = 40;
|
||||
// Whom the kernel lets read any file and enter any directory, whatever
|
||||
// their modes.
|
||||
private static final String SUPERUSER = "root";
|
||||
|
||||
private final String name;
|
||||
private final Set<String> groups;
|
||||
@@ -82,6 +88,11 @@ final class ServiceAccount {
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Whether the account is root, by its name. */
|
||||
boolean superuser() {
|
||||
return name.equals(SUPERUSER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account can read the file. It must enter every directory
|
||||
* the kernel looks a name up in while it resolves the path: those on the
|
||||
@@ -100,6 +111,15 @@ final class ServiceAccount {
|
||||
}
|
||||
|
||||
private boolean can(Path file, boolean read) throws IOException {
|
||||
if (superuser()) {
|
||||
// Root may wherever the file is; where it is not, nor may root.
|
||||
try {
|
||||
Files.readAttributes(file, BasicFileAttributes.class);
|
||||
} catch (AccessDeniedException hidden) {
|
||||
// This process cannot look; root can.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Path absolute = file.toAbsolutePath();
|
||||
Path current = Objects.requireNonNull(absolute.getRoot(), "an absolute path has a root");
|
||||
Deque<Path> names = new ArrayDeque<>();
|
||||
@@ -139,9 +159,13 @@ final class ServiceAccount {
|
||||
return allows(Files.readAttributes(current, PosixFileAttributes.class), read);
|
||||
}
|
||||
|
||||
// As the kernel does: the owner bits apply to the owner alone, then the
|
||||
// group bits to any member, then the others bits.
|
||||
// As the kernel does: root may, and for any other account the owner bits
|
||||
// apply to the owner alone, then the group bits to any member, then the
|
||||
// others bits.
|
||||
boolean allows(PosixFileAttributes attributes, boolean read) {
|
||||
if (superuser()) {
|
||||
return true;
|
||||
}
|
||||
Set<PosixFilePermission> permissions = attributes.permissions();
|
||||
if (attributes.owner().getName().equals(name)) {
|
||||
return permissions.contains(read ? PosixFilePermission.OWNER_READ : PosixFilePermission.OWNER_EXECUTE);
|
||||
|
||||
+169
-15
@@ -96,6 +96,19 @@ class ServerEnvironmentTest {
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* An environment Spring has prepared, or failed to, which keeps the
|
||||
* files it looked for, as the one a failure is read from does.
|
||||
*/
|
||||
private static ConfigurableEnvironment looked(ConfigurableEnvironment environment) {
|
||||
try {
|
||||
ServerEnvironment.prepare(new String[0], environment);
|
||||
} catch (RuntimeException refused) {
|
||||
// Spring looked before it refused.
|
||||
}
|
||||
return environment;
|
||||
}
|
||||
|
||||
/** What Spring throws for a file, as the server would meet it. */
|
||||
private static RuntimeException thrown(ConfigurableEnvironment environment) {
|
||||
return assertThrows(RuntimeException.class,
|
||||
@@ -247,10 +260,11 @@ class ServerEnvironmentTest {
|
||||
assertEquals(broken + ", line 2, column 1: expected the node content,"
|
||||
+ " but found '<stream end>'", yaml.detail());
|
||||
assertEquals("fix the YAML syntax", yaml.fix());
|
||||
// In a file an import names from inside another, it says what it can.
|
||||
// As in a file an import names from inside another, which Spring
|
||||
// looked up as any other.
|
||||
Path importer = write("importer.yml",
|
||||
"spring.config.import: file:" + broken + "\n");
|
||||
assertEquals("a configuration file, line 2, column 1: expected the node"
|
||||
assertEquals(broken + ", line 2, column 1: expected the node"
|
||||
+ " content, but found '<stream end>'",
|
||||
refused(named("file:" + importer)).detail());
|
||||
}
|
||||
@@ -504,6 +518,39 @@ class ServerEnvironmentTest {
|
||||
assertEquals("no configuration file; packaged defaults and the"
|
||||
+ " environment only", rows.get(0).detail());
|
||||
}
|
||||
// Root enters it whatever its modes.
|
||||
rows = ManagedAgentPreflight.configChecks(prepared,
|
||||
new ServiceAccount("root", Set.of("root")), runner);
|
||||
assertEquals(1, rows.size(), rows.toString());
|
||||
assertEquals(ManagedAgentPreflight.Status.FAIL, rows.get(0).status());
|
||||
assertEquals("run --check as root", rows.get(0).fix());
|
||||
// A wildcard's directory it may list but not enter hides the
|
||||
// directories in it, which Spring then finds none of.
|
||||
Path wild = Files.createDirectories(directory.resolve("wild/edge")).getParent();
|
||||
Path realWild = wild.toRealPath();
|
||||
Files.setPosixFilePermissions(wild, PosixFilePermissions.fromString("r--r-xr--"));
|
||||
try {
|
||||
ManagedAgentPreflight.Prepared listed = prepare(
|
||||
named("optional:file:" + wild + "/*/"), new ArrayList<>());
|
||||
assertEquals(List.of(new ServerEnvironment.Hidden(realWild, false)),
|
||||
listed.hidden());
|
||||
assertEquals(ManagedAgentPreflight.Status.FAIL, ManagedAgentPreflight.hiddenCheck(
|
||||
listed.hidden().get(0), member, runner).status());
|
||||
} finally {
|
||||
Files.setPosixFilePermissions(wild, PosixFilePermissions.fromString("rwxr-xr-x"));
|
||||
}
|
||||
// As does one it may not list for a file's wildcard.
|
||||
assertEquals(List.of(new ServerEnvironment.Hidden(realEtc, true)), prepare(
|
||||
named("optional:file:" + etc + "/*/application.yml"),
|
||||
new ArrayList<>()).hidden());
|
||||
// However Spring is told of a file there: a scheme in another
|
||||
// case, a resource: prefix or an extension hint.
|
||||
for (String location : List.of("optional:File:" + etc + "/",
|
||||
"optional:resource:file:" + secret,
|
||||
"optional:file:" + etc.resolve("app.conf") + "[.properties]")) {
|
||||
assertEquals(List.of(new ServerEnvironment.Hidden(realEtc, false)),
|
||||
prepare(named(location), new ArrayList<>()).hidden(), location);
|
||||
}
|
||||
// A file it does see is still judged beside the directory it cannot.
|
||||
Path seen = write("seen.yml", "server.port: 9090\n");
|
||||
prepared = prepare(named("file:" + seen + ",optional:file:" + secret),
|
||||
@@ -548,25 +595,33 @@ class ServerEnvironmentTest {
|
||||
Path locked = write("dist/config/application.yml", "server.port: 9091\n");
|
||||
String user = System.getProperty("user.name");
|
||||
ServerEnvironment.Failure failure = ServerEnvironment.failure(cannotRead("x"),
|
||||
named("file:" + fine + ",optional:file:" + locked.getParent() + "/"),
|
||||
looked(named("file:" + fine + ",optional:file:" + locked.getParent() + "/")),
|
||||
file -> !file.toAbsolutePath().normalize().equals(locked),
|
||||
file -> false, directory);
|
||||
assertEquals(locked + " is not readable by " + user, failure.detail());
|
||||
assertEquals(locked, failure.unreadable());
|
||||
// A file it reads and that parses is no culprit.
|
||||
assertEquals("x (Permission denied)", ServerEnvironment.failure(cannotRead("x"),
|
||||
named("file:" + fine), file -> true, file -> false, directory).detail());
|
||||
looked(named("file:" + fine)), file -> true, file -> false, directory).detail());
|
||||
// Nor is one it cannot read when Spring failed on no read.
|
||||
assertEquals("bad value", ServerEnvironment.failure(
|
||||
new IllegalStateException("bad value"),
|
||||
named("file:" + fine + ",optional:file:" + locked.getParent() + "/"),
|
||||
looked(named("file:" + fine + ",optional:file:" + locked.getParent() + "/")),
|
||||
file -> !file.toAbsolutePath().normalize().equals(locked),
|
||||
file -> false, directory).detail());
|
||||
// A profile variant may be what Spring could not read.
|
||||
// A variant of a profile Spring activated may be what it could not read.
|
||||
Path variant = write("dist/config/application-edge.yml", "server.port: 9092\n");
|
||||
Map<String, Object> edge = new HashMap<>();
|
||||
edge.put("SPRING_CONFIG_ADDITIONAL_LOCATION",
|
||||
"file:" + fine + ",optional:file:" + locked.getParent() + "/");
|
||||
edge.put("SPRING_PROFILES_ACTIVE", "edge");
|
||||
assertEquals(variant + " is not readable by " + user, ServerEnvironment.failure(
|
||||
cannotRead("x"),
|
||||
named("file:" + fine + ",optional:file:" + locked.getParent() + "/"),
|
||||
cannotRead("x"), looked(environment(edge)),
|
||||
file -> !file.toAbsolutePath().normalize().equals(variant),
|
||||
file -> false, directory).detail());
|
||||
// Not that of a profile it did not.
|
||||
assertEquals("x (Permission denied)", ServerEnvironment.failure(cannotRead("x"),
|
||||
looked(named("file:" + fine + ",optional:file:" + locked.getParent() + "/")),
|
||||
file -> !file.toAbsolutePath().normalize().equals(variant),
|
||||
file -> false, directory).detail());
|
||||
}
|
||||
@@ -578,12 +633,12 @@ class ServerEnvironmentTest {
|
||||
Path config = write("config/application.yml", "server:\n port: [\n");
|
||||
RuntimeException error = thrown(named("file:" + config));
|
||||
ServerEnvironment.Failure failure = ServerEnvironment.failure(error,
|
||||
environment(Map.of()), file -> true, file -> false, directory);
|
||||
looked(environment(Map.of())), file -> true, file -> false, directory);
|
||||
assertEquals(config + ", line 3, column 1: expected the node content,"
|
||||
+ " but found '<stream end>'", failure.detail());
|
||||
// Unless spring.config.location replaces them.
|
||||
ServerEnvironment.Failure replaced = ServerEnvironment.failure(error,
|
||||
environment(Map.of("SPRING_CONFIG_LOCATION", "optional:classpath:/")),
|
||||
looked(environment(Map.of("SPRING_CONFIG_LOCATION", "optional:classpath:/"))),
|
||||
file -> true, file -> false, directory);
|
||||
assertEquals("a configuration file, line 3, column 1: expected the node content,"
|
||||
+ " but found '<stream end>'", replaced.detail());
|
||||
@@ -598,14 +653,25 @@ class ServerEnvironmentTest {
|
||||
// A location named relative to the working directory is read there.
|
||||
Path relative = write("etc/relative.yml", "server.port: 9094\n");
|
||||
assertEquals(relative + " is not readable by " + System.getProperty("user.name"),
|
||||
ServerEnvironment.failure(cannotRead("x"), named("file:etc/relative.yml"),
|
||||
ServerEnvironment.failure(cannotRead("x"),
|
||||
looked(named("file:etc/relative.yml")),
|
||||
file -> !file.toAbsolutePath().normalize().equals(relative),
|
||||
file -> false, directory).detail());
|
||||
// A file both a location and config/*/ name is named once, though
|
||||
// one names it from a relative working directory, as the server's is.
|
||||
Files.writeString(nested, "server: [\n");
|
||||
RuntimeException twice = thrown(named("optional:file:" + nested.getParent() + "/"));
|
||||
assertEquals(nested + ", line 2, column 1: expected the node content, but found"
|
||||
+ " '<stream end>'", ServerEnvironment.failure(twice,
|
||||
looked(named("optional:file:" + nested.getParent() + "/")),
|
||||
file -> true, file -> false,
|
||||
Path.of("").toAbsolutePath().relativize(directory)).detail());
|
||||
Files.writeString(nested, "server.port: 9091\n");
|
||||
// So is spring.config.name's.
|
||||
Path renamed = write("config/server.yml", "server.port: 9093\n");
|
||||
assertEquals(renamed + " is not readable by " + System.getProperty("user.name"),
|
||||
ServerEnvironment.failure(cannotRead("x"),
|
||||
environment(Map.of("SPRING_CONFIG_NAME", "server")),
|
||||
looked(environment(Map.of("SPRING_CONFIG_NAME", "server"))),
|
||||
file -> !file.toAbsolutePath().normalize().equals(renamed),
|
||||
file -> false, directory).detail());
|
||||
}
|
||||
@@ -645,6 +711,75 @@ class ServerEnvironmentTest {
|
||||
assertEquals(List.of(named), ServerEnvironment.files(prepared.environment(), directory));
|
||||
}
|
||||
|
||||
// The files, each once, in no order: those of locations that hold no
|
||||
// setting have no precedence to keep.
|
||||
private static void assertSameFiles(Set<Path> expected, List<Path> files) {
|
||||
assertEquals(expected, Set.copyOf(files), files.toString());
|
||||
assertEquals(expected.size(), files.size(), files.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findsTheFilesSpringLooksForHoweverALocationIsSpelled() throws IOException {
|
||||
// Each holds no setting, so only where Spring looked finds it: a
|
||||
// scheme in another case, a resource: prefix, and a name with a
|
||||
// colon, which Spring reads as a file in the working directory.
|
||||
Path upper = write("upper/application.yml", "# spring.datasource.password: x\n");
|
||||
Path resource = write("resource/application.yml", "# server.port: 9090\n");
|
||||
Path colon = write("foo:bar.yml", "# server.port: 9090\n");
|
||||
ManagedAgentPreflight.Prepared prepared = prepare(named("optional:File:"
|
||||
+ upper.getParent() + "/,optional:resource:file:" + resource.getParent()
|
||||
+ "/,optional:foo:bar.yml"), new ArrayList<>());
|
||||
assertNull(prepared.failure());
|
||||
assertSameFiles(Set.of(colon, resource, upper),
|
||||
ServerEnvironment.files(prepared.environment(), directory));
|
||||
// A wildcard, which Spring lists rather than looks a file up in,
|
||||
// named so or imported beside the file that imports it.
|
||||
Path listed = write("listed/edge/application.yml", "# server.port: 9090\n");
|
||||
Path importer = write("importer/application.yml",
|
||||
"spring.config.import: optional:conf.d/*/\n");
|
||||
Path imported = write("importer/conf.d/edge/application.yml", "# server.port: 9091\n");
|
||||
prepared = prepare(named("optional:File:" + directory.resolve("listed") + "/*/,optional:file:"
|
||||
+ importer.getParent() + "/"), new ArrayList<>());
|
||||
assertNull(prepared.failure());
|
||||
assertSameFiles(Set.of(importer, listed, imported),
|
||||
ServerEnvironment.files(prepared.environment(), directory));
|
||||
// A file's wildcard: Spring looks for the one name in each directory,
|
||||
// and for the variant of each profile, named as for a hinted file.
|
||||
Path wild = write("fw/b/application.yml", "# server.port: 9090\n");
|
||||
Path hinted = write("fn/a/notes.conf", "qwen.preflight.probe=hinted\n");
|
||||
Path variant = write("fn/a/notes.conf-edge", "# server.port: 9091\n");
|
||||
prepared = prepare(named("optional:file:" + directory.resolve("fw") + "/*/application.yml"
|
||||
+ ",optional:file:" + directory.resolve("fn") + "/*/notes.conf[.properties]"),
|
||||
new ArrayList<>(), "--spring.profiles.active=edge");
|
||||
assertNull(prepared.failure());
|
||||
assertEquals("hinted", prepared.environment().getProperty("qwen.preflight.probe"));
|
||||
assertSameFiles(Set.of(wild, hinted, variant),
|
||||
ServerEnvironment.files(prepared.environment(), directory));
|
||||
// Spring matches the extension in any case and looks for the
|
||||
// loader's own, and reads nothing for an extension no loader takes.
|
||||
Path lower = write("fu/a/application.yml", "# server.port: 9090\n");
|
||||
write("fj/a/notes.conf", "# server.port: 9090\n");
|
||||
prepared = prepare(named("optional:file:" + directory.resolve("fu") + "/*/application.YML"
|
||||
+ ",optional:file:" + directory.resolve("fj") + "/*/notes.conf[.json]"),
|
||||
new ArrayList<>());
|
||||
assertNull(prepared.failure());
|
||||
assertSameFiles(Set.of(lower), ServerEnvironment.files(prepared.environment(), directory));
|
||||
// An empty spring.config.location or spring.config.name leaves
|
||||
// Spring no file to look for, not its defaults.
|
||||
Path config = write("config/application.yml", "# server.port: 9090\n");
|
||||
write("config/edge/application.yml", "# server.port: 9091\n");
|
||||
// Spring lists no directory named from "..", as a ConfigMap's ..data.
|
||||
write("config/..data/application.yml", "# server.port: 9092\n");
|
||||
assertEquals(List.of(config, directory.resolve("config/edge/application.yml")),
|
||||
ServerEnvironment.files(prepare(environment(Map.of()), new ArrayList<>())
|
||||
.environment(), directory));
|
||||
for (String property : List.of("SPRING_CONFIG_LOCATION", "SPRING_CONFIG_NAME")) {
|
||||
assertEquals(List.of(), ServerEnvironment.files(prepare(
|
||||
environment(Map.of(property, "")), new ArrayList<>()).environment(),
|
||||
directory), property);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsEachFileOfAConfigTree() throws IOException {
|
||||
// Kubernetes mounts a Secret as one file for each property.
|
||||
@@ -869,6 +1004,21 @@ class ServerEnvironmentTest {
|
||||
assertEquals("s3cret", prepared.environment().getProperty("spring.datasource.password"));
|
||||
assertEquals(List.of(hinted), prepared.files());
|
||||
assertEquals(ManagedAgentPreflight.Status.WARN, rows(prepared).get(0).status());
|
||||
// Spring reads one whose lines are all comments too, though it
|
||||
// declares no key, and the variant of a profile beside it, which
|
||||
// Spring names after the hinted file as written.
|
||||
Path template = write("notes/notes.conf", "# spring.datasource.password=change-me\n");
|
||||
Path edge = write("notes/notes.conf-edge", "# spring.datasource.password=change-me\n");
|
||||
for (Path file : List.of(template, edge)) {
|
||||
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-r--r--"));
|
||||
}
|
||||
prepared = prepare(named("file:" + template + "[.properties]"), new ArrayList<>(),
|
||||
"--spring.profiles.active=edge");
|
||||
assertEquals(List.of(template, edge), prepared.files());
|
||||
rows = rows(prepared);
|
||||
assertEquals(List.of(ManagedAgentPreflight.Status.WARN, ManagedAgentPreflight.Status.WARN),
|
||||
rows.stream().map(ManagedAgentPreflight.Check::status).toList(), rows.toString());
|
||||
assertEquals("chmod 600 " + template, rows.get(0).fix());
|
||||
// It is a value file too, of a property named app.conf, under a config
|
||||
// tree imported over its directory; its keys still tell.
|
||||
prepared = prepare(named("file:" + hinted + "[.properties]"), new ArrayList<>(),
|
||||
@@ -967,9 +1117,9 @@ class ServerEnvironmentTest {
|
||||
variables.put("SPRING_PROFILES_INCLUDE", "zzz");
|
||||
assertEquals(zzz + problem, refused(environment(variables)).detail());
|
||||
// The profile set in a file, which the environment Spring was given
|
||||
// does not hold: both are named.
|
||||
// does not hold: Spring looked up its variant alone.
|
||||
Files.writeString(config, "spring.profiles.active: zzz\n");
|
||||
assertEquals(aaa + " or " + zzz + problem,
|
||||
assertEquals(zzz + problem,
|
||||
refused(named("optional:file:" + config.getParent() + "/")).detail());
|
||||
// A base file Spring reads for certain is the one, even beside a
|
||||
// variant with the same error.
|
||||
@@ -1066,7 +1216,11 @@ class ServerEnvironmentTest {
|
||||
Path little = directory.resolve("config/application.yaml");
|
||||
Files.write(little, ("\ufeffspring.datasource.password: s3cret\n")
|
||||
.getBytes(StandardCharsets.UTF_16LE));
|
||||
for (Path file : List.of(properties, big, little)) {
|
||||
// Or as UTF-8 after one, which it drops, as before a commented-out key.
|
||||
Path marked = directory.resolve("config/marked.yml");
|
||||
Files.write(marked, ("\ufeff# spring.datasource.password: s3cret\n")
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
for (Path file : List.of(properties, big, little, marked)) {
|
||||
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-r--r--"));
|
||||
ManagedAgentPreflight.Check check = ManagedAgentPreflight.configFileCheck(file, null);
|
||||
assertEquals(ManagedAgentPreflight.Status.WARN, check.status(), check.toString());
|
||||
|
||||
+26
@@ -30,6 +30,12 @@ class ServiceAccountTest {
|
||||
Path file = Files.writeString(directory.resolve("harness.env"), "");
|
||||
PosixFileAttributes attributes = Files.readAttributes(file, PosixFileAttributes.class);
|
||||
String owner = attributes.owner().getName();
|
||||
if (owner.equals("root")) {
|
||||
// Root reads any file, so the owner bits need another owner.
|
||||
owner = "nobody";
|
||||
Files.setOwner(file, file.getFileSystem().getUserPrincipalLookupService()
|
||||
.lookupPrincipalByName(owner));
|
||||
}
|
||||
String group = attributes.group().getName();
|
||||
ServiceAccount self = new ServiceAccount(owner, Set.of());
|
||||
ServiceAccount member = new ServiceAccount(STRANGER, Set.of(group));
|
||||
@@ -50,6 +56,26 @@ class ServiceAccountTest {
|
||||
assertFalse(self.canRead(file));
|
||||
}
|
||||
|
||||
@Test
|
||||
void letsRootReadAndEnterWhateverTheModes(@TempDir Path directory) throws IOException {
|
||||
Path locked = Files.createDirectory(directory.resolve("locked"));
|
||||
Path file = Files.writeString(locked.resolve("application.yml"), "");
|
||||
mode(file, "---------");
|
||||
mode(locked, "---------");
|
||||
try {
|
||||
ServiceAccount root = new ServiceAccount("root", Set.of("root"));
|
||||
assertTrue(root.canEnter(locked));
|
||||
assertTrue(root.canRead(file));
|
||||
assertTrue(root.allows(Files.readAttributes(locked, PosixFileAttributes.class), true));
|
||||
// Not an account of root's group, which the modes keep out.
|
||||
assertFalse(new ServiceAccount(STRANGER, Set.of("root")).canEnter(locked));
|
||||
// Nor root a file that is not there.
|
||||
assertThrows(IOException.class, () -> root.canRead(directory.resolve("missing.env")));
|
||||
} finally {
|
||||
mode(locked, "rwx------");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void needsEveryDirectoryAboveTheFile(@TempDir Path directory) throws IOException {
|
||||
Path nested = Files.createDirectory(directory.resolve("config"));
|
||||
|
||||
Reference in new issue
Block a user