Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,5 @@ cython_debug/
# PyPI configuration file
.pypirc
.idea
/plugins/*
!/plugins/.gitkeep
25 changes: 20 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
ARG PYTHON_IMAGE=higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/python:3.11-alpine
ARG NGINX_IMAGE=higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/nginx:alpine
ARG ALPINE_MIRROR=""
ARG USE_LOCAL_PLUGINS=false

FROM $PYTHON_IMAGE AS builder
FROM $PYTHON_IMAGE AS builder-base

# 配置 Alpine 镜像源(可选,本地构建时可指定国内镜像加速)
ARG ALPINE_MIRROR
Expand All @@ -26,14 +27,28 @@ RUN set -eux; \
&& rm -rf /tmp/oras.tar.gz oras \
&& oras version

# 创建工作目录
WORKDIR /workspace

# 复制脚本
# 复制脚本和公共文件
COPY pull_plugins.py plugins.properties ./

# 执行构建操作
RUN python3 pull_plugins.py --download-v2
FROM builder-base AS local-true
# 启用本地模式:复制本地插件源
COPY plugins/ ./plugins/

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

本 PR 引入: local-true 阶段 COPY plugins/ ./plugins/ 把本 PR 新增的 plugins/.gitkeep 拷进 builder 镜像,最终经 Dockerfile:57 COPY --from=builder /workspace/plugins /usr/share/nginx/html/plugins 泄漏到 nginx 运行时镜像;nginx.conf autoindex on + root /usr/share/nginx/html 使其可被 HTTP GET 拉取。

失败场景: docker build --build-arg USE_LOCAL_PLUGINS=true 后,curl http://host:8080/plugins/.gitkeep 返回 200 OK 与 ~331 字节中文注释,泄露构建使用了本地覆盖模式。仅 USE_LOCAL_PLUGINS=true 受影响。

修复建议: 在 builder 阶段末尾加 RUN rm -f /workspace/plugins/.gitkeep(或 find /workspace/plugins -name .gitkeep -delete);或在 COPY plugins/ ./plugins/ 之后立即清理。


FROM builder-base AS local-false
# 不启用本地模式:创建空的 plugins 目录
RUN mkdir -p plugins

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

本 PR 引入 (cleanup): local-false 阶段的 RUN mkdir -p pluginspull_plugins.py:126os.makedirs(plugins_base_path, exist_ok=True) 重复 —— 脚本首次写入前会自行创建该目录。

失败场景: 多出一个空 RUN 层(每层几 MB、增加 cache key 一个 hash),reader 必须跳到脚本验证 mkdir 是否必需。删除后 local-false 可折叠为 FROM builder-base AS local-false 无 body,简化阶段选择链而不影响功能。

修复建议: 直接删除该 RUN 行。pull_plugins.py 会负责创建目录。


FROM local-${USE_LOCAL_PLUGINS:-false} AS builder

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

本 PR 引入: FROM local-${USE_LOCAL_PLUGINS:-false} AS builder 通过字符串拼接解析阶段名,只接受字面量 true(或未设置取默认 false);其他任何取值(TRUE/True/1/yes/on)都引用未定义阶段,BuildKit 报「failed to resolve stage name」。

失败场景: 用户按常见约定传 --build-arg USE_LOCAL_PLUGINS=TRUE(首字母大写在 YAML/JSON 里常见),阶段解析失败;即便用户传了非 true 值,line 47 [ "$USE_LOCAL_PLUGINS" = "true" ] 同样大小写敏感,会落到 else 分支执行 OCI 下载,行为与构建参数意图不一致。

修复建议: 在 builder 阶段开头加一次规范化(例如 RUN case "$USE_LOCAL_PLUGINS" in true|True|1|yes|YES) ;; *) echo "invalid USE_LOCAL_PLUGINS value: $USE_LOCAL_PLUGINS"; exit 1 ;; esac);或在 if 比较前用 tr 统一小写。


# 根据 USE_LOCAL_PLUGINS 决定是否启用本地 WASM 文件覆盖
ARG USE_LOCAL_PLUGINS
RUN if [ "$USE_LOCAL_PLUGINS" = "true" ]; then \
python3 pull_plugins.py --download-v2 --use-local; \
else \
python3 pull_plugins.py --download-v2; \
fi

# 运行阶段:最终镜像
FROM $NGINX_IMAGE
Expand Down
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,52 @@ MCP_SERVER_WASM_IMAGE_URL=http://higress-plugin-server.higress-system.svc/plugin

> `higress-plugin-server.higress-system.svc` 替换为 Higress Gateway 可以用来访问插件服务器的地址。

## 使用本地 WASM 文件

不想把 WASM 推送到 OCI 仓库时(自定义插件,或用自编译版本覆盖官方插件),可直接把 `plugin.wasm` 放进仓库的 `plugins/` 目录。

> ⚠️ **默认行为**:`USE_LOCAL_PLUGINS` 默认为 `false`,构建时按 `plugins.properties` 从 OCI 仓库下载所有插件,`plugins/` 目录被忽略。
> 要启用本地 WASM 覆盖,需在构建时指定 `--build-arg USE_LOCAL_PLUGINS=true`。

启用本地模式后,`plugins/<插件名>/<版本>/plugin.wasm` 会随构建上下文带入;与本地同名同版本的 OCI 下载会被自动跳过,构建结束时为 `plugins/<插件名>/<版本>/` 下的 `plugin.wasm` 自动生成 `metadata.txt`。**无需修改 `plugins.properties`**——本地插件按目录约定自动识别。

### 启用本地 WASM 构建

```bash
docker build --build-arg USE_LOCAL_PLUGINS=true -t higress-plugin-server:1.0.0 -f Dockerfile .
```

也可与国内镜像加速参数组合使用:

```bash
docker build \
--build-arg ALPINE_MIRROR=mirrors.aliyun.com \
--build-arg USE_LOCAL_PLUGINS=true \
-t higress-plugin-server:1.0.0 \
-f Dockerfile .
```

### 两种用法

1. **新增自定义插件**:在 `plugins/<插件名>/<版本>/` 下放置 `plugin.wasm`,不要写进 `plugins.properties`。它不会被下载,构建后自动生成 `metadata.txt` 并被 serve。需要哪些版本就放哪些版本目录。

2. **覆盖官方插件**:保留 `plugins.properties` 中该插件的 OCI 配置,同时把你的 `plugin.wasm` 放到同名同版本目录下。启用本地模式后脚本检测到本地副本会跳过下载。`Dockerfile` 构建时同时处理 `1.0.0` 和 `2.0.0` 两个版本,所以要覆盖哪个版本就把文件放进对应版本目录。

### 目录结构

按 `plugins/<插件名>/<版本>/plugin.wasm` 放置;`metadata.txt` 无需手动提供,构建时按实际文件自动(重)生成:

```
. ← 仓库根目录(构建上下文)
├── plugins.properties
└── plugins/
└── my-plugin/
├── 1.0.0/
│ └── plugin.wasm
└── 2.0.0/
└── plugin.wasm
```

## 参考文档

- 如何修改内置插件镜像地址:https://higress.cn/docs/latest/ops/how-tos/builtin-plugin-url/
Expand Down
4 changes: 4 additions & 0 deletions plugins/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# 此占位文件确保 plugins/ 目录被 git 跟踪,从而使 Dockerfile 的
# "COPY plugins/ ./plugins/" 在全新克隆后仍有源目录可用。
# 本地/自定义插件以 plugins/<插件名>/<版本>/plugin.wasm 形式放入此目录;
# 下载产物与 metadata.txt 由构建生成,不纳入版本控制(见 .gitignore)。
133 changes: 82 additions & 51 deletions pull_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,61 +93,46 @@ def generate_metadata(plugin_dir, plugin_name):
except Exception as e:
print(f"生成元数据失败: {e}")

def main():
parser = argparse.ArgumentParser(description='处理插件配置文件')
parser.add_argument('properties_path', nargs='?', default=None,
help='properties文件路径(默认:脚本所在目录下的plugins.properties)')
parser.add_argument('--download-v2', action='store_true',
help='是否下载 2.0.0 版本插件')
args = parser.parse_args()

# 用户未提供路径时,使用默认逻辑
if args.properties_path is None:
script_dir = os.path.dirname(os.path.abspath(__file__))
default_path = os.path.join(script_dir, 'plugins.properties')

args.properties_path = default_path

base_path = os.path.dirname(args.properties_path)
properties = read_properties(args.properties_path)

if not properties:
print("未找到有效的插件配置")
return

failed_plugins = []

for plugin_name, plugin_url in properties.items():
print(f"\n正在处理插件: {plugin_name}")
# 处理原始版本(1.0.0)
success = process_plugin(base_path, plugin_name, plugin_url, "1.0.0")
if not success:
failed_plugins.append(f"{plugin_name}:1.0.0")

# 如果指定了 --download-v2 参数,则额外处理 2.0.0 版本
if args.download_v2:
v2_url = plugin_url.replace(":1.0.0", ":2.0.0")
print(f"\n正在处理插件 {plugin_name} 的 2.0.0 版本")
success = process_plugin(base_path, plugin_name, v2_url, "2.0.0")
if not success:
failed_plugins.append(f"{plugin_name}:2.0.0")

if failed_plugins:
print("\n以下插件未成功处理:")
for plugin in failed_plugins:
print(f"- {plugin}")
sys.exit(1)

def process_plugin(base_path, plugin_name, plugin_url, version):
def generate_all_metadata(base_path):
"""
统一生成 metadata.txt:扫描 plugins/<name>/<version>/plugin.wasm,
覆盖 properties 中下载/跳过的插件、本地预置插件,以及未在 properties
中定义的自定义插件。临时目录(<name>_<version>_temp)与失败目录
因不匹配该路径模板,不会被遍历。
"""
plugins_base_path = os.path.join(base_path, 'plugins')
if not os.path.isdir(plugins_base_path):
return 0
count = 0
for plugin_name in sorted(os.listdir(plugins_base_path)):
name_dir = os.path.join(plugins_base_path, plugin_name)
if not os.path.isdir(name_dir):
continue
for version in sorted(os.listdir(name_dir)):
plugin_dir = os.path.join(name_dir, version)
if os.path.isfile(os.path.join(plugin_dir, 'plugin.wasm')):
generate_metadata(plugin_dir, plugin_name)
count += 1
print(f"共为 {count} 个插件目录生成 metadata.txt")
return count

def process_plugin(base_path, plugin_name, plugin_url, version, use_local=False):
"""
处理单个插件下载和信息获取
处理单个 OCI 插件:把 plugin.wasm 放到 plugins/<name>/<version>/。
本地已预置(通过 Dockerfile 的 COPY plugins/)则跳过下载。
metadata.txt 不在此处生成,由 generate_all_metadata() 统一处理。
"""
plugins_base_path = os.path.join(base_path, 'plugins')
os.makedirs(plugins_base_path, exist_ok=True)

plugin_dir = os.path.join(plugins_base_path, plugin_name, version)
os.makedirs(plugin_dir, exist_ok=True)

# 只有 use_local=True 时才检查本地已预置的 plugin.wasm:跳过下载
if use_local and os.path.isfile(os.path.join(plugin_dir, 'plugin.wasm')):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

本 PR 引入: use_local=True 提前返回仅检查 os.path.isfile(...),但 os.path.isfile 对 0 字节文件返回 True —— 损坏/截断/空白的本地 plugin.wasm 会被当作合法副本跳过 OCI 下载。

失败场景: 用户把截断的 plugins/foo/1.0.0/plugin.wasm(0 字节或中间截断)放进去,docker build --build-arg USE_LOCAL_PLUGINS=true 后:line 132 通过 → 跳过下载 → generate_all_metadata 算出 MD5: d41d8cd98f00b204e9800998ecf8427e / Size: 0 bytes 并写入 metadata.txt → 镜像里实际 serve 的是损坏 wasm。同样的残留也会在 handle_wasm_layershutil.copy2 被 Ctrl-C(BaseException 不被 except Exception 捕获)打断时出现,且 finally 仅清理 temp dir、不清理 plugin_dir。

修复建议: 在 line 132 之后增加 and os.path.getsize(...) > 0 检查;或对 plugin.wasm 做最小 magic-byte / size 校验(wasm magic 是 \x00asm)。

print(f"{plugin_name} ({version}) 检测到本地副本,跳过下载")
return True

temp_download_dir = os.path.join(plugins_base_path, f"{plugin_name}_{version}_temp")
os.makedirs(temp_download_dir, exist_ok=True)

Expand Down Expand Up @@ -191,13 +176,59 @@ def process_plugin(base_path, plugin_name, plugin_url, version):
finally:
shutil.rmtree(temp_download_dir, ignore_errors=True)

if wasm_found:
generate_metadata(plugin_dir, plugin_name)
else:
if not wasm_found:
print(f"{plugin_name} ({version}) 未找到 .wasm 文件")
shutil.rmtree(plugin_dir, ignore_errors=True)

return wasm_found

def main():
parser = argparse.ArgumentParser(description='处理插件配置文件')
parser.add_argument('properties_path', nargs='?', default=None,
help='properties文件路径(默认:脚本所在目录下的plugins.properties)')
parser.add_argument('--download-v2', action='store_true',
help='是否下载 2.0.0 版本插件')
parser.add_argument('--use-local', action='store_true',
help='启用本地 WASM 文件:plugins/<name>/<version>/plugin.wasm 存在时跳过 OCI 下载')
args = parser.parse_args()

# 用户未提供路径时,使用默认逻辑
if args.properties_path is None:
script_dir = os.path.dirname(os.path.abspath(__file__))
args.properties_path = os.path.join(script_dir, 'plugins.properties')

base_path = os.path.dirname(args.properties_path)
properties = read_properties(args.properties_path)

if not properties:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

本 PR 引入: 当 plugins.properties 为空时 main() 在 line 205 早退,新增的 generate_all_metadata(base_path) (line 225) 永不执行 —— 与 README 第 161 行承诺的「新增自定义插件:不写入 plugins.properties」用例矛盾。

失败场景: 开发者用空 plugins.properties + docker build --build-arg USE_LOCAL_PLUGINS=true,本地 plugins/foo/1.0.0/plugin.wasm 经 Dockerfile:37 拷入镜像,但脚本在 if not properties: return 直接返回;镜像中本地插件无 metadata.txt,failed_plugins 为空 → 退出 0,构建「成功」但元数据缺失。

修复建议: 把 generate_all_metadata(base_path) 调用移到 if not properties 早退之前(或在 properties 为空时也调用一次)。

print("未找到有效的插件配置")
return

failed_plugins = []

for plugin_name, plugin_url in properties.items():
print(f"\n正在处理插件: {plugin_name}")
# 处理原始版本(1.0.0)
success = process_plugin(base_path, plugin_name, plugin_url, "1.0.0", use_local=args.use_local)
if not success:
failed_plugins.append(f"{plugin_name}:1.0.0")

# 如果指定了 --download-v2 参数,则额外处理 2.0.0 版本
if args.download_v2:
v2_url = plugin_url.replace(":1.0.0", ":2.0.0")
print(f"\n正在处理插件 {plugin_name} 的 2.0.0 版本")
success = process_plugin(base_path, plugin_name, v2_url, "2.0.0", use_local=args.use_local)
if not success:
failed_plugins.append(f"{plugin_name}:2.0.0")

# 统一生成 metadata.txt:覆盖下载的、本地预置的、以及未在 properties 中定义的自定义插件
generate_all_metadata(base_path)

if failed_plugins:
print("\n以下插件未成功处理:")
for plugin in failed_plugins:
print(f"- {plugin}")
sys.exit(1)

if __name__ == '__main__':
main()
main()