fix: restore container logging compatibility and normalize legacy policies

This commit is contained in:
elky
2026-09-08 11:43:51 +08:00
parent cf8ea19856
commit c7e403b410
17 changed files with 849 additions and 149 deletions
-5
View File
@@ -15,11 +15,6 @@ APP_PORT=8084
# APP_IMAGE=ghcr.io/fawney19/aether:beta
# APP_IMAGE=ghcr.io/fawney19/aether:0.7.0-rc.1
# Compose 应用容器的非 root 数字身份。
# install.sh 会自动写入安装用户的 UID/GID。
AETHER_CONTAINER_UID=65532
AETHER_CONTAINER_GID=65532
# API Key 前缀(默认 sk
API_KEY_PREFIX=sk
+1 -1
View File
@@ -44,5 +44,5 @@ EXPOSE 8084
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
USER 65532:65532
USER 0:0
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]
+1
View File
@@ -157,4 +157,5 @@ EXPOSE 8084
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ["/usr/local/bin/aether-gateway", "--healthcheck"]
USER 0:0
ENTRYPOINT ["/usr/local/bin/aether-gateway"]
+1
View File
@@ -156,4 +156,5 @@ EXPOSE 8084
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
USER 0:0
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]
+1 -73
View File
@@ -50,82 +50,10 @@ chmod 600 .env
./generate_keys.sh
# 编辑 .env 设置 ADMIN_PASSWORD
# 3. 首次部署 / 更新 (从以下部署形态任选其一)
# Postgres + Redis (推荐)
# 3. Docker 部署 / 更新(PostgreSQL + Redis
docker compose pull && docker compose up -d
# Single Node:同样使用 PostgreSQL + Redis,无需挂载本地数据库文件
docker compose -f docker-compose.single-node.yml pull && docker compose -f docker-compose.single-node.yml up -d
```
应用镜像默认以固定非 root 身份 `65532:65532` 运行;Compose 移除全部 Linux capabilities、禁止提权、启用只读根文件系统,并提供带 `nosuid,nodev,noexec``/tmp`。如需使用其他身份,可在 `.env` 中设置非零的 `AETHER_CONTAINER_UID` / `AETHER_CONTAINER_GID`。数据库使用独立 PostgreSQL 容器和 named volume,不再需要调整应用数据库目录的权限。
### 一键更新
Docker Compose 部署后,可在部署目录直接执行:
```bash
./update.sh
```
`update.sh` 会拉取最新 `app` 镜像并重建 `app` 容器,Docker named volumes、`./data``./logs` 不会被删除。Single Node 部署也可显式指定:
```bash
./update.sh --mode single-node
```
现在仅支持 PostgreSQL。标准和单节点 Docker Compose 均部署 PostgreSQL + Redis;原生 systemd / launchd 安装需要显式提供 PostgreSQL `DATABASE_URL`,例如 `DATABASE_URL=postgresql://user:password@host:5432/aether`。旧数据库不会自动迁移或清空。升级时保留原有 PostgreSQL 密码、`JWT_SECRET_KEY``ENCRYPTION_KEY`,不要重新生成整个 `.env`
仓库自带的 Docker Compose 默认把应用日志输出到容器 `stdout/stderr`,直接用 `docker compose logs -f app` 查看,并由 Docker 轮转日志,避免非 root 用户被宿主机日志目录权限拖垮启动。如果你确实需要文件日志,需要在 compose 里把 `AETHER_LOG_DESTINATION` 改成 `file|both`,额外挂载目录到 `/opt/aether/logs`,并让它归 `.env` 中配置的容器 UID/GID 所有;只读根文件系统不会阻止显式可写挂载。
管理后台右上角“版本信息”会检测新版本。Docker Compose 部署只提示版本,实际更新继续执行 `./update.sh`systemd / launchd / 二进制部署才使用后台自更新,流程是下载对应平台的 GitHub Release 包、强制校验 `SHA256SUMS`、解压到 `/opt/aether/releases/<version>`,再切换 `/opt/aether/current` 并退出进程,交给 systemd / launchd 拉起新版本。
正式 Release 还会发布由 GitHub Actions OIDC / Sigstore 签发的 SLSA build provenance。需要验证发布者身份时,下载目标 tarball 和 `AETHER_RELEASE_PROVENANCE.sigstore.json`,并把 `TAG` 设置为对应 Release tag
```bash
gh attestation verify "aether-${TAG}-linux-amd64.tar.gz" \
--repo fawney19/Aether \
--signer-workflow fawney19/Aether/.github/workflows/release.yml \
--source-ref "refs/tags/${TAG}" \
--bundle AETHER_RELEASE_PROVENANCE.sigstore.json
```
`docker-compose.yml` 中的官方 PostgreSQL 和 Redis 镜像均固定到多架构 OCI index digest。升级这些依赖时应在发布变更中显式更新 digest,避免同名 tag 在无人审查的情况下改变部署内容。
正式发布到 GHCR 和 Docker Hub 的多架构 Aether 镜像也带有同一 GitHub Actions OIDC / Sigstore provenance;生产 `Dockerfile.app` 的 BusyBox 与 Distroless 基础镜像同样固定到多架构 OCI index digest。
源码或本地构建版本不会启用后台在线更新,请继续使用源码更新流程。Docker Compose 用户如果希望“容器重建后也保持镜像层面的新版本”,仍建议定期运行 `./update.sh` 拉取并重建 app 镜像。服务器访问 GitHub 需要代理时,可设置 `AETHER_UPDATE_PROXY_URL`,也兼容 `UPDATE_PROXY_URL``HTTPS_PROXY``ALL_PROXY``HTTP_PROXY` 以及 `NO_PROXY`。共享出口触发 GitHub API 限流时,可设置只读 `AETHER_UPDATE_GITHUB_TOKEN`,也兼容 `GITHUB_TOKEN` / `GH_TOKEN`。下载总超时默认 600 秒,连续无响应/无数据默认 30 秒,可通过 `AETHER_UPDATE_DOWNLOAD_TIMEOUT_SECS``AETHER_UPDATE_DOWNLOAD_IDLE_TIMEOUT_SECS` 调整。
标准和 Single Node Docker Compose 均使用 Docker named volume 存放 PostgreSQL 数据。
如果是本地源码构建镜像的部署,继续使用:
```bash
./deploy.sh
```
如果要在本机联调“管理后台在线更新”本身,可启动仓库内置的 release-layout 测试环境:
```bash
docker compose -f docker-compose.release-local.yml up -d --build
```
这套环境会用当前源码构建一个本地测试镜像,但编译为 `release` 类型,并默认伪装成 `v0.7.0`,这样后台会按正式发布版逻辑开放“立即更新”。默认监听 `http://127.0.0.1:18085`,数据目录使用 `./data-release-local`;日志默认走 `docker logs`,不会影响你正在跑的源码构建容器。
如果这套容器在 `prepare-update` 时访问 GitHub 失败,而你本机是通过代理出网,请在 `.env` 里把 `AETHER_UPDATE_PROXY_URL` 写成宿主机地址,例如 `http://host.docker.internal:7890`;容器内的 `127.0.0.1` 指向容器自身,不是宿主机。
如果想重置这套联调环境(包括 `/opt/aether/current` 和已下载的历史版本),执行:
```bash
docker compose -f docker-compose.release-local.yml down -v
```
可选变量:
- `AETHER_RELEASE_LOCAL_VERSION`:本地联调镜像对外声明的当前版本,默认 `v0.7.0`
- `AETHER_RELEASE_LOCAL_PORT`:本地联调端口,默认 `18085`
- `LOCAL_RELEASE_APP_IMAGE`:本地联调镜像名,默认 `aether-app:release-local`
### 一键安装(PostgreSQL + Redis
```bash
@@ -0,0 +1,45 @@
DO $migration$
DECLARE
policy_column record;
BEGIN
FOR policy_column IN
SELECT *
FROM (VALUES
('api_keys', 'allowed_providers'),
('api_keys', 'allowed_api_formats'),
('api_keys', 'allowed_models'),
('api_keys', 'ip_rules'),
('users', 'allowed_providers'),
('users', 'allowed_api_formats'),
('users', 'allowed_models'),
('user_groups', 'allowed_providers'),
('user_groups', 'allowed_api_formats'),
('user_groups', 'allowed_models'),
('provider_api_keys', 'api_formats'),
('provider_api_keys', 'allowed_models')
) AS policy_columns(table_name, column_name)
LOOP
EXECUTE format(
$statement$
UPDATE public.%1$I
SET %2$I = NULL
WHERE json_typeof(%2$I::json) = 'null'
OR (
json_typeof(%2$I::json) = 'string'
AND (%2$I::json #>> '{}') ~* '^[[:space:]]*(null)?[[:space:]]*$'
)
$statement$,
policy_column.table_name,
policy_column.column_name
);
END LOOP;
END;
$migration$;
UPDATE public.management_tokens
SET allowed_ips = NULL
WHERE json_typeof(allowed_ips::json) = 'null';
UPDATE public.management_tokens
SET permissions = NULL
WHERE json_typeof(permissions::json) = 'null';
@@ -27,6 +27,8 @@ use crate::lifecycle::bootstrap::postgres::{
EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION, EMPTY_DATABASE_SNAPSHOT_SQL,
};
mod policy_nulls;
/// A clean PostgreSQL database is bootstrapped from the schema snapshot first;
/// migrations after the privacy/security frontier are intentionally left
/// pending so their data-preserving changes still execute. Exercise the same
@@ -1572,6 +1574,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
20260831030000,
20260901000000,
20260903000000,
20260908000000,
]
);
}
@@ -0,0 +1,380 @@
use aether_data_contracts::repository::{
auth::AuthApiKeyLookupKey, management_tokens::ManagementTokenReadRepository,
};
use aether_data_postgres::{
SqlxAuthApiKeySnapshotReadRepository, SqlxManagementTokenRepository, SqlxUserReadRepository,
};
use serde_json::{json, Value};
use sqlx::{migrate::Migrate, query, query_scalar, Connection, PgConnection, PgPool};
use super::{prepare_database_for_startup, ManagedPostgresServer, POSTGRES_MIGRATOR};
use crate::lifecycle::migrate::run_migrations;
const POLICY_NULL_MIGRATION_VERSION: i64 = 20260908000000;
const POLICY_COLUMNS: &[(&str, &[&str])] = &[
(
"api_keys",
&[
"allowed_providers",
"allowed_api_formats",
"allowed_models",
"ip_rules",
],
),
(
"users",
&["allowed_providers", "allowed_api_formats", "allowed_models"],
),
(
"user_groups",
&["allowed_providers", "allowed_api_formats", "allowed_models"],
),
("provider_api_keys", &["api_formats", "allowed_models"]),
("management_tokens", &["allowed_ips", "permissions"]),
];
#[tokio::test]
async fn postgres_policy_null_migration_preserves_non_null_policies_and_is_idempotent() {
let migration = POSTGRES_MIGRATOR
.iter()
.find(|migration| migration.version == POLICY_NULL_MIGRATION_VERSION)
.expect("legacy policy null migration should be embedded");
let legacy_values = [
Value::Null,
json!("null"),
json!(" NULL "),
json!("\tNuLl\r\n"),
json!(""),
json!(" \t\r\n"),
];
let preserved_values = [
json!([]),
json!(["provider-allowed"]),
json!(["openai:chat", "claude:chat"]),
json!(["127.0.0.1/32"]),
json!(["null"]),
json!("provider-allowed"),
json!("[\"provider-allowed\"]"),
json!("null-provider"),
json!("nullnull"),
json!([null]),
json!([" "]),
json!([1]),
json!({}),
json!({"policy": null}),
json!(true),
json!(42),
];
let cases = std::iter::once((None, None))
.chain(legacy_values.into_iter().map(|value| (Some(value), None)))
.chain(
preserved_values
.into_iter()
.map(|value| (Some(value.clone()), Some(value))),
)
.collect::<Vec<_>>();
for storage_type in ["json", "jsonb"] {
let Some(server) = ManagedPostgresServer::try_start()
.await
.expect("postgres policy null test should start or skip")
else {
return;
};
let pool = PgPool::connect(server.database_url())
.await
.expect("policy fixture pool should connect");
for &(table_name, columns) in POLICY_COLUMNS {
let definitions = columns
.iter()
.map(|column| format!("{column} {storage_type}"))
.collect::<Vec<_>>()
.join(", ");
let modes = if matches!(table_name, "users" | "user_groups") {
", allowed_providers_mode text DEFAULT 'deny_all', \
allowed_api_formats_mode text DEFAULT 'specific', \
allowed_models_mode text DEFAULT 'inherit'"
} else {
""
};
query(&format!(
"CREATE TABLE public.{table_name} \
(id integer PRIMARY KEY, {definitions}, metadata {storage_type}{modes})"
))
.execute(&pool)
.await
.expect("policy fixture table should be created");
let placeholders = vec![format!("$2::{storage_type}"); columns.len()].join(", ");
let insert_sql = format!(
"INSERT INTO public.{table_name} (id, {}, metadata) \
VALUES ($1, {placeholders}, 'null'::{storage_type})",
columns.join(", ")
);
for (case_index, (input, _)) in cases.iter().enumerate() {
query(&insert_sql)
.bind(i32::try_from(case_index).expect("fixture index should fit i32"))
.bind(input.clone())
.execute(&pool)
.await
.expect("policy fixture should insert");
}
}
for attempt in 0..2 {
sqlx::raw_sql(&migration.sql)
.execute(&pool)
.await
.expect("legacy policy null migration should execute");
for &(table_name, columns) in POLICY_COLUMNS {
let expected_values = cases
.iter()
.map(|(input, expected)| match (table_name, input) {
("management_tokens", Some(Value::String(_))) => input.clone(),
_ => expected.clone(),
})
.collect::<Vec<_>>();
let expected_sql_null_ids = expected_values
.iter()
.enumerate()
.filter(|(_, value)| value.is_none())
.map(|(index, _)| i32::try_from(index).expect("fixture index should fit i32"))
.collect::<Vec<_>>();
for column in columns {
let actual_values = query_scalar::<_, Option<Value>>(&format!(
"SELECT {column} FROM public.{table_name} ORDER BY id"
))
.fetch_all(&pool)
.await
.expect("migrated policy values should be readable");
assert_eq!(
actual_values, expected_values,
"{storage_type}: {table_name}.{column}, attempt {attempt}"
);
let sql_null_ids = query_scalar::<_, i32>(&format!(
"SELECT id FROM public.{table_name} WHERE {column} IS NULL ORDER BY id"
))
.fetch_all(&pool)
.await
.expect("actual SQL NULL rows should be readable");
assert_eq!(
sql_null_ids, expected_sql_null_ids,
"{storage_type}: {table_name}.{column} must contain actual SQL NULL"
);
}
let metadata_preserved: bool = query_scalar(&format!(
"SELECT bool_and(metadata IS NOT NULL AND json_typeof(metadata::json) = 'null') \
FROM public.{table_name}"
))
.fetch_one(&pool)
.await
.expect("unrelated JSON null metadata should remain readable");
assert!(
metadata_preserved,
"{table_name}.metadata must not be cleared"
);
if matches!(table_name, "users" | "user_groups") {
let modes: Vec<(String, String, String)> = sqlx::query_as(&format!(
"SELECT DISTINCT allowed_providers_mode, allowed_api_formats_mode, \
allowed_models_mode FROM public.{table_name}"
))
.fetch_all(&pool)
.await
.expect("policy modes should be readable");
assert_eq!(
modes,
vec![(
"deny_all".to_string(),
"specific".to_string(),
"inherit".to_string(),
)],
"{table_name} policy modes must not change"
);
}
}
}
pool.close().await;
}
}
#[tokio::test]
async fn postgres_policy_null_migration_repairs_legacy_upgrade_before_auth_reads() {
let Some(server) = ManagedPostgresServer::try_start()
.await
.expect("postgres policy upgrade test should start or skip")
else {
return;
};
let mut connection = PgConnection::connect(server.database_url())
.await
.expect("legacy database connection should open");
connection
.ensure_migrations_table()
.await
.expect("legacy migration bookkeeping should be created");
for migration in POSTGRES_MIGRATOR
.iter()
.filter(|migration| migration.version < POLICY_NULL_MIGRATION_VERSION)
{
connection
.apply(migration)
.await
.expect("previous PostgreSQL migrations should apply");
}
drop(connection);
let pool = PgPool::connect(server.database_url())
.await
.expect("legacy database pool should connect");
sqlx::raw_sql(
r#"
INSERT INTO public.users (id, username, email_verified)
VALUES ('policy-key-owner', 'policy-key-owner', FALSE),
('legacy-policy-user', 'legacy-policy-user', FALSE);
UPDATE public.users
SET allowed_providers = 'null',
allowed_api_formats = '"null"',
allowed_models = '""',
allowed_models_mode = 'deny_all'
WHERE id = 'legacy-policy-user';
INSERT INTO public.api_keys (
id, user_id, key_hash, allowed_providers, allowed_api_formats, allowed_models, ip_rules
)
VALUES (
'legacy-policy-key', 'policy-key-owner', repeat('a', 64), 'null', '"null"', '""', 'null'
);
INSERT INTO public.user_groups (
id, name, normalized_name, allowed_providers, allowed_api_formats, allowed_models,
allowed_providers_mode, allowed_api_formats_mode, allowed_models_mode
)
VALUES (
'legacy-policy-group', 'Legacy policy', 'legacy-policy', 'null', '"null"', '""',
'deny_all', 'specific', 'inherit'
);
INSERT INTO public.management_tokens (
id, user_id, token_hash, name, allowed_ips, permissions
)
VALUES (
'legacy-policy-token', 'policy-key-owner', repeat('b', 64), 'Legacy token', 'null', 'null'
), (
'restricted-policy-token', 'policy-key-owner', repeat('c', 64), 'Restricted token',
'["127.0.0.1"]', '["admin:users:read"]'
);
"#,
)
.execute(&pool)
.await
.expect("legacy policy fixtures should insert");
let auth_repository = SqlxAuthApiKeySnapshotReadRepository::new(pool.clone());
let users_repository = SqlxUserReadRepository::new(pool.clone());
let tokens_repository = SqlxManagementTokenRepository::new(pool.clone());
let api_key_error = auth_repository
.find_api_key_snapshot(AuthApiKeyLookupKey::ApiKeyId("legacy-policy-key"))
.await
.expect_err("legacy API key JSON null should reproduce the upgrade failure");
assert!(api_key_error
.to_string()
.contains("api_keys.allowed_providers contains JSON null"));
assert!(users_repository
.find_user_auth_by_id("legacy-policy-user")
.await
.expect_err("legacy user JSON null should fail strict policy decoding")
.to_string()
.contains("users.allowed_providers contains JSON null"));
assert!(users_repository
.find_user_group_by_id("legacy-policy-group")
.await
.expect_err("legacy group JSON null should fail strict policy decoding")
.to_string()
.contains("user_groups.allowed_providers contains JSON null"));
let legacy_token = tokens_repository
.get_management_token_with_user("legacy-policy-token")
.await
.expect("legacy management token should be readable")
.expect("legacy management token should exist");
assert_eq!(legacy_token.token.allowed_ips, Some(Value::Null));
assert_eq!(legacy_token.token.permissions, Some(Value::Null));
let pending = prepare_database_for_startup(&pool)
.await
.expect("legacy database startup preparation should succeed");
assert_eq!(
pending.first().map(|migration| migration.version),
Some(POLICY_NULL_MIGRATION_VERSION)
);
run_migrations(&pool)
.await
.expect("startup should normalize legacy policies");
let snapshot = auth_repository
.find_api_key_snapshot(AuthApiKeyLookupKey::ApiKeyId("legacy-policy-key"))
.await
.expect("upgraded API key should decode")
.expect("upgraded API key should still exist");
assert!(snapshot.api_key_allowed_providers.is_none());
assert!(snapshot.api_key_allowed_api_formats.is_none());
assert!(snapshot.api_key_allowed_models.is_none());
assert!(snapshot.api_key_ip_rules.is_none());
let exported_keys = auth_repository
.list_export_api_keys_by_ids(&["legacy-policy-key".to_string()])
.await
.expect("upgraded API key listing should decode");
assert_eq!(exported_keys.len(), 1);
assert!(exported_keys[0].allowed_providers.is_none());
let user = users_repository
.find_user_auth_by_id("legacy-policy-user")
.await
.expect("upgraded user should decode")
.expect("upgraded user should still exist");
assert!(user.allowed_providers.is_none());
assert!(user.allowed_api_formats.is_none());
assert!(user.allowed_models.is_none());
assert_eq!(user.allowed_models_mode, "deny_all");
let group = users_repository
.find_user_group_by_id("legacy-policy-group")
.await
.expect("upgraded group should decode")
.expect("upgraded group should still exist");
assert!(group.allowed_providers.is_none());
assert!(group.allowed_api_formats.is_none());
assert!(group.allowed_models.is_none());
assert_eq!(group.allowed_providers_mode, "deny_all");
assert_eq!(group.allowed_api_formats_mode, "specific");
assert_eq!(group.allowed_models_mode, "inherit");
let legacy_token = tokens_repository
.get_management_token_with_user("legacy-policy-token")
.await
.expect("upgraded management token should decode")
.expect("upgraded management token should still exist");
assert!(legacy_token.token.allowed_ips.is_none());
assert!(legacy_token.token.permissions.is_none());
let restricted_token = tokens_repository
.get_management_token_with_user("restricted-policy-token")
.await
.expect("restricted management token should decode")
.expect("restricted management token should still exist");
assert_eq!(
restricted_token.token.allowed_ips,
Some(json!(["127.0.0.1"]))
);
assert_eq!(
restricted_token.token.permissions,
Some(json!(["admin:users:read"]))
);
run_migrations(&pool)
.await
.expect("restarting an upgraded database should be safe");
assert!(prepare_database_for_startup(&pool)
.await
.expect("upgraded database should remain current")
.is_empty());
pool.close().await;
}
+150 -6
View File
@@ -666,7 +666,8 @@ impl RollingFileSink {
config: FileLoggingConfig,
cleanup: fn(&str, &FileLoggingConfig) -> io::Result<usize>,
) -> io::Result<(Self, Option<StartupCleanupWarning>)> {
fs::create_dir_all(&config.dir)?;
fs::create_dir_all(&config.dir)
.map_err(|error| log_destination_error("create log directory", &config.dir, error))?;
let startup_cleanup_warning =
cleanup(service_name, &config)
.err()
@@ -727,13 +728,39 @@ fn open_bucketed_log_file(dir: &Path, service_name: &str, bucket: &str) -> io::R
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
}
let file = options.open(&path)?;
validate_open_log_file(&file, &path)?;
let file = options
.open(&path)
.map_err(|error| log_destination_error("open log file", &path, error))?;
validate_open_log_file(&file, &path)
.map_err(|error| log_destination_error("validate log file", &path, error))?;
Ok(file)
}
fn log_destination_error(operation: &str, path: &Path, error: io::Error) -> io::Error {
let mut message = format!("failed to {operation} {}: {error}", path.display());
#[cfg(unix)]
{
let process_uid = unsafe { libc::geteuid() };
let process_gid = unsafe { libc::getegid() };
message.push_str(&format!(" (process uid={process_uid}, gid={process_gid})"));
}
message.push_str(
"; check ownership and write permissions for the log directory and existing log files",
);
io::Error::new(error.kind(), message)
}
#[cfg(unix)]
fn validate_open_log_file(file: &File, path: &Path) -> io::Result<()> {
validate_open_log_file_for_user(file, path, unsafe { libc::geteuid() })
}
#[cfg(unix)]
fn validate_open_log_file_for_user(
file: &File,
path: &Path,
process_uid: libc::uid_t,
) -> io::Result<()> {
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
let metadata = file.metadata()?;
@@ -743,12 +770,14 @@ fn validate_open_log_file(file: &File, path: &Path) -> io::Result<()> {
format!("log destination is not a regular file: {}", path.display()),
));
}
if metadata.uid() != unsafe { libc::geteuid() } {
if process_uid != 0 && metadata.uid() != process_uid {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"log destination is owned by another user: {}",
path.display()
"log destination is owned by another user: {} (owner uid={}, expected uid={process_uid}); \
chmod does not change file ownership",
path.display(),
metadata.uid()
),
));
}
@@ -1081,6 +1110,121 @@ mod tests {
fs::remove_dir_all(&warning.log_dir).expect("temp dir should be removable");
}
#[test]
fn rolling_file_sink_reports_directory_creation_failure_with_context() {
let dir = std::env::temp_dir().join(format!("aether-runtime-logs-{}", Uuid::new_v4()));
fs::create_dir_all(&dir).expect("temp dir should exist");
let blocker = dir.join("not-a-directory");
fs::write(&blocker, b"unchanged").expect("blocking file should exist");
let log_dir = blocker.join("logs");
let config = FileLoggingConfig::new(&log_dir, LogRotation::Daily, 7, 30);
let error = RollingFileSink::new("runtime-test", config)
.expect_err("a regular file must not be treated as a directory");
let message = error.to_string();
assert!(message.contains("failed to create log directory"));
assert!(message.contains(&log_dir.display().to_string()));
assert!(message.contains("ownership and write permissions"));
assert_eq!(fs::read(&blocker).expect("blocking file"), b"unchanged");
fs::remove_dir_all(&dir).expect("temp dir should be removable");
}
#[test]
fn rolling_log_file_reports_open_failure_with_context() {
let dir = std::env::temp_dir().join(format!("aether-runtime-logs-{}", Uuid::new_v4()));
let path = bucketed_log_path(&dir, "runtime-test", "missing");
let error = open_bucketed_log_file(&dir, "runtime-test", "missing")
.expect_err("a missing parent directory must prevent file creation");
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
let message = error.to_string();
assert!(message.contains("failed to open log file"));
assert!(message.contains(&path.display().to_string()));
assert!(message.contains("ownership and write permissions"));
#[cfg(unix)]
{
assert!(message.contains(&format!("process uid={}", unsafe { libc::geteuid() })));
assert!(message.contains(&format!("gid={}", unsafe { libc::getegid() })));
}
}
#[cfg(unix)]
#[test]
fn rolling_log_file_rejects_another_owner_even_with_world_writable_permissions() {
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
let dir = std::env::temp_dir().join(format!("aether-runtime-logs-{}", Uuid::new_v4()));
fs::create_dir_all(&dir).expect("temp dir should exist");
let path = bucketed_log_path(&dir, "runtime-test", "another-owner");
fs::write(&path, b"unchanged").expect("log file should exist");
fs::set_permissions(&path, fs::Permissions::from_mode(0o777))
.expect("log permissions should be writable by all users");
let file = fs::File::open(&path).expect("log should open");
let owner_uid = file.metadata().expect("log metadata").uid();
let process_uid = owner_uid.wrapping_add(1);
let error = super::validate_open_log_file_for_user(&file, &path, process_uid)
.expect_err("chmod must not bypass the ownership check");
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
let message = error.to_string();
assert!(message.contains("log destination is owned by another user"));
assert!(message.contains(&format!("owner uid={owner_uid}")));
assert!(message.contains(&format!("expected uid={process_uid}")));
assert!(message.contains("chmod does not change file ownership"));
assert_eq!(fs::read(&path).expect("log contents"), b"unchanged");
assert_eq!(
file.metadata().expect("log metadata").permissions().mode() & 0o777,
0o777
);
drop(file);
let reopened = open_bucketed_log_file(&dir, "runtime-test", "another-owner")
.expect("the actual owner should be able to reopen the log");
assert_eq!(
reopened
.metadata()
.expect("log metadata")
.permissions()
.mode()
& 0o777,
0o600
);
assert_eq!(fs::read(&path).expect("log contents"), b"unchanged");
drop(reopened);
fs::remove_dir_all(&dir).expect("temp dir should be removable");
}
#[cfg(unix)]
#[test]
fn root_log_validation_accepts_existing_owners_but_rejects_hardlinks() {
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
let dir = std::env::temp_dir().join(format!("aether-runtime-logs-{}", Uuid::new_v4()));
fs::create_dir_all(&dir).expect("temp dir should exist");
let path = bucketed_log_path(&dir, "runtime-test", "existing-owner");
fs::write(&path, b"unchanged").expect("log file should exist");
let file = fs::File::open(&path).expect("log should open");
let owner_uid = file.metadata().expect("log metadata").uid();
super::validate_open_log_file_for_user(&file, &path, 0)
.expect("root may use a regular log file without changing its owner");
let metadata = file.metadata().expect("log metadata");
assert_eq!(metadata.uid(), owner_uid);
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
assert_eq!(fs::read(&path).expect("log contents"), b"unchanged");
fs::hard_link(&path, dir.join("other-link.log")).expect("hardlink should exist");
let error = super::validate_open_log_file_for_user(&file, &path, 0)
.expect_err("root must still reject a log file with multiple hard links");
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
assert!(error.to_string().contains("multiple hard links"));
drop(file);
fs::remove_dir_all(&dir).expect("temp dir should be removable");
}
#[test]
fn pretty_formatter_omits_service_identity_fields() {
let writer = SharedBuffer::default();
@@ -0,0 +1,140 @@
#![cfg(target_os = "linux")]
use std::fs;
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
use std::path::PathBuf;
use std::process::Command;
use aether_runtime::{
init_reloadable_service_tracing, init_service_runtime, FileLoggingConfig, LogDestination,
LogFormat, LogRotation, ServiceRuntimeConfig,
};
#[test]
#[ignore = "requires the fixture image from tests/root_logging.Dockerfile and the production capability profile"]
fn root_appends_to_existing_logs_without_changing_ownership() {
if let Ok(scenario) = std::env::var("AETHER_TEST_ROOT_LOGGING_CASE") {
run_scenario(&scenario);
return;
}
for entrypoint in ["standard", "reloadable"] {
for destination in ["file", "both"] {
for format in ["pretty", "json"] {
for owner in ["0", "1000", "65532", "new"] {
let scenario = format!("{entrypoint}-{destination}-{format}-{owner}");
let output = Command::new(std::env::current_exe().expect("test executable"))
.args([
"--ignored",
"--exact",
"root_appends_to_existing_logs_without_changing_ownership",
"--nocapture",
])
.env("AETHER_TEST_ROOT_LOGGING_CASE", &scenario)
.env_remove("RUST_LOG")
.output()
.expect("root logging subprocess");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "{scenario}: {stdout}\n{stderr}");
assert_eq!(
stdout.matches("root logging ready").count(),
usize::from(destination == "both"),
"{scenario}: {stdout}"
);
assert!(!stderr.contains("stdout logging"), "{scenario}: {stderr}");
}
}
}
}
}
fn run_scenario(scenario: &str) {
assert_eq!(unsafe { libc::geteuid() }, 0);
assert_eq!(unsafe { libc::getegid() }, 0);
let process_status = fs::read_to_string("/proc/self/status").expect("process status");
for capability in ["CapEff", "CapPrm", "CapBnd"] {
let value = process_status
.lines()
.find_map(|line| line.strip_prefix(&format!("{capability}:")))
.expect("capability field");
assert_eq!(
u64::from_str_radix(value.trim(), 16).expect("capability bits"),
0xa
);
}
assert!(process_status.lines().any(|line| {
line.strip_prefix("NoNewPrivs:")
.is_some_and(|value| value.trim() == "1")
}));
let parts: Vec<_> = scenario.split('-').collect();
let [entrypoint, destination, format, owner] = parts.as_slice() else {
panic!("invalid scenario: {scenario}");
};
let dir = PathBuf::from("/logs").join(scenario);
let bucket = chrono::Local::now().format("%Y-%m-%d");
let log_file = dir.join(format!("root-logging-test.{bucket}.log"));
let directory_metadata = fs::metadata(&dir).expect("pre-existing foreign-owned directory");
assert_eq!(directory_metadata.uid(), 1000);
assert_eq!(directory_metadata.gid(), 1000);
assert_eq!(directory_metadata.permissions().mode() & 0o777, 0o750);
let expected_owner = if *owner == "new" {
assert!(!log_file.exists());
0
} else {
let owner_uid: u32 = owner.parse().expect("fixture owner");
let metadata = fs::metadata(&log_file).expect("pre-existing log");
assert_eq!(metadata.uid(), owner_uid);
assert_eq!(metadata.gid(), owner_uid);
assert_eq!(metadata.permissions().mode() & 0o777, 0o640);
owner_uid
};
let config = ServiceRuntimeConfig::new("root-logging-test", "info")
.with_log_destination(match *destination {
"file" => LogDestination::File,
"both" => LogDestination::Both,
other => panic!("unknown destination: {other}"),
})
.with_log_format(match *format {
"pretty" => LogFormat::Pretty,
"json" => LogFormat::Json,
other => panic!("unknown format: {other}"),
})
.with_file_logging(FileLoggingConfig::new(&dir, LogRotation::Daily, 7, 30));
let _reloader = match *entrypoint {
"standard" => {
init_service_runtime(config).expect("root should initialize file logging");
None
}
"reloadable" => Some(
init_reloadable_service_tracing("info", config)
.expect("root should initialize reloadable file logging"),
),
other => panic!("unknown entrypoint: {other}"),
};
tracing::info!("root logging ready");
let metadata = fs::metadata(&log_file).expect("written log file");
assert_eq!(metadata.uid(), expected_owner);
assert_eq!(metadata.gid(), expected_owner);
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
let contents = fs::read_to_string(&log_file).expect("log contents");
assert_eq!(contents.matches("root logging ready").count(), 1);
if *owner != "new" {
assert!(contents.starts_with("historical log\n"));
}
if *format == "json" {
let event: serde_json::Value =
serde_json::from_str(contents.lines().last().expect("log event"))
.expect("JSON file log");
assert_eq!(event["fields"]["message"], "root logging ready");
}
let directory_metadata = fs::metadata(&dir).expect("log directory");
assert_eq!(directory_metadata.uid(), 1000);
assert_eq!(directory_metadata.gid(), 1000);
assert_eq!(directory_metadata.permissions().mode() & 0o777, 0o750);
}
+4 -1
View File
@@ -58,10 +58,13 @@ services:
app:
image: ${APP_IMAGE:-ghcr.io/fawney19/aether:latest}
container_name: aether-app
user: "${AETHER_CONTAINER_UID:-65532}:${AETHER_CONTAINER_GID:-65532}"
user: "0:0"
read_only: true
cap_drop:
- ALL
cap_add:
- DAC_OVERRIDE
- FOWNER
security_opt:
- no-new-privileges:true
tmpfs:
+4 -1
View File
@@ -58,10 +58,13 @@ services:
app:
image: ${APP_IMAGE:-ghcr.io/fawney19/aether:latest}
container_name: aether-app
user: "${AETHER_CONTAINER_UID:-65532}:${AETHER_CONTAINER_GID:-65532}"
user: "0:0"
read_only: true
cap_drop:
- ALL
cap_add:
- DAC_OVERRIDE
- FOWNER
security_opt:
- no-new-privileges:true
tmpfs:
@@ -0,0 +1,47 @@
# 历史权限空值升级修复
## 原因
旧版 API Key、用户、用户组及上游 Key 允许把 JSON 字面量 `null`、字符串 `"null"`(忽略大小写和首尾空白)以及空字符串作为未设置的权限。严格权限读取启用后,这些值会触发 `contains JSON null; use SQL NULL for an unset policy` 等错误,影响 API Key 鉴权、列表和用户、用户组读取。管理令牌的 IP 限制和权限也曾接受 JSON 字面量 `null`,但不接受字符串空值;严格校验同样会使这些旧令牌失效。
增量迁移 `20260908000000_normalize_legacy_policy_nulls.sql` 将这些已知的旧版空值转换成 SQL `NULL`,不修改既有迁移及其校验和。新安装和已有数据库升级均通过同一迁移机制执行。
## 覆盖范围
| 表 | 字段 |
| --- | --- |
| `api_keys` | `allowed_providers``allowed_api_formats``allowed_models``ip_rules` |
| `users` | `allowed_providers``allowed_api_formats``allowed_models` |
| `user_groups` | `allowed_providers``allowed_api_formats``allowed_models` |
| `provider_api_keys` | `api_formats``allowed_models` |
| `management_tokens` | `allowed_ips``permissions`(仅 JSON 字面量 `null` |
共 5 张表、14 个字段,同时兼容 `json``jsonb` 列。迁移可重复执行,且只更新命中旧版空值的字段:
- 保留 SQL `NULL`、空数组 `[]`、正常名单、字符串化的名单和单字符串策略。
- 保留 `specific``deny_all``inherit` 等权限模式,不修改用户组成员关系。
- 管理令牌只转换 JSON 字面量 `null`,恢复旧版既有的未设置 IP 限制或 `legacy_full` 权限语义;字符串 `"null"`、空字符串、空数组和其他非法权限不转换,避免把原本无效的令牌权限变成旧版全权限。
- 不清理数组内部的 `null`、空白元素、数字、对象或其他异常权限;它们仍由严格读取逻辑拒绝,不能借迁移变成无限制访问。
- 不修改其他 JSON 字段,例如 `metadata` 内的 JSON `null`
## 升级方式
先备份数据库,部署包含此迁移的新网关二进制或镜像,并保留原来的数据库连接配置与密钥。仅重启不包含此迁移的旧版本不会修复数据。
默认 `AETHER_GATEWAY_DATABASE_MODE=auto` 会在启动时执行挂起迁移,再进入正常服务。使用 `verify-only` 的部署需在相同数据库连接配置下先执行新版本的准备命令,再重启服务:
```sh
aether-gateway db prepare
```
迁移只更新上述权限列,不删除业务记录、不替换用户或 Key、不重建数据库。不要把空数组或任意异常 JSON 统一改成 SQL `NULL`,也不要通过关闭严格权限校验绕过问题。
升级后可以只读检查迁移记录:
```sql
SELECT version, description, success
FROM public._sqlx_migrations
WHERE version = 20260908000000;
```
该记录应存在且 `success = true`。若仍有权限解码错误,核对实际报错实例连接的数据库,以及是否有旧进程或外部工具继续写入旧格式;不要将其他类型的权限错误直接作为空值清除。
-42
View File
@@ -48,10 +48,6 @@ COMPOSE_LOG_ROTATION_DEFAULT="daily"
COMPOSE_LOG_RETENTION_DAYS_DEFAULT="7"
COMPOSE_LOG_MAX_FILES_DEFAULT="30"
COMPOSE_APP_PORT_DEFAULT="8084"
COMPOSE_CONTAINER_UID_DEFAULT="65532"
COMPOSE_CONTAINER_GID_DEFAULT="65532"
COMPOSE_CONTAINER_UID=""
COMPOSE_CONTAINER_GID=""
COMPOSE_CLI=()
LAUNCHD_LABEL="${AETHER_LAUNCHD_LABEL:-com.aether.gateway}"
LAUNCHD_LOG_DIR="${AETHER_LAUNCHD_LOG_DIR:-/var/log/aether}"
@@ -1764,41 +1760,6 @@ compose_app_port() {
printf '%s\n' "${APP_PORT:-${COMPOSE_APP_PORT_DEFAULT}}"
}
validate_compose_container_id() {
local label="$1"
local value="$2"
[[ "${value}" =~ ^[1-9][0-9]*$ ]] \
|| die "${label} must be a positive numeric id, not root: ${value}"
[[ ${#value} -le 10 ]] \
|| die "${label} is outside the supported numeric id range: ${value}"
(( 10#${value} <= 2147483647 )) \
|| die "${label} is outside the supported numeric id range: ${value}"
}
resolve_new_compose_container_identity() {
local uid="${AETHER_CONTAINER_UID:-}"
local gid="${AETHER_CONTAINER_GID:-}"
if [[ -z "${uid}" || -z "${gid}" ]]; then
if [[ "${EUID}" -eq 0 && -n "${SUDO_UID:-}" && -n "${SUDO_GID:-}" ]]; then
uid="${uid:-${SUDO_UID}}"
gid="${gid:-${SUDO_GID}}"
elif [[ "${EUID}" -ne 0 ]]; then
uid="${uid:-$(id -u)}"
gid="${gid:-$(id -g)}"
else
uid="${uid:-${COMPOSE_CONTAINER_UID_DEFAULT}}"
gid="${gid:-${COMPOSE_CONTAINER_GID_DEFAULT}}"
fi
fi
validate_compose_container_id "AETHER_CONTAINER_UID" "${uid}"
validate_compose_container_id "AETHER_CONTAINER_GID" "${gid}"
COMPOSE_CONTAINER_UID="$((10#${uid}))"
COMPOSE_CONTAINER_GID="$((10#${gid}))"
}
append_compose_log_env_defaults() {
local output="$1"
replace_or_append_env "${output}" "AETHER_LOG_DESTINATION" "${COMPOSE_LOG_DESTINATION_DEFAULT}"
@@ -1817,13 +1778,10 @@ generate_compose_env() {
encryption_key="$(urlsafe_rand 32)"
db_password="$(urlsafe_rand 32)"
redis_password="$(urlsafe_rand 32)"
resolve_new_compose_container_identity
cp "${COMPOSE_DIR}/.env.example" "${output}"
replace_or_append_env "${output}" "APP_IMAGE" "$(compose_image)"
replace_or_append_env "${output}" "APP_PORT" "$(compose_app_port)"
replace_or_append_env "${output}" "AETHER_CONTAINER_UID" "${COMPOSE_CONTAINER_UID}"
replace_or_append_env "${output}" "AETHER_CONTAINER_GID" "${COMPOSE_CONTAINER_GID}"
replace_or_append_env "${output}" "DB_PASSWORD" "${db_password}"
replace_or_append_env "${output}" "REDIS_PASSWORD" "${redis_password}"
replace_or_append_env "${output}" "JWT_SECRET_KEY" "${JWT_SECRET_KEY:-${jwt_key}}"
+34 -5
View File
@@ -33,21 +33,31 @@ with tempfile.TemporaryDirectory(prefix="aether-compose-databases-") as director
capture_output=True, text=True, check=False,
)
env_file.write_text("DB_PASSWORD=fixture-postgres\nREDIS_PASSWORD=fixture-redis\n")
for files in (
database_environment = "DB_PASSWORD=fixture-postgres\nREDIS_PASSWORD=fixture-redis\n"
standard_compose_files = (
["docker-compose.yml"],
["docker-compose.yml", "docker-compose.local.yml"],
["docker-compose.single-node.yml"],
):
)
env_file.write_text(database_environment)
for files in standard_compose_files:
result = compose_config(files)
assert result.returncode == 0, result.stderr
config = json.loads(result.stdout)
assert set(config["services"]) == {"app", "postgres", "redis"}
assert set(config["volumes"]) == {"postgres_data"}
app_env = config["services"]["app"]["environment"]
app = config["services"]["app"]
assert app["user"] == "0:0"
assert app["read_only"] is True
assert app["cap_drop"] == ["ALL"]
assert set(app["cap_add"]) == {"DAC_OVERRIDE", "FOWNER"}
assert app["security_opt"] == ["no-new-privileges:true"]
assert not app.get("privileged", False)
app_env = app["environment"]
assert app_env["AETHER_DATABASE_DRIVER"] == "postgres"
assert app_env["DATABASE_URL"] == "postgresql://postgres:fixture-postgres@postgres:5432/aether"
assert app_env["REDIS_URL"] == "redis://:fixture-redis@redis:6379/0"
assert app_env["AETHER_LOG_DESTINATION"] == "stdout"
for key in ("DB_PASSWORD", "REDIS_PASSWORD"):
result = compose_config(files, overrides={key: ""})
assert result.returncode != 0, f"empty {key} was accepted"
@@ -65,4 +75,23 @@ with tempfile.TemporaryDirectory(prefix="aether-compose-databases-") as director
assert result.returncode != 0, "empty DB_PASSWORD was accepted"
assert "set DB_PASSWORD in .env" in result.stderr, result.stderr
print("PASS: PostgreSQL/Redis Compose configurations")
for log_destination in ("file", "both"):
env_file.write_text(
database_environment
+ f"AETHER_LOG_DESTINATION={log_destination}\nAETHER_LOG_DIR=/app/logs\n"
+ "AETHER_CONTAINER_UID=65532\nAETHER_CONTAINER_GID=65532\n"
)
for files in standard_compose_files:
result = compose_config(files)
assert result.returncode == 0, result.stderr
app = json.loads(result.stdout)["services"]["app"]
assert app["user"] == "0:0", files
assert app["environment"]["AETHER_LOG_DESTINATION"] == "stdout", files
assert all(
volume["target"] not in ("/app/logs", "/opt/aether/logs")
for volume in app.get("volumes", [])
), files
assert app["logging"]["driver"] == "json-file"
assert app["logging"]["options"] == {"max-size": "100m", "max-file": "10"}
print("PASS: PostgreSQL/Redis Compose configurations and legacy file logging overrides")
@@ -42,25 +42,23 @@ COMPOSE_FILES=(
"${REPO_ROOT}/docker-compose.single-node.yml"
)
assert_line "${APP_DOCKERFILE}" "USER 65532:65532"
assert_line "${APP_DOCKERFILE}" "USER 0:0"
assert_line "${REPO_ROOT}/Dockerfile.app.local" "USER 0:0"
assert_line "${REPO_ROOT}/Dockerfile.app.release-local" "USER 0:0"
assert_line "${APP_DOCKERFILE}" " HOME=/tmp/aether-home \\"
if grep -Eq '^USER[[:space:]]+(root|0)(:0)?[[:space:]]*$' "${APP_DOCKERFILE}"; then
fail_test "production image still selects a root runtime identity"
fi
for compose_file in "${COMPOSE_FILES[@]}"; do
assert_line "${compose_file}" \
' user: "${AETHER_CONTAINER_UID:-65532}:${AETHER_CONTAINER_GID:-65532}"'
assert_line "${compose_file}" ' user: "0:0"'
assert_line "${compose_file}" " read_only: true"
assert_line "${compose_file}" " cap_drop:"
assert_line "${compose_file}" " - ALL"
assert_line "${compose_file}" " cap_add:"
assert_line "${compose_file}" " - DAC_OVERRIDE"
assert_line "${compose_file}" " - FOWNER"
assert_line "${compose_file}" " security_opt:"
assert_line "${compose_file}" " - no-new-privileges:true"
assert_line "${compose_file}" " tmpfs:"
assert_line "${compose_file}" " - /tmp:rw,nosuid,nodev,noexec,mode=1777"
if grep -Eq '^[[:space:]]+user:[[:space:]]+"?(root|0)(:0)?"?[[:space:]]*$' "${compose_file}"; then
fail_test "production Compose file still selects a root runtime identity: ${compose_file}"
fi
done
assert_line "${REPO_ROOT}/.env.example" "DB_PASSWORD="
@@ -84,19 +82,16 @@ mkdir -p "${fake_bin}"
printf '#!/usr/bin/env bash\nprintf "false\\n"\n' >"${fake_bin}/docker"
chmod 0755 "${fake_bin}/docker"
PATH="${fake_bin}:${PATH}"
fixture_uid="$(id -u)"
fixture_gid="$(id -g)"
[[ "${fixture_uid}" != "0" ]] || fixture_uid="65532"
[[ "${fixture_gid}" != "0" ]] || fixture_gid="65532"
cp "${REPO_ROOT}/.env.example" "${COMPOSE_DIR}/.env.example"
ADMIN_PASSWORD="test-admin-password"
APP_IMAGE="example.invalid/aether:test"
AETHER_CONTAINER_UID="${fixture_uid}"
AETHER_CONTAINER_GID="${fixture_gid}"
JWT_SECRET_KEY=""
ENCRYPTION_KEY=""
generated_env="${TEST_ROOT}/generated.env"
generate_compose_env "${generated_env}"
if grep -Eq '^AETHER_CONTAINER_(UID|GID)=' "${generated_env}"; then
fail_test "installer still generates obsolete non-root container identity settings"
fi
generated_secrets=()
for key in \
+28
View File
@@ -0,0 +1,28 @@
FROM ubuntu:24.04
COPY root-logging-tests /usr/local/bin/root-logging-tests
RUN set -eu; \
bucket="$(date -u +%Y-%m-%d)"; \
for entrypoint in standard reloadable; do \
for destination in file both; do \
for format in pretty json; do \
for owner in 0 1000 65532 new; do \
directory="/logs/${entrypoint}-${destination}-${format}-${owner}"; \
mkdir -p "${directory}"; \
chown 1000:1000 "${directory}"; \
chmod 0750 "${directory}"; \
if [ "${owner}" != new ]; then \
logfile="${directory}/root-logging-test.${bucket}.log"; \
printf 'historical log\n' >"${logfile}"; \
chown "${owner}:${owner}" "${logfile}"; \
chmod 0640 "${logfile}"; \
fi; \
done; \
done; \
done; \
done
USER 0:0
VOLUME ["/logs"]
ENTRYPOINT ["/usr/local/bin/root-logging-tests", "--ignored", "--exact", "root_appends_to_existing_logs_without_changing_ownership", "--nocapture"]