Compare commits

...
187 Commits
Author SHA1 Message Date
ssongliu e7ef35740c fix: align compose project name handling (#13500) 2026-08-07 12:44:29 +08:00
ssongliu b0d561e33b feat: show license expiration alert on dashboard (#13499) 2026-08-07 10:22:23 +08:00
CityFun 75b362fa9b chore: update dependencies (#13497) 2026-08-06 21:47:20 +08:00
蘭 466f373ef6 feat: change some translate (#13496) 2026-08-06 18:27:27 +08:00
ssongliu 4489641b54 perf(snapshot): hard link local recovery archive (#13494) 2026-08-06 16:08:58 +08:00
蘭 1971d9dec2 fix: handle external login state and emit readiness event (#13493)
* fix: handle external login state and emit readiness event

* fix: handle external login state and improve SAML2 logout response handling
2026-08-06 13:53:36 +08:00
CityFun c38d741770 fix: Fix an issue where the website monitoring directory was not completely removed when deleting a website. (#13492) 2026-08-06 13:52:53 +08:00
蘭 122a474032 feat: enhance alert log search with start and end time filters (#13489) 2026-08-05 21:13:25 +08:00
CityFun 54854e99e7 feat: change deepseek api url (#13487) 2026-08-05 17:10:34 +08:00
CityFun e01fb7c905 fix: Fixed bug with website template (#13484) 2026-08-05 16:56:08 +08:00
CityFun 83a3675a0c feat: QwenPaw support config username/password (#13478) 2026-08-05 16:35:09 +08:00
ssongliu 9dfa451fae fix: adjust host column display (#13477) 2026-08-05 13:52:33 +08:00
ssongliu 9204a287fd fix: improve community restore loading state (#13475)
* fix: improve community restore loading state

* fix: resume license page initialization after restore
2026-08-05 13:52:14 +08:00
f027507f9a fix: preserve active cronjob records during cleanup (#13474)
* fix: preserve active cronjob records during cleanup

* fix: delete cronjob records before removing files

Co-authored-by: ssongliu <73214554+ssongliu@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-08-05 11:32:20 +08:00
ssongliu 01aee89f3b fix: correct process start time in LXC (#13473)
* fix: correct process start time in LXC

* fix: reject unresolved process start times
2026-08-05 11:22:26 +08:00
CityFun 17285d4397 fix: Fixed issue with upgrade openresty failed (#13470)
* fix: Fixed issue with upgrade openresty failed

* fix: Fixed issue with upgrade openresty failed
2026-08-05 09:37:02 +08:00
蘭 91ae846418 feat: enhance archiving and extraction capabilities with additional compression formats and ownership preservation (#13458)
* feat: enhance archiving and extraction capabilities with additional compression formats and ownership preservation

* feat: enhance archiving and extraction capabilities with additional compression formats and ownership preservation

* feat: enhance archiving and extraction capabilities with additional compression formats and ownership preservation
2026-08-04 17:34:50 +08:00
ssongliu 1eb429631d fix: resolve compose project names consistently (#13468) 2026-08-04 17:17:07 +08:00
CityFun 6584e3b868 chore: update dependencies (#13467) 2026-08-04 16:46:27 +08:00
ssongliu 14e2294db9 feat: refine node health check settings (#13463) 2026-08-04 15:09:34 +08:00
ssongliu 26b69bc208 fix: bypass cached route in user info navigation (#13462)
* fix: bypass cached route in user info navigation

* fix: avoid caching entrance route
2026-08-04 15:09:17 +08:00
CityFun 2111d4c16b style: change table page config (#13456) 2026-08-04 10:45:13 +08:00
蘭 b682835b4e fix: Implement file sharing password processing and remote download file name processing (#13452) 2026-08-04 09:37:26 +08:00
蘭 c34ac2f31e feat: change some translate (#13451) 2026-08-04 09:37:08 +08:00
CityFun c28047374a feat: Fix the issue where website configuration files fail to restore from backups. (#13445) 2026-08-03 15:26:20 +08:00
ssongliu 60d16609f7 fix: clarify API trusted proxy placeholder (#13444) 2026-08-03 14:30:50 +08:00
CityFun 02ca9347dc feat: Update Xiaomi Models (#13443) 2026-08-03 14:28:26 +08:00
ssongliu d0187994ee fix: ignore empty directories in device clean scan (#13442)
* fix: ignore empty directories in device clean scan

* fix: skip zero-size device clean entries
2026-08-03 14:06:56 +08:00
蘭 be672d604f feat:Support logging in with oidc and saml2 (#13441)
* feat(auth): implement OIDC authentication endpoints and error handling

* feat(auth): add OIDC provider discovery endpoint and related functionality

* feat(auth): add SAML2 authentication support and related functionality

* feat(auth): add LDAP authentication support and related functionality

* fix(auth): improve login keydown handler for better event handling
2026-08-03 13:43:01 +08:00
CityFun 16e3d496eb Merge branch 'dev-v2' into pr@dev-v2@common (#13436) 2026-08-01 14:08:03 +08:00
ssongliu 9159ab842d feat: support filtering app store composes (#13432)
* feat: support filtering app store composes

* fix: initialize app store filter during setup
2026-07-31 18:27:10 +08:00
ssongliu 0d8835d494 fix: constrain dashboard column height (#13433) 2026-07-31 18:26:02 +08:00
ssongliu 9f9e3aacfc feat: add Community Edition restore UI (#13431) 2026-07-31 18:15:24 +08:00
CityFun 7bd11fe73e feat: Keep Website List Order Stable After Editing (#13424) 2026-07-31 11:05:23 +08:00
BugPleaseGoandCityFun e11dc5fadd Add Website Template (#13400)
* feat: Add Website Template

* fix: Don't display the template list

* feat: Add Mcp TopList

* docs: Remove Mcp TopList

* Add more languages

---------

Co-authored-by: CityFun <31820853+zhengkunwang223@users.noreply.github.com>
2026-07-31 10:01:20 +08:00
HynoR f35b0deb29 refactor(firewall): extract port forwarding subsystem (#13347)
Port forwarding no longer shares the filter client. FilterClient keeps only
filter capabilities, and forwarding gets its own adapter, service and boot
replay:

- utils/firewall/forwarding holds the provider adapters. firewalld uses native
  forward-port, ufw and iptables share the NAT implementation moved out of
  client/iptables/forward.go.
- service/forwarding.go owns base info, search, operate, enable and replay.
  The API keeps its routes and dispatches on name/type/operate.
- init/firewall replays forwarding through that service instead of loading NAT
  rule files inline.

Also adds 1PANEL_FORWARD to the IptablesOp name enum: the frontend already
sends {"name":"1PANEL_FORWARD","operate":"init-forward"} and the validator
rejected it with 400 before reaching the service. Besides that, the only
observable difference is that a forward-tab search no longer triggers the
port/address record cleanup goroutine on the side.
2026-07-30 14:07:41 +08:00
ssongliu 33b3eecb95 feat: unify pin actions (#13417) 2026-07-30 09:39:47 +08:00
CityFun 6ac7a5f167 feat: Optimize the application upgrade logic. (#13416) 2026-07-29 17:44:50 +08:00
CityFun a5fbbfc460 feat: Optimize the application upgrade logic. (#13415) 2026-07-29 17:19:52 +08:00
ssongliu 563df3da71 fix: support trusted proxies for API allowlist (#13409) 2026-07-29 16:48:59 +08:00
ssongliu 13e6bc4fac feat: separate website and standalone FTP identities (#13412) 2026-07-29 16:41:43 +08:00
ssongliu 357d77856a fix: align dashboard uptime with boot time (#13410)
* fix: align dashboard uptime with boot time

* fix: handle invalid dashboard boot time
2026-07-29 16:36:05 +08:00
ssongliu 4279339189 fix: simplify cronjob record duration display (#13398) 2026-07-29 10:57:40 +08:00
ssongliuandCopilot Autofix powered by AI 380033dfe0 fix: support MySQL backup GTID options (#13407)
* fix: support MySQL backup GTID options

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-29 10:42:21 +08:00
ssongliu 97d383ed12 feat: add explicit FTP identity initialization (#13390) 2026-07-28 17:07:57 +08:00
ssongliu 52e6a63ebc fix: improve host system log pagination (#13395) 2026-07-28 17:07:22 +08:00
CityFun 7506e709e2 Add plugin management support to OpenClaw. (#13388) 2026-07-28 14:20:55 +08:00
蘭 cf37de66fc feat(auth): add LDAP authentication support and related configurations (#13385)
* feat(auth): add LDAP authentication support and related configurations

* feat(ldap): implement LDAP synchronization features and enhance user import logic

* feat(auth): add authSource and authSourceStatus to user information

* feat(i18n): update LDAP synchronization error messages in multiple languages

* feat(i18n): update LDAP synchronization error messages in multiple languages

* feat(i18n): update LDAP synchronization error messages in multiple languages
2026-07-28 14:20:42 +08:00
CityFun e2754b447d feat: Add support for text-to-image APIs. (#13380)
* feat: Add support for text-to-image APIs.

* feat: Add support for text-to-image APIs.
2026-07-28 09:42:23 +08:00
ssongliu c04848a92a feat: sync script library on startup (#13376) 2026-07-27 15:10:09 +08:00
ssongliu 748f5f070a fix: show placeholder for empty host log service (#13375) 2026-07-27 13:56:24 +08:00
蘭 2dcb367622 fix: Fix quick data selection errors with Shift (#13374)
* fix: Fix quick data selection errors with Shift

* fix: Fix quick data selection errors with Shift
2026-07-27 13:48:32 +08:00
ssongliu 26bd2d08be fix: improve host system log filtering compatibility (#13373) 2026-07-27 11:25:26 +08:00
ssongliu 739686ff89 fix: avoid decimal time unit conversions (#13372) 2026-07-27 10:50:42 +08:00
ssongliu 68a871a5d7 fix: restore fail2ban after firewall start (#13364) 2026-07-27 10:16:04 +08:00
ssongliu b47bba4827 feat: add VM health check translations (#13361) 2026-07-24 16:00:29 +08:00
CityFun 3b91859a49 fix: Fix an issue where an application could still be installed after its version had been deleted remotely. (#13360) 2026-07-24 16:00:02 +08:00
CityFun c417bc9c2b chore: change lego sdk (#13357) 2026-07-23 18:37:36 +08:00
CityFun 7c95becfeb feat: Optimize the OpenResty module compilation logic. (#13355)
* refactor: separate openresty module catalog and state

* feat: Optimize the OpenResty module compilation logic.
2026-07-23 16:53:56 +08:00
ssongliu 6b1480c5fa feat: move container batch operations to table footer (#13348) 2026-07-23 15:45:53 +08:00
ssongliu 7f450544e1 feat: improve host log management (#13345) 2026-07-23 14:53:26 +08:00
ssongliu 0c046edfe5 fix(database): improve MySQL user display (#13344) 2026-07-23 14:44:07 +08:00
Snrat 2a2e6607b8 feat: 重构OpenResty模块部分以支持动态编译 (#13291)
* feat: support dynamic module build for OpenResty

* feat: add dynamic module build page for OpenResty

* refactor: drop auto fallback, gate dynamic build by version support

- remove auto-to-static fallback; dynamic build failure now reports the
  error and hints switching to static build manually
- gate dynamic builds on module support files (Dockerfile.modules +
  module.catalog.json) instead of version numbers, expose
  dynamicSupported in the modules API
- collect repeated path/status/operate strings into constants
- move nginx module regex patterns into utils/re with semantic helpers
- reorganize nginx_module.go around the main build flows and inline
  single-use thin helpers

* feat: limit nginx module build mode options by version support

- build mode radio offers only dynamic and static (auto maps to dynamic
  for legacy data)
- disable the dynamic option with a hint when the installed OpenResty
  version lacks dynamic build support

* feat: complete i18n for nginx module pages

Fill in the new nginx module keys for all eleven language files
(translations other than zh/en are draft machine translations).

* feat: probe dynamic module support on load and drop the auto build mode

- probe each non-static module's configure params when loading the
  module list and report dynamicSupport=supported/unsupported up front
- normalize the legacy auto build mode to dynamic

* feat: clarify module build modes in the UI

- build drawer lists dynamic modules (tagged, hot-reload) and static
  modules (tagged, full rebuild + container restart) separately
- disable the dynamic option per module when its params do not support
  dynamic build, distinct from the version gate hint
- drop the auto build mode wording everywhere and sync all eleven
  language files

* feat: clarify purpose of the nginx module build drawer

- add a purpose hint explaining dynamic (hot reload) vs static (full
  rebuild + container restart)
- drop the per-module mode tags now that section headers carry the
  semantics
- allow submitting with zero dynamic modules selected when static
  modules are present, so static-only users can trigger a build

* feat: pass apt mirror through to dynamic module builds

The mirror selected in the build dialog (or CONTAINER_PACKAGE_URL in the
app env as fallback) is now forwarded as a build arg so the module
builder uses the same apt source as the static build path. test-builder
gains a --mirror option.

* feat: add Lao translations for nginx module pages
2026-07-23 13:55:42 +08:00
ssongliu f6ed11ac55 fix(database): support multiple MySQL user hosts (#13342) 2026-07-23 10:01:39 +08:00
ssongliu d330f60b2d fix: avoid duplicate disk IO aggregation (#13333) 2026-07-22 22:23:39 +08:00
CityFun 10c8cafcee chore: update dependencies (#13341) 2026-07-22 18:41:21 +08:00
ssongliu d1534c88aa fix: align dashboard column heights (#13332) 2026-07-22 14:20:58 +08:00
CityFun eb6b4c7cb7 chore: update dependencies (#13327) 2026-07-21 19:08:20 +08:00
ssongliu c77ddcfebe feat(database): improve MySQL user authorization (#13326) 2026-07-21 19:08:09 +08:00
ssongliu af12526a6c fix(container): make updates rollback-safe (#13315) 2026-07-21 15:40:07 +08:00
CityFun b347abe100 feat: Cache List Page Filter Conditions (#13314) 2026-07-21 15:39:46 +08:00
蘭 092b57413b feat(i18n): improve Lao (lo) language support (#13313) 2026-07-20 18:38:42 +08:00
ssongliu e8f35e4e06 fix: optimize terminal output performance (#13276) (#13312) 2026-07-20 18:26:00 +08:00
BoneNI b019e7526d feat: Add official Lao (lo_LA) language support (#13154)
* Add Lao language localization for lo.ts

* Add support for Lao language module

* Add Lao language translations to fu.ts

* Add Lao language support in i18n.go

* Add Lao language localization for lo.yaml
2026-07-20 17:23:23 +08:00
CityFun 1b06467521 feat: change website ssl apply logic (#13296) 2026-07-20 14:06:59 +08:00
ssongliu 7f3fd0cb1d feat: add runtime diagnostics (#13295) 2026-07-20 13:55:55 +08:00
CityFun 3e9848554b feat: update some translate (#13288) 2026-07-20 10:27:40 +08:00
ssongliu b02cdbc5f9 feat: add host system logs (#13285) 2026-07-17 17:54:36 +08:00
ssongliu d7723fd42a refactor(agent): replace onedrive sdk with graph api (#13284) 2026-07-17 13:48:04 +08:00
CityFun 8b265bb81e feat: Support fetching model lists for model accounts (#13281) 2026-07-17 13:47:49 +08:00
ssongliu 89b6c94e42 refactor(frontend): consolidate table components (#13280) 2026-07-17 10:02:40 +08:00
ssongliu 219ac160b6 feat: support mysql database users (#13278) 2026-07-16 14:24:38 +08:00
ssongliu 51ca1eab18 fix: refine virtual machine network hints (#13277) 2026-07-16 11:21:52 +08:00
ssongliu 1ab3da1fab fix: apply timeout to snapshot uploads (#13275)
* fix: apply timeout to snapshot uploads

* fix: honor snapshot upload timeouts for sftp and upyun
2026-07-16 11:21:26 +08:00
ssongliu 1d1b12c2fa fix: clean failed backup artifacts (#13274) 2026-07-15 18:06:19 +08:00
ssongliu 07a658582d fix: restore fail2ban bans after firewall restart (#13272) 2026-07-15 16:31:09 +08:00
ssongliu 61da74ae50 feat: hint file creation in protected directories (#13270) 2026-07-15 16:30:52 +08:00
王贺 90334f3cb0 fix: load SSL certificates when binding MCP website (#13271) 2026-07-15 16:08:55 +08:00
ssongliu fb9278bfc6 fix: prevent empty image import (#13269)
Refs #13228
2026-07-15 15:55:21 +08:00
ssongliu 7750936af6 feat: add node health check settings (#13268) 2026-07-15 15:55:02 +08:00
蘭 7b695af996 fix: Fix the issue of file permissions changing after decompressing files (#13267) 2026-07-15 15:54:05 +08:00
CityFun 2874849557 feat: Add Custom CLI Arguments Field for MCP Server Configuration (#13265) 2026-07-15 15:53:44 +08:00
王贺 9fd1f3e661 ci: remove disabled workflows (#13261) 2026-07-14 10:02:17 +08:00
Kobi Hikri 85182d9164 ci: pin third-party actions to full commit SHAs (#13259)
Pin the third-party actions referenced by mutable @master/@main tags to their
current commit SHA (tag kept in a trailing comment). Several run in jobs holding
secrets:

- SonarSource/sonarcloud-github-action@master (sonarcloud-scan.yml) — SONAR_TOKEN
- Yikun/hub-mirror-action@master (sync2gitee.yml) — GITEE_PRIVATE_KEY, GITEE_TOKEN
- fit2cloud/LLM-CodeReview-Action@main (llm-code-review.yml) — tokens + LLM API key
- crate-ci/typos@master (tyops-check.yml)

A moved tag would run unreviewed code with those secrets. Behaviour unchanged;
per GitHub's guidance to pin actions to a full-length commit SHA.

Signed-off-by: Kobi Hikri <kobi.hikri@gmail.com>
2026-07-14 09:58:52 +08:00
王贺 d9b2ea051f fix: resolve locale build errors (#13258) 2026-07-13 22:43:02 +08:00
ssongliu 097fe6dcfb fix: align remote download translations (#13255) 2026-07-13 18:16:58 +08:00
CityFun d5beca6d96 feat: change some translate (#13253) 2026-07-13 11:29:33 +08:00
ssongliu e3a356f893 feat: add VM orphan cleanup UI translations (#13252) 2026-07-13 10:48:51 +08:00
ssongliu 8be2a9ab02 fix: initialize enterprise database before hooks (#13247) 2026-07-10 18:31:04 +08:00
ssongliu 3d7f0f5143 fix: support cancelling chunk uploads (#13246) 2026-07-10 18:00:45 +08:00
ssongliu 1fd62fbaf7 fix: support cancelling chunk uploads (#13233)
* fix: support cancelling chunk uploads

* fix: validate chunk upload filenames
2026-07-10 15:46:59 +08:00
CityFun f1372dda85 core: Upgrade dependencies (#13231) 2026-07-09 21:38:14 +08:00
ssongliu 64c354adb2 feat: add VM overview status (#13232) 2026-07-09 21:36:48 +08:00
蘭 a379e1f840 ref: add some translate (#13225) 2026-07-09 18:32:16 +08:00
蘭 270ba799f7 feat: implement directory size caching and sorting functionality (#13220) 2026-07-09 11:11:37 +08:00
ssongliu 60dcfacb88 feat: add vm overview translations (#13224) 2026-07-09 11:11:15 +08:00
CityFun b474d720e9 style: change website page style (#13219) 2026-07-08 18:37:37 +08:00
ssongliu b2192b7252 fix: trim mariadb remote backup image tag (#13218) 2026-07-08 17:04:18 +08:00
ssongliu 6a988ad96a feat: update vm resource messages (#13216) 2026-07-08 15:01:49 +08:00
CityFun 029ac5efee feat: add some translate (#13215) 2026-07-07 18:42:55 +08:00
ssongliu 582cf1f533 feat: update vm and file resources (#13210) 2026-07-07 14:33:34 +08:00
ssongliu b8a4e8e9e0 chore: update xpack menu migration (#13207) 2026-07-06 11:14:29 +08:00
ssongliu 4a462aecc7 fix: reset firewall import dialogs on open (#13198) 2026-07-03 16:51:08 +08:00
ssongliu 74f221b895 fix: use svg renderer for container monitor charts (#13197) 2026-07-03 16:50:47 +08:00
ssongliu cbcb628bff fix: normalize firewall import addresses (#13196) 2026-07-03 16:37:03 +08:00
蘭 f602645e31 feat: Implement code editor layout with splitter for improved UI (#13195) 2026-07-03 16:01:44 +08:00
CityFun ec9e609f10 feat: add some translate (#13194) 2026-07-03 15:59:35 +08:00
ssongliu 1c1f3c7761 feat: support importing all firewall rules (#13192)
* feat: support importing all firewall rules

* fix: preserve selected firewall import action
2026-07-03 15:59:22 +08:00
ssongliu fae49284b7 fix: support mariadb manual backup args (#13193) 2026-07-03 15:58:15 +08:00
蘭 072a608a63 feat: File editor supports right-click operation (#13191) 2026-07-03 15:04:04 +08:00
蘭 1e6fd9c3a0 ref: Optimize menu settings (#13190) 2026-07-03 12:10:47 +08:00
ssongliu 23200cbe85 fix: support mariadb cronjob backup args (#13188) 2026-07-03 11:41:52 +08:00
ssongliu 08604e8c02 fix: fix dashboard running time calculation (#13187) 2026-07-03 11:41:37 +08:00
蘭 62a472d2d1 ref: refactor code editor theme management (#13186) 2026-07-03 11:41:22 +08:00
ssongliu 2c84665e8b chore: organize i18n language files (#13183) 2026-07-02 21:13:22 +08:00
蘭 616bd6830c feat: add menu accordion setting and update related components (#13182) 2026-07-02 21:13:07 +08:00
ssongliu e55ea96f00 feat: support virtual machine (#13171)
* feat: support virtual machine

* feat: add vm operation logs
2026-07-02 15:07:48 +08:00
蘭 aa676e139c feat: implement code editor theme management and synchronization (#13169) 2026-07-02 14:49:52 +08:00
ssongliu 7708fea58d feat: update operation log content (#13166) 2026-07-01 18:14:33 +08:00
ssongliu 34bdff01ad fix: allow ssl push on selected node (#13165) 2026-07-01 17:04:00 +08:00
CityFun 5a49579ddf feat: Optimize error messages for HTTP certificate requests (#13164) 2026-07-01 16:19:57 +08:00
CityFun b98a2b06c9 feat: Add multiple dimensions to website search (#13163) 2026-07-01 16:19:43 +08:00
ssongliu 9073b16baa feat: support compose rebuild operation (#13162) 2026-07-01 16:19:25 +08:00
ssongliu 2ec4db8d7e feat: adjust batch application installation API (#13161) 2026-07-01 16:19:12 +08:00
CityFun fe9628f91c feat: Add TaskLog for MCP Servers (#13160) 2026-07-01 16:18:59 +08:00
蘭 18a469e4be fix: Enhance Python language support in Monaco editor (#13158) 2026-07-01 16:18:42 +08:00
蘭 8c691cc074 fix: Resolve the issue of inability to access mobile web login (#13157) 2026-07-01 16:18:26 +08:00
ssongliu 2cd31a47f8 fix: complete operation logs (#13155) 2026-06-30 22:22:20 +08:00
CityFun d97aa9c2a0 feat: Optimize SSH session performance on Ubuntu 25. (#13152)
* feat: Optimize SSH session performance on Ubuntu 25.

* feat: Optimize SSH session performance on Ubuntu 25.

* feat: Optimize SSH session performance on Ubuntu 25.

* feat: Optimize SSH session performance on Ubuntu 25.
2026-06-30 18:36:28 +08:00
ssongliu f31f0a89ff feat: support certificate synchronization (#13153) 2026-06-30 18:31:45 +08:00
CityFun 0a1621166e feat: Unify the default website and default site under the same button. (#13149) 2026-06-30 15:56:25 +08:00
蘭 8623aa5318 feat: Enhance API authentication with HMAC-SHA256 support and user role management (#13148) 2026-06-30 14:16:36 +08:00
CityFun ec50599526 feat: Node runtime environment supports configuring whether to install node_modules. (#13147) 2026-06-30 14:16:22 +08:00
CityFun 458a30c520 feat: Add a description for syncing WAF IP groups. (#13143) 2026-06-29 18:42:02 +08:00
Andrea Leone 9a660db4c3 feat: add Ionos DNS Provider (#13024)
* feat: add Ionos DNS Provider

* Fix typo string formatting for APIKey in Ionos config
2026-06-29 16:11:33 +08:00
Reza Alipour Kondori f4fc71d32b add some translate and update Persian (#13132)
* Add Persian (fa) translations

* Update index.ts

Add fa to LOCALE_LOADERS

* Update fu.ts

Add fa item

* Update index.vue

enable Persian (fa) in language selector

* Update login-form.vue

enable Persian (fa) in language selector

* Create fa.yaml

add fa backend translations

* Update i18n.go

Add fa to langFiles variable

* Add Persian (fa) Translation to agent

* Add Persian README file

Add Persian (fa) README.fa.md to project

* fix(i18n): add Persian (fa) to backend validation and login dropdown

* Update README.fa.md

* Upade link in readme
2026-06-29 15:25:56 +08:00
王贺 e45a8185e3 fix container upgrade network IP handling (#13126) 2026-06-26 18:38:11 +08:00
CityFun 7be9189b3c fix: Fixed issue with reload ssl failed (#13129) 2026-06-26 18:08:38 +08:00
蘭 bdf13c01f6 ref: Improve support for Persian (fa) language (#13128) 2026-06-26 18:08:22 +08:00
王贺 902bc90aef fix: Fix updating the application host binding IP (#13127) 2026-06-26 18:08:08 +08:00
Octopusandocto-patch 27ba50d8be feat: add MiniMax M3 model to provider catalog (#13119)
Add the latest MiniMax-M3 flagship model to the MiniMax provider model
list and set it as the default. M3 offers a 1M context window, 128K max
output, reasoning, and image input. Drop the older M2.5 entries while
keeping M2.7 as an alternative, and update the account verification probe
to use M3.

Co-authored-by: octo-patch <266937838+octo-patch@users.noreply.github.com>
2026-06-26 17:55:04 +08:00
Reza Alipour Kondori fb483ecb90 Add Persian (fa) translations (#13100)
* Add Persian (fa) translations

* Update index.ts

Add fa to LOCALE_LOADERS

* Update fu.ts

Add fa item

* Update index.vue

enable Persian (fa) in language selector

* Update login-form.vue

enable Persian (fa) in language selector

* Create fa.yaml

add fa backend translations

* Update i18n.go

Add fa to langFiles variable

* Add Persian (fa) Translation to agent
2026-06-26 17:17:32 +08:00
蘭 2de7079dd2 feat: add new localization strings for skill management features (#13124) 2026-06-26 16:17:57 +08:00
CityFun 2e4ac604a8 fix: Fix incomplete log download issue (#13123) 2026-06-26 16:17:40 +08:00
CityFun 46af80142e feat: add username/password support for hermes-agent (#13121)
* feat: add username/password support for hermes-agent

* feat: add username/password support for hermes-agent

* feat: add username/password support for hermes-agent
2026-06-26 16:17:23 +08:00
CityFun 677b47dcdb fix: Fixed issue with openclaw config telegram failed (#13114) 2026-06-25 18:40:34 +08:00
王贺 56cccdf7c0 refactor: remove skills hub server leftovers (#13115) 2026-06-25 15:09:21 +08:00
蘭 3e0d0bb809 feat: add quick toggle comment functionality in the editor (#13107) 2026-06-24 18:23:51 +08:00
ssongliu 86aa6c09c6 fix: resolve snapshot extraction directory missing issue (#13104) 2026-06-24 18:23:37 +08:00
蘭 f67e1d897c ref: add some translate (#13102) 2026-06-24 18:23:23 +08:00
蘭 9e518daa62 ref: add some translate (#13093) 2026-06-23 14:05:48 +08:00
CityFun 2e5455e655 feat: Add “Remove old images during app upgrade” configuration to the App Store. (#13092) 2026-06-23 14:05:33 +08:00
CityFun 868f1db4c5 feat: Create and delete runtime environments, and add task logs. (#13081) 2026-06-18 18:46:32 +08:00
王贺 efd84b1e1b feat: support skills hub server frontend (#13074) 2026-06-18 16:57:53 +08:00
CityFun 5591fe50f0 chore: optimize runtime environment container configuration (#13071)
* chore: optimize runtime environment container configuration

* chore: optimize runtime environment container configuration
2026-06-17 19:09:31 +08:00
王贺 5e0a598799 feat: update waf prompt translations (#13070) 2026-06-17 17:00:28 +08:00
CityFun 45d7b30abc feat: Add support for syncing self-signed certificates and manually u… (#13068)
* feat: Add support for syncing self-signed certificates and manually uploaded certificates to other nodes.

* feat: Add support for syncing self-signed certificates and manually
2026-06-17 17:00:15 +08:00
ssongliu 46af88e1d0 feat: support app and image synchronization (#13067) 2026-06-17 15:41:09 +08:00
CityFun 7f331f5503 fix: 解决证书推送到其他节点报错的问题 (#13065) 2026-06-17 15:31:51 +08:00
igophper 7b74e09d30 fix: manage expanded node states in the tree view component (#13064) 2026-06-17 12:36:23 +08:00
蘭 01e4455110 refactor: Remove error logging for missing alert config in alert handling (#13060) 2026-06-17 11:08:45 +08:00
CityFun 922a2d21c8 feat: add protocolVersion param for MCP Server (#13057)
* feat: add protocolVersion param for MCP Server

* feat: add protocolVersion param for MCP Server
2026-06-16 16:47:27 +08:00
CityFun 439c4794b7 feat: Optimize the vLLM display (#13056) 2026-06-15 16:42:00 +08:00
ssongliu 5ac7f16b67 feat: optimize image deletion logs (#13053) 2026-06-15 15:41:46 +08:00
蘭 bfb79066b4 fix: Adjust the default values of alert attributes (#13052) 2026-06-15 15:41:33 +08:00
蘭 19602b42fb feat: add QR code verification endpoint to password expiration checks (#13049) 2026-06-15 15:15:09 +08:00
ssongliu f987af264f feat: disable script library auto-sync on startup (#13047) 2026-06-15 13:46:24 +08:00
ssongliu a6c76dffc3 feat: add snapshot rollback prompt (#13045) 2026-06-15 13:46:13 +08:00
CityFun a2eed66bc4 fix: fixed issue with refresh agent page failed (#13035) 2026-06-12 18:14:35 +08:00
CityFun a49b9b132d feat: update app upgrade logic (#13032) 2026-06-12 17:44:34 +08:00
ssongliu e8505a249a fix: resolve password expiration issue in multi-user mode (#13030) 2026-06-12 14:16:16 +08:00
王贺 dbff9c02e9 fix: improve login page compatibility (#13029) 2026-06-12 11:45:14 +08:00
ssongliu 45c5c5b40f fix: resolve SSH key generation issue (#13027) 2026-06-12 11:17:08 +08:00
CityFun aea71664ee feat: add some translate (#13019) 2026-06-12 11:13:49 +08:00
ssongliu fc65fb9323 fix: fix monitoring tooltip styles (#13017) 2026-06-11 17:26:00 +08:00
ssongliu f476823531 fix: correct node administrator permissions (#13016) 2026-06-11 17:25:47 +08:00
CityFun 8222579d99 feat: update website ssl apply logic (#13015) 2026-06-11 17:25:34 +08:00
585 changed files with 73410 additions and 70403 deletions
-25
View File
@@ -1,25 +0,0 @@
name: LLM Code Review
permissions:
contents: read
pull-requests: write
on:
pull_request:
types: [opened, reopened, synchronize]
jobs:
llm-code-review:
runs-on: ubuntu-latest
steps:
- uses: fit2cloud/LLM-CodeReview-Action@main
env:
GITHUB_TOKEN: ${{ secrets.FIT2CLOUDRD_LLM_CODE_REVIEW_TOKEN }}
OPENAI_API_KEY: ${{ secrets.ALIYUN_LLM_API_KEY }}
LANGUAGE: English
OPENAI_API_ENDPOINT: https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL: qwen2.5-coder-3b-instruct
PROMPT: "Please check the following code differences for any irregularities, potential issues, or optimization suggestions, and provide your answers in English."
top_p: 1
temperature: 1
# max_tokens: 10000
MAX_PATCH_LENGTH: 10000
IGNORE_PATTERNS: "/node_modules,*.md,/dist,/.github"
FILE_PATTERNS: "*.java,*.go,*.py,*.vue,*.ts,*.js,*.css,*.scss,*.html"
-21
View File
@@ -1,21 +0,0 @@
name: SonarCloud Scan
on:
push:
branches:
- dev
pull_request:
types: [opened, synchronize, reopened]
jobs:
sonarcloud:
name: SonarCloud
if: github.repository == '1Panel-dev/1Panel'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUBTOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+1 -1
View File
@@ -6,7 +6,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Mirror the Github organization repos to Gitee.
uses: Yikun/hub-mirror-action@master
uses: Yikun/hub-mirror-action@ba51c01b28a6c9f95a25d4f1bcf6af2a147c0e18 # master
with:
src: 'github/1Panel-dev'
dst: 'gitee/fit2cloud-feizhiyun'
-11
View File
@@ -1,11 +0,0 @@
name: Typos Check
on: pull_request
jobs:
run:
name: Spell Check with Typos
runs-on: ubuntu-latest
steps:
- name: Checkout Actions Repository
uses: actions/checkout@v2
- name: Check spelling
uses: crate-ci/typos@master
+3 -1
View File
@@ -25,7 +25,7 @@
<a href="/docs/README.pt-br.md"><img alt="Português (Brasil)" src="https://img.shields.io/badge/Português (Brasil)-d9d9d9"></a>
<a href="/docs/README.ar.md"><img alt="العربية" src="https://img.shields.io/badge/العربية-d9d9d9"></a>
<a href="/docs/README.de.md"><img alt="Deutsch" src="https://img.shields.io/badge/Deutsch-d9d9d9"></a>
<a href="/docs/README.es.md"><img alt="Español" src="https://img.shields.io/badge/Español-d9d9d9"></a>
<a href="/docs/README.es-es.md"><img alt="Español" src="https://img.shields.io/badge/Español-d9d9d9"></a>
<a href="/docs/README.fr.md"><img alt="français" src="https://img.shields.io/badge/français-d9d9d9"></a>
<a href="/docs/README.ko.md"><img alt="한국어" src="https://img.shields.io/badge/한국어-d9d9d9"></a>
<a href="/docs/README.id.md"><img alt="Bahasa Indonesia" src="https://img.shields.io/badge/Bahasa Indonesia-d9d9d9"></a>
@@ -33,6 +33,8 @@
<a href="/docs/README.tr.md"><img alt="Türkçe" src="https://img.shields.io/badge/Türkçe-d9d9d9"></a>
<a href="/docs/README.ru.md"><img alt="Русский" src="https://img.shields.io/badge/Русский-d9d9d9"></a>
<a href="/docs/README.ms.md"><img alt="Bahasa Melayu" src="https://img.shields.io/badge/Bahasa Melayu-d9d9d9"></a>
<a href="/docs/README.fa.md"><img alt="Persian" src="https://img.shields.io/badge/%D9%81%D8%A7%D8%B1%D8%B3%DB%8C-d9d9d9"></a>
<a href="/docs/README.lo.md"><img alt="ພາສາລາວ" src="https://img.shields.io/badge/%E0%BA%9E%E0%BA%B2%E0%BA%AA%E0%BA%B2%E0%BA%A5%E0%BA%B2%E0%BA%A7-d9d9d9"></a>
</p>
---
+103
View File
@@ -500,6 +500,27 @@ func (b *BaseApi) GetAgentAccountModels(c *gin.Context) {
helper.SuccessWithData(c, list)
}
// @Tags AI
// @Summary Discover custom provider models
// @Accept json
// @Param request body dto.AgentAccountModelDiscoverReq true "request"
// @Success 200 {array} dto.AgentAccountModel
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/accounts/models/discover [post]
func (b *BaseApi) DiscoverAgentAccountModels(c *gin.Context) {
var req dto.AgentAccountModelDiscoverReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
list, err := agentService.DiscoverAccountModels(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
}
// @Tags AI
// @Summary Create model account model
// @Accept json
@@ -1357,6 +1378,88 @@ func (b *BaseApi) UninstallAgentSkill(c *gin.Context) {
helper.Success(c)
}
// @Tags AI
// @Summary List OpenClaw plugins
// @Accept json
// @Param request body dto.AgentPluginsReq true "request"
// @Success 200 {array} dto.AgentPluginItem
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/plugins/list [post]
func (b *BaseApi) ListAgentPlugins(c *gin.Context) {
var req dto.AgentPluginsReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := agentService.ListPlugins(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags AI
// @Summary Search OpenClaw plugins
// @Accept json
// @Param request body dto.AgentPluginSearchReq true "request"
// @Success 200 {array} dto.AgentPluginSearchItem
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/plugins/search [post]
func (b *BaseApi) SearchAgentPlugins(c *gin.Context) {
var req dto.AgentPluginSearchReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := agentService.SearchPlugins(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags AI
// @Summary Install an OpenClaw marketplace plugin
// @Accept json
// @Param request body dto.AgentPluginMarketInstallReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/plugins/install [post]
func (b *BaseApi) InstallAgentMarketPlugin(c *gin.Context) {
var req dto.AgentPluginMarketInstallReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.InstallMarketPlugin(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Operate an OpenClaw plugin
// @Accept json
// @Param request body dto.AgentPluginOperateReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/plugins/operate [post]
func (b *BaseApi) OperateAgentPlugin(c *gin.Context) {
var req dto.AgentPluginOperateReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.OperatePlugin(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Login Agent Weixin channel
// @Accept json
+6 -1
View File
@@ -413,10 +413,15 @@ func (b *BaseApi) Backup(c *gin.Context) {
switch req.Type {
case "app":
if _, err := backupService.AppBackup(req); err != nil {
record, err := backupService.AppBackup(req)
if err != nil {
helper.InternalServer(c, err)
return
}
if req.IsImmediate {
helper.SuccessWithData(c, record)
return
}
case "mysql", "mariadb", constant.AppMysqlCluster:
if err := backupService.MysqlBackup(req); err != nil {
helper.InternalServer(c, err)
+20
View File
@@ -881,6 +881,26 @@ func (b *BaseApi) ComposeUpdate(c *gin.Context) {
helper.Success(c)
}
// @Tags Container Compose
// @Summary Pin compose
// @Accept json
// @Param request body dto.ComposePin true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/compose/pin [post]
func (b *BaseApi) ComposePin(c *gin.Context) {
var req dto.ComposePin
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := containerService.ComposePin(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Container Compose
// @Summary Load compose environment variables
// @Accept json
+215 -12
View File
@@ -41,20 +41,40 @@ func (b *BaseApi) CreateMysql(c *gin.Context) {
}
// @Tags Database Mysql
// @Summary Bind user of mysql database
// @Summary List mysql users
// @Accept json
// @Param request body dto.BindUser true "request"
// @Success 200
// @Param request body dto.MysqlUserSearch true "request"
// @Success 200 {array} dto.MysqlUser
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/bind [post]
// @x-panel-log {"bodyKeys":["database", "username"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"绑定 mysql 数据库名 [database] [username]","formatEN":"bind mysql database [database] [username]"}
func (b *BaseApi) BindUser(c *gin.Context) {
var req dto.BindUser
// @Router /databases/users/search [post]
func (b *BaseApi) ListMysqlUsers(c *gin.Context) {
var req dto.MysqlUserSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := mysqlService.ListUsers(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags Database Mysql
// @Summary Create mysql user
// @Accept json
// @Param request body dto.MysqlUserCreate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/users [post]
// @x-panel-log {"bodyKeys":["database","username","host"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [database] 用户 [username]@[host]","formatEN":"create mysql database [database] user [username]@[host]"}
func (b *BaseApi) CreateMysqlUser(c *gin.Context) {
var req dto.MysqlUserCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if len(req.Password) != 0 {
password, err := base64.StdEncoding.DecodeString(req.Password)
if err != nil {
@@ -63,8 +83,191 @@ func (b *BaseApi) BindUser(c *gin.Context) {
}
req.Password = string(password)
}
if err := mysqlService.CreateUser(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
if err := mysqlService.BindUser(req); err != nil {
// @Tags Database Mysql
// @Summary Delete mysql user
// @Accept json
// @Param request body dto.MysqlUserDelete true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/users/del [post]
// @x-panel-log {"bodyKeys":["database","username","host"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除 mysql 数据库 [database] 用户 [username]@[host]","formatEN":"delete mysql database [database] user [username]@[host]"}
func (b *BaseApi) DeleteMysqlUser(c *gin.Context) {
var req dto.MysqlUserDelete
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := mysqlService.DeleteUser(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Database Mysql
// @Summary Update mysql user
// @Accept json
// @Param request body dto.MysqlUserUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/users/update [post]
// @x-panel-log {"bodyKeys":["database","username","host","newHost","description"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 mysql 数据库 [database] 用户 [username] 访问权限 [host] => [newHost] 描述 [description]","formatEN":"update mysql database [database] user [username] access [host] => [newHost] description [description]"}
func (b *BaseApi) UpdateMysqlUser(c *gin.Context) {
var req dto.MysqlUserUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := mysqlService.UpdateUser(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Database Mysql
// @Summary Change mysql user password
// @Accept json
// @Param request body dto.MysqlUserPassword true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/users/password [post]
// @x-panel-log {"bodyKeys":["database","username","host"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 mysql 数据库 [database] 用户 [username]@[host] 密码","formatEN":"update mysql database [database] user [username]@[host] password"}
func (b *BaseApi) ChangeMysqlUserPassword(c *gin.Context) {
var req dto.MysqlUserPassword
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if len(req.Password) != 0 {
password, err := base64.StdEncoding.DecodeString(req.Password)
if err != nil {
helper.BadRequest(c, err)
return
}
req.Password = string(password)
}
if err := mysqlService.ChangeUserPassword(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Database Mysql
// @Summary Save mysql user password locally
// @Accept json
// @Param request body dto.MysqlUserPassword true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/users/password/save [post]
// @x-panel-log {"bodyKeys":["database","username","host"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"补充 mysql 数据库 [database] 用户 [username]@[host] 密码","formatEN":"save mysql database [database] user [username]@[host] password locally"}
func (b *BaseApi) SaveMysqlUserPassword(c *gin.Context) {
var req dto.MysqlUserPassword
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if len(req.Password) != 0 {
password, err := base64.StdEncoding.DecodeString(req.Password)
if err != nil {
helper.BadRequest(c, err)
return
}
req.Password = string(password)
}
if err := mysqlService.SaveUserPassword(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Database Mysql
// @Summary List mysql grants
// @Accept json
// @Param request body dto.MysqlUserSearch true "request"
// @Success 200 {array} dto.MysqlGrant
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/grants/search [post]
func (b *BaseApi) ListMysqlGrants(c *gin.Context) {
var req dto.MysqlUserSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := mysqlService.ListGrants(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags Database Mysql
// @Summary List mysql grant summary
// @Accept json
// @Param request body dto.MysqlGrantSummarySearch true "request"
// @Success 200 {object} map[string][]dto.MysqlUser
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/grants/summary [post]
func (b *BaseApi) ListMysqlGrantSummary(c *gin.Context) {
var req dto.MysqlGrantSummarySearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := mysqlService.ListGrantSummary(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags Database Mysql
// @Summary Grant mysql user
// @Accept json
// @Param request body dto.MysqlGrantCreate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/grants [post]
// @x-panel-log {"bodyKeys":["database","db","username","host"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"授权 mysql 数据库 [database] 用户 [username]@[host] 访问 [db]","formatEN":"grant mysql database [database] user [username]@[host] access to [db]"}
func (b *BaseApi) GrantMysqlUser(c *gin.Context) {
var req dto.MysqlGrantCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := mysqlService.GrantUser(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Database Mysql
// @Summary Revoke mysql grant
// @Accept json
// @Param request body dto.MysqlGrantDelete true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/grants/del [post]
// @x-panel-log {"bodyKeys":["database","db","username","host"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"取消 mysql 数据库 [database] 用户 [username]@[host] 对 [db] 的授权","formatEN":"revoke mysql database [database] user [username]@[host] access to [db]"}
func (b *BaseApi) RevokeMysqlGrant(c *gin.Context) {
var req dto.MysqlGrantDelete
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := mysqlService.RevokeGrant(req); err != nil {
helper.InternalServer(c, err)
return
}
@@ -94,14 +297,14 @@ func (b *BaseApi) UpdateMysqlDescription(c *gin.Context) {
}
// @Tags Database Mysql
// @Summary Change mysql password
// @Summary Change mysql root password
// @Accept json
// @Param request body dto.ChangeDBInfo true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/change/password [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"database_mysqls","output_column":"name","output_value":"name"}],"formatZH":"更新数据库 [name] 密码","formatEN":"Update database [name] password"}
// @x-panel-log {"bodyKeys":["database"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新数据库 [database] root 密码","formatEN":"Update database [database] root password"}
func (b *BaseApi) ChangeMysqlPassword(c *gin.Context) {
var req dto.ChangeDBInfo
if err := helper.CheckBindAndValidate(&req, c); err != nil {
@@ -125,14 +328,14 @@ func (b *BaseApi) ChangeMysqlPassword(c *gin.Context) {
}
// @Tags Database Mysql
// @Summary Change mysql access
// @Summary Change mysql root access
// @Accept json
// @Param request body dto.ChangeDBInfo true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/change/access [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"database_mysqls","output_column":"name","output_value":"name"}],"formatZH":"更新数据库 [name] 访问权限","formatEN":"Update database [name] access"}
// @x-panel-log {"bodyKeys":["database"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新数据库 [database] root 访问权限","formatEN":"Update database [database] root access"}
func (b *BaseApi) ChangeMysqlAccess(c *gin.Context) {
var req dto.ChangeDBInfo
if err := helper.CheckBindAndValidate(&req, c); err != nil {
+11 -8
View File
@@ -37,14 +37,16 @@ var (
cronjobService = service.NewICronjobService()
fileService = service.NewIFileService()
fileHistoryService = service.NewIFileHistoryService()
fileShareService = service.NewIFileShareService()
sshService = service.NewISSHService()
firewallService = service.NewIFirewallService()
iptablesService = service.NewIIptablesService()
monitorService = service.NewIMonitorService()
systemService = service.NewISystemService()
fileService = service.NewIFileService()
fileHistoryService = service.NewIFileHistoryService()
fileShareService = service.NewIFileShareService()
sshService = service.NewISSHService()
firewallService = service.NewIFirewallService()
forwardingService = service.NewIForwardingService()
iptablesService = service.NewIIptablesService()
monitorService = service.NewIMonitorService()
systemService = service.NewISystemService()
runtimeDiagnosticsService = service.NewIRuntimeDiagnosticsService()
deviceService = service.NewIDeviceService()
fail2banService = service.NewIFail2BanService()
@@ -59,6 +61,7 @@ var (
websiteDnsAccountService = service.NewIWebsiteDnsAccountService()
websiteSSLService = service.NewIWebsiteSSLService()
websiteAcmeAccountService = service.NewIWebsiteAcmeAccountService()
websiteTemplateService = service.NewIWebsiteTemplateService()
nginxService = service.NewINginxService()
+69 -2
View File
@@ -12,7 +12,9 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -28,6 +30,11 @@ import (
qrcode "github.com/skip2/go-qrcode"
)
var cancelledChunkUploads = struct {
sync.RWMutex
ids map[string]struct{}
}{ids: make(map[string]struct{})}
// @Tags File
// @Summary List files
// @Accept json
@@ -873,7 +880,24 @@ func (b *BaseApi) UploadChunkFiles(c *gin.Context) {
}
}
filename := c.PostForm("filename")
fileDir := filepath.Join(tmpDir, filename)
if filename == "" || filepath.Base(filename) != filename || strings.ContainsAny(filename, `/\\`) {
helper.BadRequest(c, errors.New("invalid filename"))
return
}
uploadID := strings.TrimSpace(c.PostForm("uploadID"))
cancellable := uploadID != ""
if cancellable && (filepath.Base(uploadID) != uploadID || strings.ContainsAny(uploadID, `/\\`)) {
helper.BadRequest(c, errors.New("invalid upload ID"))
return
}
if !cancellable {
uploadID = filename
}
fileDir := filepath.Join(tmpDir, uploadID)
if cancellable && chunkUploadCancelled(uploadID) {
helper.BadRequest(c, errors.New("upload cancelled"))
return
}
if chunkIndex == 0 {
if fileOp.Stat(fileDir) {
_ = fileOp.DeleteDir(fileDir)
@@ -884,7 +908,7 @@ func (b *BaseApi) UploadChunkFiles(c *gin.Context) {
defer func() {
if err != nil {
_ = os.Remove(fileDir)
_ = os.RemoveAll(fileDir)
}
}()
var (
@@ -904,6 +928,11 @@ func (b *BaseApi) UploadChunkFiles(c *gin.Context) {
helper.InternalServer(c, buserr.WithMap("ErrFileUpload", map[string]interface{}{"name": filename, "detail": err.Error()}, err))
return
}
if cancellable && chunkUploadCancelled(uploadID) {
err = errors.New("upload cancelled")
helper.BadRequest(c, err)
return
}
chunkPath := filepath.Join(fileDir, fmt.Sprintf("%s.%d", filename, chunkIndex))
err = os.WriteFile(chunkPath, chunkData, constant.DirPerm)
@@ -922,12 +951,50 @@ func (b *BaseApi) UploadChunkFiles(c *gin.Context) {
helper.InternalServer(c, buserr.WithMap("ErrFileUpload", map[string]interface{}{"name": filename, "detail": err.Error()}, err))
return
}
if cancellable {
cancelledChunkUploads.Lock()
delete(cancelledChunkUploads.ids, uploadID)
cancelledChunkUploads.Unlock()
}
helper.SuccessWithData(c, true)
} else {
return
}
}
// StopChunkUpload removes temporary chunks left by a cancelled upload.
func (b *BaseApi) StopChunkUpload(c *gin.Context) {
var req request.FileProcessReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
uploadID := strings.TrimSpace(req.Key)
if uploadID == "" || filepath.Base(uploadID) != uploadID || strings.ContainsAny(uploadID, `/\\`) {
helper.BadRequest(c, errors.New("invalid upload ID"))
return
}
cancelledChunkUploads.Lock()
cancelledChunkUploads.ids[uploadID] = struct{}{}
cancelledChunkUploads.Unlock()
time.AfterFunc(10*time.Minute, func() {
cancelledChunkUploads.Lock()
delete(cancelledChunkUploads.ids, uploadID)
cancelledChunkUploads.Unlock()
})
if err := os.RemoveAll(filepath.Join(global.Dir.TmpDir, "upload", uploadID)); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
func chunkUploadCancelled(uploadID string) bool {
cancelledChunkUploads.RLock()
_, ok := cancelledChunkUploads.ids[uploadID]
cancelledChunkUploads.RUnlock()
return ok
}
var wsUpgrade = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
+32 -4
View File
@@ -20,7 +20,15 @@ func (b *BaseApi) LoadFirewallBaseInfo(c *gin.Context) {
return
}
data, err := firewallService.LoadBaseInfo(req.Name)
var (
data dto.FirewallBaseInfo
err error
)
if req.Name == "forward" {
data, err = forwardingService.LoadBaseInfo()
} else {
data, err = firewallService.LoadBaseInfo(req.Name)
}
if err != nil {
helper.InternalServer(c, err)
return
@@ -43,7 +51,21 @@ func (b *BaseApi) SearchFirewallRule(c *gin.Context) {
return
}
total, list, err := firewallService.SearchWithPage(req)
var (
total int64
list interface{}
err error
)
if req.Type == "forward" {
total, list, err = forwardingService.SearchWithPage(dto.ForwardRuleSearch{
PageInfo: req.PageInfo,
Info: req.Info,
Status: req.Status,
Strategy: req.Strategy,
})
} else {
total, list, err = firewallService.SearchWithPage(req)
}
if err != nil {
helper.InternalServer(c, err)
return
@@ -116,7 +138,7 @@ func (b *BaseApi) OperateForwardRule(c *gin.Context) {
return
}
if err := firewallService.OperateForwardRule(req); err != nil {
if err := forwardingService.Operate(req); err != nil {
helper.InternalServer(c, err)
return
}
@@ -313,7 +335,13 @@ func (b *BaseApi) OperateFilterChain(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := iptablesService.Operate(req); err != nil {
var err error
if req.Operate == "init-forward" {
err = forwardingService.Enable()
} else {
err = iptablesService.Operate(req)
}
if err != nil {
helper.InternalServer(c, err)
return
}
+55
View File
@@ -2,6 +2,7 @@ package v2
import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/gin-gonic/gin"
)
@@ -20,3 +21,57 @@ func (b *BaseApi) GetSystemFiles(c *gin.Context) {
helper.SuccessWithData(c, data)
}
// @Tags Logs
// @Summary Get host system log status
// @Produce json
// @Success 200 {object} dto.SystemLogStatus
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /logs/system/status [get]
func (b *BaseApi) GetSystemLogStatus(c *gin.Context) {
data, err := logService.GetSystemLogStatus()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags Logs
// @Summary Read host logs
// @Accept json
// @Param request body dto.SystemLogReq true "request"
// @Produce json
// @Success 200 {object} dto.SystemLogRes
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /logs/system/read [post]
func (b *BaseApi) ReadSystemLog(c *gin.Context) {
var req dto.SystemLogReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := logService.ReadSystemLog(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags Logs
// @Summary List running host services
// @Produce json
// @Success 200 {array} string
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /logs/system/services [get]
func (b *BaseApi) ListRunningServices(c *gin.Context) {
data, err := logService.ListRunningServices()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
+63
View File
@@ -23,6 +23,27 @@ func (b *BaseApi) PageMcpServers(c *gin.Context) {
helper.SuccessWithData(c, list)
}
// @Tags McpServer
// @Summary Load mcp server detail
// @Accept json
// @Param request body request.McpServerDetail true "request"
// @Success 200 {object} response.McpServerDTO
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/mcp/server/detail [post]
func (b *BaseApi) LoadMcpServerDetail(c *gin.Context) {
var req request.McpServerDetail
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
res, err := mcpServerService.Detail(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, res)
}
// @Tags McpServer
// @Summary Create mcp server
// @Accept json
@@ -107,6 +128,48 @@ func (b *BaseApi) OperateMcpServer(c *gin.Context) {
helper.Success(c)
}
// @Tags McpServer
// @Summary Sync mcp server status
// @Accept json
// @Param request body request.McpServerStatusSync true "request"
// @Success 200 {array} response.McpServerStatusDTO
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/mcp/server/status/sync [post]
func (b *BaseApi) SyncMcpServerStatus(c *gin.Context) {
var req request.McpServerStatusSync
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
res, err := mcpServerService.SyncStatus(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, res)
}
// @Tags McpServer
// @Summary Test mcp server connection
// @Accept json
// @Param request body request.McpServerConnectionTest true "request"
// @Success 200 {object} response.McpServerConnectionTestRes
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/mcp/server/connection/test [post]
func (b *BaseApi) TestMcpServerConnection(c *gin.Context) {
var req request.McpServerConnectionTest
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
res, err := mcpServerService.TestConnection(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, res)
}
// @Tags McpServer
// @Summary Bind Domain for mcp server
// @Accept json
+63
View File
@@ -0,0 +1,63 @@
package v2
import (
"os"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/gin-gonic/gin"
)
// @Tags RuntimeDiagnostics
// @Summary Load runtime diagnostics summary
// @Success 200 {object} dto.RuntimeDiagnosticsSummary
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/diagnostics/summary [get]
func (b *BaseApi) LoadRuntimeDiagnosticsSummary(c *gin.Context) {
data, err := runtimeDiagnosticsService.Summary()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags RuntimeDiagnostics
// @Summary Load grouped goroutine snapshot
// @Success 200 {object} dto.RuntimeGoroutineSnapshot
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/diagnostics/goroutines [get]
func (b *BaseApi) LoadRuntimeGoroutines(c *gin.Context) {
data, err := runtimeDiagnosticsService.Goroutines()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithDataGzipped(c, data)
}
// @Tags RuntimeDiagnostics
// @Summary Capture runtime profile
// @Param request body dto.RuntimeProfileCreate true "request"
// @Success 200 {file} file
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/diagnostics/profiles [post]
func (b *BaseApi) CreateRuntimeProfile(c *gin.Context) {
var req dto.RuntimeProfileCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
profile, err := runtimeDiagnosticsService.CreateProfile(req)
if err != nil {
helper.BadRequest(c, err)
return
}
defer os.Remove(profile.Path)
c.Header("Content-Disposition", `attachment; filename="`+profile.Name+`"`)
c.Header("Content-Type", "application/octet-stream")
c.File(profile.Path)
c.Abort()
}
+15
View File
@@ -224,6 +224,21 @@ func (b *BaseApi) ExportSSHLogs(c *gin.Context) {
helper.SuccessWithData(c, tmpFile)
}
// @Tags SSH
// @Summary Clean host SSH logs
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/ssh/log/clean [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"清空 SSH 登录日志","formatEN":"clean SSH login logs"}
func (b *BaseApi) CleanSSHLogs(c *gin.Context) {
if err := sshService.CleanLog(); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags SSH
// @Summary Load host SSH conf
// @Accept json
+25
View File
@@ -210,6 +210,27 @@ func (b *BaseApi) UpdateWebsiteSSL(c *gin.Context) {
helper.Success(c)
}
// @Tags Website SSL
// @Summary Push ssl to nodes
// @Accept json
// @Param request body request.WebsiteSSLPush true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/ssl/push [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"website_ssls","output_column":"primary_domain","output_value":"domain"}],"formatZH":"推送证书到节点 [domain]","formatEN":"Push ssl to nodes [domain]"}
func (b *BaseApi) PushWebsiteSSLToNode(c *gin.Context) {
var req request.WebsiteSSLPush
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteSSLService.PushToNode(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website SSL
// @Summary Upload ssl
// @Accept json
@@ -248,6 +269,8 @@ func (b *BaseApi) UploadSSLFile(c *gin.Context) {
var req request.WebsiteSSLFileUpload
req.Description = c.PostForm("description")
req.Nodes = c.PostForm("nodes")
req.PushNode, _ = strconv.ParseBool(c.PostForm("pushNode"))
sslID := c.PostForm("sslID")
if sslID != "" {
req.SSLID, _ = strconv.ParseUint(sslID, 10, 64)
@@ -283,6 +306,8 @@ func (b *BaseApi) UploadSSLFile(c *gin.Context) {
Certificate: string(certificateContent),
Description: req.Description,
SSLID: uint(req.SSLID),
PushNode: req.PushNode,
Nodes: req.Nodes,
}
if err := websiteSSLService.Upload(uploadReq); err != nil {
+259
View File
@@ -0,0 +1,259 @@
package v2
import (
"io"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/gin-gonic/gin"
)
// @Tags Website Template
// @Summary Page website templates
// @Accept json
// @Param request body request.WebsiteTemplateSearch true "request"
// @Success 200 {object} dto.PageResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/search [post]
func (b *BaseApi) PageWebsiteTemplate(c *gin.Context) {
var req request.WebsiteTemplateSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, templates, err := websiteTemplateService.PageTemplate(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{
Total: total,
Items: templates,
})
}
// @Tags Website Template
// @Summary Create website template
// @Accept json
// @Param request body request.WebsiteTemplateCreate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建网站模板 [name]","formatEN":"Create website template [name]"}
func (b *BaseApi) CreateWebsiteTemplate(c *gin.Context) {
var req request.WebsiteTemplateCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteTemplateService.CreateTemplate(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website Template
// @Summary Update website template
// @Accept json
// @Param request body request.WebsiteTemplateUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/update [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新网站模板 [name]","formatEN":"Update website template [name]"}
func (b *BaseApi) UpdateWebsiteTemplate(c *gin.Context) {
var req request.WebsiteTemplateUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteTemplateService.UpdateTemplate(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website Template
// @Summary Delete website template
// @Accept json
// @Param request body dto.OperateByID true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/del [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"website_templates","output_column":"name","output_value":"name"}],"formatZH":"删除网站模板 [name]","formatEN":"Delete website template [name]"}
func (b *BaseApi) DeleteWebsiteTemplate(c *gin.Context) {
var req dto.OperateByID
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteTemplateService.DeleteTemplate(req.ID); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website Template
// @Summary Get website template
// @Accept json
// @Param request body dto.OperateByID true "request"
// @Success 200 {object} response.WebsiteTemplateDTO
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/get [post]
func (b *BaseApi) GetWebsiteTemplate(c *gin.Context) {
var req dto.OperateByID
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
template, err := websiteTemplateService.GetTemplate(req.ID)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, template)
}
// @Tags Website Template
// @Summary Upload website template zip
// @Accept multipart/form-data
// @Param file formData file true "file"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/upload [post]
func (b *BaseApi) UploadTemplateZip(c *gin.Context) {
fileHeader, err := c.FormFile("file")
if err != nil {
helper.BadRequest(c, err)
return
}
file, err := fileHeader.Open()
if err != nil {
helper.InternalServer(c, err)
return
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
helper.InternalServer(c, err)
return
}
filePath, variables, err := websiteTemplateService.SaveUploadZip(fileHeader.Filename, content)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, gin.H{"filePath": filePath, "variables": variables})
}
// @Tags Website Template
// @Summary Preview website template
// @Accept json
// @Param request body request.WebsitePreviewReq true "request"
// @Success 200 {object} response.WebsitePreviewDTO
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/preview [post]
func (b *BaseApi) PreviewWebsiteTemplate(c *gin.Context) {
var req request.WebsitePreviewReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
preview, err := websiteTemplateService.Preview(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, preview)
}
// @Tags Website Template
// @Summary Page website template outputs
// @Accept json
// @Param request body request.WebsiteTemplateOutputSearch true "request"
// @Success 200 {object} dto.PageResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/outputs/search [post]
func (b *BaseApi) PageWebsiteTemplateOutput(c *gin.Context) {
var req request.WebsiteTemplateOutputSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, outputs, err := websiteTemplateService.PageOutput(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{
Total: total,
Items: outputs,
})
}
// @Tags Website Template
// @Summary Create website template output
// @Accept json
// @Param request body request.WebsiteTemplateOutputCreate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/outputs [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成模板产物 [name]","formatEN":"Generate template output [name]"}
func (b *BaseApi) CreateWebsiteTemplateOutput(c *gin.Context) {
var req request.WebsiteTemplateOutputCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteTemplateService.CreateOutput(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website Template
// @Summary Delete website template output
// @Accept json
// @Param request body dto.OperateByID true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/outputs/del [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"website_template_outputs","output_column":"name","output_value":"name"}],"formatZH":"删除模板产物 [name]","formatEN":"Delete template output [name]"}
func (b *BaseApi) DeleteWebsiteTemplateOutput(c *gin.Context) {
var req dto.OperateByID
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteTemplateService.DeleteOutput(req.ID); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website Template
// @Summary Get website template output
// @Accept json
// @Param request body dto.OperateByID true "request"
// @Success 200 {object} response.WebsiteTemplateOutputDTO
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/templates/outputs/get [post]
func (b *BaseApi) GetWebsiteTemplateOutput(c *gin.Context) {
var req dto.OperateByID
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
output, err := websiteTemplateService.GetOutput(req.ID)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, output)
}
+143 -70
View File
@@ -3,28 +3,30 @@ package dto
import "time"
type AgentCreateReq struct {
Name string `json:"name" validate:"required"`
Remark string `json:"remark"`
AppVersion string `json:"appVersion" validate:"required"`
WebUIPort int `json:"webUIPort" validate:"required,min=1,max=65535"`
BridgePort int `json:"bridgePort"`
AllowedOrigins []string `json:"allowedOrigins"`
AgentType string `json:"agentType" validate:"required,oneof=openclaw copaw hermes-agent"`
Model string `json:"model"`
AccountID uint `json:"accountId"`
Token string `json:"token"`
TaskID string `json:"taskID"`
Advanced bool `json:"advanced"`
ContainerName string `json:"containerName"`
AllowPort bool `json:"allowPort"`
SpecifyIP string `json:"specifyIP"`
RestartPolicy string `json:"restartPolicy"`
CpuQuota float64 `json:"cpuQuota"`
MemoryLimit float64 `json:"memoryLimit"`
MemoryUnit string `json:"memoryUnit"`
PullImage bool `json:"pullImage"`
EditCompose bool `json:"editCompose"`
DockerCompose string `json:"dockerCompose"`
Name string `json:"name" validate:"required"`
Remark string `json:"remark"`
AppVersion string `json:"appVersion" validate:"required"`
WebUIPort int `json:"webUIPort" validate:"required,min=1,max=65535"`
BridgePort int `json:"bridgePort"`
AllowedOrigins []string `json:"allowedOrigins"`
AgentType string `json:"agentType" validate:"required,oneof=openclaw copaw hermes-agent"`
Model string `json:"model"`
AccountID uint `json:"accountId"`
Token string `json:"token"`
DashboardUsername string `json:"dashboardUsername"`
DashboardPassword string `json:"dashboardPassword"`
TaskID string `json:"taskID"`
Advanced bool `json:"advanced"`
ContainerName string `json:"containerName"`
AllowPort bool `json:"allowPort"`
SpecifyIP string `json:"specifyIP"`
RestartPolicy string `json:"restartPolicy"`
CpuQuota float64 `json:"cpuQuota"`
MemoryLimit float64 `json:"memoryLimit"`
MemoryUnit string `json:"memoryUnit"`
PullImage bool `json:"pullImage"`
EditCompose bool `json:"editCompose"`
DockerCompose string `json:"dockerCompose"`
}
type AgentBatchInstallReq struct {
@@ -38,6 +40,8 @@ type AgentBatchInstallReq struct {
Model string `json:"model"`
AccountID uint `json:"accountId"`
Token string `json:"token"`
DashboardUsername string `json:"dashboardUsername"`
DashboardPassword string `json:"dashboardPassword"`
TaskID string `json:"taskID"`
Advanced bool `json:"advanced"`
ContainerName string `json:"containerName"`
@@ -114,11 +118,11 @@ type AgentItem struct {
ProviderName string `json:"providerName"`
Model string `json:"model"`
APIType string `json:"apiType"`
MaxTokens int `json:"maxTokens"`
ContextWindow int `json:"contextWindow"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Token string `json:"token"`
DashboardUsername string `json:"dashboardUsername"`
DashboardPassword string `json:"dashboardPassword"`
Status string `json:"status"`
Message string `json:"message"`
AppInstallID uint `json:"appInstallId"`
@@ -286,19 +290,22 @@ type AgentOverviewSnapshot struct {
}
type AgentAccountModel struct {
RecordID uint `json:"recordId"`
ID string `json:"id"`
Name string `json:"name"`
ContextWindow int `json:"contextWindow"`
MaxTokens int `json:"maxTokens"`
Reasoning bool `json:"reasoning"`
Input []string `json:"input"`
RecordID uint `json:"recordId"`
ID string `json:"id"`
Name string `json:"name"`
}
type AgentAccountModelReq struct {
AccountID uint `json:"accountId" validate:"required"`
}
type AgentAccountModelDiscoverReq struct {
Provider string `json:"provider" validate:"required"`
BaseURL string `json:"baseURL" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
APIType string `json:"apiType" validate:"required"`
}
type AgentAccountModelCreateReq struct {
AccountID uint `json:"accountId" validate:"required"`
Model AgentAccountModel `json:"model" validate:"required"`
@@ -315,31 +322,40 @@ type AgentAccountModelDeleteReq struct {
}
type AgentAccountCreateReq struct {
Provider string `json:"provider" validate:"required"`
Name string `json:"name" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
RememberAPIKey bool `json:"rememberApiKey"`
BaseURL string `json:"baseURL"`
Models []AgentAccountModel `json:"models"`
APIType string `json:"apiType" validate:"required"`
Remark string `json:"remark"`
Provider string `json:"provider" validate:"required"`
Name string `json:"name" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
RememberAPIKey bool `json:"rememberApiKey"`
BaseURL string `json:"baseURL"`
Models []AgentAccountModel `json:"models"`
APIType string `json:"apiType" validate:"required"`
AuthMode string `json:"authMode"`
VerifyModel string `json:"verifyModel"`
ValidateAvailability *bool `json:"validateAvailability"`
Remark string `json:"remark"`
}
type AgentAccountUpdateReq struct {
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
RememberAPIKey bool `json:"rememberApiKey"`
BaseURL string `json:"baseURL"`
APIType string `json:"apiType" validate:"required"`
Remark string `json:"remark"`
SyncAgents bool `json:"syncAgents"`
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
RememberAPIKey bool `json:"rememberApiKey"`
BaseURL string `json:"baseURL"`
APIType string `json:"apiType" validate:"required"`
AuthMode string `json:"authMode"`
VerifyModel string `json:"verifyModel"`
ValidateAvailability *bool `json:"validateAvailability"`
Remark string `json:"remark"`
SyncAgents bool `json:"syncAgents"`
}
type AgentAccountVerifyReq struct {
Provider string `json:"provider" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
BaseURL string `json:"baseURL"`
APIType string `json:"apiType" validate:"required"`
AuthMode string `json:"authMode"`
Model string `json:"model"`
}
type AgentAccountDeleteReq struct {
@@ -349,6 +365,8 @@ type AgentAccountDeleteReq struct {
type AgentAccountSearch struct {
PageInfo
Provider string `json:"provider"`
APIType string `json:"apiType"`
TextOnly bool `json:"textOnly"`
Name string `json:"name"`
}
@@ -367,26 +385,36 @@ type AgentAccountInfo struct {
BaseURL string `json:"baseUrl"`
Models []AgentAccountModel `json:"models"`
APIType string `json:"apiType"`
AuthMode string `json:"authMode"`
VerifyModel string `json:"verifyModel"`
Verified bool `json:"verified"`
Remark string `json:"remark"`
CreatedAt time.Time `json:"createdAt"`
}
type ProviderModelInfo struct {
ID string `json:"id"`
Name string `json:"name"`
ContextWindow int `json:"contextWindow"`
MaxTokens int `json:"maxTokens"`
Reasoning bool `json:"reasoning"`
Input []string `json:"input"`
ID string `json:"id"`
Name string `json:"name"`
}
type ProviderAPIInfo struct {
APIType string `json:"apiType"`
BaseURL string `json:"baseUrl"`
EditableBaseURL bool `json:"editableBaseUrl"`
SupportsModelDiscovery bool `json:"supportsModelDiscovery"`
DefaultAuthMode string `json:"defaultAuthMode"`
AuthModes []string `json:"authModes"`
Models []ProviderModelInfo `json:"models"`
}
type ProviderInfo struct {
Sort uint `json:"-"`
Provider string `json:"provider"`
DisplayName string `json:"displayName"`
BaseURL string `json:"baseUrl"`
Models []ProviderModelInfo `json:"models"`
Sort uint `json:"-"`
Provider string `json:"provider"`
DisplayName string `json:"displayName"`
BaseURL string `json:"baseUrl"`
DefaultAPIType string `json:"defaultApiType"`
APITypes []ProviderAPIInfo `json:"apiTypes"`
Models []ProviderModelInfo `json:"models"`
}
type AgentFeishuConfigReq struct {
@@ -407,11 +435,6 @@ type AgentFeishuConfigUpdateReq struct {
Bots []AgentFeishuBot `json:"bots" validate:"required,min=1"`
}
type AgentFeishuPairingApproveReq struct {
AgentID uint `json:"agentId" validate:"required"`
PairingCode string `json:"pairingCode" validate:"required"`
}
type AgentFeishuConfig struct {
Enabled bool `json:"enabled"`
ThreadSession bool `json:"threadSession"`
@@ -581,6 +604,52 @@ type AgentPluginStatus struct {
Upgradable bool `json:"upgradable"`
}
type AgentPluginsReq struct {
AgentID uint `json:"agentId" validate:"required"`
}
type AgentPluginSearchReq struct {
AgentID uint `json:"agentId" validate:"required"`
Keyword string `json:"keyword" validate:"required,max=100"`
Limit int `json:"limit" validate:"omitempty,min=1,max=100"`
}
type AgentPluginMarketInstallReq struct {
AgentID uint `json:"agentId" validate:"required"`
Package string `json:"package" validate:"required,max=200"`
Version string `json:"version" validate:"required,max=100"`
TaskID string `json:"taskID" validate:"required"`
}
type AgentPluginOperateReq struct {
AgentID uint `json:"agentId" validate:"required"`
PluginID string `json:"pluginId" validate:"required,max=200"`
Operate string `json:"operate" validate:"required,oneof=enable disable update uninstall"`
TaskID string `json:"taskID" validate:"required"`
}
type AgentPluginItem struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Origin string `json:"origin"`
Enabled bool `json:"enabled"`
}
type AgentPluginSearchItem struct {
Package string `json:"package"`
PluginID string `json:"pluginId"`
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
Channel string `json:"channel"`
VerificationTier string `json:"verificationTier"`
Categories []string `json:"categories"`
Official bool `json:"official"`
Downloads int64 `json:"downloads"`
Score float64 `json:"score"`
}
type AgentDiscordConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
@@ -660,16 +729,20 @@ type AgentSecurityConfig struct {
}
type AgentOtherConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
UserTimezone string `json:"userTimezone" validate:"required"`
BrowserEnabled bool `json:"browserEnabled"`
NPMRegistry string `json:"npmRegistry" validate:"required"`
AgentID uint `json:"agentId" validate:"required"`
UserTimezone string `json:"userTimezone"`
BrowserEnabled bool `json:"browserEnabled"`
NPMRegistry string `json:"npmRegistry"`
DashboardUsername string `json:"dashboardUsername"`
DashboardPassword string `json:"dashboardPassword"`
}
type AgentOtherConfig struct {
UserTimezone string `json:"userTimezone"`
BrowserEnabled bool `json:"browserEnabled"`
NPMRegistry string `json:"npmRegistry"`
UserTimezone string `json:"userTimezone"`
BrowserEnabled bool `json:"browserEnabled"`
NPMRegistry string `json:"npmRegistry"`
DashboardUsername string `json:"dashboardUsername"`
DashboardPassword string `json:"dashboardPassword"`
}
type AgentConfigFileReq struct {
+4 -2
View File
@@ -113,8 +113,10 @@ type DiskDTO struct {
type AlertLogSearch struct {
PageInfo
Count uint `json:"count"`
Status string `json:"status"`
Count uint `json:"count"`
Status string `json:"status"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
}
type AlertLogDTO struct {
+3
View File
@@ -2,6 +2,7 @@ package dto
import (
"context"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task"
)
@@ -132,6 +133,8 @@ type Locale struct {
Ko string `json:"ko"`
Tr string `json:"tr"`
Es string `json:"es-es" yaml:"es-es"`
Fa string `json:"fa"`
Lo string `json:"lo"`
}
type AppForm struct {
+9 -8
View File
@@ -65,14 +65,15 @@ type UploadForRecover struct {
}
type CommonBackup struct {
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mongodb mysql-cluster postgresql-cluster redis-cluster container compose"`
Name string `json:"name"`
DetailName string `json:"detailName"`
Secret string `json:"secret"`
StopBefore bool `json:"stopBefore"`
TaskID string `json:"taskID"`
FileName string `json:"fileName"`
Args []string `json:"args"`
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mongodb mysql-cluster postgresql-cluster redis-cluster container compose"`
Name string `json:"name"`
DetailName string `json:"detailName"`
Secret string `json:"secret"`
IsImmediate bool `json:"isImmediate"`
StopBefore bool `json:"stopBefore"`
TaskID string `json:"taskID"`
FileName string `json:"fileName"`
Args []string `json:"args"`
Description string `json:"description"`
}
+2 -1
View File
@@ -2,7 +2,8 @@ package dto
type SearchWithPage struct {
PageInfo
Info string `json:"info"`
Info string `json:"info"`
ExcludeAppStore bool `json:"excludeAppStore"`
}
type SearchPageWithType struct {
+16 -5
View File
@@ -134,10 +134,15 @@ type ExtraHost struct {
IP string `json:"ip"`
}
type ContainerNetwork struct {
Network string `json:"network"`
Ipv4 string `json:"ipv4"`
Ipv6 string `json:"ipv6"`
MacAddr string `json:"macAddr"`
Network string `json:"network"`
Ipv4 string `json:"ipv4"`
Ipv6 string `json:"ipv6"`
MacAddr string `json:"macAddr"`
Links []string `json:"links"`
Aliases []string `json:"aliases"`
DriverOpts map[string]string `json:"driverOpts"`
GwPriority int `json:"gwPriority"`
LinkLocalIPs []string `json:"linkLocalIPs"`
}
type ContainerCreateByCommand struct {
@@ -295,6 +300,7 @@ type ComposeInfo struct {
ConfigFile string `json:"configFile"`
Workdir string `json:"workdir"`
ComposeFileExists bool `json:"composeFileExists"`
IsPinned bool `json:"isPinned"`
Path string `json:"path"`
Containers []ComposeContainer `json:"containers"`
Env string `json:"env"`
@@ -309,6 +315,7 @@ type ComposeContainer struct {
type ComposeCreate struct {
TaskID string `json:"taskID"`
Name string `json:"name"`
DirName string `json:"dirName"`
From string `json:"from" validate:"required,oneof=edit path template"`
File string `json:"file"`
Path string `json:"path"`
@@ -319,7 +326,7 @@ type ComposeCreate struct {
type ComposeOperation struct {
Name string `json:"name" validate:"required"`
Path string `json:"path"`
Operation string `json:"operation" validate:"required,oneof=up start restart stop down delete"`
Operation string `json:"operation" validate:"required,oneof=up start restart stop down delete rebuild"`
WithFile bool `json:"withFile"`
Force bool `json:"force"`
}
@@ -332,6 +339,10 @@ type ComposeUpdate struct {
Env string `json:"env"`
ForcePull bool `json:"forcePull"`
}
type ComposePin struct {
Name string `json:"name" validate:"required"`
IsPinned bool `json:"isPinned"`
}
type ComposeLogClean struct {
Name string `json:"name" validate:"required"`
Path string `json:"path" validate:"required"`
+1
View File
@@ -197,6 +197,7 @@ type SearchRecord struct {
type Record struct {
ID uint `json:"id"`
CronjobID uint `json:"cronjobID"`
TaskID string `json:"taskID"`
StartTime string `json:"startTime"`
Records string `json:"records"`
+10 -2
View File
@@ -79,8 +79,9 @@ type NodeCurrent struct {
}
type DashboardCurrent struct {
Uptime uint64 `json:"uptime"`
TimeSinceUptime string `json:"timeSinceUptime"`
Uptime uint64 `json:"uptime"`
TimeSinceUptime string `json:"timeSinceUptime"`
RunningTime RunningTime `json:"runningTime"`
Procs uint64 `json:"procs"`
@@ -128,6 +129,13 @@ type DashboardCurrent struct {
ShotTime time.Time `json:"shotTime"`
}
type RunningTime struct {
Days uint64 `json:"days"`
Hours uint64 `json:"hours"`
Minutes uint64 `json:"minutes"`
Seconds uint64 `json:"seconds"`
}
type AppLauncherSync struct {
Keys []string `json:"keys"`
}
+69 -10
View File
@@ -60,25 +60,84 @@ type MysqlDBCreate struct {
Database string `json:"database" validate:"required"`
Format string `json:"format" validate:"required"`
Collation string `json:"collation"`
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
Username string `json:"username"`
Password string `json:"password"`
Permission string `json:"permission" validate:"required"`
Description string `json:"description"`
}
type MysqlUser struct {
Username string `json:"username"`
Host string `json:"host"`
Password string `json:"password"`
Description string `json:"description"`
IsDelete bool `json:"isDelete"`
}
type MysqlGrant struct {
Database string `json:"database"`
Username string `json:"username"`
Host string `json:"host"`
}
type MysqlGrantSummarySearch struct {
Database string `json:"database" validate:"required"`
DBs []string `json:"dbs" validate:"required"`
}
type MysqlUserSearch struct {
Database string `json:"database" validate:"required"`
}
type MysqlUserCreate struct {
Database string `json:"database" validate:"required"`
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
Host string `json:"host" validate:"required"`
Description string `json:"description"`
DBs []string `json:"dbs"`
}
type MysqlUserDelete struct {
Database string `json:"database" validate:"required"`
Username string `json:"username" validate:"required"`
Host string `json:"host" validate:"required"`
}
type MysqlUserUpdate struct {
Database string `json:"database" validate:"required"`
Username string `json:"username" validate:"required"`
Host string `json:"host" validate:"required"`
NewHost string `json:"newHost" validate:"required"`
Description string `json:"description"`
}
type MysqlUserPassword struct {
Database string `json:"database" validate:"required"`
Username string `json:"username" validate:"required"`
Host string `json:"host" validate:"required"`
Password string `json:"password" validate:"required"`
}
type MysqlGrantCreate struct {
Database string `json:"database" validate:"required"`
DB string `json:"db" validate:"required"`
Username string `json:"username" validate:"required"`
Host string `json:"host" validate:"required"`
}
type MysqlGrantDelete struct {
Database string `json:"database" validate:"required"`
DB string `json:"db" validate:"required"`
Username string `json:"username" validate:"required"`
Host string `json:"host" validate:"required"`
}
type MysqlFormatCollationOption struct {
Format string `json:"format"`
Collations []string `json:"collations"`
}
type BindUser struct {
Database string `json:"database" validate:"required"`
DB string `json:"db" validate:"required"`
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
Permission string `json:"permission" validate:"required"`
}
type MysqlLoadDB struct {
From string `json:"from" validate:"required,oneof=local remote"`
Type string `json:"type" validate:"required,oneof=mysql mariadb mysql-cluster"`
+1 -14
View File
@@ -35,19 +35,6 @@ type PortRuleOperate struct {
Description string `json:"description"`
}
type ForwardRuleOperate struct {
ForceDelete bool `json:"forceDelete"`
Rules []struct {
Operation string `json:"operation" validate:"required,oneof=add remove"`
Num string `json:"num"`
Protocol string `json:"protocol" validate:"required,oneof=tcp udp tcp/udp"`
Interface string `json:"interface"`
Port string `json:"port" validate:"required"`
TargetIP string `json:"targetIP"`
TargetPort string `json:"targetPort" validate:"required"`
} `json:"rules"`
}
type UpdateFirewallDescription struct {
Type string `json:"type"`
Chain string `json:"chain"`
@@ -86,7 +73,7 @@ type BatchRuleOperate struct {
}
type IptablesOp struct {
Name string `json:"name" validate:"required,oneof=1PANEL_INPUT 1PANEL_OUTPUT 1PANEL_BASIC"`
Name string `json:"name" validate:"required,oneof=1PANEL_INPUT 1PANEL_OUTPUT 1PANEL_BASIC 1PANEL_FORWARD"`
Operate string `json:"operate" validate:"required,oneof=init-base init-forward init-advance bind-base unbind-base bind unbind"`
}
+43
View File
@@ -0,0 +1,43 @@
package dto
type ForwardRuleSearch struct {
PageInfo
Info string `json:"info"`
Status string `json:"status"`
Strategy string `json:"strategy"`
}
// ForwardRule preserves the existing firewall search response shape while
// keeping forwarding data separate from the filter client model.
type ForwardRule struct {
ID uint `json:"id"`
Chain string `json:"chain"`
Family string `json:"family"`
Address string `json:"address"`
Port string `json:"port"`
Protocol string `json:"protocol"`
Strategy string `json:"strategy"`
Num string `json:"num"`
TargetIP string `json:"targetIP"`
TargetPort string `json:"targetPort"`
Interface string `json:"interface"`
UsedStatus string `json:"usedStatus"`
Description string `json:"description"`
}
type ForwardRuleOperate struct {
ForceDelete bool `json:"forceDelete"`
Rules []ForwardRuleOperation `json:"rules"`
}
type ForwardRuleOperation struct {
Operation string `json:"operation" validate:"required,oneof=add remove"`
Num string `json:"num"`
Protocol string `json:"protocol" validate:"required,oneof=tcp udp tcp/udp"`
Interface string `json:"interface"`
Port string `json:"port" validate:"required"`
TargetIP string `json:"targetIP"`
TargetPort string `json:"targetPort" validate:"required"`
}
+36
View File
@@ -1,6 +1,8 @@
package dto
import (
"time"
"github.com/1Panel-dev/1Panel/agent/app/model"
)
@@ -14,3 +16,37 @@ type SearchTaskLogReq struct {
type TaskDTO struct {
model.Task
}
type SystemLogReq struct {
PageSize int `json:"pageSize" validate:"omitempty,min=1,max=500"`
Cursor string `json:"cursor"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
Keyword string `json:"keyword"`
Priority string `json:"priority"`
Service string `json:"service"`
}
type SystemLogRes struct {
Source string `json:"source"`
Items []SystemLogItem `json:"items"`
HasMore bool `json:"hasMore"`
NextCursor string `json:"nextCursor"`
}
type SystemLogStatus struct {
Source string `json:"source"`
Version string `json:"version"`
KeywordFilterSupported bool `json:"keywordFilterSupported"`
Message string `json:"message"`
}
type SystemLogItem struct {
Timestamp int64 `json:"-"`
Cursor string `json:"-"`
Time string `json:"time"`
Priority string `json:"priority"`
Service string `json:"service"`
Message string `json:"message"`
Raw string `json:"raw"`
}
+39 -5
View File
@@ -1,6 +1,8 @@
package dto
import (
"time"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/utils/nginx/components"
)
@@ -87,9 +89,41 @@ var LBAlgorithms = map[string]struct{}{"ip_hash": {}, "least_conn": {}}
var RealIPKeys = map[string]struct{}{"X-Forwarded-For": {}, "X-Real-IP": {}, "CF-Connecting-IP": {}}
type NginxModule struct {
Name string `json:"name"`
Script string `json:"script"`
Packages []string `json:"packages"`
Params string `json:"params"`
Enable bool `json:"enable"`
Name string `json:"name"`
Custom bool `json:"custom,omitempty"`
Script string `json:"script"`
Packages []string `json:"packages"`
Params string `json:"params"`
Enable bool `json:"enable"`
BuildMode string `json:"buildMode,omitempty"`
Provider string `json:"provider,omitempty"`
LoadOrder int `json:"loadOrder,omitempty"`
Builds []NginxModuleBuild `json:"builds,omitempty"`
LastError string `json:"lastError,omitempty"`
}
type NginxModuleBuild struct {
Provider string `json:"provider"`
BuildMode string `json:"buildMode"`
Status string `json:"status"`
Hash string `json:"hash"`
Target NginxModuleTarget `json:"target"`
Artifacts []NginxModuleArtifact `json:"artifacts,omitempty"`
Error string `json:"error,omitempty"`
BuiltAt time.Time `json:"builtAt,omitempty"`
}
type NginxModuleTarget struct {
Key string `json:"key"`
OpenRestyVersion string `json:"openrestyVersion"`
Architecture string `json:"architecture"`
Image string `json:"image,omitempty"`
ImageDigest string `json:"imageDigest,omitempty"`
BuilderDigest string `json:"builderDigest,omitempty"`
}
type NginxModuleArtifact struct {
Name string `json:"name"`
Path string `json:"path"`
Checksum string `json:"checksum"`
}
+1
View File
@@ -99,6 +99,7 @@ type AppInstallUpgrade struct {
DetailID uint `json:"detailId"`
Backup bool `json:"backup"`
PullImage bool `json:"pullImage"`
DeleteImage bool `json:"deleteImage"`
DockerCompose string `json:"dockerCompose"`
TaskID string `json:"taskID"`
}
+16
View File
@@ -21,6 +21,10 @@ type McpServerCreate struct {
StreamableHttpPath string `json:"streamableHttpPath"`
OutputTransport string `json:"outputTransport" validate:"required"`
Type string `json:"type" validate:"required"`
GatewayImage string `json:"gatewayImage"`
ProtocolVersion string `json:"protocolVersion"`
GatewayArgs string `json:"gatewayArgs" validate:"max=4096"`
TaskID string `json:"taskID"`
}
type McpServerUpdate struct {
@@ -32,11 +36,23 @@ type McpServerDelete struct {
ID uint `json:"id" validate:"required"`
}
type McpServerDetail struct {
ID uint `json:"id" validate:"required"`
}
type McpServerStatusSync struct {
IDs []uint `json:"ids"`
}
type McpServerOperate struct {
ID uint `json:"id" validate:"required"`
Operate string `json:"operate" validate:"required"`
}
type McpServerConnectionTest struct {
ID uint `json:"id" validate:"required"`
}
type McpBindDomain struct {
Domain string `json:"domain" validate:"required"`
SSLID uint `json:"sslID"`
+13 -8
View File
@@ -114,17 +114,22 @@ type NginxRedirectUpdate struct {
}
type NginxBuildReq struct {
TaskID string `json:"taskID" validate:"required"`
Mirror string `json:"mirror" validate:"required"`
TaskID string `json:"taskID" validate:"required"`
Mirror string `json:"mirror" validate:"required"`
Modules []string `json:"modules"`
Force bool `json:"force"`
}
type NginxModuleUpdate struct {
Operate string `json:"operate" validate:"required,oneof=create delete update"`
Name string `json:"name" validate:"required"`
Script string `json:"script"`
Packages string `json:"packages"`
Enable bool `json:"enable"`
Params string `json:"params"`
Operate string `json:"operate" validate:"required,oneof=create delete update"`
Name string `json:"name" validate:"required"`
Script string `json:"script"`
Packages string `json:"packages"`
Enable bool `json:"enable"`
Params string `json:"params"`
BuildMode string `json:"buildMode" validate:"omitempty,oneof=dynamic static"`
Provider string `json:"provider" validate:"omitempty,oneof=local prebuilt"`
LoadOrder int `json:"loadOrder" validate:"omitempty,min=0,max=9999"`
}
type NginxOperateReq struct {
+8 -3
View File
@@ -21,13 +21,14 @@ type RuntimeCreate struct {
Source string `json:"source"`
CodeDir string `json:"codeDir"`
Remark string `json:"remark"`
TaskID string `json:"taskID"`
Params map[string]interface{} `json:"params"`
NodeConfig
}
type NodeConfig struct {
Install bool `json:"install"`
Install *bool `json:"install"`
Clean bool `json:"clean"`
ExposedPorts []ExposedPort `json:"exposedPorts"`
Environments []Environment `json:"environments"`
@@ -42,12 +43,14 @@ type Environment struct {
type Volume struct {
Source string `json:"source"`
Target string `json:"target"`
Mode string `json:"mode"`
}
type ExposedPort struct {
HostPort int `json:"hostPort"`
ContainerPort int `json:"containerPort"`
HostIP string `json:"hostIP"`
Protocol string `json:"protocol"`
}
type ExtraHost struct {
@@ -56,8 +59,10 @@ type ExtraHost struct {
}
type RuntimeDelete struct {
ID uint `json:"id"`
ForceDelete bool `json:"forceDelete"`
ID uint `json:"id"`
ForceDelete bool `json:"forceDelete"`
DeleteImage bool `json:"deleteImage"`
TaskID string `json:"taskID"`
}
type RuntimeUpdate struct {
+2
View File
@@ -34,6 +34,8 @@ type WebsiteCreate struct {
SiteDir string `json:"siteDir"`
TemplateOutputID uint `json:"templateOutputID"`
RuntimeConfig
FtpConfig
DataBaseConfig
+15 -1
View File
@@ -6,7 +6,7 @@ type WebsiteSSLSearch struct {
dto.PageInfo
AcmeAccountID string `json:"acmeAccountID"`
Domain string `json:"domain"`
OrderBy string `json:"orderBy" validate:"omitempty,oneof=created_at expire_date"`
OrderBy string `json:"orderBy" validate:"omitempty,oneof=created_at updated_at expire_date"`
Order string `json:"order" validate:"omitempty,oneof=null ascending descending"`
}
@@ -126,6 +126,16 @@ type WebsiteSSLUpload struct {
Type string `json:"type" validate:"required,oneof=paste local"`
SSLID uint `json:"sslID"`
Description string `json:"description"`
PushNode bool `json:"pushNode"`
Nodes string `json:"nodes"`
}
type WebsiteSSLPush struct {
ID uint `json:"id" validate:"required"`
PushNode bool `json:"pushNode"`
Nodes string `json:"nodes"`
TaskID string `json:"taskID" validate:"required"`
Sync bool `json:"sync"`
}
type WebsiteCASearch struct {
@@ -157,6 +167,8 @@ type WebsiteCAObtain struct {
Description string `json:"description"`
ExecShell bool `json:"execShell"`
Shell string `json:"shell"`
PushNode bool `json:"pushNode"`
Nodes string `json:"nodes"`
}
type WebsiteCARenew struct {
@@ -167,4 +179,6 @@ type WebsiteSSLFileUpload struct {
Type string `json:"type"`
Description string `json:"description"`
SSLID uint64 `json:"sslID"`
PushNode bool `json:"pushNode"`
Nodes string `json:"nodes"`
}
+46
View File
@@ -0,0 +1,46 @@
package request
import (
"github.com/1Panel-dev/1Panel/agent/app/dto"
)
type WebsiteTemplateSearch struct {
dto.PageInfo
Name string `json:"name"`
Type string `json:"type"`
}
type WebsiteTemplateCreate struct {
Name string `json:"name" validate:"required"`
Type string `json:"type" validate:"required,oneof=single multi"`
Content string `json:"content"`
FilePath string `json:"filePath"`
Variables string `json:"variables"`
Remark string `json:"remark"`
}
type WebsiteTemplateUpdate struct {
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
Type string `json:"type" validate:"required,oneof=single multi"`
Content string `json:"content"`
FilePath string `json:"filePath"`
Variables string `json:"variables"`
Remark string `json:"remark"`
}
type WebsiteTemplateOutputSearch struct {
dto.PageInfo
TemplateID uint `json:"templateID"`
}
type WebsiteTemplateOutputCreate struct {
TemplateID uint `json:"templateID" validate:"required"`
Name string `json:"name" validate:"required"`
VariableValues map[string]string `json:"variableValues"`
}
type WebsitePreviewReq struct {
TemplateID uint `json:"templateID" validate:"required"`
VariableValues map[string]string `json:"variableValues"`
}
+30 -28
View File
@@ -104,34 +104,36 @@ type AppDetail struct {
}
type AppInstallDTO struct {
ID uint `json:"id"`
Name string `json:"name"`
AppID uint `json:"appID"`
AppDetailID uint `json:"appDetailID"`
Version string `json:"version"`
Status string `json:"status"`
Message string `json:"message"`
HttpPort int `json:"httpPort"`
HttpsPort int `json:"httpsPort"`
Path string `json:"path"`
CanUpdate bool `json:"canUpdate"`
Icon string `json:"icon"`
AppName string `json:"appName"`
Ready int `json:"ready"`
Total int `json:"total"`
AppKey string `json:"appKey"`
AppType string `json:"appType"`
AppStatus string `json:"appStatus"`
DockerCompose string `json:"dockerCompose"`
WebUI string `json:"webUI"`
CreatedAt time.Time `json:"createdAt"`
Favorite bool `json:"favorite"`
SortOrder int `json:"sortOrder"`
App AppDetail `json:"app"`
Container string `json:"container"`
IsEdit bool `json:"isEdit"`
LinkDB bool `json:"linkDB"`
ServiceName string `json:"serviceName"`
ID uint `json:"id"`
Name string `json:"name"`
AppID uint `json:"appID"`
AppDetailID uint `json:"appDetailID"`
Version string `json:"version"`
Status string `json:"status"`
Message string `json:"message"`
HttpPort int `json:"httpPort"`
HttpsPort int `json:"httpsPort"`
Path string `json:"path"`
CanUpdate bool `json:"canUpdate"`
Icon string `json:"icon"`
AppName string `json:"appName"`
Ready int `json:"ready"`
Total int `json:"total"`
AppKey string `json:"appKey"`
AppType string `json:"appType"`
AppStatus string `json:"appStatus"`
DockerCompose string `json:"dockerCompose"`
WebUI string `json:"webUI"`
CreatedAt time.Time `json:"createdAt"`
Favorite bool `json:"favorite"`
SortOrder int `json:"sortOrder"`
App AppDetail `json:"app"`
Container string `json:"container"`
IsEdit bool `json:"isEdit"`
LinkDB bool `json:"linkDB"`
ResourceKeys []string `json:"resourceKeys"`
ServiceName string `json:"serviceName"`
Env map[string]interface{} `json:"env"`
}
type AppInstallInfo struct {
+14
View File
@@ -16,6 +16,12 @@ type McpServerDTO struct {
Volumes []request.Volume `json:"volumes"`
}
type McpServerStatusDTO struct {
ID uint `json:"id"`
Status string `json:"status"`
Message string `json:"message"`
}
type McpBindDomainRes struct {
Domain string `json:"domain"`
SSLID uint `json:"sslID"`
@@ -24,3 +30,11 @@ type McpBindDomainRes struct {
WebsiteID uint `json:"websiteID"`
ConnUrl string `json:"connUrl"`
}
type McpServerConnectionTestRes struct {
Success bool `json:"success"`
Endpoint string `json:"endpoint"`
OutputTransport string `json:"outputTransport"`
ProtocolVersion string `json:"protocolVersion,omitempty"`
Message string `json:"message"`
}
+16 -7
View File
@@ -69,16 +69,25 @@ type NginxProxyCache struct {
}
type NginxModule struct {
Name string `json:"name"`
Script string `json:"script"`
Packages string `json:"packages"`
Params string `json:"params"`
Enable bool `json:"enable"`
Name string `json:"name"`
Custom bool `json:"custom"`
Script string `json:"script"`
Packages string `json:"packages"`
Params string `json:"params"`
Enable bool `json:"enable"`
BuildMode string `json:"buildMode"`
Provider string `json:"provider"`
LoadOrder int `json:"loadOrder"`
BuildStatus string `json:"buildStatus"`
LoadStatus string `json:"loadStatus"`
Artifacts []dto.NginxModuleArtifact `json:"artifacts"`
LastError string `json:"lastError"`
}
type NginxBuildConfig struct {
Mirror string `json:"mirror"`
Modules []NginxModule `json:"modules"`
Mirror string `json:"mirror"`
DynamicSupported bool `json:"dynamicSupported"`
Modules []NginxModule `json:"modules"`
}
type NginxConfigRes struct {
@@ -0,0 +1,18 @@
package response
import (
"github.com/1Panel-dev/1Panel/agent/app/model"
)
type WebsiteTemplateDTO struct {
model.WebsiteTemplate
}
type WebsiteTemplateOutputDTO struct {
model.WebsiteTemplateOutput
TemplateName string `json:"templateName"`
}
type WebsitePreviewDTO struct {
HTML string `json:"html"`
}
+30
View File
@@ -0,0 +1,30 @@
package dto
import "time"
type RuntimeDiagnosticsSummary struct {
RSS uint64 `json:"rss"`
HeapAlloc uint64 `json:"heapAlloc"`
HeapObjects uint64 `json:"heapObjects"`
Goroutines int `json:"goroutines"`
}
type RuntimeGoroutineGroup struct {
State string `json:"state"`
Top string `json:"top"`
Count int `json:"count"`
Stack []string `json:"stack"`
}
type RuntimeGoroutineSnapshot struct {
Total int `json:"total"`
GroupCount int `json:"groupCount"`
Truncated bool `json:"truncated"`
CapturedAt time.Time `json:"capturedAt"`
Goroutines []RuntimeGoroutineGroup `json:"goroutines"`
}
type RuntimeProfileCreate struct {
Type string `json:"type" validate:"required,oneof=cpu heap goroutine mutex block"`
Duration int `json:"duration" validate:"omitempty,min=5,max=30"`
}
+2
View File
@@ -7,6 +7,8 @@ type AgentAccount struct {
APIKey string `json:"apiKey"`
BaseURL string `json:"baseUrl"`
APIType string `json:"apiType"`
AuthMode string `json:"authMode"`
VerifyModel string `json:"verifyModel"`
RememberAPIKey bool `json:"rememberApiKey"`
Verified bool `json:"verified"`
Remark string `json:"remark"`
+4 -8
View File
@@ -2,14 +2,10 @@ package model
type AgentAccountModel struct {
BaseModel
AccountID uint `json:"accountId" gorm:"index"`
Model string `json:"model" gorm:"index"`
Name string `json:"name"`
ContextWindow int `json:"contextWindow"`
MaxTokens int `json:"maxTokens"`
Reasoning bool `json:"reasoning"`
Input string `json:"input" gorm:"type:text"`
SortOrder int `json:"sortOrder" gorm:"index"`
AccountID uint `json:"accountId" gorm:"index"`
Model string `json:"model" gorm:"index"`
Name string `json:"name"`
SortOrder int `json:"sortOrder" gorm:"index"`
}
func (AgentAccountModel) TableName() string {
+1
View File
@@ -31,4 +31,5 @@ type BackupRecord struct {
Status string `json:"status"`
Message string `json:"message"`
Description string `json:"description"`
Args string `gorm:"not null;default:''" json:"args"`
}
+3 -2
View File
@@ -11,6 +11,7 @@ type ComposeTemplate struct {
type Compose struct {
BaseModel
Name string `json:"name"`
Path string `json:"path"`
Name string `json:"name"`
Path string `json:"path"`
IsPinned bool `json:"isPinned"`
}
+21
View File
@@ -0,0 +1,21 @@
package model
type DatabaseUser struct {
BaseModel
Type string `json:"type" gorm:"not null;uniqueIndex:idx_database_user"`
Database string `json:"database" gorm:"not null;uniqueIndex:idx_database_user"`
Username string `json:"username" gorm:"not null;uniqueIndex:idx_database_user"`
Host string `json:"host" gorm:"uniqueIndex:idx_database_user"`
Password string `json:"password"`
Description string `json:"description"`
IsDelete bool `json:"isDelete"`
}
type DatabaseUserGrant struct {
BaseModel
Type string `json:"type" gorm:"not null;uniqueIndex:idx_database_user_grant"`
Database string `json:"database" gorm:"not null;uniqueIndex:idx_database_user_grant"`
DBName string `json:"dbName" gorm:"not null;uniqueIndex:idx_database_user_grant"`
Username string `json:"username" gorm:"not null;uniqueIndex:idx_database_user_grant"`
Host string `json:"host" gorm:"not null;uniqueIndex:idx_database_user_grant"`
}
+2
View File
@@ -8,4 +8,6 @@ type Ftp struct {
Status string `gorm:"not null" json:"status"`
Path string `gorm:"not null" json:"path"`
Description string `gorm:"not null" json:"description"`
UID uint `gorm:"column:uid;not null;default:1000" json:"-"`
GID uint `gorm:"column:gid;not null;default:1000" json:"-"`
}
+3
View File
@@ -18,4 +18,7 @@ type McpServer struct {
StreamableHttpPath string `json:"streamableHttpPath"`
OutputTransport string `json:"outputTransport"`
Type string `json:"type"`
GatewayImage string `json:"gatewayImage"`
ProtocolVersion string `json:"protocolVersion"`
GatewayArgs string `json:"gatewayArgs"`
}
+28
View File
@@ -0,0 +1,28 @@
package model
type WebsiteTemplate struct {
BaseModel
Name string `gorm:"not null" json:"name"`
Type string `gorm:"not null" json:"type"` // single | multi
Content string `gorm:"type:longtext" json:"content"`
FilePath string `json:"filePath"`
Variables string `gorm:"type:text" json:"variables"`
Remark string `json:"remark"`
}
func (w WebsiteTemplate) TableName() string {
return "website_templates"
}
type WebsiteTemplateOutput struct {
BaseModel
Name string `gorm:"not null" json:"name"`
TemplateID uint `gorm:"not null" json:"templateID"`
TemplateType string `json:"templateType"`
VariableValues string `gorm:"type:text" json:"variableValues"`
OutputPath string `json:"outputPath"`
}
func (w WebsiteTemplateOutput) TableName() string {
return "website_template_outputs"
}
+356 -325
View File
@@ -1,319 +1,248 @@
package provider
import (
"fmt"
"net/url"
"strings"
)
type Model struct {
ID string
Name string
ContextWindow int
MaxTokens int
Reasoning bool
Input []string
type APIConfig struct {
APIType string
BaseURL string
EditableBaseURL bool
DiscoverModels bool
DefaultAuthMode string
AuthModes []string
Models []Model
}
type RuntimeDefault struct {
APIType string
ContextWindow int
MaxTokens int
Input []string
const (
AuthModeBearer = "bearer"
AuthModeXAPIKey = "x-api-key"
)
type Model struct {
ID string
Name string
}
type Meta struct {
Key string
DisplayName string
DisplayNameKey string
Sort uint
DefaultBaseURL string
DefaultAPIType string
APIConfigs []APIConfig
EnvKey string
Default RuntimeDefault
Models []Model
}
var catalog = map[string]Meta{
"custom": {
Key: "custom",
DisplayName: "Custom",
Sort: 10,
DefaultBaseURL: "",
EnvKey: "CUSTOM_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 128000,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{},
Key: "custom", DisplayName: "Custom", Sort: 10, DefaultAPIType: "openai-completions", EnvKey: "CUSTOM_API_KEY",
APIConfigs: editableAPIConfigs(true, "openai-completions", "openai-responses", "anthropic-messages", "openai-images"),
},
"ollama": {
Key: "ollama",
DisplayName: "Ollama",
Sort: 15,
Default: RuntimeDefault{
APIType: "openai-responses",
ContextWindow: 160000,
MaxTokens: 8192,
Input: []string{"text"},
},
Key: "ollama", DisplayName: "Ollama", Sort: 15, DefaultAPIType: "openai-responses",
APIConfigs: editableAPIConfigs(false, "openai-responses", "openai-completions"),
},
"vllm": {
Key: "vllm",
DisplayName: "vLLM",
Sort: 20,
DefaultBaseURL: "",
EnvKey: "VLLM_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 128000,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{},
Key: "vllm", DisplayName: "vLLM", Sort: 20, DefaultAPIType: "openai-completions", EnvKey: "VLLM_API_KEY",
APIConfigs: editableAPIConfigs(false, "openai-completions", "openai-responses", "anthropic-messages", "openai-images"),
},
"deepseek": {
Key: "deepseek",
DisplayName: "DeepSeek",
Sort: 25,
DefaultBaseURL: "https://api.deepseek.com/v1",
EnvKey: "DEEPSEEK_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 131072,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{
{ID: "deepseek/deepseek-v4-flash", Name: "deepseek-v4-flash"},
{ID: "deepseek/deepseek-v4-pro", Name: "deepseek-v4-pro"},
Key: "deepseek", DisplayName: "DeepSeek", Sort: 25, DefaultAPIType: "openai-completions", EnvKey: "DEEPSEEK_API_KEY",
APIConfigs: []APIConfig{
{APIType: "openai-completions", BaseURL: "https://api.deepseek.com"},
anthropicAPIConfig("https://api.deepseek.com/anthropic", AuthModeXAPIKey),
},
Models: []Model{{ID: "deepseek-v4-flash", Name: "deepseek-v4-flash"}, {ID: "deepseek-v4-pro", Name: "deepseek-v4-pro"}},
},
"bailian-coding-plan": {
Key: "bailian-coding-plan",
DisplayName: "阿里云百炼 Coding Plan",
Sort: 30,
DefaultBaseURL: "https://coding.dashscope.aliyuncs.com/v1",
EnvKey: "QWEN_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 256000,
MaxTokens: 8192,
Input: []string{"text"},
Key: "bailian-coding-plan", DisplayNameKey: "AIProviderBailianCodingPlan", Sort: 30, DefaultAPIType: "openai-completions", EnvKey: "QWEN_API_KEY",
APIConfigs: []APIConfig{
{APIType: "openai-completions", BaseURL: "https://coding.dashscope.aliyuncs.com/v1"},
anthropicAPIConfig("https://coding.dashscope.aliyuncs.com/apps/anthropic", AuthModeBearer),
},
Models: []Model{
{ID: "bailian-coding-plan/qwen3.5-plus", Name: "Qwen3.5-Plus", Reasoning: true},
{ID: "bailian-coding-plan/qwen3-max", Name: "Qwen3-Max", Reasoning: true},
{ID: "bailian-coding-plan/qwen3-coder-next", Name: "Qwen3-Coder-Next", Reasoning: true},
{ID: "bailian-coding-plan/qwen3-coder-plus", Name: "Qwen3-Coder-Plus", Reasoning: true},
{ID: "bailian-coding-plan/minimax-m2.5", Name: "MiniMax M2.5", Reasoning: true},
{ID: "bailian-coding-plan/glm-5", Name: "GLM-5", Reasoning: true},
{ID: "bailian-coding-plan/kimi-k2.5", Name: "Kimi-k2.5", Reasoning: true},
{ID: "bailian-coding-plan/glm-4.7", Name: "GLM-4.7", Reasoning: true},
{ID: "qwen3-coder-plus", Name: "Qwen3-Coder-Plus"},
{ID: "qwen3-max-2026-01-23", Name: "Qwen3-Max-2026-01-23"},
{ID: "qwen3-coder-next", Name: "Qwen3-Coder-Next"},
{ID: "glm-4.7", Name: "GLM-4.7"},
{ID: "kimi-k2.5", Name: "Kimi K2.5"},
{ID: "qwen3.5-plus", Name: "Qwen3.5-Plus"},
{ID: "glm-5", Name: "GLM-5"},
{ID: "MiniMax-M2.5", Name: "MiniMax M2.5"},
{ID: "qwen3.6-plus", Name: "Qwen3.6-Plus"},
{ID: "qwen3.7-plus", Name: "Qwen3.7-Plus"},
},
},
"ark-coding-plan": {
Key: "ark-coding-plan",
DisplayName: "方舟 Coding Plan",
Sort: 35,
DefaultBaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3",
EnvKey: "ARK_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 256000,
MaxTokens: 4096,
Input: []string{"text"},
Key: "ark-coding-plan", DisplayNameKey: "AIProviderArkCodingPlan", Sort: 35, DefaultAPIType: "openai-completions", EnvKey: "ARK_API_KEY",
APIConfigs: []APIConfig{
{APIType: "openai-completions", BaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3"},
anthropicAPIConfig("https://ark.cn-beijing.volces.com/api/coding", AuthModeBearer),
},
Models: []Model{
{ID: "ark-coding-plan/ark-code-latest", Name: "Ark Coding Plan", ContextWindow: 256000, MaxTokens: 4096},
{ID: "ark-coding-plan/doubao-seed-code", Name: "Doubao Seed Code", ContextWindow: 256000, MaxTokens: 4096},
{ID: "ark-coding-plan/glm-4.7", Name: "GLM 4.7 Coding", ContextWindow: 200000, MaxTokens: 4096},
{ID: "ark-coding-plan/kimi-k2-thinking", Name: "Kimi K2 Thinking", ContextWindow: 256000, MaxTokens: 4096},
{ID: "ark-coding-plan/kimi-k2.5", Name: "Kimi K2.5 Coding", ContextWindow: 256000, MaxTokens: 4096},
{ID: "ark-coding-plan/doubao-seed-code-preview-251028", Name: "Doubao Seed Code Preview", ContextWindow: 256000, MaxTokens: 4096},
{ID: "ark-code-latest", Name: "Ark Coding Plan"}, {ID: "doubao-seed-code", Name: "Doubao Seed Code"},
{ID: "glm-4.7", Name: "GLM 4.7 Coding"}, {ID: "kimi-k2-thinking", Name: "Kimi K2 Thinking"},
{ID: "kimi-k2.5", Name: "Kimi K2.5 Coding"}, {ID: "doubao-seed-code-preview-251028", Name: "Doubao Seed Code Preview"},
},
},
"zai": {
Key: "zai",
DisplayName: "Z.ai",
Sort: 40,
DefaultBaseURL: "https://open.bigmodel.cn/api/paas/v4",
EnvKey: "ZAI_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 204800,
MaxTokens: 131072,
Input: []string{"text"},
},
Models: []Model{
{ID: "zai/glm-5", Name: "GLM-5", Reasoning: true},
{ID: "zai/glm-4.7", Name: "GLM-4.7", Reasoning: true},
{ID: "zai/glm-4.7-flash", Name: "GLM-4.7-Flash", Reasoning: true},
{ID: "zai/glm-4.7-flashx", Name: "GLM-4.7-FlashX", Reasoning: true},
Key: "zai", DisplayName: "Z.ai", Sort: 40, DefaultAPIType: "openai-completions", EnvKey: "ZAI_API_KEY",
APIConfigs: []APIConfig{
{APIType: "openai-completions", BaseURL: "https://open.bigmodel.cn/api/paas/v4", EditableBaseURL: true},
{APIType: "openai-images", BaseURL: "https://open.bigmodel.cn/api/paas/v4", EditableBaseURL: true},
},
Models: []Model{{ID: "glm-5", Name: "GLM-5"}, {ID: "glm-4.7", Name: "GLM-4.7"}, {ID: "glm-4.7-flash", Name: "GLM-4.7-Flash"}, {ID: "glm-4.7-flashx", Name: "GLM-4.7-FlashX"}},
},
"minimax": {
Key: "minimax",
DisplayName: "MiniMax (CN)",
Sort: 45,
DefaultBaseURL: "https://api.minimaxi.com/anthropic",
EnvKey: "MINIMAX_API_KEY",
Default: RuntimeDefault{
APIType: "anthropic-messages",
ContextWindow: 200000,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{
{ID: "minimax/MiniMax-M2.7", Name: "MiniMax M2.7"},
{ID: "minimax/MiniMax-M2.7-highspeed", Name: "MiniMax M2.7 highspeed"},
{ID: "minimax/MiniMax-M2.5", Name: "MiniMax M2.5", Reasoning: true},
{ID: "minimax/MiniMax-M2.5-highspeed", Name: "MiniMax M2.5 highspeed"},
Key: "minimax", DisplayName: "MiniMax (CN)", Sort: 45, DefaultAPIType: "anthropic-messages", EnvKey: "MINIMAX_API_KEY",
APIConfigs: []APIConfig{
anthropicAPIConfig("https://api.minimaxi.com/anthropic", AuthModeXAPIKey, AuthModeBearer),
{APIType: "openai-completions", BaseURL: "https://api.minimaxi.com/v1"},
{APIType: "minimax-images", BaseURL: "https://api.minimaxi.com"},
},
Models: []Model{{ID: "MiniMax-M3", Name: "MiniMax M3"}, {ID: "MiniMax-M2.7", Name: "MiniMax M2.7"}, {ID: "MiniMax-M2.7-highspeed", Name: "MiniMax M2.7 highspeed"}},
},
"xiaomi": {
Key: "xiaomi",
DisplayName: "Xiaomi",
Sort: 46,
DefaultBaseURL: "https://api.xiaomimimo.com/v1",
EnvKey: "XIAOMI_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 262144,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{
{ID: "xiaomi/mimo-v2-flash", Name: "Xiaomi MiMo V2 Flash", ContextWindow: 262144, MaxTokens: 8192, Input: []string{"text"}},
{ID: "xiaomi/mimo-v2-pro", Name: "Xiaomi MiMo V2 Pro", ContextWindow: 1048576, MaxTokens: 32000, Reasoning: true, Input: []string{"text"}},
{ID: "xiaomi/mimo-v2-omni", Name: "Xiaomi MiMo V2 Omni", ContextWindow: 262144, MaxTokens: 32000, Reasoning: true, Input: []string{"text", "image"}},
Key: "xiaomi", DisplayName: "Xiaomi", Sort: 46, DefaultAPIType: "openai-completions", EnvKey: "XIAOMI_API_KEY",
APIConfigs: []APIConfig{
{APIType: "openai-completions", BaseURL: "https://api.xiaomimimo.com/v1"},
{APIType: "openai-responses", BaseURL: "https://api.xiaomimimo.com/v1"},
anthropicAPIConfig("https://api.xiaomimimo.com/anthropic", AuthModeBearer),
},
Models: []Model{{ID: "mimo-v2.5", Name: "Xiaomi MiMo V2.5"}, {ID: "mimo-v2.5-pro", Name: "Xiaomi MiMo V2.5 Pro"}},
},
"kimi": {
Key: "kimi",
DisplayName: "Kimi (CN)",
Sort: 50,
DefaultBaseURL: "https://api.moonshot.cn/v1",
EnvKey: "KIMI_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 256000,
MaxTokens: 8192,
Input: []string{"text", "image"},
},
Models: []Model{
{ID: "kimi/kimi-k2.5", Name: "Kimi K2.5", Reasoning: true},
{ID: "kimi/kimi-k2-0905-preview", Name: "Kimi K2 0905 Preview"},
{ID: "kimi/kimi-k2-thinking", Name: "Kimi K2 Thinking", Reasoning: true},
},
Key: "kimi", DisplayName: "Kimi (CN)", Sort: 50, DefaultAPIType: "openai-completions", EnvKey: "KIMI_API_KEY",
APIConfigs: []APIConfig{{APIType: "openai-completions", BaseURL: "https://api.moonshot.cn/v1"}},
Models: []Model{{ID: "kimi-k2.5", Name: "Kimi K2.5"}, {ID: "kimi-k2-0905-preview", Name: "Kimi K2 0905 Preview"}, {ID: "kimi-k2-thinking", Name: "Kimi K2 Thinking"}},
},
"kimi-coding": {
Key: "kimi-coding",
DisplayName: "Kimi Coding",
Sort: 51,
DefaultBaseURL: "https://api.kimi.com/coding/",
EnvKey: "KIMI_API_KEY",
Default: RuntimeDefault{
APIType: "anthropic-messages",
ContextWindow: 262144,
MaxTokens: 32768,
Input: []string{"text", "image"},
},
Models: []Model{
{ID: "kimi-coding/kimi-code", Name: "Kimi Code", ContextWindow: 262144, MaxTokens: 32768, Reasoning: true, Input: []string{"text", "image"}},
{ID: "kimi-coding/k2p5", Name: "Kimi K2.5", ContextWindow: 262144, MaxTokens: 32768, Reasoning: true, Input: []string{"text", "image"}},
},
Key: "kimi-coding", DisplayName: "Kimi Coding", Sort: 51, DefaultAPIType: "anthropic-messages", EnvKey: "KIMI_API_KEY",
APIConfigs: []APIConfig{anthropicAPIConfig("https://api.kimi.com/coding/", AuthModeXAPIKey)},
Models: []Model{{ID: "kimi-code", Name: "Kimi Code"}, {ID: "k2p5", Name: "Kimi K2.5"}},
},
"openai": {
Key: "openai",
DisplayName: "OpenAI",
Sort: 55,
DefaultBaseURL: "https://api.openai.com/v1",
EnvKey: "OPENAI_API_KEY",
Default: RuntimeDefault{
APIType: "openai-responses",
ContextWindow: 272000,
MaxTokens: 128000,
Input: []string{"text", "image"},
},
Models: []Model{
{ID: "openai/gpt-5.4", Name: "gpt-5.4", ContextWindow: 272000, MaxTokens: 128000, Reasoning: true, Input: []string{"text", "image"}},
{ID: "openai/gpt-5.4-pro", Name: "gpt-5.4-pro", ContextWindow: 1050000, MaxTokens: 128000, Reasoning: true, Input: []string{"text", "image"}},
{ID: "openai/gpt-5.4-mini", Name: "gpt-5.4-mini", ContextWindow: 400000, MaxTokens: 128000, Reasoning: true, Input: []string{"text", "image"}},
{ID: "openai/gpt-5.4-nano", Name: "gpt-5.4-nano", ContextWindow: 400000, MaxTokens: 128000, Reasoning: true, Input: []string{"text", "image"}},
Key: "openai", DisplayName: "OpenAI", Sort: 55, DefaultAPIType: "openai-responses", EnvKey: "OPENAI_API_KEY",
APIConfigs: []APIConfig{
{APIType: "openai-responses", BaseURL: "https://api.openai.com/v1"},
{APIType: "openai-completions", BaseURL: "https://api.openai.com/v1"},
{APIType: "openai-images", BaseURL: "https://api.openai.com/v1"},
},
Models: []Model{{ID: "gpt-5.4", Name: "gpt-5.4"}, {ID: "gpt-5.4-pro", Name: "gpt-5.4-pro"}, {ID: "gpt-5.4-mini", Name: "gpt-5.4-mini"}, {ID: "gpt-5.4-nano", Name: "gpt-5.4-nano"}},
},
"openrouter": {
Key: "openrouter",
DisplayName: "OpenRouter",
Sort: 56,
DefaultBaseURL: "https://openrouter.ai/api/v1",
EnvKey: "OPENROUTER_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 128000,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{
{ID: "openrouter/free", Name: "openrouter/free"},
{ID: "openrouter/auto", Name: "openrouter/auto"},
Key: "openrouter", DisplayName: "OpenRouter", Sort: 56, DefaultAPIType: "openai-completions", EnvKey: "OPENROUTER_API_KEY",
APIConfigs: []APIConfig{
{APIType: "openai-completions", BaseURL: "https://openrouter.ai/api/v1"},
{APIType: "openrouter-images", BaseURL: "https://openrouter.ai"},
},
Models: []Model{{ID: "openrouter/free", Name: "openrouter/free"}, {ID: "openrouter/auto", Name: "openrouter/auto"}},
},
"anthropic": {
Key: "anthropic",
DisplayName: "Anthropic",
Sort: 60,
DefaultBaseURL: "https://api.anthropic.com",
EnvKey: "ANTHROPIC_API_KEY",
Default: RuntimeDefault{
APIType: "anthropic-messages",
ContextWindow: 256000,
MaxTokens: 8192,
Input: []string{"text", "image"},
},
Models: []Model{
{ID: "anthropic/claude-sonnet-4-6", Name: "Claude Sonnet 4.6", Reasoning: true},
{ID: "anthropic/claude-opus-4-6", Name: "Claude Opus 4.6", Reasoning: true},
{ID: "anthropic/claude-opus-4-5", Name: "Claude Opus 4.5"},
{ID: "anthropic/claude-sonnet-4-5", Name: "Claude Sonnet 4.5"},
{ID: "anthropic/claude-haiku-4-5", Name: "Claude Haiku 4.5"},
},
Key: "anthropic", DisplayName: "Anthropic", Sort: 60, DefaultAPIType: "anthropic-messages", EnvKey: "ANTHROPIC_API_KEY",
APIConfigs: []APIConfig{anthropicAPIConfig("https://api.anthropic.com", AuthModeXAPIKey)},
Models: []Model{{ID: "claude-sonnet-4-6", Name: "Claude Sonnet 4.6"}, {ID: "claude-opus-4-6", Name: "Claude Opus 4.6"}, {ID: "claude-opus-4-5", Name: "Claude Opus 4.5"}, {ID: "claude-sonnet-4-5", Name: "Claude Sonnet 4.5"}, {ID: "claude-haiku-4-5", Name: "Claude Haiku 4.5"}},
},
"gemini": {
Key: "gemini",
DisplayName: "Gemini",
Sort: 65,
DefaultBaseURL: "https://generativelanguage.googleapis.com",
EnvKey: "GEMINI_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 256000,
MaxTokens: 8192,
Input: []string{"text", "image"},
},
Models: []Model{
{ID: "google/gemini-3-flash-preview", Name: "Gemini 3 Flash Preview", Reasoning: true},
{ID: "google/gemini-flash-latest", Name: "Gemini Flash Latest"},
{ID: "google/gemini-3-pro-preview", Name: "Gemini 3 Pro Preview", Reasoning: true},
},
Key: "gemini", DisplayName: "Gemini", Sort: 65, DefaultAPIType: "gemini-generate-content", EnvKey: "GEMINI_API_KEY",
APIConfigs: []APIConfig{{APIType: "gemini-generate-content", BaseURL: "https://generativelanguage.googleapis.com"}},
Models: []Model{{ID: "gemini-3-flash-preview", Name: "Gemini 3 Flash Preview"}, {ID: "gemini-flash-latest", Name: "Gemini Flash Latest"}, {ID: "gemini-3-pro-preview", Name: "Gemini 3 Pro Preview"}},
},
"moonshot": {
Key: "moonshot",
DisplayName: "Moonshot (Global)",
Sort: 70,
DefaultBaseURL: "https://api.moonshot.ai/v1",
EnvKey: "MOONSHOT_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 256000,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{
{ID: "moonshot/kimi-k2.5", Name: "Kimi K2.5", Reasoning: true},
{ID: "moonshot/kimi-k2-0905-preview", Name: "Kimi K2 0905 Preview"},
{ID: "moonshot/kimi-k2-thinking", Name: "Kimi K2 Thinking", Reasoning: true},
Key: "moonshot", DisplayName: "Moonshot (Global)", Sort: 70, DefaultAPIType: "openai-completions", EnvKey: "MOONSHOT_API_KEY",
APIConfigs: []APIConfig{{APIType: "openai-completions", BaseURL: "https://api.moonshot.ai/v1"}},
Models: []Model{{ID: "kimi-k2.5", Name: "Kimi K2.5"}, {ID: "kimi-k2-0905-preview", Name: "Kimi K2 0905 Preview"}, {ID: "kimi-k2-thinking", Name: "Kimi K2 Thinking"}},
},
"bailian": {
Key: "bailian", DisplayNameKey: "AIProviderBailian", Sort: 31, DefaultAPIType: "openai-completions", EnvKey: "DASHSCOPE_API_KEY",
APIConfigs: []APIConfig{
{
APIType: "openai-completions", BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
DiscoverModels: true,
Models: []Model{{ID: "qwen3.7-plus", Name: "qwen3.7-plus"}, {ID: "qwen3.6-plus", Name: "qwen3.6-plus"}, {ID: "qwen3.6-flash", Name: "qwen3.6-flash"}},
},
{
APIType: "openai-responses", BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
DiscoverModels: true,
Models: []Model{{ID: "qwen3.7-plus", Name: "qwen3.7-plus"}, {ID: "qwen3.6-plus", Name: "qwen3.6-plus"}, {ID: "qwen3.6-flash", Name: "qwen3.6-flash"}},
},
{
APIType: "anthropic-messages", BaseURL: "https://dashscope.aliyuncs.com/apps/anthropic",
DefaultAuthMode: AuthModeBearer,
AuthModes: []string{AuthModeBearer},
Models: []Model{{ID: "qwen3.7-plus", Name: "qwen3.7-plus"}, {ID: "qwen3.6-plus", Name: "qwen3.6-plus"}, {ID: "qwen3.6-flash", Name: "qwen3.6-flash"}},
},
{
APIType: "dashscope-images", BaseURL: "https://dashscope.aliyuncs.com",
Models: []Model{
{ID: "qwen-image-2.0-pro", Name: "qwen-image-2.0-pro"},
{ID: "qwen-image-2.0", Name: "qwen-image-2.0"},
{ID: "wan2.7-image-pro", Name: "wan2.7-image-pro"},
{ID: "wan2.7-image", Name: "wan2.7-image"},
},
},
},
},
"ark": {
Key: "ark", DisplayNameKey: "AIProviderArk", Sort: 36, DefaultAPIType: "openai-completions", EnvKey: "ARK_API_KEY",
APIConfigs: []APIConfig{
{
APIType: "openai-completions", BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
DiscoverModels: true,
Models: []Model{{ID: "doubao-seed-2-0-pro-260215", Name: "doubao-seed-2-0-pro-260215"}, {ID: "doubao-seed-2-0-lite-260215", Name: "doubao-seed-2-0-lite-260215"}},
},
{
APIType: "openai-responses", BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
DiscoverModels: true,
Models: []Model{{ID: "doubao-seed-2-0-pro-260215", Name: "doubao-seed-2-0-pro-260215"}, {ID: "doubao-seed-2-0-lite-260215", Name: "doubao-seed-2-0-lite-260215"}},
},
{
APIType: "openai-images", BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
Models: []Model{
{ID: "doubao-seedream-5-0-260128", Name: "doubao-seedream-5-0-260128"},
{ID: "doubao-seedream-5-0-lite-260128", Name: "doubao-seedream-5-0-lite-260128"},
{ID: "doubao-seedream-4-5-251128", Name: "doubao-seedream-4-5-251128"},
},
},
},
},
}
func editableAPIConfigs(discoverModels bool, apiTypes ...string) []APIConfig {
configs := make([]APIConfig, 0, len(apiTypes))
for _, apiType := range apiTypes {
if apiType == "anthropic-messages" {
config := anthropicAPIConfig("", AuthModeXAPIKey, AuthModeBearer)
config.EditableBaseURL = true
configs = append(configs, config)
continue
}
configs = append(configs, APIConfig{
APIType: apiType,
EditableBaseURL: true,
DiscoverModels: discoverModels && (apiType == "openai-completions" || apiType == "openai-responses"),
})
}
return configs
}
func anthropicAPIConfig(baseURL, defaultAuthMode string, additionalAuthModes ...string) APIConfig {
authModes := []string{defaultAuthMode}
for _, authMode := range additionalAuthModes {
if authMode != defaultAuthMode {
authModes = append(authModes, authMode)
}
}
return APIConfig{
APIType: "anthropic-messages",
BaseURL: baseURL,
DefaultAuthMode: defaultAuthMode,
AuthModes: authModes,
}
}
func Get(key string) (Meta, bool) {
@@ -332,12 +261,142 @@ func All() map[string]Meta {
return result
}
func DefaultBaseURL(key string) (string, bool) {
func FindAPIConfig(key, apiType string) (APIConfig, bool) {
meta, ok := catalog[key]
if !ok || strings.TrimSpace(meta.DefaultBaseURL) == "" {
if !ok {
return APIConfig{}, false
}
target := strings.TrimSpace(apiType)
if target == "" {
target = meta.DefaultAPIType
}
for _, config := range meta.APIConfigs {
if config.APIType == target {
config.AuthModes = append([]string(nil), config.AuthModes...)
config.Models = append([]Model(nil), config.Models...)
return config, true
}
}
return APIConfig{}, false
}
func DefaultModels(key, apiType string) []Model {
meta, ok := catalog[key]
if !ok {
return nil
}
target := strings.TrimSpace(apiType)
if target == "" {
target = meta.DefaultAPIType
}
for _, config := range meta.APIConfigs {
if config.APIType != target {
continue
}
if len(config.Models) > 0 {
return append([]Model(nil), config.Models...)
}
if IsImageAPIType(config.APIType) {
return nil
}
break
}
return append([]Model(nil), meta.Models...)
}
func ResolveAuthMode(provider, apiType, requested string) (string, error) {
config, ok := FindAPIConfig(provider, apiType)
if !ok {
return "", fmt.Errorf("provider %s does not support api type %s", provider, apiType)
}
if config.APIType != "anthropic-messages" {
return "", nil
}
authMode := strings.TrimSpace(requested)
if authMode == "" {
authMode = config.DefaultAuthMode
}
for _, allowed := range config.AuthModes {
if authMode == allowed {
return authMode, nil
}
}
return "", fmt.Errorf("provider %s does not support auth mode %s", provider, authMode)
}
func DefaultBaseURL(key string) (string, bool) {
config, ok := FindAPIConfig(key, "")
if !ok || strings.TrimSpace(config.BaseURL) == "" {
return "", false
}
return meta.DefaultBaseURL, true
return config.BaseURL, true
}
func DefaultAPIType(key string) string {
meta, ok := catalog[key]
if !ok || strings.TrimSpace(meta.DefaultAPIType) == "" {
return "openai-completions"
}
return meta.DefaultAPIType
}
func ResolveBaseURL(key, apiType, requested string) (string, error) {
config, ok := FindAPIConfig(key, apiType)
if !ok {
return "", fmt.Errorf("provider %s does not support api type %s", key, apiType)
}
if !config.EditableBaseURL {
return strings.TrimRight(config.BaseURL, "/"), nil
}
baseURL := strings.TrimSpace(requested)
if baseURL == "" {
baseURL = config.BaseURL
}
if baseURL == "" {
return "", fmt.Errorf("base url is required")
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid base url")
}
if key == "custom" && IsImageAPIType(config.APIType) {
return baseURL, nil
}
parsed.Path = normalizeEndpointPath(config.APIType, parsed.Path)
parsed.RawQuery = ""
parsed.Fragment = ""
return strings.TrimRight(parsed.String(), "/"), nil
}
func normalizeEndpointPath(apiType, value string) string {
if IsImageAPIType(apiType) {
return strings.TrimRight(value, "/")
}
path := strings.TrimRight(value, "/")
suffixes := []string{}
switch apiType {
case "openai-completions":
suffixes = []string{"/chat/completions"}
case "openai-responses":
suffixes = []string{"/responses"}
case "anthropic-messages":
suffixes = []string{"/v1/messages", "/messages"}
}
for _, suffix := range suffixes {
if strings.HasSuffix(strings.ToLower(path), suffix) {
return path[:len(path)-len(suffix)]
}
}
return path
}
func IsImageAPIType(apiType string) bool {
switch apiType {
case "openai-images", "dashscope-images", "minimax-images", "openrouter-images":
return true
default:
return false
}
}
func EnvKey(key string) string {
@@ -350,89 +409,61 @@ func EnvKey(key string) string {
func DisplayName(key string) string {
meta, ok := catalog[key]
if !ok {
return key
}
if strings.TrimSpace(meta.DisplayName) == "" {
if !ok || strings.TrimSpace(meta.DisplayName) == "" {
return key
}
return meta.DisplayName
}
func FindModel(key, modelID string) (Model, bool) {
meta, ok := Get(key)
func DisplayNameKey(key string) string {
meta, ok := catalog[key]
if !ok {
return Model{}, false
return ""
}
for _, item := range meta.Models {
if item.ID == modelID {
return item, true
return meta.DisplayNameKey
}
func NormalizeModelID(provider, modelID string) string {
target := strings.TrimLeft(strings.TrimSpace(modelID), "/")
for _, prefix := range legacyModelPrefixes[provider] {
legacyPrefix := prefix + "/"
if !strings.HasPrefix(target, legacyPrefix) {
continue
}
candidate := strings.TrimSpace(strings.TrimPrefix(target, legacyPrefix))
if candidate != "" {
return candidate
}
}
return Model{}, false
return target
}
var legacyModelPrefixes = map[string][]string{
"custom": {"custom"},
"vllm": {"custom"},
"ollama": {"ollama"},
"deepseek": {"deepseek"},
"bailian-coding-plan": {"bailian-coding-plan"},
"ark-coding-plan": {"ark-coding-plan"},
"zai": {"zai"},
"minimax": {"minimax"},
"xiaomi": {"xiaomi"},
"kimi": {"kimi", "moonshot"},
"kimi-coding": {"kimi-coding"},
"openai": {"openai"},
"anthropic": {"anthropic"},
"gemini": {"google", "gemini"},
"moonshot": {"moonshot"},
}
func cloneMeta(meta Meta) Meta {
clone := meta
if len(meta.Default.Input) > 0 {
clone.Default.Input = make([]string, len(meta.Default.Input))
copy(clone.Default.Input, meta.Default.Input)
}
if len(meta.Models) > 0 {
clone.Models = make([]Model, len(meta.Models))
for i, item := range meta.Models {
clone.Models[i] = normalizeModel(meta, item)
}
clone.APIConfigs = make([]APIConfig, len(meta.APIConfigs))
for index, config := range meta.APIConfigs {
clone.APIConfigs[index] = config
clone.APIConfigs[index].AuthModes = append([]string(nil), config.AuthModes...)
clone.APIConfigs[index].Models = append([]Model(nil), config.Models...)
}
clone.Models = append([]Model(nil), meta.Models...)
return clone
}
func normalizeModel(meta Meta, model Model) Model {
clone := model
clone.ID = strings.TrimSpace(clone.ID)
clone.Name = strings.TrimSpace(clone.Name)
if clone.Name == "" {
clone.Name = clone.ID
}
if clone.MaxTokens <= 0 {
clone.MaxTokens = meta.Default.MaxTokens
}
if clone.ContextWindow <= 0 {
clone.ContextWindow = meta.Default.ContextWindow
}
if len(clone.Input) == 0 && len(meta.Default.Input) > 0 {
clone.Input = make([]string, len(meta.Default.Input))
copy(clone.Input, meta.Default.Input)
}
return clone
}
func ResolveRuntimeParams(provider, apiType string, maxTokens, contextWindow int) (string, int, int) {
defaultAPIType := "openai-completions"
defaultMaxTokens := 8192
defaultContextWindow := 256000
if meta, ok := Get(provider); ok {
if meta.Default.APIType != "" {
defaultAPIType = meta.Default.APIType
}
if meta.Default.MaxTokens > 0 {
defaultMaxTokens = meta.Default.MaxTokens
}
if meta.Default.ContextWindow > 0 {
defaultContextWindow = meta.Default.ContextWindow
}
}
resolvedAPI := apiType
if strings.TrimSpace(apiType) == "" {
resolvedAPI = defaultAPIType
}
resolvedMaxTokens := defaultMaxTokens
resolvedContextWindow := defaultContextWindow
if maxTokens > 0 {
resolvedMaxTokens = maxTokens
}
if contextWindow > 0 {
resolvedContextWindow = contextWindow
}
return resolvedAPI, resolvedMaxTokens, resolvedContextWindow
}
+74
View File
@@ -0,0 +1,74 @@
package provider
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
)
func DiscoverModels(baseURL, apiKey string) ([]string, error) {
req, err := http.NewRequest(http.MethodGet, buildModelDiscoveryURL(baseURL), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := (&http.Client{Timeout: defaultVerifyTimeout}).Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
return nil, fmt.Errorf("request failed: %s", resp.Status)
}
var payload struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
}
models := make([]string, 0, len(payload.Data))
seen := make(map[string]struct{}, len(payload.Data))
for _, item := range payload.Data {
id := strings.TrimSpace(item.ID)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
models = append(models, id)
}
if len(models) == 0 {
return nil, fmt.Errorf("no models found")
}
return models, nil
}
func buildModelDiscoveryURL(baseURL string) string {
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
for _, apiType := range []string{"openai-completions", "openai-responses", "anthropic-messages"} {
base = normalizeEndpointPath(apiType, base)
}
switch {
case strings.HasSuffix(base, "/models"):
return base
case hasAPIVersionSuffix(base):
return base + "/models"
default:
return base + "/v1/models"
}
}
func hasAPIVersionSuffix(value string) bool {
segment := value[strings.LastIndex(value, "/")+1:]
if len(segment) < 2 || segment[0] != 'v' {
return false
}
_, err := strconv.Atoi(segment[1:])
return err == nil
}
+34 -54
View File
@@ -15,72 +15,52 @@ type OpenClawProviderPatch struct {
AuthHeader bool
}
func BuildOpenClawProviderPatch(provider, modelName, apiType, baseURL, apiKey string) (*OpenClawProviderPatch, error) {
func BuildOpenClawProviderPatch(provider, modelName, apiType, authMode, baseURL, apiKey string) (*OpenClawProviderPatch, error) {
if modelName == "" {
return nil, fmt.Errorf("model is required")
}
resolvedAPIType, _, _ := ResolveRuntimeParams(provider, apiType, 0, 0)
modelID := resolveOpenClawModelID(provider, modelName)
switch provider {
case "deepseek":
return newOpenClawProviderPatch(modelName, "deepseek", modelID, apiKey, baseURL, "openai-completions", false), nil
case "gemini":
return newOpenClawProviderPatch("google/"+modelID, "google", modelID, apiKey, baseURL, resolvedAPIType, false), nil
case "moonshot", "kimi":
return buildMoonshotProviderPatch(provider, modelName, modelID, baseURL, apiKey), nil
case "bailian-coding-plan":
return newOpenClawProviderPatch("bailian-coding-plan/"+modelID, "bailian-coding-plan", modelID, apiKey, baseURL, "openai-completions", false), nil
case "ark-coding-plan":
return newOpenClawProviderPatch("ark-coding-plan/"+modelID, "ark-coding-plan", modelID, apiKey, baseURL, "openai-completions", false), nil
case "minimax":
return newOpenClawProviderPatch("minimax/"+modelID, "minimax", modelID, apiKey, baseURL, "anthropic-messages", true), nil
case "xiaomi":
return newOpenClawProviderPatch("xiaomi/"+modelID, "xiaomi", modelID, apiKey, baseURL, "openai-completions", false), nil
case "custom", "vllm":
return newOpenClawProviderPatch(provider+"/"+modelID, provider, modelID, apiKey, baseURL, resolvedAPIType, false), nil
case "ollama":
return newOpenClawProviderPatch(modelName, "ollama", modelID, "ollama", baseURL, resolvedAPIType, false), nil
case "kimi-coding":
return newOpenClawProviderPatch(modelName, "kimi-coding", modelID, apiKey, baseURL, "anthropic-messages", false), nil
case "zai":
return newOpenClawProviderPatch("zai/"+modelID, "zai", modelID, apiKey, baseURL, "openai-completions", false), nil
default:
return newOpenClawProviderPatch(modelName, provider, modelID, apiKey, baseURL, resolvedAPIType, false), nil
resolvedAPIType := apiType
if _, ok := FindAPIConfig(provider, resolvedAPIType); !ok {
resolvedAPIType = DefaultAPIType(provider)
}
}
func buildMoonshotProviderPatch(provider, modelName, modelID, baseURL, apiKey string) *OpenClawProviderPatch {
if IsImageAPIType(resolvedAPIType) {
return nil, fmt.Errorf("api type %s does not support text generation", resolvedAPIType)
}
resolvedAuthMode, err := ResolveAuthMode(provider, resolvedAPIType, authMode)
if err != nil {
return nil, err
}
usesBearer := resolvedAuthMode == AuthModeBearer
modelID := NormalizeModelID(provider, modelName)
providerKey := provider
primaryModel := modelName
if provider == "kimi" {
preserveQualifiedModel := false
switch provider {
case "gemini":
providerKey = "google"
resolvedAPIType = "google-generative-ai"
usesBearer = false
case "moonshot", "kimi":
providerKey = "moonshot"
primaryModel = "moonshot/" + modelID
resolvedAPIType = "openai-completions"
usesBearer = false
case "ollama":
apiKey = "ollama"
usesBearer = false
case "openai", "openrouter", "anthropic":
preserveQualifiedModel = strings.Contains(modelName, "/")
}
return newOpenClawProviderPatch(primaryModel, providerKey, modelID, apiKey, baseURL, "openai-completions", false)
}
func newOpenClawProviderPatch(primaryModel, providerKey, modelID, apiKey, baseURL, apiType string, authHeader bool) *OpenClawProviderPatch {
primaryModel := providerKey + "/" + modelID
if preserveQualifiedModel {
primaryModel = modelName
}
return &OpenClawProviderPatch{
PrimaryModel: primaryModel,
ProviderKey: providerKey,
ModelID: modelID,
APIKey: apiKey,
BaseURL: baseURL,
APIType: apiType,
AuthHeader: authHeader,
}
}
func resolveOpenClawModelID(provider, modelName string) string {
if provider == "custom" || provider == "vllm" {
target := strings.TrimLeft(modelName, "/")
if parts := strings.SplitN(target, "/", 2); len(parts) == 2 && parts[0] == "custom" {
return strings.TrimLeft(parts[1], "/")
}
return target
}
if parts := strings.SplitN(modelName, "/", 2); len(parts) == 2 {
return parts[1]
}
return modelName
APIType: resolvedAPIType,
AuthHeader: usesBearer,
}, nil
}
+100 -118
View File
@@ -3,7 +3,9 @@ package provider
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
@@ -18,160 +20,140 @@ type VerifyRequest struct {
Body []byte
}
const (
defaultVerifyTimeout = 30 * time.Second
)
type verifyErrorResponse struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
Message string `json:"message"`
}
func SkipVerification(key string) bool {
switch key {
case "custom", "vllm", "ollama", "kimi-coding":
const defaultVerifyTimeout = 30 * time.Second
func SkipVerification(provider string) bool {
switch provider {
case "vllm", "ollama", "kimi-coding":
return true
default:
return false
}
}
func VerifyAccount(provider, baseURL, apiKey string) error {
req := BuildVerifyRequest(provider, baseURL, apiKey)
var body *bytes.Buffer
if len(req.Body) > 0 {
body = bytes.NewBuffer(req.Body)
} else {
body = bytes.NewBuffer(nil)
}
httpReq, err := http.NewRequest(req.Method, req.URL, body)
func VerifyAccount(provider, apiType, authMode, baseURL, apiKey, model string) error {
req := BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model)
httpReq, err := http.NewRequest(req.Method, req.URL, bytes.NewReader(req.Body))
if err != nil {
return err
}
for key, value := range req.Headers {
httpReq.Header.Set(key, value)
}
resp, err := (&http.Client{Timeout: verifyTimeout()}).Do(httpReq)
httpReq.Header.Set("Accept", "application/json")
resp, err := (&http.Client{Timeout: defaultVerifyTimeout}).Do(httpReq)
if err != nil {
return buserr.WithErr("ErrAgentAccountUnavailable", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return buserr.WithErr("ErrAgentAccountUnavailable", fmt.Errorf("verify failed: %s", resp.Status))
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
if readErr != nil {
return buserr.WithErr("ErrAgentAccountUnavailable", readErr)
}
return buserr.WithErr("ErrAgentAccountUnavailable", errors.New(verifyHTTPError(resp.StatusCode, body)))
}
return nil
}
func verifyTimeout() time.Duration {
return defaultVerifyTimeout
}
func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model string) VerifyRequest {
baseURL = strings.TrimSpace(baseURL)
if provider != "custom" || !IsImageAPIType(apiType) {
baseURL = strings.TrimRight(baseURL, "/")
}
headers := map[string]string{"Content-Type": "application/json"}
request := VerifyRequest{Method: http.MethodPost, Headers: headers}
func BuildVerifyRequest(provider, baseURL, apiKey string) VerifyRequest {
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
headers := map[string]string{}
request := VerifyRequest{Method: http.MethodGet, Headers: headers}
switch provider {
case "anthropic", "kimi-coding":
headers["x-api-key"] = apiKey
headers["anthropic-version"] = "2023-06-01"
if strings.Contains(base, "/v1") {
request.URL = base + "/models"
} else {
request.URL = base + "/v1/models"
}
case "gemini":
request.Method = http.MethodPost
if strings.Contains(base, "/v1beta") {
request.URL = base + "/models/gemini-3-flash-preview:generateContent"
} else {
request.URL = base + "/v1beta/models/gemini-3-flash-preview:generateContent"
}
if provider == "gemini" {
request.URL = baseURL + "/v1beta/models/" + strings.TrimSpace(model) + ":generateContent"
headers["x-goog-api-key"] = apiKey
headers["Content-Type"] = "application/json"
request.Body = mustJSON(map[string]interface{}{
"contents": []map[string]interface{}{{
"parts": []map[string]string{{
"text": "Explain how AI works in a few words",
}},
"contents": []map[string]interface{}{{"parts": []map[string]string{{"text": "test"}}}},
})
return request
}
switch apiType {
case "openai-images":
request.URL = imageVerifyURL(provider, baseURL, "/images/generations")
headers["Authorization"] = "Bearer " + apiKey
request.Body = mustJSON(map[string]interface{}{"model": model, "prompt": "test", "n": 1, "response_format": "url"})
case "dashscope-images":
request.URL = imageVerifyURL(provider, baseURL, "/api/v1/services/aigc/multimodal-generation/generation")
headers["Authorization"] = "Bearer " + apiKey
request.Body = mustJSON(map[string]interface{}{
"model": model,
"input": map[string]interface{}{"messages": []map[string]interface{}{
{"role": "user", "content": []map[string]string{{"text": "test"}}},
}},
"parameters": map[string]interface{}{"n": 1},
})
case "zai":
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
request.URL = base + "/models"
case "bailian-coding-plan":
request.Method = http.MethodPost
if !strings.Contains(base, "/v1") {
base = base + "/v1"
case "minimax-images":
request.URL = imageVerifyURL(provider, baseURL, "/v1/image_generation")
headers["Authorization"] = "Bearer " + apiKey
request.Body = mustJSON(map[string]interface{}{"model": model, "prompt": "test", "n": 1, "response_format": "url"})
case "openrouter-images":
request.URL = imageVerifyURL(provider, baseURL, "/api/v1/images")
headers["Authorization"] = "Bearer " + apiKey
request.Body = mustJSON(map[string]interface{}{"model": model, "prompt": "test", "n": 1, "response_format": "url"})
case "anthropic-messages":
request.URL = baseURL + "/v1/messages"
if authMode == AuthModeBearer {
headers["Authorization"] = "Bearer " + apiKey
} else {
headers["x-api-key"] = apiKey
}
request.URL = base + "/chat/completions"
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
headers["Content-Type"] = "application/json"
request.Body = mustJSON(map[string]interface{}{
"model": "qwen3.5-plus",
"messages": []map[string]string{{"role": "user", "content": "test"}},
"max_tokens": 1,
})
case "ark-coding-plan":
request.Method = http.MethodPost
if !strings.Contains(base, "/api/coding/v3") {
base = "https://ark.cn-beijing.volces.com/api/coding/v3"
}
request.URL = base + "/chat/completions"
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
headers["Content-Type"] = "application/json"
request.Body = mustJSON(map[string]interface{}{
"model": "ark-code-latest",
"messages": []map[string]string{{"role": "user", "content": "test"}},
"max_tokens": 1,
})
case "minimax":
request.Method = http.MethodPost
headers["x-api-key"] = apiKey
headers["anthropic-version"] = "2023-06-01"
headers["Content-Type"] = "application/json"
if strings.Contains(base, "/v1") {
request.URL = base + "/messages"
} else {
request.URL = base + "/v1/messages"
}
request.Body = mustJSON(map[string]interface{}{
"model": "MiniMax-M2.5",
"max_tokens": 1,
"messages": []map[string]interface{}{{
"role": "user",
"content": []map[string]string{{
"type": "text",
"text": "test",
}},
}},
"model": model, "max_tokens": 1, "stream": false,
"messages": []map[string]interface{}{{"role": "user", "content": []map[string]string{{"type": "text", "text": "test"}}}},
})
case "xiaomi":
request.Method = http.MethodPost
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
headers["Content-Type"] = "application/json"
if !strings.Contains(base, "/v1") {
base = base + "/v1"
}
request.URL = base + "/chat/completions"
request.Body = mustJSON(map[string]interface{}{
"model": "mimo-v2-flash",
"max_tokens": 1,
"messages": []map[string]string{{"role": "user", "content": "test"}},
})
case "openrouter":
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
if strings.Contains(base, "/v1") {
request.URL = base + "/key"
} else {
request.URL = base + "/v1/key"
}
case "openai-responses":
request.URL = baseURL + "/responses"
headers["Authorization"] = "Bearer " + apiKey
request.Body = mustJSON(map[string]interface{}{"model": model, "input": "test", "max_output_tokens": 1, "stream": false})
default:
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
if strings.Contains(base, "/v1") {
request.URL = base + "/models"
} else {
request.URL = base + "/v1/models"
request.URL = baseURL + "/chat/completions"
if provider != "ollama" || strings.TrimSpace(apiKey) != "" {
headers["Authorization"] = "Bearer " + apiKey
}
request.Body = mustJSON(map[string]interface{}{
"model": model, "messages": []map[string]string{{"role": "user", "content": "test"}}, "max_tokens": 1, "stream": false,
})
}
return request
}
func imageVerifyURL(provider, baseURL, endpoint string) string {
if provider == "custom" || strings.HasSuffix(strings.ToLower(baseURL), endpoint) {
return baseURL
}
return baseURL + endpoint
}
func verifyHTTPError(statusCode int, body []byte) string {
message := strings.TrimSpace(string(body))
var payload verifyErrorResponse
if err := json.Unmarshal(body, &payload); err == nil {
if value := strings.TrimSpace(payload.Error.Message); value != "" {
message = value
} else if value := strings.TrimSpace(payload.Message); value != "" {
message = value
}
}
if message == "" {
return fmt.Sprintf("validation request returned status %d", statusCode)
}
return message
}
func mustJSON(value interface{}) []byte {
payload, err := json.Marshal(value)
if err != nil {
+3 -2
View File
@@ -17,7 +17,7 @@ type IAgentAccountRepo interface {
Save(account *model.AgentAccount) error
DeleteByID(id uint) error
List(opts ...DBOption) ([]model.AgentAccount, error)
CountByProviders(providers []string) (map[string]int64, error)
CountTextByProviders(providers []string) (map[string]int64, error)
}
func NewIAgentAccountRepo() IAgentAccountRepo {
@@ -67,7 +67,7 @@ func (a AgentAccountRepo) List(opts ...DBOption) ([]model.AgentAccount, error) {
return accounts, nil
}
func (a AgentAccountRepo) CountByProviders(providers []string) (map[string]int64, error) {
func (a AgentAccountRepo) CountTextByProviders(providers []string) (map[string]int64, error) {
normalizedProviders := normalizeProviders(providers)
counts := make(map[string]int64, len(normalizedProviders))
for _, provider := range normalizedProviders {
@@ -86,6 +86,7 @@ func (a AgentAccountRepo) CountByProviders(providers []string) (map[string]int64
Model(&model.AgentAccount{}).
Select("provider, COUNT(*) as count").
Where("provider IN ?", normalizedProviders).
Where("api_type NOT LIKE ?", "%-images").
Group("provider").
Scan(&rows).Error; err != nil {
return nil, err
+7
View File
@@ -15,6 +15,7 @@ type IAppInstallResourceRpo interface {
WithAppInstallId(appInstallId uint) DBOption
WithLinkId(linkId uint) DBOption
WithResourceId(resourceId uint) DBOption
WithResourceIds(resourceIds []uint) DBOption
GetBy(opts ...DBOption) ([]model.AppInstallResource, error)
GetFirst(opts ...DBOption) (model.AppInstallResource, error)
Create(ctx context.Context, resource *model.AppInstallResource) error
@@ -44,6 +45,12 @@ func (a AppInstallResourceRpo) WithResourceId(resourceId uint) DBOption {
}
}
func (a AppInstallResourceRpo) WithResourceIds(resourceIds []uint) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("resource_id IN ?", resourceIds)
}
}
func (a AppInstallResourceRpo) GetBy(opts ...DBOption) ([]model.AppInstallResource, error) {
db := global.DB.Model(&model.AppInstallResource{})
var resources []model.AppInstallResource
+18
View File
@@ -49,6 +49,12 @@ func WithByName(name string) DBOption {
}
}
func WithByPath(path string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("path = ?", path)
}
}
func WithByAddr(addr string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("addr = ?", addr)
@@ -94,6 +100,18 @@ func WithByProvider(provider string) DBOption {
}
}
func WithByAPIType(apiType string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("api_type = ?", apiType)
}
}
func WithTextAPIType() DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("api_type NOT LIKE ?", "%-images")
}
}
func WithByModel(model string) DBOption {
return func(g *gorm.DB) *gorm.DB {
if len(model) == 0 {
+121
View File
@@ -0,0 +1,121 @@
package repo
import (
"context"
"fmt"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"gorm.io/gorm"
)
type DatabaseUserRepo struct{}
type IDatabaseUserRepo interface {
Get(opts ...DBOption) (model.DatabaseUser, error)
List(opts ...DBOption) ([]model.DatabaseUser, error)
Save(user *model.DatabaseUser) error
Delete(opts ...DBOption) error
DeleteBy(ctx context.Context, opts ...DBOption) error
Update(vars map[string]interface{}, opts ...DBOption) error
WithByDatabase(database string) DBOption
WithByUser(username, host string) DBOption
WithByUserList(users [][2]string) DBOption
}
func NewIDatabaseUserRepo() IDatabaseUserRepo {
return &DatabaseUserRepo{}
}
func (u *DatabaseUserRepo) Get(opts ...DBOption) (model.DatabaseUser, error) {
var user model.DatabaseUser
db := global.DB.Model(&model.DatabaseUser{})
for _, opt := range opts {
db = opt(db)
}
if err := db.First(&user).Error; err != nil {
return user, err
}
password, err := encrypt.StringDecrypt(user.Password)
if err != nil {
global.LOG.Errorf("decrypt database user %s password failed, err: %v", user.Username, err)
}
user.Password = password
return user, nil
}
func (u *DatabaseUserRepo) List(opts ...DBOption) ([]model.DatabaseUser, error) {
var users []model.DatabaseUser
db := global.DB.Model(&model.DatabaseUser{})
for _, opt := range opts {
db = opt(db)
}
if err := db.Find(&users).Error; err != nil {
return users, err
}
for i := 0; i < len(users); i++ {
password, err := encrypt.StringDecrypt(users[i].Password)
if err != nil {
global.LOG.Errorf("decrypt database user %s password failed, err: %v", users[i].Username, err)
}
users[i].Password = password
}
return users, nil
}
func (u *DatabaseUserRepo) Save(user *model.DatabaseUser) error {
if len(user.Password) != 0 {
password, err := encrypt.StringEncrypt(user.Password)
if err != nil {
return fmt.Errorf("encrypt database user %s password failed, err: %v", user.Username, err)
}
user.Password = password
}
return global.DB.Save(user).Error
}
func (u *DatabaseUserRepo) Delete(opts ...DBOption) error {
db := global.DB
for _, opt := range opts {
db = opt(db)
}
return db.Delete(&model.DatabaseUser{}).Error
}
func (u *DatabaseUserRepo) DeleteBy(ctx context.Context, opts ...DBOption) error {
return getTx(ctx, opts...).Delete(&model.DatabaseUser{}).Error
}
func (u *DatabaseUserRepo) Update(vars map[string]interface{}, opts ...DBOption) error {
db := global.DB.Model(&model.DatabaseUser{})
for _, opt := range opts {
db = opt(db)
}
return db.Updates(vars).Error
}
func (u *DatabaseUserRepo) WithByDatabase(database string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("database = ?", database)
}
}
func (u *DatabaseUserRepo) WithByUser(username, host string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("username = ? AND host = ?", username, host)
}
}
func (u *DatabaseUserRepo) WithByUserList(users [][2]string) DBOption {
return func(g *gorm.DB) *gorm.DB {
if len(users) == 0 {
return g.Where("1 = 0")
}
values := make([][]interface{}, 0, len(users))
for _, user := range users {
values = append(values, []interface{}{user[0], user[1]})
}
return g.Where("(username, host) IN ?", values)
}
}
+109
View File
@@ -0,0 +1,109 @@
package repo
import (
"context"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
type DatabaseUserGrantRepo struct{}
type IDatabaseUserGrantRepo interface {
Get(opts ...DBOption) (model.DatabaseUserGrant, error)
List(opts ...DBOption) ([]model.DatabaseUserGrant, error)
Save(grant *model.DatabaseUserGrant) error
Replace(dbType, database string, grants []model.DatabaseUserGrant) error
Delete(opts ...DBOption) error
DeleteBy(ctx context.Context, opts ...DBOption) error
Update(vars map[string]interface{}, opts ...DBOption) error
WithByDatabase(database string) DBOption
WithByDBName(dbName string) DBOption
WithByDBNames(dbNames []string) DBOption
WithByUser(username, host string) DBOption
}
func (u *DatabaseUserGrantRepo) Get(opts ...DBOption) (model.DatabaseUserGrant, error) {
var grant model.DatabaseUserGrant
db := global.DB.Model(&model.DatabaseUserGrant{})
for _, opt := range opts {
db = opt(db)
}
err := db.First(&grant).Error
return grant, err
}
func NewIDatabaseUserGrantRepo() IDatabaseUserGrantRepo {
return &DatabaseUserGrantRepo{}
}
func (u *DatabaseUserGrantRepo) List(opts ...DBOption) ([]model.DatabaseUserGrant, error) {
var grants []model.DatabaseUserGrant
db := global.DB.Model(&model.DatabaseUserGrant{})
for _, opt := range opts {
db = opt(db)
}
err := db.Find(&grants).Error
return grants, err
}
func (u *DatabaseUserGrantRepo) Save(grant *model.DatabaseUserGrant) error {
return global.DB.Save(grant).Error
}
func (u *DatabaseUserGrantRepo) Replace(dbType, database string, grants []model.DatabaseUserGrant) error {
return global.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("`type` = ? AND database = ?", dbType, database).Delete(&model.DatabaseUserGrant{}).Error; err != nil {
return err
}
if len(grants) != 0 {
return tx.Create(&grants).Error
}
return nil
})
}
func (u *DatabaseUserGrantRepo) Delete(opts ...DBOption) error {
db := global.DB
for _, opt := range opts {
db = opt(db)
}
return db.Delete(&model.DatabaseUserGrant{}).Error
}
func (u *DatabaseUserGrantRepo) DeleteBy(ctx context.Context, opts ...DBOption) error {
return getTx(ctx, opts...).Delete(&model.DatabaseUserGrant{}).Error
}
func (u *DatabaseUserGrantRepo) Update(vars map[string]interface{}, opts ...DBOption) error {
db := global.DB.Model(&model.DatabaseUserGrant{})
for _, opt := range opts {
db = opt(db)
}
return db.Updates(vars).Error
}
func (u *DatabaseUserGrantRepo) WithByDatabase(database string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("database = ?", database)
}
}
func (u *DatabaseUserGrantRepo) WithByDBName(dbName string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("db_name = ?", dbName)
}
}
func (u *DatabaseUserGrantRepo) WithByDBNames(dbNames []string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("db_name IN ?", dbNames)
}
}
func (u *DatabaseUserGrantRepo) WithByUser(username, host string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("username = ? AND host = ?", username, host)
}
}
+21
View File
@@ -17,10 +17,12 @@ type IWebsiteRepo interface {
WithGroupID(groupId uint) DBOption
WithDefaultServer() DBOption
WithDomainLike(domain string) DBOption
WithSearchKeyword(keyword string, ids []uint) DBOption
WithRuntimeID(runtimeID uint) DBOption
WithParentID(websiteID uint) DBOption
WithType(websiteType string) DBOption
WithDBType(dbType string) DBOption
WithDBTypes(dbTypes []string) DBOption
WithDBID(dbID uint) DBOption
Page(page, size int, opts ...DBOption) (int64, []model.Website, error)
@@ -76,6 +78,19 @@ func (w *WebsiteRepo) WithDomainLike(domain string) DBOption {
}
}
func (w *WebsiteRepo) WithSearchKeyword(keyword string, ids []uint) DBOption {
return func(db *gorm.DB) *gorm.DB {
if keyword == "" {
return db
}
keyword = "%" + keyword + "%"
if len(ids) == 0 {
return db.Where("(primary_domain like ? OR alias like ?)", keyword, keyword)
}
return db.Where("(primary_domain like ? OR alias like ? OR id in (?))", keyword, keyword, ids)
}
}
func (w *WebsiteRepo) WithAlias(alias string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("alias = ?", alias)
@@ -118,6 +133,12 @@ func (w *WebsiteRepo) WithDBType(dbType string) DBOption {
}
}
func (w *WebsiteRepo) WithDBTypes(dbTypes []string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("db_type IN ?", dbTypes)
}
}
func (w *WebsiteRepo) WithDBID(dbID uint) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("db_id = ?", dbID)
+20 -6
View File
@@ -2,8 +2,10 @@ package repo
import (
"context"
"time"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"gorm.io/gorm"
)
@@ -21,6 +23,7 @@ type ISSLRepo interface {
Page(page, size int, opts ...DBOption) (int64, []model.WebsiteSSL, error)
GetFirst(opts ...DBOption) (*model.WebsiteSSL, error)
List(opts ...DBOption) ([]model.WebsiteSSL, error)
TryMarkApplying(id uint) (bool, error)
Create(ctx context.Context, ssl *model.WebsiteSSL) error
Save(ssl *model.WebsiteSSL) error
DeleteBy(opts ...DBOption) error
@@ -76,12 +79,12 @@ func (w WebsiteSSLRepo) Page(page, size int, opts ...DBOption) (int64, []model.W
}
func (w WebsiteSSLRepo) GetFirst(opts ...DBOption) (*model.WebsiteSSL, error) {
var website *model.WebsiteSSL
var website model.WebsiteSSL
db := getDb(opts...).Model(&model.WebsiteSSL{})
if err := db.Preload("AcmeAccount").Preload("DnsAccount").First(&website).Error; err != nil {
return website, err
return nil, err
}
return website, nil
return &website, nil
}
func (w WebsiteSSLRepo) List(opts ...DBOption) ([]model.WebsiteSSL, error) {
@@ -93,14 +96,25 @@ func (w WebsiteSSLRepo) List(opts ...DBOption) ([]model.WebsiteSSL, error) {
return websites, nil
}
func (w WebsiteSSLRepo) TryMarkApplying(id uint) (bool, error) {
db := getDb().Model(&model.WebsiteSSL{}).
Where("id = ? AND status <> ?", id, constant.SSLApply).
Updates(map[string]interface{}{
"status": constant.SSLApply,
"updated_at": time.Now(),
})
if db.Error != nil {
return false, db.Error
}
return db.RowsAffected > 0, nil
}
func (w WebsiteSSLRepo) Create(ctx context.Context, ssl *model.WebsiteSSL) error {
return getTx(ctx).Create(ssl).Error
}
func (w WebsiteSSLRepo) Save(ssl *model.WebsiteSSL) error {
return getDb().Model(&model.WebsiteSSL{BaseModel: model.BaseModel{
ID: ssl.ID,
}}).Save(&ssl).Error
return getDb().Save(ssl).Error
}
func (w WebsiteSSLRepo) SaveByMap(ssl *model.WebsiteSSL, params map[string]interface{}) error {
+131
View File
@@ -0,0 +1,131 @@
package repo
import (
"github.com/1Panel-dev/1Panel/agent/app/model"
"gorm.io/gorm"
)
type IWebsiteTemplateRepo interface {
Page(page, size int, opts ...DBOption) (int64, []model.WebsiteTemplate, error)
GetFirst(opts ...DBOption) (*model.WebsiteTemplate, error)
List(opts ...DBOption) ([]model.WebsiteTemplate, error)
Create(template *model.WebsiteTemplate) error
Save(template *model.WebsiteTemplate) error
DeleteBy(opts ...DBOption) error
WithName(name string) DBOption
WithType(templateType string) DBOption
}
func NewIWebsiteTemplateRepo() IWebsiteTemplateRepo {
return &WebsiteTemplateRepo{}
}
type WebsiteTemplateRepo struct {
}
func (w *WebsiteTemplateRepo) WithName(name string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("name like ?", "%"+name+"%")
}
}
func (w *WebsiteTemplateRepo) WithType(templateType string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("type = ?", templateType)
}
}
func (w *WebsiteTemplateRepo) Page(page, size int, opts ...DBOption) (int64, []model.WebsiteTemplate, error) {
var templates []model.WebsiteTemplate
db := getDb(opts...).Model(&model.WebsiteTemplate{})
count := int64(0)
db = db.Count(&count)
err := db.Limit(size).Offset(size * (page - 1)).Find(&templates).Error
return count, templates, err
}
func (w *WebsiteTemplateRepo) GetFirst(opts ...DBOption) (*model.WebsiteTemplate, error) {
var template model.WebsiteTemplate
db := getDb(opts...).Model(&model.WebsiteTemplate{})
if err := db.First(&template).Error; err != nil {
return nil, err
}
return &template, nil
}
func (w *WebsiteTemplateRepo) List(opts ...DBOption) ([]model.WebsiteTemplate, error) {
var templates []model.WebsiteTemplate
err := getDb(opts...).Model(&model.WebsiteTemplate{}).Find(&templates).Error
return templates, err
}
func (w *WebsiteTemplateRepo) Create(template *model.WebsiteTemplate) error {
return getDb().Create(template).Error
}
func (w *WebsiteTemplateRepo) Save(template *model.WebsiteTemplate) error {
return getDb().Save(template).Error
}
func (w *WebsiteTemplateRepo) DeleteBy(opts ...DBOption) error {
return getDb(opts...).Delete(&model.WebsiteTemplate{}).Error
}
type IWebsiteTemplateOutputRepo interface {
Page(page, size int, opts ...DBOption) (int64, []model.WebsiteTemplateOutput, error)
GetFirst(opts ...DBOption) (*model.WebsiteTemplateOutput, error)
List(opts ...DBOption) ([]model.WebsiteTemplateOutput, error)
Create(output *model.WebsiteTemplateOutput) error
Save(output *model.WebsiteTemplateOutput) error
DeleteBy(opts ...DBOption) error
WithByTemplateID(templateID uint) DBOption
}
func NewIWebsiteTemplateOutputRepo() IWebsiteTemplateOutputRepo {
return &WebsiteTemplateOutputRepo{}
}
type WebsiteTemplateOutputRepo struct {
}
func (w *WebsiteTemplateOutputRepo) WithByTemplateID(templateID uint) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("template_id = ?", templateID)
}
}
func (w *WebsiteTemplateOutputRepo) Page(page, size int, opts ...DBOption) (int64, []model.WebsiteTemplateOutput, error) {
var outputs []model.WebsiteTemplateOutput
db := getDb(opts...).Model(&model.WebsiteTemplateOutput{})
count := int64(0)
db = db.Count(&count)
err := db.Limit(size).Offset(size * (page - 1)).Find(&outputs).Error
return count, outputs, err
}
func (w *WebsiteTemplateOutputRepo) GetFirst(opts ...DBOption) (*model.WebsiteTemplateOutput, error) {
var output model.WebsiteTemplateOutput
db := getDb(opts...).Model(&model.WebsiteTemplateOutput{})
if err := db.First(&output).Error; err != nil {
return nil, err
}
return &output, nil
}
func (w *WebsiteTemplateOutputRepo) List(opts ...DBOption) ([]model.WebsiteTemplateOutput, error) {
var outputs []model.WebsiteTemplateOutput
err := getDb(opts...).Model(&model.WebsiteTemplateOutput{}).Find(&outputs).Error
return outputs, err
}
func (w *WebsiteTemplateOutputRepo) Create(output *model.WebsiteTemplateOutput) error {
return getDb().Create(output).Error
}
func (w *WebsiteTemplateOutputRepo) Save(output *model.WebsiteTemplateOutput) error {
return getDb().Save(output).Error
}
func (w *WebsiteTemplateOutputRepo) DeleteBy(opts ...DBOption) error {
return getDb(opts...).Delete(&model.WebsiteTemplateOutput{}).Error
}
+278 -139
View File
@@ -8,8 +8,8 @@ import (
"os"
"path"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -30,6 +30,8 @@ import (
"gorm.io/gorm"
)
var agentAccountMutationMu sync.Mutex
type IAgentService interface {
Create(req dto.AgentCreateReq) (*dto.AgentItem, error)
BatchInstall(req dto.AgentBatchInstallReq) (*dto.AgentItem, error)
@@ -77,6 +79,7 @@ type IAgentService interface {
PageAccounts(req dto.AgentAccountSearch) (int64, []dto.AgentAccountInfo, error)
CountAccountsByProviders(req dto.AgentAccountProviderCountReq) (map[string]int64, error)
GetAccountModels(req dto.AgentAccountModelReq) ([]dto.AgentAccountModel, error)
DiscoverAccountModels(req dto.AgentAccountModelDiscoverReq) ([]dto.AgentAccountModel, error)
CreateAccountModel(req dto.AgentAccountModelCreateReq) error
UpdateAccountModel(req dto.AgentAccountModelUpdateReq) error
DeleteAccountModel(req dto.AgentAccountModelDeleteReq) error
@@ -102,6 +105,10 @@ type IAgentService interface {
UpgradePlugin(req dto.AgentPluginUpgradeReq) error
UninstallPlugin(req dto.AgentPluginUninstallReq) error
CheckPlugin(req dto.AgentPluginCheckReq) (*dto.AgentPluginStatus, error)
ListPlugins(req dto.AgentPluginsReq) ([]dto.AgentPluginItem, error)
SearchPlugins(req dto.AgentPluginSearchReq) ([]dto.AgentPluginSearchItem, error)
InstallMarketPlugin(req dto.AgentPluginMarketInstallReq) error
OperatePlugin(req dto.AgentPluginOperateReq) error
ApproveChannelPairing(req dto.AgentChannelPairingApproveReq) error
}
@@ -144,7 +151,7 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
if installs, _ := appInstallRepo.ListBy(context.Background(), repo.WithByLowerName(req.Name)); len(installs) > 0 {
return nil, buserr.New("ErrNameIsExist")
}
if !xpack.MultiNodeProvider.IsXpack() {
if !global.CONF.Base.IsEnterprise && !xpack.MultiNodeProvider.IsXpack() {
count, _, err := agentRepo.Page(1, 1)
if err != nil {
return nil, err
@@ -165,8 +172,6 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
provider := ""
baseURL := ""
apiType := ""
maxTokens := 0
contextWindow := 0
apiKey := ""
runtimeModel := ""
accountID := uint(0)
@@ -176,6 +181,7 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
var allowedOrigins []string
var account *model.AgentAccount
var installHooks *appInstallHooks
var dashboardAuth agentDashboardAuth
if agentType == constant.AppOpenclaw || agentType == constant.AppHermesAgent {
if req.AccountID == 0 {
@@ -196,8 +202,6 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
}
storedModel = resolvedRuntime.StoredModel
apiType = resolvedRuntime.APIType
maxTokens = resolvedRuntime.MaxTokens
contextWindow = resolvedRuntime.ContextWindow
runtimeModel = resolvedRuntime.PrimaryModel
apiKey = account.APIKey
accountID = account.ID
@@ -222,11 +226,17 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
},
}
} else if agentType == constant.AppHermesAgent {
dashboardAuth = normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword)
installHooks = &appInstallHooks{
AfterCopyData: func(appInstall *model.AppInstall) error {
return prepareHermesInstallFiles(appInstall, account, storedModel)
if err := prepareHermesInstallFiles(appInstall, account, storedModel); err != nil {
return err
}
return writeAgentDashboardAuthEnv(appInstall.GetEnvPath(), agentType, dashboardAuth, false)
},
}
} else if agentType == constant.AppCopaw {
dashboardAuth = normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword)
}
params := map[string]interface{}{
@@ -242,12 +252,17 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
params["PROVIDER"] = provider
params["MODEL"] = runtimeModel
params["API_TYPE"] = apiType
params["MAX_TOKENS"] = maxTokens
params["CONTEXT_WINDOW"] = contextWindow
params["BASE_URL"] = baseURL
params["API_KEY"] = apiKey
params["OPENCLAW_GATEWAY_TOKEN"] = token
}
if usernameKey, passwordKey, ok := agentDashboardAuthEnvKeys(agentType); ok {
params[usernameKey] = dashboardAuth.Username
params[passwordKey] = dashboardAuth.Password
if agentType == constant.AppCopaw {
params[qwenPawAuthEnabledEnvKey] = "true"
}
}
if req.EditCompose && strings.TrimSpace(req.DockerCompose) == "" {
return nil, buserr.New("ErrAgentComposeRequired")
@@ -282,22 +297,20 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
configPath = path.Join(appInstall.GetPath(), "data", "config.yaml")
}
agent := &model.Agent{
Name: req.Name,
Remark: req.Remark,
AgentType: agentType,
Provider: provider,
Model: storedModel,
APIType: apiType,
MaxTokens: maxTokens,
ContextWindow: contextWindow,
BaseURL: baseURL,
APIKey: apiKey,
Token: token,
Status: appInstall.Status,
Message: appInstall.Message,
AppInstallID: appInstall.ID,
AccountID: accountID,
ConfigPath: configPath,
Name: req.Name,
Remark: req.Remark,
AgentType: agentType,
Provider: provider,
Model: storedModel,
APIType: apiType,
BaseURL: baseURL,
APIKey: apiKey,
Token: token,
Status: appInstall.Status,
Message: appInstall.Message,
AppInstallID: appInstall.ID,
AccountID: accountID,
ConfigPath: configPath,
}
if err := agentRepo.Create(agent); err != nil {
return nil, err
@@ -395,7 +408,7 @@ func (a AgentService) BatchInstallSkill(req dto.AgentBatchSkillInstallReq) ([]dt
results = append(results, result)
continue
}
installTask, err := task.NewTaskWithOps(skillName, task.TaskInstall, task.TaskScopeAI, buildBatchSkillInstallTaskID(req.TaskID, agent.ID), agent.ID)
installTask, err := task.NewTaskWithOps(skillName, task.TaskInstall, task.TaskScopeAI, buildBatchTaskID(req.TaskID, "batch-skill-install", agent.ID), agent.ID)
if err != nil {
result.Message = err.Error()
results = append(results, result)
@@ -437,7 +450,7 @@ func (a AgentService) BatchOperate(req dto.AgentBatchOperateReq) ([]dto.AgentBat
if operate == constant.Delete {
if err := a.Delete(dto.AgentDeleteReq{
ID: agent.ID,
TaskID: buildBatchOperateTaskID(req.TaskID, agent.ID),
TaskID: buildBatchTaskID(req.TaskID, "batch-operate", agent.ID),
ForceDelete: req.ForceDelete,
}); err != nil {
result.Message = err.Error()
@@ -465,7 +478,7 @@ func (a AgentService) BatchOperate(req dto.AgentBatchOperateReq) ([]dto.AgentBat
if err := NewIAppInstalledService().Operate(request.AppInstalledOperate{
InstallId: install.ID,
Operate: operate,
TaskID: buildBatchOperateTaskID(req.TaskID, agent.ID),
TaskID: buildBatchTaskID(req.TaskID, "batch-operate", agent.ID),
}); err != nil {
result.Message = err.Error()
} else {
@@ -549,35 +562,19 @@ func buildBatchUpgradePlans(req dto.AgentBatchUpgradeReq) ([]batchUpgradePlan, [
DetailID: detail.ID,
Backup: req.Backup,
PullImage: req.PullImage,
TaskID: buildBatchUpgradeTaskID(req.TaskID, install.ID),
TaskID: buildBatchTaskID(req.TaskID, "batch-upgrade", install.ID),
},
})
}
return plans, results, nil
}
func buildBatchUpgradeTaskID(taskID string, appInstallID uint) string {
func buildBatchTaskID(taskID, prefix string, id uint) string {
taskID = strings.TrimSpace(taskID)
if taskID == "" {
taskID = fmt.Sprintf("batch-upgrade-%d-%d", appInstallID, time.Now().UnixNano())
taskID = fmt.Sprintf("%s-%d-%d", prefix, id, time.Now().UnixNano())
}
return fmt.Sprintf("%s-%d", taskID, appInstallID)
}
func buildBatchSkillInstallTaskID(taskID string, agentID uint) string {
taskID = strings.TrimSpace(taskID)
if taskID == "" {
taskID = fmt.Sprintf("batch-skill-install-%d-%d", agentID, time.Now().UnixNano())
}
return fmt.Sprintf("%s-%d", taskID, agentID)
}
func buildBatchOperateTaskID(taskID string, agentID uint) string {
taskID = strings.TrimSpace(taskID)
if taskID == "" {
taskID = fmt.Sprintf("batch-operate-%d-%d", agentID, time.Now().UnixNano())
}
return fmt.Sprintf("%s-%d", taskID, agentID)
return fmt.Sprintf("%s-%d", taskID, id)
}
func batchOperateSkipMessage(operate constant.AppOperate, status string) string {
@@ -604,28 +601,30 @@ func batchOperateSkipMessage(operate constant.AppOperate, status string) string
func buildCreateReqFromBatchInstallReq(req dto.AgentBatchInstallReq) dto.AgentCreateReq {
return dto.AgentCreateReq{
Name: req.Name,
Remark: req.Remark,
AppVersion: req.AppVersion,
WebUIPort: req.WebUIPort,
BridgePort: req.BridgePort,
AllowedOrigins: req.AllowedOrigins,
AgentType: req.AgentType,
Model: req.Model,
AccountID: req.AccountID,
Token: req.Token,
TaskID: req.TaskID,
Advanced: req.Advanced,
ContainerName: req.ContainerName,
AllowPort: req.AllowPort,
SpecifyIP: req.SpecifyIP,
RestartPolicy: req.RestartPolicy,
CpuQuota: req.CpuQuota,
MemoryLimit: req.MemoryLimit,
MemoryUnit: req.MemoryUnit,
PullImage: req.PullImage,
EditCompose: req.EditCompose,
DockerCompose: req.DockerCompose,
Name: req.Name,
Remark: req.Remark,
AppVersion: req.AppVersion,
WebUIPort: req.WebUIPort,
BridgePort: req.BridgePort,
AllowedOrigins: req.AllowedOrigins,
AgentType: req.AgentType,
Model: req.Model,
AccountID: req.AccountID,
Token: req.Token,
DashboardUsername: req.DashboardUsername,
DashboardPassword: req.DashboardPassword,
TaskID: req.TaskID,
Advanced: req.Advanced,
ContainerName: req.ContainerName,
AllowPort: req.AllowPort,
SpecifyIP: req.SpecifyIP,
RestartPolicy: req.RestartPolicy,
CpuQuota: req.CpuQuota,
MemoryLimit: req.MemoryLimit,
MemoryUnit: req.MemoryUnit,
PullImage: req.PullImage,
EditCompose: req.EditCompose,
DockerCompose: req.DockerCompose,
}
}
@@ -704,6 +703,7 @@ func (a AgentService) ensureBatchInstallAccount(req dto.AgentBatchInstallReq) (u
account.RememberAPIKey = snapshot.RememberAPIKey
account.BaseURL = snapshot.BaseURL
account.APIType = snapshot.APIType
account.AuthMode = snapshot.AuthMode
account.Remark = snapshot.Remark
account.Verified = true
@@ -711,6 +711,11 @@ func (a AgentService) ensureBatchInstallAccount(req dto.AgentBatchInstallReq) (u
if err != nil {
return 0, err
}
verifyModel, err := resolveAgentAccountVerifyModel(account.Provider, snapshot.VerifyModel, initialModels)
if err != nil {
return 0, err
}
account.VerifyModel = verifyModel
if err := global.DB.Transaction(func(tx *gorm.DB) error {
if account.ID == 0 {
if err := tx.Create(account).Error; err != nil {
@@ -948,7 +953,7 @@ func (a AgentService) UpdateModelConfig(req dto.AgentModelConfigUpdateReq) error
return err
}
modelName := resolvedRuntime.StoredModel
apiType, maxTokens, contextWindow := resolvedRuntime.APIType, resolvedRuntime.MaxTokens, resolvedRuntime.ContextWindow
apiType := resolvedRuntime.APIType
confDir := path.Dir(agent.ConfigPath)
if agent.AgentType == constant.AppHermesAgent {
cfg, err := readHermesConfig(agent.ConfigPath)
@@ -969,8 +974,6 @@ func (a AgentService) UpdateModelConfig(req dto.AgentModelConfigUpdateReq) error
agent.Provider = account.Provider
agent.Model = modelName
agent.APIType = apiType
agent.MaxTokens = maxTokens
agent.ContextWindow = contextWindow
agent.BaseURL = account.BaseURL
agent.APIKey = account.APIKey
agent.AccountID = account.ID
@@ -984,20 +987,38 @@ func (a AgentService) GetProviders() ([]dto.ProviderInfo, error) {
models := make([]dto.ProviderModelInfo, 0, len(def.Models))
for _, item := range def.Models {
models = append(models, dto.ProviderModelInfo{
ID: item.ID,
Name: item.Name,
ContextWindow: item.ContextWindow,
MaxTokens: item.MaxTokens,
Reasoning: item.Reasoning,
Input: append([]string(nil), item.Input...),
ID: item.ID,
Name: item.Name,
})
}
apiTypes := make([]dto.ProviderAPIInfo, 0, len(def.APIConfigs))
for _, item := range def.APIConfigs {
apiModels := make([]dto.ProviderModelInfo, 0, len(item.Models))
for _, model := range item.Models {
apiModels = append(apiModels, dto.ProviderModelInfo{
ID: model.ID,
Name: model.Name,
})
}
apiTypes = append(apiTypes, dto.ProviderAPIInfo{
APIType: item.APIType,
BaseURL: item.BaseURL,
EditableBaseURL: item.EditableBaseURL,
SupportsModelDiscovery: item.DiscoverModels,
DefaultAuthMode: item.DefaultAuthMode,
AuthModes: item.AuthModes,
Models: apiModels,
})
}
baseURL, _ := providercatalog.DefaultBaseURL(key)
providers = append(providers, dto.ProviderInfo{
Sort: def.Sort,
Provider: key,
DisplayName: def.DisplayName,
BaseURL: def.DefaultBaseURL,
Models: models,
Sort: def.Sort,
Provider: key,
DisplayName: localizedAgentProviderName(key),
BaseURL: baseURL,
DefaultAPIType: def.DefaultAPIType,
APITypes: apiTypes,
Models: models,
})
}
sort.Slice(providers, func(i, j int) bool {
@@ -1007,11 +1028,22 @@ func (a AgentService) GetProviders() ([]dto.ProviderInfo, error) {
}
func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock()
provider := req.Provider
if exist, _ := agentAccountRepo.GetFirst(repo.WithByProvider(provider), repo.WithByName(req.Name)); exist != nil && exist.ID > 0 {
return buserr.New("ErrRecordExist")
if err := ensureAgentAccountNameAvailable(provider, req.Name, 0); err != nil {
return err
}
resolvedInput, err := resolveAgentAccountInput(provider, req.APIKey, req.BaseURL)
initialModels, err := buildInitialAgentAccountModels(&model.AgentAccount{Provider: provider, APIType: req.APIType}, req.Models)
if err != nil {
return err
}
verifyModel, err := resolveAgentAccountVerifyModel(provider, req.VerifyModel, initialModels)
if err != nil {
return err
}
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability)
if err != nil {
return err
}
@@ -1021,14 +1053,12 @@ func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
APIKey: resolvedInput.APIKey,
RememberAPIKey: req.RememberAPIKey,
BaseURL: resolvedInput.BaseURL,
APIType: req.APIType,
APIType: resolvedInput.APIType,
AuthMode: resolvedInput.AuthMode,
VerifyModel: verifyModel,
Verified: true,
Remark: req.Remark,
}
initialModels, err := buildInitialAgentAccountModels(account, req.Models)
if err != nil {
return err
}
if err := global.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(account).Error; err != nil {
return err
@@ -1045,12 +1075,33 @@ func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
}
func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock()
account, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID))
if err != nil {
return err
}
if req.APIType != account.APIType {
return buserr.WithDetail("ErrInvalidParams", "API type cannot be changed", nil)
}
provider := account.Provider
resolvedInput, err := resolveAgentAccountInput(provider, req.APIKey, req.BaseURL)
if err := ensureAgentAccountNameAvailable(provider, req.Name, account.ID); err != nil {
return err
}
models, err := loadAgentAccountModels(account)
if err != nil {
return err
}
requestedVerifyModel := req.VerifyModel
if strings.TrimSpace(requestedVerifyModel) == "" {
requestedVerifyModel = account.VerifyModel
}
verifyModel, err := resolveAgentAccountVerifyModel(provider, requestedVerifyModel, models)
if err != nil {
return err
}
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability)
if err != nil {
return err
}
@@ -1058,7 +1109,9 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
account.APIKey = resolvedInput.APIKey
account.RememberAPIKey = req.RememberAPIKey
account.BaseURL = resolvedInput.BaseURL
account.APIType = req.APIType
account.APIType = resolvedInput.APIType
account.AuthMode = resolvedInput.AuthMode
account.VerifyModel = verifyModel
account.Remark = req.Remark
account.Verified = true
@@ -1080,6 +1133,12 @@ func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.Age
if strings.TrimSpace(req.Provider) != "" {
opts = append(opts, repo.WithByProvider(req.Provider))
}
if apiType := strings.TrimSpace(req.APIType); apiType != "" {
opts = append(opts, repo.WithByAPIType(apiType))
}
if req.TextOnly {
opts = append(opts, repo.WithTextAPIType())
}
if strings.TrimSpace(req.Name) != "" {
opts = append(opts, repo.WithByLikeName(req.Name))
}
@@ -1097,30 +1156,46 @@ func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.Age
ID: item.ID,
MasterAccountID: item.MasterAccountID,
Provider: item.Provider,
ProviderName: providercatalog.DisplayName(item.Provider),
ProviderName: localizedAgentProviderName(item.Provider),
Name: item.Name,
APIKey: apiKey,
RememberAPIKey: item.RememberAPIKey,
BaseURL: item.BaseURL,
Models: nil,
APIType: item.APIType,
AuthMode: item.AuthMode,
VerifyModel: item.VerifyModel,
Verified: item.Verified,
Remark: item.Remark,
CreatedAt: item.CreatedAt,
})
}
for i := range items {
models, err := loadAgentAccountModels(&list[i])
if err != nil {
if len(list) > 0 {
accountIDs := make([]uint, 0, len(list))
for _, account := range list {
accountIDs = append(accountIDs, account.ID)
}
var rows []model.AgentAccountModel
if err := global.DB.Where("account_id IN ?", accountIDs).Order("account_id ASC, sort_order ASC, id ASC").Find(&rows).Error; err != nil {
return 0, nil, err
}
items[i].Models = models
modelsByAccount := make(map[uint][]dto.AgentAccountModel, len(list))
for _, row := range rows {
modelsByAccount[row.AccountID] = append(modelsByAccount[row.AccountID], dto.AgentAccountModel{
RecordID: row.ID,
ID: strings.TrimSpace(row.Model),
Name: strings.TrimSpace(row.Name),
})
}
for index, account := range list {
items[index].Models = modelsByAccount[account.ID]
}
}
return count, items, nil
}
func (a AgentService) CountAccountsByProviders(req dto.AgentAccountProviderCountReq) (map[string]int64, error) {
return agentAccountRepo.CountByProviders(req.Providers)
return agentAccountRepo.CountTextByProviders(req.Providers)
}
func (a AgentService) GetAccountModels(req dto.AgentAccountModelReq) ([]dto.AgentAccountModel, error) {
@@ -1131,6 +1206,22 @@ func (a AgentService) GetAccountModels(req dto.AgentAccountModelReq) ([]dto.Agen
return loadAgentAccountModels(account)
}
func (a AgentService) DiscoverAccountModels(req dto.AgentAccountModelDiscoverReq) ([]dto.AgentAccountModel, error) {
config, ok := providercatalog.FindAPIConfig(req.Provider, req.APIType)
if !ok || !config.DiscoverModels {
return nil, buserr.New("ErrAgentAccountModelsRequired")
}
baseURL, err := providercatalog.ResolveBaseURL(req.Provider, req.APIType, req.BaseURL)
if err != nil {
return nil, buserr.WithErr("ErrAgentAccountUnavailable", err)
}
models, err := providercatalog.DiscoverModels(baseURL, req.APIKey)
if err != nil {
return nil, buserr.WithErr("ErrAgentAccountUnavailable", err)
}
return buildDiscoveredAgentAccountModels(models), nil
}
func (a AgentService) CreateAccountModel(req dto.AgentAccountModelCreateReq) error {
account, err := agentAccountRepo.GetFirst(repo.WithByID(req.AccountID))
if err != nil {
@@ -1140,24 +1231,19 @@ func (a AgentService) CreateAccountModel(req dto.AgentAccountModelCreateReq) err
if err != nil {
return err
}
nextModel := cloneAgentAccountModel(req.Model)
if _, ok := findAgentAccountModelForProvider(account.Provider, models, nextModel.ID); ok {
return buserr.New("ErrRecordExist")
}
inputPayload, err := json.Marshal(nextModel.Input)
nextModel, err := normalizeAgentAccountModel(account, req.Model)
if err != nil {
return err
}
if _, ok := findAgentAccountModelForProvider(account.Provider, models, nextModel.ID); ok {
return buserr.New("ErrRecordExist")
}
sortOrder := len(models) + 1
record := &model.AgentAccountModel{
AccountID: account.ID,
Model: nextModel.ID,
Name: nextModel.Name,
ContextWindow: nextModel.ContextWindow,
MaxTokens: nextModel.MaxTokens,
Reasoning: nextModel.Reasoning,
Input: string(inputPayload),
SortOrder: sortOrder,
AccountID: account.ID,
Model: nextModel.ID,
Name: nextModel.Name,
SortOrder: sortOrder,
}
if err := agentAccountModelRepo.Create(record); err != nil {
return err
@@ -1178,7 +1264,11 @@ func (a AgentService) UpdateAccountModel(req dto.AgentAccountModelUpdateReq) err
if err != nil {
return err
}
nextModel := cloneAgentAccountModel(req.Model)
nextModel, err := normalizeAgentAccountModel(account, req.Model)
if err != nil {
return err
}
nextModel.RecordID = req.Model.RecordID
for _, item := range models {
if item.RecordID == req.Model.RecordID {
continue
@@ -1198,19 +1288,18 @@ func (a AgentService) UpdateAccountModel(req dto.AgentAccountModelUpdateReq) err
if err := ensureAccountModelsNotBound(account, nextModels); err != nil {
return err
}
inputPayload, err := json.Marshal(nextModel.Input)
if err != nil {
return err
}
previousModelID := record.Model
record.Model = nextModel.ID
record.Name = nextModel.Name
record.ContextWindow = nextModel.ContextWindow
record.MaxTokens = nextModel.MaxTokens
record.Reasoning = nextModel.Reasoning
record.Input = string(inputPayload)
if err := agentAccountModelRepo.Save(record); err != nil {
return err
}
if sameProviderModelID(account.Provider, account.VerifyModel, previousModelID) {
account.VerifyModel = nextModel.ID
if err := agentAccountRepo.Save(account); err != nil {
return err
}
}
terminalai.InvalidateTerminalRuntimeCache()
terminalai.InvalidateFileAIRuntimeCache()
return a.syncAgentsByAccount(account)
@@ -1221,9 +1310,13 @@ func (a AgentService) DeleteAccountModel(req dto.AgentAccountModelDeleteReq) err
if err != nil {
return err
}
if _, err := agentAccountModelRepo.GetFirst(repo.WithByID(req.RecordID), repo.WithByAccountID(req.AccountID)); err != nil {
record, err := agentAccountModelRepo.GetFirst(repo.WithByID(req.RecordID), repo.WithByAccountID(req.AccountID))
if err != nil {
return err
}
if sameProviderModelID(account.Provider, account.VerifyModel, record.Model) {
return buserr.New("ErrAgentVerifyModelInUse")
}
models, err := loadAgentAccountModels(account)
if err != nil {
return err
@@ -1257,25 +1350,45 @@ func (a AgentService) SyncAgentsByAccount(account *model.AgentAccount) error {
}
func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error {
_, err := resolveAgentAccountInput(req.Provider, req.APIKey, req.BaseURL)
_, err := resolveAgentAccountInput(req.Provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, req.Model, true)
return err
}
func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error {
if exists, _ := agentRepo.GetFirst(repo.WithByAccountID(req.ID)); exists != nil && exists.ID > 0 {
return buserr.New("ErrAgentAccountBound")
}
if aiStatus, _ := settingRepo.GetValueByKey("AIStatus"); strings.EqualFold(strings.TrimSpace(aiStatus), constant.StatusEnable) {
if aiAccountID, _ := settingRepo.GetValueByKey("AIAccountID"); strings.TrimSpace(aiAccountID) == strconv.FormatUint(uint64(req.ID), 10) {
agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock()
if err := global.DB.Transaction(func(tx *gorm.DB) error {
var agentCount int64
if err := tx.Model(&model.Agent{}).Where("account_id = ?", req.ID).Count(&agentCount).Error; err != nil {
return err
}
if agentCount > 0 {
return buserr.New("ErrAgentAccountBound")
}
used, err := agentAccountUsedBySetting(tx, req.ID, "AIStatus", "AIAccountID")
if err != nil {
return err
}
if used {
return buserr.New("ErrTerminalAIAccountInUse")
}
}
if err := agentAccountModelRepo.Delete(repo.WithByAccountID(req.ID)); err != nil {
used, err = agentAccountUsedBySetting(tx, req.ID, "FileAIStatus", "FileAIAccountID")
if err != nil {
return err
}
if used {
return buserr.New("ErrFileAIAccountInUse")
}
if err := tx.Where("account_id = ?", req.ID).Delete(&model.AgentAccountModel{}).Error; err != nil {
return err
}
return tx.Delete(&model.AgentAccount{}, req.ID).Error
}); err != nil {
return err
}
terminalai.InvalidateTerminalRuntimeCache()
terminalai.InvalidateFileAIRuntimeCache()
return agentAccountRepo.DeleteByID(req.ID)
return nil
}
func (a AgentService) GetSecurityConfig(req dto.AgentIDReq) (*dto.AgentSecurityConfig, error) {
@@ -1327,10 +1440,20 @@ func (a AgentService) GetOtherConfig(req dto.AgentIDReq) (*dto.AgentOtherConfig,
if err != nil {
return nil, err
}
auth := readAgentDashboardAuthFromInstall(install, agent.AgentType)
return &dto.AgentOtherConfig{
UserTimezone: cfg.Timezone,
BrowserEnabled: true,
NPMRegistry: "https://registry.npmjs.org/",
UserTimezone: cfg.Timezone,
BrowserEnabled: true,
NPMRegistry: "https://registry.npmjs.org/",
DashboardUsername: auth.Username,
DashboardPassword: auth.Password,
}, nil
}
if agent.AgentType == constant.AppCopaw {
auth := readAgentDashboardAuthFromInstall(install, agent.AgentType)
return &dto.AgentOtherConfig{
DashboardUsername: auth.Username,
DashboardPassword: auth.Password,
}, nil
}
conf, err := readOpenclawConfig(agent.ConfigPath)
@@ -1351,18 +1474,36 @@ func (a AgentService) UpdateOtherConfig(req dto.AgentOtherConfigUpdateReq) error
return err
}
if agent.AgentType == constant.AppHermesAgent {
if strings.TrimSpace(req.UserTimezone) == "" {
return buserr.New("ErrInvalidParams")
}
account, err := agentAccountRepo.GetFirst(repo.WithByID(agent.AccountID))
if err != nil {
return err
}
previousAuth := readAgentDashboardAuthFromInstall(install, agent.AgentType)
nextAuth := normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword)
if err := writeHermesConfig(path.Dir(agent.ConfigPath), account, agent.Model, strings.TrimSpace(req.UserTimezone)); err != nil {
return err
}
if err := writeAgentDashboardAuthEnv(install.GetEnvPath(), agent.AgentType, nextAuth, true); err != nil {
return err
}
operate := constant.Restart
if previousAuth.Username != nextAuth.Username || previousAuth.Password != nextAuth.Password {
operate = constant.Rebuild
}
return NewIAppInstalledService().Operate(request.AppInstalledOperate{
InstallId: install.ID,
Operate: constant.Restart,
Operate: operate,
})
}
if agent.AgentType == constant.AppCopaw {
return updateQwenPawDashboardAuth(install, normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword))
}
if strings.TrimSpace(req.UserTimezone) == "" || strings.TrimSpace(req.NPMRegistry) == "" {
return buserr.New("ErrInvalidParams")
}
if err := ensureContainerRunning(install.ContainerName); err != nil {
return err
}
@@ -1534,7 +1675,7 @@ func (a AgentService) syncAgentsByAccount(account *model.AgentAccount) error {
return err
}
modelName := resolvedRuntime.StoredModel
apiType, maxTokens, contextWindow := resolvedRuntime.APIType, resolvedRuntime.MaxTokens, resolvedRuntime.ContextWindow
apiType := resolvedRuntime.APIType
confDir := path.Dir(agent.ConfigPath)
switch agent.AgentType {
case constant.AppOpenclaw:
@@ -1562,8 +1703,6 @@ func (a AgentService) syncAgentsByAccount(account *model.AgentAccount) error {
agent.Provider = account.Provider
agent.Model = modelName
agent.APIType = apiType
agent.MaxTokens = maxTokens
agent.ContextWindow = contextWindow
_ = agentRepo.Save(&agent)
}
return nil
+27 -6
View File
@@ -811,9 +811,7 @@ func extractTelegramConfig(conf map[string]interface{}) dto.AgentTelegramConfig
result.RequireMention = result.GroupPolicy == "allowlist"
result.GroupAllowFrom = extractStringList(telegram["groupAllowFrom"])
result.Proxy = extractStringValue(telegram["proxy"])
if streaming := extractStringValue(telegram["streaming"]); streaming != "" {
result.Streaming = streaming
}
result.Streaming = normalizeTelegramStreamingMode(telegram["streaming"], result.Streaming)
accounts := childMap(telegram, "accounts")
if len(accounts) == 0 {
botToken := extractStringValue(telegram["botToken"])
@@ -839,7 +837,7 @@ func extractTelegramConfig(conf map[string]interface{}) dto.AgentTelegramConfig
BotToken: extractStringValue(account["botToken"]),
DmPolicy: extractStringValue(account["dmPolicy"]),
GroupPolicy: extractStringValue(account["groupPolicy"]),
Streaming: extractStringValue(account["streaming"]),
Streaming: normalizeTelegramStreamingMode(account["streaming"], result.Streaming),
})
}
result.DefaultAccount = normalizeDefaultAccount(extractStringValue(telegram["defaultAccount"]), getTelegramBotAccountIDs(bots))
@@ -879,7 +877,7 @@ func setTelegramConfig(conf map[string]interface{}, config dto.AgentTelegramConf
} else {
delete(telegram, "proxy")
}
telegram["streaming"] = config.Streaming
telegram["streaming"] = buildTelegramStreamingConfig(config.Streaming)
accounts := make(map[string]interface{}, len(config.Bots))
for _, bot := range config.Bots {
account := map[string]interface{}{
@@ -888,7 +886,7 @@ func setTelegramConfig(conf map[string]interface{}, config dto.AgentTelegramConf
"botToken": bot.BotToken,
"dmPolicy": bot.DmPolicy,
"groupPolicy": bot.GroupPolicy,
"streaming": bot.Streaming,
"streaming": buildTelegramStreamingConfig(bot.Streaming),
}
if bot.DmPolicy == "open" {
account["allowFrom"] = []string{"*"}
@@ -899,6 +897,29 @@ func setTelegramConfig(conf map[string]interface{}, config dto.AgentTelegramConf
delete(telegram, "botToken")
}
func normalizeTelegramStreamingMode(value interface{}, defaultMode string) string {
mode := defaultMode
switch typed := value.(type) {
case string:
mode = typed
case map[string]interface{}:
mode = extractStringValue(typed["mode"])
}
mode = strings.ToLower(strings.TrimSpace(mode))
switch mode {
case "off", "partial", "block", "progress":
return mode
default:
return "partial"
}
}
func buildTelegramStreamingConfig(mode string) map[string]interface{} {
return map[string]interface{}{
"mode": normalizeTelegramStreamingMode(mode, "partial"),
}
}
func extractDiscordConfig(conf map[string]interface{}) dto.AgentDiscordConfig {
result := dto.AgentDiscordConfig{Enabled: true, DmPolicy: "pairing", AllowFrom: []string{}, RequireMention: false, GroupPolicy: "open"}
discord := getChannelConfig(conf, "discord")
+128
View File
@@ -0,0 +1,128 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
)
type qwenPawAuthStatus struct {
Enabled bool `json:"enabled"`
HasUsers bool `json:"has_users"`
}
type qwenPawLoginResponse struct {
Token string `json:"token"`
}
func updateQwenPawDashboardAuth(install *model.AppInstall, next agentDashboardAuth) error {
if install == nil || install.ID == 0 {
return buserr.New("ErrRecordNotFound")
}
current, err := readAgentDashboardAuthEnv(install.GetEnvPath(), constant.AppCopaw)
if err != nil {
return err
}
if current == next {
return writeAgentDashboardAuthEnv(install.GetEnvPath(), constant.AppCopaw, next, true)
}
if err := ensureContainerRunning(install.ContainerName); err != nil {
return err
}
baseURL := fmt.Sprintf("http://127.0.0.1:%d/api/auth", install.HttpPort)
var status qwenPawAuthStatus
if _, err := requestQwenPawAuth(http.MethodGet, baseURL+"/status", nil, "", &status); err != nil {
return buserr.WithMap("ErrQwenPawAuthRequest", map[string]interface{}{"err": err.Error()}, err)
}
if !status.Enabled {
return buserr.New("ErrQwenPawAuthDisabled")
}
if !status.HasUsers {
payload := map[string]string{"username": next.Username, "password": next.Password}
if _, err := requestQwenPawAuth(http.MethodPost, baseURL+"/register", payload, "", nil); err != nil {
return buserr.WithMap("ErrQwenPawAuthRequest", map[string]interface{}{"err": err.Error()}, err)
}
} else {
var login qwenPawLoginResponse
payload := map[string]string{"username": current.Username, "password": current.Password}
statusCode, err := requestQwenPawAuth(http.MethodPost, baseURL+"/login", payload, "", &login)
if statusCode == http.StatusUnauthorized {
return buserr.New("ErrQwenPawAuthOutOfSync")
}
if err != nil {
return buserr.WithMap("ErrQwenPawAuthRequest", map[string]interface{}{"err": err.Error()}, err)
}
payload = map[string]string{"current_password": current.Password}
if current.Username != next.Username {
payload["new_username"] = next.Username
}
if current.Password != next.Password {
payload["new_password"] = next.Password
}
if _, err := requestQwenPawAuth(http.MethodPost, baseURL+"/update-profile", payload, login.Token, nil); err != nil {
return buserr.WithMap("ErrQwenPawAuthRequest", map[string]interface{}{"err": err.Error()}, err)
}
}
return writeAgentDashboardAuthEnv(install.GetEnvPath(), constant.AppCopaw, next, true)
}
func requestQwenPawAuth(method, reqURL string, payload interface{}, token string, result interface{}) (int, error) {
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return 0, err
}
body = bytes.NewReader(data)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return resp.StatusCode, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
detail := strings.TrimSpace(string(data))
var errorResponse struct {
Detail string `json:"detail"`
}
if json.Unmarshal(data, &errorResponse) == nil && strings.TrimSpace(errorResponse.Detail) != "" {
detail = strings.TrimSpace(errorResponse.Detail)
}
if detail == "" {
detail = resp.Status
}
return resp.StatusCode, errors.New(detail)
}
if result != nil && len(data) > 0 {
if err := json.Unmarshal(data, result); err != nil {
return resp.StatusCode, err
}
}
return resp.StatusCode, nil
}
+31 -68
View File
@@ -13,9 +13,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/utils/common"
agentenv "github.com/1Panel-dev/1Panel/agent/utils/env"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/joho/godotenv"
"gopkg.in/yaml.v3"
)
@@ -71,7 +69,7 @@ func writeHermesConfig(confDir string, account *model.AgentAccount, modelName st
}
}
provider := resolveHermesProvider(account.Provider)
provider := resolveHermesProvider(account.Provider, account.APIType)
configPath := path.Join(confDir, "config.yaml")
cfg, err := readHermesConfigMap(configPath)
if err != nil {
@@ -152,7 +150,7 @@ func writeHermesConfigMap(configPath string, cfg map[string]interface{}) error {
}
func readHermesTelegramChannelConfig(confDir string) (*dto.AgentTelegramConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env"))
envMap, err := readAgentEnvMap(path.Join(confDir, ".env"))
if err != nil {
return nil, err
}
@@ -209,7 +207,7 @@ func readHermesTelegramChannelConfig(confDir string) (*dto.AgentTelegramConfig,
func writeHermesTelegramChannelConfig(confDir string, config dto.AgentTelegramConfig) error {
envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath)
envMap, err := readAgentEnvMap(envPath)
if err != nil {
return err
}
@@ -229,7 +227,7 @@ func writeHermesTelegramChannelConfig(confDir string, config dto.AgentTelegramCo
envMap["TELEGRAM_ALLOWED_USERS"] = allow
}
}
if err := writeHermesEnvMap(envPath, envMap, []string{
if err := writeAgentEnvMap(envPath, envMap, []string{
"TELEGRAM_BOT_TOKEN",
"TELEGRAM_ALLOWED_USERS",
"TELEGRAM_ALLOW_ALL_USERS",
@@ -248,7 +246,7 @@ func writeHermesTelegramChannelConfig(confDir string, config dto.AgentTelegramCo
}
func readHermesDiscordChannelConfig(confDir string) (*dto.AgentDiscordConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env"))
envMap, err := readAgentEnvMap(path.Join(confDir, ".env"))
if err != nil {
return nil, err
}
@@ -300,7 +298,7 @@ func readHermesDiscordChannelConfig(confDir string) (*dto.AgentDiscordConfig, er
func writeHermesDiscordChannelConfig(confDir string, config dto.AgentDiscordConfig) error {
envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath)
envMap, err := readAgentEnvMap(envPath)
if err != nil {
return err
}
@@ -320,7 +318,7 @@ func writeHermesDiscordChannelConfig(confDir string, config dto.AgentDiscordConf
envMap["DISCORD_ALLOWED_USERS"] = allow
}
}
if err := writeHermesEnvMap(envPath, envMap, []string{
if err := writeAgentEnvMap(envPath, envMap, []string{
"DISCORD_BOT_TOKEN",
"DISCORD_ALLOWED_USERS",
"DISCORD_ALLOW_ALL_USERS",
@@ -340,14 +338,14 @@ func writeHermesDiscordChannelConfig(confDir string, config dto.AgentDiscordConf
func deleteHermesEnvKeys(confDir string, keys ...string) error {
envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath)
envMap, err := readAgentEnvMap(envPath)
if err != nil {
return err
}
for _, key := range keys {
delete(envMap, key)
}
return writeHermesEnvMap(envPath, envMap, keys)
return writeAgentEnvMap(envPath, envMap, keys)
}
func deleteHermesConfigSections(confDir string, topLevelKeys []string, platformKeys []string) error {
@@ -407,11 +405,18 @@ func normalizeHermesTimezone(timezone string) string {
return timezone
}
func resolveHermesProvider(provider string) string {
func resolveHermesProvider(provider, apiType string) string {
if apiType == "anthropic-messages" && (provider == "deepseek" || provider == "bailian-coding-plan" || provider == "ark-coding-plan" || provider == "xiaomi") {
return "anthropic"
}
switch provider {
case "":
return "custom"
case "openrouter", "anthropic", "gemini", "zai", "kimi-coding", "xiaomi":
case "moonshot":
return "kimi-coding"
case "kimi":
return "kimi-coding-cn"
case "openrouter", "anthropic", "deepseek", "gemini", "zai", "kimi-coding", "xiaomi":
return provider
case "minimax":
return "minimax-cn"
@@ -428,17 +433,7 @@ func resolveHermesModel(sourceProvider, targetProvider, modelName string) string
if targetProvider != "custom" {
return target
}
if sourceProvider == "custom" || sourceProvider == "vllm" {
return normalizeCustomModel(target)
}
if strings.Contains(target, "/") {
parts := strings.SplitN(target, "/", 2)
model := strings.TrimSpace(parts[1])
if model != "" {
return model
}
}
return target
return providercatalog.NormalizeModelID(sourceProvider, target)
}
func resolveHermesConfiguredModelID(account *model.AgentAccount, accountModels []dto.AgentAccountModel, configuredModel string) (string, error) {
@@ -449,7 +444,7 @@ func resolveHermesConfiguredModelID(account *model.AgentAccount, accountModels [
if configuredModel == "" {
return "", buserr.New("ErrAgentModelNotInAccount")
}
provider := resolveHermesProvider(account.Provider)
provider := resolveHermesProvider(account.Provider, account.APIType)
for _, item := range accountModels {
if resolveHermesModel(account.Provider, provider, item.ID) == configuredModel {
return item.ID, nil
@@ -462,7 +457,8 @@ func resolveHermesEnvEntries(account *model.AgentAccount) []hermesEnvEntry {
if account == nil {
return nil
}
if resolveHermesProvider(account.Provider) == "custom" {
resolvedProvider := resolveHermesProvider(account.Provider, account.APIType)
if resolvedProvider == "custom" {
if account.APIKey == "" {
return nil
}
@@ -480,6 +476,11 @@ func resolveHermesEnvEntries(account *model.AgentAccount) []hermesEnvEntry {
}
entries = append(entries, hermesEnvEntry{Key: key, Value: value})
}
if resolvedProvider == "anthropic" {
appendEntry("ANTHROPIC_API_KEY", apiKey)
appendEntry("ANTHROPIC_BASE_URL", baseURL)
return entries
}
switch account.Provider {
case "openrouter":
@@ -487,6 +488,7 @@ func resolveHermesEnvEntries(account *model.AgentAccount) []hermesEnvEntry {
appendEntry("OPENROUTER_BASE_URL", baseURL)
case "anthropic":
appendEntry("ANTHROPIC_API_KEY", apiKey)
appendEntry("ANTHROPIC_BASE_URL", baseURL)
case "gemini":
appendEntry("GOOGLE_API_KEY", apiKey)
appendEntry("GEMINI_API_KEY", apiKey)
@@ -532,7 +534,7 @@ func resolveHermesEnvEntries(account *model.AgentAccount) []hermesEnvEntry {
}
func writeHermesModelEnv(envPath string, account *model.AgentAccount) error {
envMap, err := readHermesEnvMap(envPath)
envMap, err := readAgentEnvMap(envPath)
if err != nil {
return err
}
@@ -548,7 +550,7 @@ func writeHermesModelEnv(envPath string, account *model.AgentAccount) error {
envMap[entry.Key] = entry.Value
order = append(order, entry.Key)
}
return writeHermesEnvMap(envPath, envMap, order)
return writeAgentEnvMap(envPath, envMap, order)
}
func hermesManagedModelEnvKeys() []string {
@@ -556,6 +558,7 @@ func hermesManagedModelEnvKeys() []string {
"OPENROUTER_API_KEY",
"OPENROUTER_BASE_URL",
"ANTHROPIC_API_KEY",
"ANTHROPIC_BASE_URL",
"GOOGLE_API_KEY",
"GEMINI_API_KEY",
"GEMINI_BASE_URL",
@@ -598,46 +601,6 @@ func hermesManagedModelEnvKeys() []string {
return result
}
func writeHermesEnv(envPath string, entries []hermesEnvEntry) error {
envMap, err := readHermesEnvMap(envPath)
if err != nil {
return err
}
for _, entry := range entries {
if entry.Key == "" || entry.Value == "" {
continue
}
envMap[entry.Key] = entry.Value
}
order := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.Key == "" {
continue
}
order = append(order, entry.Key)
}
return writeHermesEnvMap(envPath, envMap, order)
}
func readHermesEnvMap(envPath string) (map[string]string, error) {
fileOp := files.NewFileOp()
if !fileOp.Stat(envPath) {
return map[string]string{}, nil
}
envMap, err := godotenv.Read(envPath)
if err != nil {
return nil, err
}
return envMap, nil
}
func writeHermesEnvMap(envPath string, envMap map[string]string, order []string) error {
if len(envMap) == 0 {
return files.NewFileOp().SaveFile(envPath, "", 0600)
}
return agentenv.WriteWithOrder(envMap, envPath, order)
}
func splitHermesEnvList(value string) []string {
if value == "" {
return []string{}
+13 -13
View File
@@ -34,7 +34,7 @@ print('Restart the Hermes-Agent container to apply the new Weixin settings.')
`
func readHermesQQBotChannelConfig(confDir string) (*dto.AgentQQBotConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env"))
envMap, err := readAgentEnvMap(path.Join(confDir, ".env"))
if err != nil {
return nil, err
}
@@ -78,7 +78,7 @@ func readHermesQQBotChannelConfig(confDir string) (*dto.AgentQQBotConfig, error)
func writeHermesQQBotChannelConfig(confDir string, config dto.AgentQQBotConfig) error {
envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath)
envMap, err := readAgentEnvMap(envPath)
if err != nil {
return err
}
@@ -102,7 +102,7 @@ func writeHermesQQBotChannelConfig(confDir string, config dto.AgentQQBotConfig)
envMap["QQ_GROUP_ALLOWED_USERS"] = allow
}
}
if err := writeHermesEnvMap(envPath, envMap, []string{
if err := writeAgentEnvMap(envPath, envMap, []string{
"QQ_APP_ID",
"QQ_CLIENT_SECRET",
"QQ_ALLOW_ALL_USERS",
@@ -163,7 +163,7 @@ func deleteHermesQQBotChannelConfig(confDir string) error {
}
func readHermesWecomChannelConfig(confDir string) (*dto.AgentWecomConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env"))
envMap, err := readAgentEnvMap(path.Join(confDir, ".env"))
if err != nil {
return nil, err
}
@@ -194,7 +194,7 @@ func readHermesWecomChannelConfig(confDir string) (*dto.AgentWecomConfig, error)
func writeHermesWecomChannelConfig(confDir string, config dto.AgentWecomConfig) error {
envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath)
envMap, err := readAgentEnvMap(envPath)
if err != nil {
return err
}
@@ -229,7 +229,7 @@ func writeHermesWecomChannelConfig(confDir string, config dto.AgentWecomConfig)
envMap["WECOM_GROUP_ALLOWED_USERS"] = allow
}
}
if err := writeHermesEnvMap(envPath, envMap, []string{
if err := writeAgentEnvMap(envPath, envMap, []string{
"WECOM_BOT_ID",
"WECOM_SECRET",
"WECOM_DM_POLICY",
@@ -282,7 +282,7 @@ func deleteHermesWecomChannelConfig(confDir string) error {
}
func readHermesDingTalkChannelConfig(confDir string) (*dto.AgentDingTalkConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env"))
envMap, err := readAgentEnvMap(path.Join(confDir, ".env"))
if err != nil {
return nil, err
}
@@ -332,7 +332,7 @@ func readHermesDingTalkChannelConfig(confDir string) (*dto.AgentDingTalkConfig,
func writeHermesDingTalkChannelConfig(confDir string, config dto.AgentDingTalkConfig) error {
envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath)
envMap, err := readAgentEnvMap(envPath)
if err != nil {
return err
}
@@ -356,7 +356,7 @@ func writeHermesDingTalkChannelConfig(confDir string, config dto.AgentDingTalkCo
} else if config.DmPolicy == "open" {
envMap["DINGTALK_ALLOW_ALL_USERS"] = "true"
}
if err := writeHermesEnvMap(envPath, envMap, []string{
if err := writeAgentEnvMap(envPath, envMap, []string{
"DINGTALK_CLIENT_ID",
"DINGTALK_CLIENT_SECRET",
"DINGTALK_ALLOW_ALL_USERS",
@@ -398,7 +398,7 @@ func deleteHermesDingTalkChannelConfig(confDir string) error {
}
func readHermesFeishuChannelConfig(confDir string) (*dto.AgentFeishuConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env"))
envMap, err := readAgentEnvMap(path.Join(confDir, ".env"))
if err != nil {
return nil, err
}
@@ -452,7 +452,7 @@ func readHermesFeishuChannelConfig(confDir string) (*dto.AgentFeishuConfig, erro
func writeHermesFeishuChannelConfig(confDir string, config dto.AgentFeishuConfig) error {
envPath := path.Join(confDir, ".env")
envMap, err := readHermesEnvMap(envPath)
envMap, err := readAgentEnvMap(envPath)
if err != nil {
return err
}
@@ -488,7 +488,7 @@ func writeHermesFeishuChannelConfig(confDir string, config dto.AgentFeishuConfig
envMap["FEISHU_ALLOWED_USERS"] = allow
}
}
if err := writeHermesEnvMap(envPath, envMap, []string{
if err := writeAgentEnvMap(envPath, envMap, []string{
"FEISHU_APP_ID",
"FEISHU_APP_SECRET",
"FEISHU_DOMAIN",
@@ -529,7 +529,7 @@ func deleteHermesFeishuChannelConfig(confDir string) error {
}
func readHermesWeixinChannelConfig(confDir string) (*dto.AgentWeixinConfig, error) {
envMap, err := readHermesEnvMap(path.Join(confDir, ".env"))
envMap, err := readAgentEnvMap(path.Join(confDir, ".env"))
if err != nil {
return nil, err
}
+315
View File
@@ -0,0 +1,315 @@
package service
import (
"database/sql"
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/compose"
)
var (
openclawPluginPackagePattern = regexp.MustCompile(`^(@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*$`)
openclawPluginVersionPattern = regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z._-]*$`)
openclawPluginIDPattern = regexp.MustCompile(`^(@[A-Za-z0-9][A-Za-z0-9._-]*/)?[A-Za-z0-9][A-Za-z0-9._-]*$`)
)
type openclawPluginListOutput struct {
Plugins []struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Origin string `json:"origin"`
Enabled bool `json:"enabled"`
} `json:"plugins"`
}
type openclawPluginIndexItem struct {
PluginID string `json:"pluginId"`
PackageName string `json:"packageName"`
PackageVersion string `json:"packageVersion"`
Origin string `json:"origin"`
Enabled bool `json:"enabled"`
}
type openclawPluginSearchOutput struct {
Results []struct {
Score float64 `json:"score"`
Package struct {
Name string `json:"name"`
RuntimeID string `json:"runtimeId"`
DisplayName string `json:"displayName"`
Summary string `json:"summary"`
LatestVersion string `json:"latestVersion"`
Categories []string `json:"categories"`
Channel string `json:"channel"`
IsOfficial bool `json:"isOfficial"`
VerificationTier string `json:"verificationTier"`
Stats struct {
Downloads int64 `json:"downloads"`
} `json:"stats"`
} `json:"package"`
} `json:"results"`
}
func (a AgentService) ListPlugins(req dto.AgentPluginsReq) ([]dto.AgentPluginItem, error) {
agent, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return nil, err
}
if plugins, err := readOpenclawPluginIndex(filepath.Join(filepath.Dir(agent.ConfigPath), "state", "openclaw.sqlite")); err == nil {
return plugins, nil
}
output, err := cmd.RunDockerExecWithStdout(2*time.Minute, install.ContainerName, "openclaw", "plugins", "list", "--json")
if err != nil {
return nil, err
}
return parseOpenclawPluginList([]byte(output))
}
func (a AgentService) SearchPlugins(req dto.AgentPluginSearchReq) ([]dto.AgentPluginSearchItem, error) {
_, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return nil, err
}
limit := req.Limit
if limit == 0 {
limit = 20
}
output, err := cmd.RunDockerExecWithStdout(
2*time.Minute,
install.ContainerName,
"openclaw", "plugins", "search", strings.TrimSpace(req.Keyword), "--limit", fmt.Sprint(limit), "--json",
)
if err != nil {
return nil, err
}
return parseOpenclawPluginSearch([]byte(output))
}
func (a AgentService) InstallMarketPlugin(req dto.AgentPluginMarketInstallReq) error {
spec, err := buildOpenclawPluginInstallSpec(req.Package, req.Version)
if err != nil {
return err
}
_, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return err
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err
}
taskName := fmt.Sprintf("%s [%s]", i18n.GetMsgByKey("AgentPluginInstall"), req.Package)
installTask, err := task.NewTask(taskName, task.TaskInstall, task.TaskScopeAI, req.TaskID, req.AgentID)
if err != nil {
return err
}
installTask.AddSubTask(taskName, func(t *task.Task) error {
mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(10*time.Minute))
return mgr.Run("docker", "exec", install.ContainerName, "openclaw", "plugins", "install", spec)
}, nil)
addOpenclawPluginRestartTask(installTask, install)
go executeAgentPluginTask(installTask)
return nil
}
func (a AgentService) OperatePlugin(req dto.AgentPluginOperateReq) error {
if !openclawPluginIDPattern.MatchString(req.PluginID) {
return buserr.New("ErrInvalidChar")
}
agent, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return err
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err
}
if req.Operate == "update" || req.Operate == "uninstall" {
plugins, err := a.ListPlugins(dto.AgentPluginsReq{AgentID: req.AgentID})
if err != nil {
return err
}
for _, plugin := range plugins {
if plugin.ID == req.PluginID && plugin.Origin == "bundled" {
return buserr.WithName("ErrNotSupportType", req.Operate)
}
}
}
taskType := map[string]string{
"enable": task.TaskUpdate,
"disable": task.TaskUpdate,
"update": task.TaskUpgrade,
"uninstall": task.TaskUninstall,
}[req.Operate]
taskName := fmt.Sprintf("%s [%s]", i18n.GetMsgByKey(map[string]string{
"enable": "AgentPluginEnable",
"disable": "AgentPluginDisable",
"update": "AgentPluginUpdate",
"uninstall": "AgentPluginUninstall",
}[req.Operate]), req.PluginID)
operateTask, err := task.NewTask(taskName, taskType, task.TaskScopeAI, req.TaskID, req.AgentID)
if err != nil {
return err
}
operateTask.AddSubTask(taskName, func(t *task.Task) error {
mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(10*time.Minute))
if req.Operate == "uninstall" {
if err := uninstallOpenclawPlugin(mgr, install.ContainerName, req.PluginID); err != nil {
return err
}
return cleanupManagedOpenclawPlugin(agent, req.PluginID)
}
return mgr.Run("docker", "exec", install.ContainerName, "openclaw", "plugins", req.Operate, req.PluginID)
}, nil)
addOpenclawPluginRestartTask(operateTask, install)
go executeAgentPluginTask(operateTask)
return nil
}
func parseOpenclawPluginList(raw []byte) ([]dto.AgentPluginItem, error) {
payload, err := extractEmbeddedJSON(string(raw))
if err != nil {
return nil, err
}
if len(payload) == 0 {
return []dto.AgentPluginItem{}, nil
}
var output openclawPluginListOutput
if err := json.Unmarshal(payload, &output); err != nil {
return nil, err
}
items := make([]dto.AgentPluginItem, 0, len(output.Plugins))
for _, plugin := range output.Plugins {
items = append(items, dto.AgentPluginItem{
ID: plugin.ID,
Name: plugin.Name,
Version: plugin.Version,
Origin: plugin.Origin,
Enabled: plugin.Enabled,
})
}
return items, nil
}
func readOpenclawPluginIndex(dbPath string) ([]dto.AgentPluginItem, error) {
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(dbPath)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
var raw []byte
if err := db.QueryRow(
"SELECT plugins_json FROM installed_plugin_index WHERE index_key = ?",
"installed-plugin-index",
).Scan(&raw); err != nil {
return nil, err
}
var plugins []openclawPluginIndexItem
if err := json.Unmarshal(raw, &plugins); err != nil {
return nil, err
}
items := make([]dto.AgentPluginItem, 0, len(plugins))
for _, plugin := range plugins {
name := plugin.PackageName
if name == "" {
name = plugin.PluginID
}
items = append(items, dto.AgentPluginItem{
ID: plugin.PluginID,
Name: name,
Version: plugin.PackageVersion,
Origin: plugin.Origin,
Enabled: plugin.Enabled,
})
}
return items, nil
}
func parseOpenclawPluginSearch(raw []byte) ([]dto.AgentPluginSearchItem, error) {
payload, err := extractEmbeddedJSON(string(raw))
if err != nil {
return nil, err
}
if len(payload) == 0 {
return []dto.AgentPluginSearchItem{}, nil
}
var output openclawPluginSearchOutput
if err := json.Unmarshal(payload, &output); err != nil {
return nil, err
}
items := make([]dto.AgentPluginSearchItem, 0, len(output.Results))
for _, result := range output.Results {
items = append(items, dto.AgentPluginSearchItem{
Package: result.Package.Name,
PluginID: result.Package.RuntimeID,
Name: result.Package.DisplayName,
Description: result.Package.Summary,
Version: result.Package.LatestVersion,
Channel: result.Package.Channel,
VerificationTier: result.Package.VerificationTier,
Categories: append([]string{}, result.Package.Categories...),
Official: result.Package.IsOfficial,
Downloads: result.Package.Stats.Downloads,
Score: result.Score,
})
}
return items, nil
}
func buildOpenclawPluginInstallSpec(packageName, version string) (string, error) {
packageName = strings.TrimSpace(packageName)
version = strings.TrimSpace(version)
if !openclawPluginPackagePattern.MatchString(packageName) || !openclawPluginVersionPattern.MatchString(version) {
return "", buserr.New("ErrInvalidChar")
}
return "clawhub:" + packageName + "@" + version, nil
}
func cleanupManagedOpenclawPlugin(agent *model.Agent, pluginID string) error {
pluginType := map[string]string{
"openclaw-lark": "feishu",
"openclaw-qqbot": "qqbot",
"wecom-openclaw-plugin": "wecom",
"dingtalk-connector": "dingtalk",
"openclaw-weixin": "weixin",
}[pluginID]
if pluginType == "" {
return nil
}
conf, err := readOpenclawConfig(agent.ConfigPath)
if err != nil {
return err
}
cleanupOpenclawPluginConfig(conf, pluginType)
return writeOpenclawConfigRaw(agent.ConfigPath, conf)
}
func addOpenclawPluginRestartTask(t *task.Task, install *model.AppInstall) {
t.AddSubTask(task.GetTaskName("OpenClaw", task.TaskRestart, task.TaskScopeAI), func(t *task.Task) error {
output, err := compose.Restart(install.GetComposePath())
if output != "" {
t.Log(output)
}
return err
}, nil)
}
func executeAgentPluginTask(t *task.Task) {
if err := t.Execute(); err != nil {
global.LOG.Errorf("operate openclaw plugin failed: %v", err)
}
}
+277 -273
View File
@@ -4,10 +4,12 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"regexp"
"strconv"
"strings"
"time"
@@ -19,9 +21,12 @@ import (
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/common"
agentenv "github.com/1Panel-dev/1Panel/agent/utils/env"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
"github.com/joho/godotenv"
"gorm.io/gorm"
)
@@ -35,6 +40,39 @@ type resolvedAgentAccountInput struct {
Provider string
APIKey string
BaseURL string
APIType string
AuthMode string
}
func ensureAgentAccountNameAvailable(provider, name string, excludeID uint) error {
opts := []repo.DBOption{repo.WithByProvider(provider), repo.WithByName(name)}
if excludeID > 0 {
opts = append(opts, repo.WithByNOTID(excludeID))
}
account, err := agentAccountRepo.GetFirst(opts...)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
if account != nil && account.ID > 0 {
return buserr.New("ErrRecordExist")
}
return nil
}
func agentAccountUsedBySetting(tx *gorm.DB, accountID uint, statusKey, accountIDKey string) (bool, error) {
var settings []model.Setting
if err := tx.Where("key IN ?", []string{statusKey, accountIDKey}).Find(&settings).Error; err != nil {
return false, err
}
values := make(map[string]string, len(settings))
for _, setting := range settings {
values[setting.Key] = setting.Value
}
return strings.EqualFold(strings.TrimSpace(values[statusKey]), constant.StatusEnable) &&
strings.TrimSpace(values[accountIDKey]) == strconv.FormatUint(uint64(accountID), 10), nil
}
func loadOpenclawAgentByID(agentID uint) (*model.Agent, error) {
@@ -59,19 +97,27 @@ func ensureContainerRunning(containerName string) error {
return nil
}
func resolveAgentAccountInput(provider, apiKey, baseURL string) (resolvedAgentAccountInput, error) {
func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability bool) (resolvedAgentAccountInput, error) {
resolvedAPIKey := strings.TrimSpace(apiKey)
resolvedBaseURL := strings.TrimSpace(baseURL)
if resolvedBaseURL == "" {
if requiresInitialAgentAccountModels(provider) {
resolvedAPIType := strings.TrimSpace(apiType)
resolvedAuthMode, err := providercatalog.ResolveAuthMode(provider, resolvedAPIType, authMode)
if err != nil {
return resolvedAgentAccountInput{}, err
}
resolvedBaseURL, err := providercatalog.ResolveBaseURL(provider, resolvedAPIType, baseURL)
if err != nil {
if strings.Contains(err.Error(), "base url is required") {
return resolvedAgentAccountInput{}, buserr.New("ErrAgentBaseURLRequired")
}
if defaultURL, ok := providercatalog.DefaultBaseURL(provider); ok {
resolvedBaseURL = defaultURL
}
return resolvedAgentAccountInput{}, err
}
if !providercatalog.SkipVerification(provider) {
if err := providercatalog.VerifyAccount(provider, resolvedBaseURL, resolvedAPIKey); err != nil {
modelID = strings.TrimSpace(modelID)
if modelID == "" {
return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired")
}
imageAPI := providercatalog.IsImageAPIType(resolvedAPIType)
if validateAvailability && (imageAPI || !providercatalog.SkipVerification(provider)) {
if err := providercatalog.VerifyAccount(provider, resolvedAPIType, resolvedAuthMode, resolvedBaseURL, resolvedAPIKey, modelID); err != nil {
return resolvedAgentAccountInput{}, err
}
}
@@ -79,6 +125,8 @@ func resolveAgentAccountInput(provider, apiKey, baseURL string) (resolvedAgentAc
Provider: provider,
APIKey: resolvedAPIKey,
BaseURL: resolvedBaseURL,
APIType: resolvedAPIType,
AuthMode: resolvedAuthMode,
}, nil
}
@@ -335,26 +383,24 @@ func setOtherConfig(conf map[string]interface{}, config dto.AgentOtherConfig) {
func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map[string]interface{}) dto.AgentItem {
agentType := agent.AgentType
item := dto.AgentItem{
ID: agent.ID,
Name: agent.Name,
Remark: agent.Remark,
AgentType: agentType,
Provider: agent.Provider,
ProviderName: providercatalog.DisplayName(agent.Provider),
Model: agent.Model,
APIType: agent.APIType,
MaxTokens: agent.MaxTokens,
ContextWindow: agent.ContextWindow,
BaseURL: agent.BaseURL,
APIKey: maskKey(agent.APIKey),
Token: agent.Token,
Status: agent.Status,
Message: agent.Message,
AppInstallID: agent.AppInstallID,
WebsiteID: agent.WebsiteID,
AccountID: agent.AccountID,
ConfigPath: agent.ConfigPath,
CreatedAt: agent.CreatedAt,
ID: agent.ID,
Name: agent.Name,
Remark: agent.Remark,
AgentType: agentType,
Provider: agent.Provider,
ProviderName: localizedAgentProviderName(agent.Provider),
Model: agent.Model,
APIType: agent.APIType,
BaseURL: agent.BaseURL,
APIKey: maskKey(agent.APIKey),
Token: agent.Token,
Status: agent.Status,
Message: agent.Message,
AppInstallID: agent.AppInstallID,
WebsiteID: agent.WebsiteID,
AccountID: agent.AccountID,
ConfigPath: agent.ConfigPath,
CreatedAt: agent.CreatedAt,
}
if appInstall != nil && appInstall.ID > 0 {
item.Container = appInstall.ContainerName
@@ -376,10 +422,24 @@ func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map
item.BridgePort = toInt(bridge)
}
}
if _, _, ok := agentDashboardAuthEnvKeys(agentType); ok {
auth := readAgentDashboardAuthFromInstall(appInstall, agentType)
item.DashboardUsername = auth.Username
item.DashboardPassword = auth.Password
}
}
return item
}
func localizedAgentProviderName(provider string) string {
if key := providercatalog.DisplayNameKey(provider); key != "" {
if name := strings.TrimSpace(i18n.GetMsgByKey(key)); name != "" {
return name
}
}
return providercatalog.DisplayName(provider)
}
func isAgentAppKey(appKey string) bool {
return appKey == constant.AppOpenclaw || appKey == constant.AppCopaw || appKey == constant.AppHermesAgent
}
@@ -677,13 +737,9 @@ type modelProvider struct {
}
type modelEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Reasoning bool `json:"reasoning"`
Input []string `json:"input"`
ContextWindow int `json:"contextWindow"`
MaxTokens int `json:"maxTokens"`
Cost modelCost `json:"cost"`
ID string `json:"id"`
Name string `json:"name"`
Input []string `json:"input,omitempty"`
}
func requiresOpenclawProviderModels(provider string) bool {
@@ -703,13 +759,6 @@ func applyOpenclawModelsConfig(conf map[string]interface{}, models *modelsConfig
return nil
}
type modelCost struct {
Input float64 `json:"input"`
Output float64 `json:"output"`
CacheRead float64 `json:"cacheRead"`
CacheWrite float64 `json:"cacheWrite"`
}
type browserConfig struct {
Enabled bool `json:"enabled"`
ExecutablePath string `json:"executablePath"`
@@ -860,13 +909,15 @@ func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName,
if err := writeOpenclawConfigRaw(configPath, conf); err != nil {
return err
}
envPath := path.Join(confDir, ".env")
lines := []string{fmt.Sprintf("OPENCLAW_GATEWAY_TOKEN=%s", token)}
if envKey := providercatalog.EnvKey(account.Provider); envKey != "" && account.APIKey != "" {
lines = append(lines, fmt.Sprintf("%s=%s", envKey, account.APIKey))
envMap := map[string]string{
"OPENCLAW_GATEWAY_TOKEN": token,
}
content := strings.Join(lines, "\n") + "\n"
return fileOp.SaveFile(envPath, content, 0600)
order := []string{"OPENCLAW_GATEWAY_TOKEN"}
if envKey := providercatalog.EnvKey(account.Provider); envKey != "" && account.APIKey != "" {
envMap[envKey] = account.APIKey
order = append(order, envKey)
}
return writeAgentEnvMap(path.Join(confDir, ".env"), envMap, order)
}
func resolveOpenclawFallbackModels(account *model.AgentAccount, primaryModel string, fallbackIDs []string) ([]string, error) {
@@ -1053,7 +1104,7 @@ func buildOpenclawModelsFromAccount(account *model.AgentAccount, selectedModel s
}
func buildOpenclawAccountModelConfig(account *model.AgentAccount, model dto.AgentAccountModel) (string, modelEntry, string, modelProvider, error) {
providerPatch, err := providercatalog.BuildOpenClawProviderPatch(account.Provider, model.ID, account.APIType, account.BaseURL, account.APIKey)
providerPatch, err := providercatalog.BuildOpenClawProviderPatch(account.Provider, model.ID, account.APIType, account.AuthMode, account.BaseURL, account.APIKey)
if err != nil {
return "", modelEntry{}, "", modelProvider{}, err
}
@@ -1065,28 +1116,24 @@ func buildOpenclawAccountModelConfig(account *model.AgentAccount, model dto.Agen
}, nil
}
var openclawVisionModelPattern = regexp.MustCompile(`(?i)(\b(gpt-4o|gpt-4\.1|gpt-[5-9]|o[134])\b|\bclaude-(3|4|sonnet|opus|haiku)\b|\bgemini\b|\b(qwen[\w.-]*-?vl|qwen-vl|qwen3\.[5-9]-plus)\b|\b(kimi-k2\.(5|6)|kimi-k2\.7-code|minimax-m3)\b|\b(vision|llava|pixtral|internvl|mllama|minicpm-v|glm-4v|omni)\b|(^|[-_/])vl([-_/]|$))`)
func buildOpenclawModelEntry(modelID string, model dto.AgentAccountModel) modelEntry {
name := strings.TrimSpace(model.Name)
if name == "" {
name = strings.TrimSpace(modelID)
}
return modelEntry{
ID: strings.TrimSpace(modelID),
Name: name,
Reasoning: model.Reasoning,
Input: sanitizeAgentAccountModelInputs(model.Input),
ContextWindow: model.ContextWindow,
MaxTokens: model.MaxTokens,
Cost: modelCost{},
entry := modelEntry{ID: strings.TrimSpace(modelID), Name: name}
if openclawVisionModelPattern.MatchString(modelID) {
entry.Input = []string{"text", "image"}
}
return entry
}
type openclawAccountModelRuntime struct {
StoredModel string
PrimaryModel string
APIType string
MaxTokens int
ContextWindow int
StoredModel string
PrimaryModel string
APIType string
}
func buildOpenclawAccountModelRuntime(account *model.AgentAccount, model dto.AgentAccountModel) (openclawAccountModelRuntime, error) {
@@ -1095,11 +1142,9 @@ func buildOpenclawAccountModelRuntime(account *model.AgentAccount, model dto.Age
return openclawAccountModelRuntime{}, err
}
return openclawAccountModelRuntime{
StoredModel: model.ID,
PrimaryModel: primaryModel,
APIType: account.APIType,
MaxTokens: model.MaxTokens,
ContextWindow: model.ContextWindow,
StoredModel: model.ID,
PrimaryModel: primaryModel,
APIType: account.APIType,
}, nil
}
@@ -1119,35 +1164,38 @@ func buildInitialAgentAccountModels(account *model.AgentAccount, requested []dto
if account == nil {
return nil, fmt.Errorf("account is required")
}
if requiresInitialAgentAccountModels(account.Provider) && len(requested) > 1 {
if account.Provider != "custom" && requiresInitialAgentAccountModels(account.Provider) && len(requested) > 1 {
return nil, buserr.New("ErrAgentAccountSingleInitialModel")
}
if len(requested) > 0 {
models := make([]dto.AgentAccountModel, 0, len(requested))
for _, item := range requested {
models = append(models, cloneAgentAccountModel(item))
}
return models, nil
return normalizeAgentAccountModels(account, requested)
}
meta, ok := providercatalog.Get(account.Provider)
if !ok || len(meta.Models) == 0 {
defaultModels := providercatalog.DefaultModels(account.Provider, account.APIType)
if len(defaultModels) == 0 {
if requiresInitialAgentAccountModels(account.Provider) {
return nil, buserr.New("ErrAgentAccountModelsRequired")
}
return nil, nil
}
requested = make([]dto.AgentAccountModel, 0, len(meta.Models))
for _, item := range meta.Models {
requested = make([]dto.AgentAccountModel, 0, len(defaultModels))
for _, item := range defaultModels {
requested = append(requested, dto.AgentAccountModel{
ID: item.ID,
Name: item.Name,
ContextWindow: item.ContextWindow,
MaxTokens: item.MaxTokens,
Reasoning: item.Reasoning,
Input: append([]string(nil), item.Input...),
ID: item.ID,
Name: item.Name,
})
}
return requested, nil
return normalizeAgentAccountModels(account, requested)
}
func buildDiscoveredAgentAccountModels(modelIDs []string) []dto.AgentAccountModel {
models := make([]dto.AgentAccountModel, 0, len(modelIDs))
for _, modelID := range modelIDs {
models = append(models, dto.AgentAccountModel{
ID: modelID,
Name: modelID,
})
}
return models
}
func compactPersistedAgentAccountModelSortOrder(accountID uint) error {
@@ -1175,18 +1223,6 @@ func loadAgentAccountModels(account *model.AgentAccount) ([]dto.AgentAccountMode
return listPersistedAgentAccountModels(account.ID)
}
func cloneAgentAccountModel(model dto.AgentAccountModel) dto.AgentAccountModel {
return dto.AgentAccountModel{
RecordID: model.RecordID,
ID: model.ID,
Name: model.Name,
ContextWindow: model.ContextWindow,
MaxTokens: model.MaxTokens,
Reasoning: model.Reasoning,
Input: append([]string(nil), model.Input...),
}
}
func MergeCatalogAgentAccountModelsForMigration(account *model.AgentAccount, existing []dto.AgentAccountModel) ([]dto.AgentAccountModel, error) {
if account == nil {
return nil, fmt.Errorf("account is required")
@@ -1210,12 +1246,8 @@ func MergeCatalogAgentAccountModelsForMigration(account *model.AgentAccount, exi
continue
}
requested = append(requested, dto.AgentAccountModel{
ID: item.ID,
Name: item.Name,
ContextWindow: item.ContextWindow,
MaxTokens: item.MaxTokens,
Reasoning: item.Reasoning,
Input: append([]string(nil), item.Input...),
ID: item.ID,
Name: item.Name,
})
}
if len(requested) == len(existing) {
@@ -1234,18 +1266,10 @@ func listPersistedAgentAccountModels(accountID uint) ([]dto.AgentAccountModel, e
}
result := make([]dto.AgentAccountModel, 0, len(rows))
for _, row := range rows {
inputs := []string{}
if strings.TrimSpace(row.Input) != "" {
_ = json.Unmarshal([]byte(row.Input), &inputs)
}
result = append(result, dto.AgentAccountModel{
RecordID: row.ID,
ID: strings.TrimSpace(row.Model),
Name: strings.TrimSpace(row.Name),
ContextWindow: row.ContextWindow,
MaxTokens: row.MaxTokens,
Reasoning: row.Reasoning,
Input: sanitizeAgentAccountModelInputs(inputs),
RecordID: row.ID,
ID: strings.TrimSpace(row.Model),
Name: strings.TrimSpace(row.Name),
})
}
return result, nil
@@ -1256,19 +1280,11 @@ func replacePersistedAgentAccountModelsWithTx(tx *gorm.DB, accountID uint, model
return err
}
for index, item := range models {
inputPayload, err := json.Marshal(sanitizeAgentAccountModelInputs(item.Input))
if err != nil {
return err
}
record := &model.AgentAccountModel{
AccountID: accountID,
Model: strings.TrimSpace(item.ID),
Name: strings.TrimSpace(item.Name),
ContextWindow: item.ContextWindow,
MaxTokens: item.MaxTokens,
Reasoning: item.Reasoning,
Input: string(inputPayload),
SortOrder: index + 1,
AccountID: accountID,
Model: strings.TrimSpace(item.ID),
Name: strings.TrimSpace(item.Name),
SortOrder: index + 1,
}
if err := tx.Create(record).Error; err != nil {
return err
@@ -1309,86 +1325,17 @@ func normalizeAgentAccountModel(account *model.AgentAccount, model dto.AgentAcco
if modelID == "" {
return dto.AgentAccountModel{}, fmt.Errorf("model is required")
}
modelID = providercatalog.NormalizeModelID(account.Provider, modelID)
name := strings.TrimSpace(model.Name)
if name == "" {
name = modelID
}
inputs := sanitizeAgentAccountModelInputs(model.Input)
return dto.AgentAccountModel{
ID: normalizeAgentAccountModelID(account.Provider, modelID),
Name: name,
ContextWindow: model.ContextWindow,
MaxTokens: model.MaxTokens,
Reasoning: model.Reasoning,
Input: inputs,
ID: modelID,
Name: name,
}, nil
}
func normalizeAgentAccountModelID(provider, requestedID string) string {
switch provider {
case "custom", "vllm":
return normalizeCustomModel(requestedID)
case "ollama":
target := strings.TrimSpace(requestedID)
if strings.HasPrefix(target, "ollama/") {
return target
}
target = strings.TrimLeft(strings.TrimSpace(target), "/")
if target == "" {
return ""
}
return "ollama/" + target
default:
target := strings.TrimSpace(requestedID)
if target == "" {
return ""
}
prefix := poolModelPrefix(provider)
if strings.Contains(target, "/") {
parts := strings.SplitN(target, "/", 2)
targetPrefix := parts[0]
targetModel := strings.TrimSpace(parts[1])
if targetModel == "" {
return strings.TrimSpace(target)
}
for _, item := range supportedProviderModelPrefixes(provider) {
if item == targetPrefix {
if prefix != "" {
return prefix + "/" + targetModel
}
return strings.TrimSpace(target)
}
}
return strings.TrimSpace(target)
}
target = strings.TrimLeft(strings.TrimSpace(target), "/")
if prefix == "" {
return target
}
return prefix + "/" + target
}
}
func sanitizeAgentAccountModelInputs(values []string) []string {
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
normalized := value
if normalized != "text" && normalized != "image" {
continue
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
result = append(result, normalized)
}
if len(result) == 0 {
return []string{"text"}
}
return result
}
func requiresInitialAgentAccountModels(provider string) bool {
switch provider {
case "custom", "vllm", "ollama":
@@ -1398,36 +1345,14 @@ func requiresInitialAgentAccountModels(provider string) bool {
}
}
func normalizeComparableProviderModelID(provider, modelID string) string {
target := strings.TrimSpace(modelID)
if target == "" {
return ""
}
if !strings.Contains(target, "/") {
return target
}
parts := strings.SplitN(target, "/", 2)
prefix := parts[0]
model := strings.TrimSpace(parts[1])
if model == "" {
return target
}
for _, item := range supportedProviderModelPrefixes(provider) {
if item == prefix {
return model
}
}
return target
}
func sameProviderModelID(provider, left, right string) bool {
leftTrimmed := strings.TrimSpace(left)
rightTrimmed := strings.TrimSpace(right)
if leftTrimmed == rightTrimmed {
return true
}
leftComparable := normalizeComparableProviderModelID(provider, leftTrimmed)
rightComparable := normalizeComparableProviderModelID(provider, rightTrimmed)
leftComparable := providercatalog.NormalizeModelID(provider, leftTrimmed)
rightComparable := providercatalog.NormalizeModelID(provider, rightTrimmed)
return leftComparable != "" && leftComparable == rightComparable
}
@@ -1448,6 +1373,20 @@ func requireAgentAccountModelForProvider(provider string, models []dto.AgentAcco
return selectedAccountModel, nil
}
func resolveAgentAccountVerifyModel(provider, requested string, models []dto.AgentAccountModel) (string, error) {
if len(models) == 0 {
return "", buserr.New("ErrAgentAccountModelsRequired")
}
if strings.TrimSpace(requested) == "" {
return models[0].ID, nil
}
selected, ok := findAgentAccountModelForProvider(provider, models, requested)
if !ok {
return "", buserr.New("ErrAgentModelNotInAccount")
}
return selected.ID, nil
}
func ensureAccountModelsNotBound(account *model.AgentAccount, models []dto.AgentAccountModel) error {
if account == nil || account.ID == 0 {
return nil
@@ -1524,6 +1463,122 @@ func readInstallEnv(envStr string) map[string]interface{} {
return data
}
const (
hermesDashboardUsernameEnvKey = "HERMES_DASHBOARD_USERNAME"
hermesDashboardPasswordEnvKey = "HERMES_DASHBOARD_PASSWORD"
qwenPawAuthEnabledEnvKey = "QWENPAW_AUTH_ENABLED"
qwenPawAuthUsernameEnvKey = "QWENPAW_AUTH_USERNAME"
qwenPawAuthPasswordEnvKey = "QWENPAW_AUTH_PASSWORD"
)
type agentDashboardAuth struct {
Username string
Password string
}
func normalizeAgentDashboardAuth(username, password string) agentDashboardAuth {
auth := agentDashboardAuth{
Username: strings.TrimSpace(username),
Password: strings.TrimSpace(password),
}
if auth.Username == "" {
auth.Username = "admin"
}
if auth.Password == "" {
auth.Password = common.RandStr(8)
}
return auth
}
func agentDashboardAuthEnvKeys(agentType string) (string, string, bool) {
switch agentType {
case constant.AppHermesAgent:
return hermesDashboardUsernameEnvKey, hermesDashboardPasswordEnvKey, true
case constant.AppCopaw:
return qwenPawAuthUsernameEnvKey, qwenPawAuthPasswordEnvKey, true
default:
return "", "", false
}
}
func writeAgentDashboardAuthEnv(envPath, agentType string, auth agentDashboardAuth, overwrite bool) error {
usernameKey, passwordKey, ok := agentDashboardAuthEnvKeys(agentType)
if !ok {
return fmt.Errorf("dashboard auth is not supported for %s", agentType)
}
values := map[string]string{
usernameKey: auth.Username,
passwordKey: auth.Password,
}
order := []string{usernameKey, passwordKey}
if agentType == constant.AppCopaw {
values[qwenPawAuthEnabledEnvKey] = "true"
order = append([]string{qwenPawAuthEnabledEnvKey}, order...)
}
return upsertAgentEnv(envPath, values, order, overwrite)
}
func readAgentDashboardAuthEnv(envPath, agentType string) (agentDashboardAuth, error) {
usernameKey, passwordKey, ok := agentDashboardAuthEnvKeys(agentType)
if !ok {
return agentDashboardAuth{}, fmt.Errorf("dashboard auth is not supported for %s", agentType)
}
envMap, err := readAgentEnvMap(envPath)
if err != nil {
return agentDashboardAuth{}, err
}
return agentDashboardAuth{
Username: strings.TrimSpace(envMap[usernameKey]),
Password: strings.TrimSpace(envMap[passwordKey]),
}, nil
}
func readAgentDashboardAuthFromInstall(appInstall *model.AppInstall, agentType string) agentDashboardAuth {
if appInstall == nil || appInstall.ID == 0 {
return agentDashboardAuth{}
}
auth, err := readAgentDashboardAuthEnv(appInstall.GetEnvPath(), agentType)
if err != nil {
return agentDashboardAuth{}
}
return auth
}
func readAgentEnvMap(envPath string) (map[string]string, error) {
fileOp := files.NewFileOp()
if !fileOp.Stat(envPath) {
return map[string]string{}, nil
}
envMap, err := godotenv.Read(envPath)
if err != nil {
return nil, err
}
return envMap, nil
}
func writeAgentEnvMap(envPath string, envMap map[string]string, order []string) error {
if len(envMap) == 0 {
return files.NewFileOp().SaveFile(envPath, "", 0600)
}
return agentenv.WriteWithOrder(envMap, envPath, order)
}
func upsertAgentEnv(envPath string, values map[string]string, order []string, overwrite bool) error {
envMap, err := readAgentEnvMap(envPath)
if err != nil {
return err
}
for key, value := range values {
if key == "" {
continue
}
if overwrite || strings.TrimSpace(envMap[key]) == "" {
envMap[key] = value
}
}
return writeAgentEnvMap(envPath, envMap, order)
}
func maskKey(value string) string {
trim := strings.TrimSpace(value)
if len(trim) <= 6 {
@@ -1551,57 +1606,6 @@ func toInt(value interface{}) int {
}
}
func normalizeCustomModel(modelName string) string {
trim := strings.TrimSpace(modelName)
trim = strings.TrimLeft(trim, "/")
if parts := strings.SplitN(trim, "/", 2); len(parts) == 2 {
if strings.EqualFold(parts[0], "custom") {
return strings.TrimLeft(strings.TrimSpace(parts[1]), "/")
}
}
return trim
}
func runtimeProviderModelPrefix(provider string) string {
switch provider {
case "gemini":
return "google"
case "kimi":
return "moonshot"
default:
return provider
}
}
func poolModelPrefix(provider string) string {
meta, ok := providercatalog.Get(provider)
if ok && len(meta.Models) > 0 {
parts := strings.SplitN(strings.TrimSpace(meta.Models[0].ID), "/", 2)
if len(parts) == 2 && strings.TrimSpace(parts[0]) != "" {
return parts[0]
}
}
return provider
}
func supportedProviderModelPrefixes(provider string) []string {
values := []string{poolModelPrefix(provider), runtimeProviderModelPrefix(provider)}
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
target := value
if target == "" {
continue
}
if _, ok := seen[target]; ok {
continue
}
seen[target] = struct{}{}
result = append(result, target)
}
return result
}
func generateToken() string {
bytes := make([]byte, 24)
if _, err := rand.Read(bytes); err != nil {
+1 -5
View File
@@ -162,7 +162,7 @@ func fillAgentWebsiteItems(items []dto.AgentItem, explicitWebsiteMap map[uint]mo
}
}
func uniqueDeploymentWebsiteMapByAppInstall(websites []model.Website) map[uint]model.Website {
func UniqueDeploymentWebsiteMapByAppInstall(websites []model.Website) map[uint]model.Website {
websiteMap := make(map[uint]model.Website)
duplicateAppInstallIDs := make(map[uint]struct{})
for _, website := range websites {
@@ -182,10 +182,6 @@ func uniqueDeploymentWebsiteMapByAppInstall(websites []model.Website) map[uint]m
return websiteMap
}
func UniqueDeploymentWebsiteMapForMigration(websites []model.Website) map[uint]model.Website {
return uniqueDeploymentWebsiteMapByAppInstall(websites)
}
func uniqueUintList(items []uint) []uint {
itemMap := make(map[uint]struct{}, len(items))
uniq := make([]uint, 0, len(items))
+139
View File
@@ -0,0 +1,139 @@
package service
import (
"encoding/json"
"strings"
"github.com/1Panel-dev/1Panel/agent/utils/common"
)
const (
vllmAppKeyForUpgrade = "vllm"
vllmImageEnvKey = "IMAGE"
vllmImageTypeNvidia = "nvidia"
vllmImageTypeIntel = "intel"
vllmImageTypeAscend = "ascend"
)
func resolveVllmVersionFamily(version, image string) string {
normalizedVersion := strings.ToLower(strings.TrimSpace(version))
if strings.HasPrefix(normalizedVersion, vllmImageTypeIntel+"-") {
return vllmImageTypeIntel
}
if strings.HasPrefix(normalizedVersion, vllmImageTypeAscend+"-") {
return vllmImageTypeAscend
}
if strings.HasPrefix(normalizedVersion, vllmImageTypeNvidia+"-") {
return vllmImageTypeNvidia
}
normalizedImage := strings.ToLower(strings.TrimSpace(image))
if strings.Contains(normalizedImage, "intel/") || strings.Contains(normalizedImage, "llm-scaler-vllm") {
return vllmImageTypeIntel
}
if strings.Contains(normalizedImage, "ascend/") || strings.Contains(normalizedImage, "vllm-ascend") {
return vllmImageTypeAscend
}
return vllmImageTypeNvidia
}
func trimVllmVersionFamily(version string) string {
trimmed := strings.TrimSpace(version)
normalized := strings.ToLower(trimmed)
for _, family := range []string{vllmImageTypeNvidia, vllmImageTypeIntel, vllmImageTypeAscend} {
prefix := family + "-"
if strings.HasPrefix(normalized, prefix) {
return strings.TrimSpace(trimmed[len(prefix):])
}
}
return trimmed
}
func buildDefaultVllmImageByVersion(version string) string {
tag := trimVllmVersionFamily(version)
family := resolveVllmVersionFamily(version, "")
if family == vllmImageTypeIntel {
return "intel/llm-scaler-vllm:" + tag
}
if tag != "" && !strings.HasPrefix(strings.ToLower(tag), "v") {
tag = "v" + tag
}
if family == vllmImageTypeAscend {
return "quay.io/ascend/vllm-ascend:" + tag
}
return "vllm/vllm-openai:" + tag
}
func isVllmUpgradeVersionAllowed(currentVersion, targetVersion, currentImage string) bool {
currentFamily := resolveVllmVersionFamily(currentVersion, currentImage)
targetFamily := resolveVllmVersionFamily(targetVersion, "")
return currentFamily == targetFamily
}
func hasVllmVersionFamilyPrefix(version string) bool {
normalized := strings.ToLower(strings.TrimSpace(version))
return strings.HasPrefix(normalized, vllmImageTypeNvidia+"-") ||
strings.HasPrefix(normalized, vllmImageTypeIntel+"-") ||
strings.HasPrefix(normalized, vllmImageTypeAscend+"-")
}
func isVllmUpgradeCandidate(currentVersion, targetVersion, currentImage string) bool {
if strings.TrimSpace(currentVersion) == strings.TrimSpace(targetVersion) {
return false
}
if !isVllmUpgradeVersionAllowed(currentVersion, targetVersion, currentImage) {
return false
}
if common.CompareVersion(targetVersion, currentVersion) {
return true
}
return !hasVllmVersionFamilyPrefix(currentVersion) &&
resolveVllmVersionFamily(targetVersion, "") == vllmImageTypeNvidia &&
trimVllmVersionFamily(currentVersion) == trimVllmVersionFamily(targetVersion)
}
func buildVllmUpgradeImage(currentImage, currentVersion, targetVersion string) string {
trimmedImage := strings.TrimSpace(currentImage)
if trimmedImage == "" || trimmedImage == buildDefaultVllmImageByVersion(currentVersion) {
return buildDefaultVllmImageByVersion(targetVersion)
}
return trimmedImage
}
func loadVllmImageFromEnv(raw string) string {
envs := make(map[string]interface{})
if strings.TrimSpace(raw) == "" {
return ""
}
if err := json.Unmarshal([]byte(raw), &envs); err != nil {
return ""
}
if image, ok := envs[vllmImageEnvKey].(string); ok {
return strings.TrimSpace(image)
}
return ""
}
func setVllmImageInEnvContent(content []byte, image string) []byte {
normalizedImage := strings.TrimSpace(image)
if normalizedImage == "" {
return content
}
lines := strings.Split(string(content), "\n")
replaced := false
for index, line := range lines {
if strings.HasPrefix(line, vllmImageEnvKey+"=") {
lines[index] = vllmImageEnvKey + "=" + normalizedImage
replaced = true
break
}
}
if !replaced {
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines[len(lines)-1] = vllmImageEnvKey + "=" + normalizedImage
lines = append(lines, "")
} else {
lines = append(lines, vllmImageEnvKey+"="+normalizedImage)
}
}
return []byte(strings.Join(lines, "\n"))
}
+3
View File
@@ -384,6 +384,9 @@ func (a AlertService) PageAlertLogs(search dto.AlertLogSearch) (int64, []dto.Ale
if search.Count != 0 {
opts = append(opts, alertRepo.WithByCount(search.Count))
}
if !search.StartTime.IsZero() && !search.EndTime.IsZero() {
opts = append(opts, repo.WithByCreatedAt(search.StartTime, search.EndTime))
}
opts = append(opts, repo.WithOrderDesc("created_at"))
total, alerts, err := alertRepo.PageLog(search.Page, search.PageSize, opts...)
-1
View File
@@ -705,7 +705,6 @@ func sendAlertsByLegacyMethod(alert dto.AlertDTO, alertType, quota, quotaType st
}
config, err := alertRepo.GetConfig(alertRepo.WithByType(configType))
if err != nil {
global.LOG.Errorf("alert config not found for type %s: %v", configType, err)
return
}
doSendAlert(alert, alertType, quota, quotaType, params, config)
-1
View File
@@ -93,7 +93,6 @@ func (s *AlertSender) sendByLegacyMethod(method string, quota string, params []d
}
config, err := alertRepo.GetConfig(alertRepo.WithByType(configType))
if err != nil {
global.LOG.Errorf("alert config not found for type %s: %v", configType, err)
return
}
if !alertUtil.IsAlertConfigEnabled(config) {
+9 -1
View File
@@ -303,6 +303,9 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
filename := filepath.Base(appDetailDTO.DownloadUrl)
dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(appDetailDTO.DownloadUrl, filename), "docker-compose.yml")
statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if statusCode == http.StatusNotFound {
return appDetailDTO, buserr.New("ErrAppVersionUnavailable")
}
if err != nil {
return appDetailDTO, buserr.WithDetail("ErrGetCompose", err.Error(), err)
}
@@ -426,7 +429,12 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
} else {
if appDetail.DockerCompose == "" {
dockerComposeUrl := fmt.Sprintf("%s/%s/1panel/%s/%s/docker-compose.yml", global.AppRepoURL(), global.CONF.Base.Mode, app.Key, appDetail.Version)
_, composeRes, err = req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
var statusCode int
statusCode, composeRes, err = req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if statusCode == http.StatusNotFound {
err = buserr.New("ErrAppVersionUnavailable")
return
}
if err != nil {
return
}
+4
View File
@@ -294,6 +294,7 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
DetailID: req.DetailId,
Backup: req.Backup,
PullImage: req.PullImage,
DeleteImage: req.DeleteImage,
DockerCompose: req.DockerCompose,
TaskID: req.TaskID,
}
@@ -600,6 +601,9 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
filename := filepath.Base(detail.DownloadUrl)
dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(detail.DownloadUrl, filename), "docker-compose.yml")
statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if statusCode == http.StatusNotFound {
return versions, buserr.New("ErrAppVersionUnavailable")
}
if err != nil {
return versions, err
}
+928
View File
@@ -0,0 +1,928 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"path"
"sort"
"strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/compose"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/joho/godotenv"
)
type appUpgradePhase int
const (
appUpgradePreparing appUpgradePhase = iota
appUpgradePrepared
appUpgradeStopped
appUpgradeBackedUp
appUpgradeDown
appUpgradeMutated
appUpgradeStarted
appUpgradeReady
appUpgradeCommitted
)
const composeServiceLabel = "com.docker.compose.service"
var appUpgradeLocks sync.Map
type appUpgradeSnapshot interface {
Restore() error
Cleanup()
}
type upgradeFileSnapshot struct {
installPath string
backupPath string
paths []string
existing map[string]bool
}
type appUpgradeContext struct {
req request.AppInstallUpgrade
original model.AppInstall
candidate model.AppInstall
detail model.AppDetail
phase appUpgradePhase
stopAttempted bool
downAttempted bool
rollbackErr error
detailDir string
stageDir string
envContent []byte
oldEnvContent []byte
oldDockerCompose string
oldImageIDs []appImageID
backupFile string
snapshot appUpgradeSnapshot
createdPaths []string
}
func upgradeInstall(req request.AppInstallUpgrade) error {
install, err := appInstallRepo.GetFirst(repo.WithByID(req.InstallID))
if err != nil {
return err
}
if install.Status == constant.StatusUpgrading {
return buserr.New("TaskIsExecuting")
}
if err = task.CheckScopeTaskIsExecuting(task.TaskScopeApp, install.ID); err != nil {
return err
}
if _, loaded := appUpgradeLocks.LoadOrStore(install.ID, struct{}{}); loaded {
return buserr.New("TaskIsExecuting")
}
releaseLock := true
defer func() {
if releaseLock {
appUpgradeLocks.Delete(install.ID)
}
}()
detail, err := appDetailRepo.GetFirst(repo.WithByID(req.DetailID))
if err != nil {
return err
}
if install.App.Key == vllmAppKeyForUpgrade && !isVllmUpgradeVersionAllowed(install.Version, detail.Version, loadVllmImageFromEnv(install.Env)) {
return errors.New("vLLM can only upgrade within the same image type")
}
if install.Version == detail.Version {
return errors.New("two version is same")
}
upgradeTask, err := task.NewTaskWithOps(install.Name, task.TaskUpgrade, task.TaskScopeApp, req.TaskID, install.ID)
if err != nil {
return err
}
ctx := &appUpgradeContext{
req: req,
original: install,
candidate: install,
detail: detail,
phase: appUpgradePreparing,
oldDockerCompose: install.DockerCompose,
}
upgradeTask.AddSubTaskWithOps(i18n.GetMsgByKey("UpgradePrepare"), ctx.prepare, nil, 0, 0)
upgradeTask.AddSubTaskWithOps(
task.GetTaskName(install.Name, task.TaskUpgrade, task.TaskScopeApp),
ctx.cutover,
func(t *task.Task) {
ctx.rollbackErr = ctx.rollback(t)
},
0,
0,
)
upgradingInstall := install
upgradingInstall.Status = constant.StatusUpgrading
upgradingInstall.Message = ""
if err = appInstallRepo.Save(context.Background(), &upgradingInstall); err != nil {
return err
}
releaseLock = false
go func() {
defer appUpgradeLocks.Delete(install.ID)
defer ctx.cleanup()
taskErr := upgradeTask.Execute()
if taskErr == nil {
return
}
if ctx.rollbackErr != nil {
taskErr = fmt.Errorf("%w; %s: %v", taskErr, i18n.GetMsgByKey("UpgradeRollbackFailed"), ctx.rollbackErr)
upgradeTask.Task.ErrorMsg = taskErr.Error()
_ = repo.NewITaskRepo().Update(context.Background(), upgradeTask.Task)
}
if !ctx.stopAttempted || ctx.rollbackErr == nil {
restored := ctx.original
_ = appInstallRepo.Save(context.Background(), &restored)
return
}
failed := ctx.original
failed.Status = constant.StatusUpgradeErr
failed.Message = taskErr.Error()
_ = appInstallRepo.Save(context.Background(), &failed)
}()
return nil
}
func (u *appUpgradeContext) prepare(t *task.Task) error {
fileOp := files.NewFileOp()
u.detailDir = path.Join(u.original.App.GetAppResourcePath(), u.detail.Version)
if u.original.App.Resource == constant.AppResourceRemote {
if err := downloadApp(u.original.App, u.detail, nil, t.Logger); err != nil {
return err
}
}
if !fileOp.Stat(u.detailDir) {
return buserr.WithName("ErrFileNotFound", u.detailDir)
}
if u.detail.DockerCompose == "" {
composeContent, err := fileOp.GetContent(path.Join(u.detailDir, "docker-compose.yml"))
if err != nil {
return err
}
u.detail.DockerCompose = string(composeContent)
_ = appDetailRepo.Update(context.Background(), u.detail)
}
if strings.TrimSpace(u.detail.DockerCompose) == "" && strings.TrimSpace(u.req.DockerCompose) == "" {
return buserr.WithName("ErrFileNotFound", "docker-compose.yml")
}
var err error
u.oldEnvContent, err = fileOp.GetContent(u.original.GetEnvPath())
if err != nil {
return err
}
u.stageDir, err = os.MkdirTemp(u.original.GetAppPath(), "."+u.original.Name+"-upgrade-")
if err != nil {
return err
}
if err = fileOp.CopyDirWithNewName(u.detailDir, u.stageDir, "."); err != nil {
return err
}
if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, ".env"); err != nil {
return err
}
if u.original.App.Key == constant.AppOpenclaw {
if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, path.Join("data", "conf", "openclaw.json")); err != nil {
return err
}
}
if u.original.App.Key == constant.AppOpenresty {
for _, relativePath := range []string{
nginxModuleBuildDir,
nginxModuleModulesDir,
path.Join(nginxModuleConfDir, nginxModuleEnabledConfDir),
} {
if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, relativePath); err != nil {
return err
}
}
}
stagedInstall := u.original
stagedInstall.Name = path.Base(u.stageDir)
stagedInstall.Version = u.detail.Version
stagedInstall.AppDetailId = u.req.DetailID
if stagedInstall.App.Key == vllmAppKeyForUpgrade {
envs := make(map[string]interface{})
if err = json.Unmarshal([]byte(stagedInstall.Env), &envs); err != nil {
return err
}
image := buildVllmUpgradeImage(loadVllmImageFromEnv(stagedInstall.Env), u.original.Version, u.detail.Version)
envs[vllmImageEnvKey] = image
paramBytes, marshalErr := json.Marshal(envs)
if marshalErr != nil {
return marshalErr
}
stagedInstall.Env = string(paramBytes)
}
if err = migrateOpenclawProtocolUpgrade(&stagedInstall, u.original.Version, u.detail.Version); err != nil {
return err
}
u.candidate = stagedInstall
u.candidate.Name = u.original.Name
u.candidate.DockerCompose, err = renderUpgradeCompose(u.candidate, u.detail, u.req.DockerCompose)
if err != nil {
return err
}
if strings.TrimSpace(u.candidate.DockerCompose) == "" {
return buserr.WithName("ErrFileNotFound", "docker-compose.yml")
}
u.envContent, err = renderUpgradeEnv(&u.candidate, u.oldEnvContent)
if err != nil {
return err
}
if err = writeUpgradeFile(path.Join(u.stageDir, ".env"), u.envContent, constant.FilePerm); err != nil {
return err
}
if err = writeUpgradeFile(path.Join(u.stageDir, "docker-compose.yml"), []byte(u.candidate.DockerCompose), constant.FilePerm); err != nil {
return err
}
project, err := docker.GetComposeProject(u.original.Name, u.stageDir, []byte(u.candidate.DockerCompose), u.envContent, false)
if err != nil {
return err
}
hasBuild := false
for _, service := range project.Services {
if service.Image == "" && service.Build == nil {
return fmt.Errorf("compose service %s has neither image nor build configuration", service.Name)
}
hasBuild = hasBuild || service.Build != nil
}
if u.req.DeleteImage {
dockerClient, clientErr := docker.NewClient()
if clientErr != nil {
return clientErr
}
u.oldImageIDs, err = getAppImageIDsByCompose(dockerClient, u.oldEnvContent, []byte(u.oldDockerCompose))
dockerClient.Close()
if err != nil {
return err
}
}
images := make([]string, 0, len(project.Services))
for _, service := range project.Services {
if service.Image != "" {
images = append(images, service.Image)
}
}
if err = prepareUpgradeImages(t, images, u.req.PullImage); err != nil {
return err
}
if u.candidate.App.Key == constant.AppOpenresty {
if err = u.prepareOpenresty(t, stagedInstall); err != nil {
return err
}
if err = verifyUpgradeImages(images); err != nil {
return err
}
} else if hasBuild {
logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("TaskBuild"), i18n.GetMsgByKey("Image"))
t.LogStart(logStr)
if err = compose.BuildWithTask(path.Join(u.stageDir, "docker-compose.yml"), project.Name, t); err != nil {
t.LogFailedWithErr(logStr, err)
return err
}
t.LogSuccess(logStr)
if err = verifyUpgradeImages(images); err != nil {
return err
}
}
if u.original.App.Resource == constant.AppResourceRemote {
go RequestDownloadCallBack(u.detail.DownloadCallBackUrl)
}
u.phase = appUpgradePrepared
return nil
}
func (u *appUpgradeContext) prepareOpenresty(t *task.Task, stagedInstall model.AppInstall) error {
fileOp := files.NewFileOp()
detailBuildDir := path.Join(u.detailDir, nginxModuleBuildDir)
installBuildDir := path.Join(u.stageDir, nginxModuleBuildDir)
if !fileOp.Stat(installBuildDir) {
if err := fileOp.CreateDir(installBuildDir, constant.DirPerm); err != nil {
return err
}
}
if err := copyAppDetailMissing(fileOp, detailBuildDir, installBuildDir); err != nil {
return err
}
if err := fileOp.DeleteDir(path.Join(installBuildDir, nginxModuleTmpDir)); err != nil {
return err
}
if err := fileOp.CopyDir(path.Join(detailBuildDir, nginxModuleTmpDir), installBuildDir); err != nil {
return err
}
for _, fileName := range []string{"Dockerfile", "nginx.conf", "nginx.vh.default.conf"} {
if err := fileOp.CopyFile(path.Join(detailBuildDir, fileName), installBuildDir); err != nil {
return err
}
}
if err := syncNginxModuleBuilder(detailBuildDir, installBuildDir); err != nil {
return err
}
targetCatalogSource := path.Join(detailBuildDir, nginxModuleCatalogFile)
if !fileOp.Stat(targetCatalogSource) {
return fmt.Errorf("target OpenResty module catalog not found: %s", targetCatalogSource)
}
targetCatalogPath := path.Join(installBuildDir, nginxModuleCatalogPendingFile)
if err := stageNginxModuleCatalog(targetCatalogSource, targetCatalogPath); err != nil {
return err
}
stagedInstall.Name = path.Base(u.stageDir)
stagedInstall.Version = u.candidate.Version
stagedInstall.Env = u.candidate.Env
stagedInstall.DockerCompose = u.candidate.DockerCompose
return buildNginx(t, stagedInstall, targetCatalogPath)
}
func (u *appUpgradeContext) cutover(t *task.Task) error {
u.stopAttempted = true
t.LogStart(i18n.GetMsgByKey("UpgradeStop"))
if out, err := compose.Stop(u.original.GetComposePath()); err != nil {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}
t.LogFailedWithErr(i18n.GetMsgByKey("UpgradeStop"), err)
return err
}
t.LogSuccess(i18n.GetMsgByKey("UpgradeStop"))
u.phase = appUpgradeStopped
var err error
if u.original.App.Key == constant.AppOpenresty {
u.snapshot, err = createOpenrestyUpgradeSnapshot(u.original.GetPath())
} else {
snapshotPaths := []string{".env", "docker-compose.yml", "scripts"}
if u.original.App.Key == constant.AppOpenclaw {
snapshotPaths = append(snapshotPaths, path.Join("data", "conf", "openclaw.json"))
}
u.snapshot, err = createUpgradeFileSnapshot(u.original.GetPath(), snapshotPaths)
}
if err != nil {
return err
}
if u.req.Backup {
if err = u.backup(t); err != nil {
return err
}
u.phase = appUpgradeBackedUp
} else {
t.Log(i18n.GetMsgByKey("UpgradeBackupDisabled"))
}
u.downAttempted = true
if out, downErr := compose.Down(u.original.GetComposePath()); downErr != nil {
if out != "" {
downErr = fmt.Errorf("%s: %w", out, downErr)
}
return downErr
}
u.phase = appUpgradeDown
u.phase = appUpgradeMutated
if err = u.applyStagedFiles(); err != nil {
return err
}
if err = writeUpgradeFile(u.original.GetEnvPath(), u.envContent, constant.FilePerm); err != nil {
return err
}
if err = runScript(t, &u.candidate, "upgrade"); err != nil {
return err
}
if err = writeUpgradeFile(u.original.GetComposePath(), []byte(u.candidate.DockerCompose), constant.FilePerm); err != nil {
return err
}
logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("Run"), i18n.GetMsgByKey("App"))
t.LogStart(logStr)
if out, upErr := compose.UpWithoutPull(u.original.GetComposePath()); upErr != nil {
if out != "" {
upErr = fmt.Errorf("%s: %w", out, upErr)
}
t.LogFailedWithErr(logStr, upErr)
return upErr
}
t.LogSuccess(logStr)
u.phase = appUpgradeStarted
t.LogStart(i18n.GetMsgByKey("UpgradeWaitReady"))
containerNames, err := waitAppContainersReady(context.Background(), u.candidate)
if err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("UpgradeWaitReady"), err)
return err
}
t.LogSuccess(i18n.GetMsgByKey("UpgradeWaitReady"))
u.phase = appUpgradeReady
u.candidate.ContainerName = strings.Join(containerNames, ",")
u.candidate.Status = constant.StatusRunning
u.candidate.Message = ""
if u.candidate.App.Key == constant.AppOpenresty {
liveCatalogPath := path.Join(u.candidate.GetPath(), nginxModuleBuildDir, nginxModuleCatalogPendingFile)
if err = commitStaticNginxModuleBuilds(u.candidate, liveCatalogPath, t); err != nil {
return err
}
activeCatalogPath := path.Join(u.candidate.GetPath(), nginxModuleBuildDir, nginxModuleCatalogFile)
if err = activateNginxModuleCatalogAndCommit(liveCatalogPath, activeCatalogPath, func() error {
return appInstallRepo.Save(context.Background(), &u.candidate)
}); err != nil {
return err
}
} else if err = appInstallRepo.Save(context.Background(), &u.candidate); err != nil {
return err
}
u.phase = appUpgradeCommitted
u.deleteOldImages(t)
return nil
}
func (u *appUpgradeContext) backup(t *task.Task) error {
fileName := fmt.Sprintf("upgrade_backup_%s_%s.tar.gz", u.original.Name, time.Now().Format(constant.DateTimeSlimLayout)+common.RandStrAndNum(5))
record, err := backupAppWithParentTask(&u.original, t, fileName)
if err != nil {
return buserr.WithNameAndErr("ErrAppBackup", u.original.Name, err)
}
u.backupFile = path.Join(global.Dir.LocalBackupDir, record.FileDir, record.FileName)
info, err := os.Stat(u.backupFile)
if err != nil || info.Size() == 0 || record.Status != constant.StatusSuccess {
if err == nil {
err = errors.New("backup archive is empty or incomplete")
}
markBackupFailed(record.ID, err)
return buserr.WithNameAndErr("ErrAppBackup", u.original.Name, err)
}
backupRecordService := NewIBackupRecordService()
backups, _ := backupRecordService.ListAppRecords(u.original.App.Key, u.original.Name, "upgrade_backup")
if len(backups) > 3 {
deleteIDs := make([]uint, 0, len(backups)-3)
for _, backup := range backups[:len(backups)-3] {
deleteIDs = append(deleteIDs, backup.ID)
}
_ = backupRecordService.BatchDeleteRecord(deleteIDs)
}
return nil
}
func (u *appUpgradeContext) applyStagedFiles() error {
fileOp := files.NewFileOp()
if err := copyAppDetailMissingTracked(fileOp, u.detailDir, u.original.GetPath(), &u.createdPaths); err != nil {
return err
}
if err := replaceUpgradePath(u.stageDir, u.original.GetPath(), "scripts"); err != nil {
return err
}
if u.original.App.Key == constant.AppOpenclaw {
if err := replaceUpgradePath(u.stageDir, u.original.GetPath(), path.Join("data", "conf", "openclaw.json")); err != nil {
return err
}
}
if u.original.App.Key == constant.AppOpenresty {
for _, relativePath := range []string{
nginxModuleBuildDir,
nginxModuleModulesDir,
path.Join(nginxModuleConfDir, nginxModuleEnabledConfDir),
path.Join(nginxModuleConfDir, "nginx.conf"),
} {
if err := replaceUpgradePath(u.stageDir, u.original.GetPath(), relativePath); err != nil {
return err
}
}
}
return nil
}
func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
if !u.stopAttempted {
return nil
}
logStr := i18n.GetWithName("AppRecover", u.original.Name)
t.LogStart(logStr)
defer func() {
if rollbackErr != nil {
t.LogFailedWithErr(logStr, rollbackErr)
} else {
t.LogSuccess(logStr)
}
}()
if !u.downAttempted {
if out, err := compose.Operate(u.original.GetComposePath(), "start"); err != nil {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}
return err
}
return u.finishRollback()
}
if u.phase < appUpgradeMutated {
if out, err := compose.UpWithoutPull(u.original.GetComposePath()); err != nil {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}
return err
}
return u.finishRollback()
}
if out, err := compose.Down(u.original.GetComposePath()); err != nil {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}
rollbackErr = err
}
if u.backupFile != "" {
_ = u.restoreManagedFiles()
if err := handleAppRecover(&u.original, t, u.backupFile, true, "", ""); err != nil {
_, _ = compose.UpWithoutPull(u.original.GetComposePath())
return errors.Join(rollbackErr, err)
}
} else {
if err := u.restoreManagedFiles(); err != nil {
return errors.Join(rollbackErr, err)
}
if out, err := compose.UpWithoutPull(u.original.GetComposePath()); err != nil {
if out != "" {
err = fmt.Errorf("%s: %w", out, err)
}
return errors.Join(rollbackErr, err)
}
}
return errors.Join(rollbackErr, u.finishRollback())
}
func (u *appUpgradeContext) finishRollback() error {
if _, err := waitAppContainersReady(context.Background(), u.original); err != nil {
return err
}
restored := u.original
if err := appInstallRepo.Save(context.Background(), &restored); err != nil {
return err
}
return nil
}
func (u *appUpgradeContext) restoreManagedFiles() error {
var restoreErr error
if u.snapshot != nil {
restoreErr = u.snapshot.Restore()
}
for index := len(u.createdPaths) - 1; index >= 0; index-- {
if err := os.RemoveAll(u.createdPaths[index]); err != nil {
restoreErr = errors.Join(restoreErr, err)
}
}
return restoreErr
}
func (u *appUpgradeContext) deleteOldImages(t *task.Task) {
if !u.req.DeleteImage {
return
}
excludeImages, err := docker.GetImagesFromDockerCompose(u.envContent, []byte(u.candidate.DockerCompose))
if err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("TaskDelete")+i18n.GetMsgByKey("Image"), err)
return
}
dockerClient, err := docker.NewClient()
if err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("TaskDelete")+i18n.GetMsgByKey("Image"), err)
return
}
defer dockerClient.Close()
if err = deleteAppImagesByIDs(t, dockerClient, u.oldImageIDs, excludeImages); err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("TaskDelete")+i18n.GetMsgByKey("Image"), err)
}
}
func (u *appUpgradeContext) cleanup() {
if u.snapshot != nil {
u.snapshot.Cleanup()
}
if u.stageDir != "" {
_ = os.RemoveAll(u.stageDir)
}
}
type upgradeImageClient interface {
PullImageWithProcess(*task.Task, string) error
ImageExists(string) (bool, error)
Close()
}
func prepareUpgradeImages(t *task.Task, images []string, pull bool) error {
dockerClient, err := docker.NewClient()
if err != nil {
return err
}
return prepareUpgradeImagesWithClient(t, dockerClient, images, pull)
}
func prepareUpgradeImagesWithClient(t *task.Task, dockerClient upgradeImageClient, images []string, pull bool) error {
defer dockerClient.Close()
seen := make(map[string]struct{}, len(images))
for _, image := range images {
image = strings.TrimSpace(image)
if image == "" {
continue
}
if _, ok := seen[image]; ok {
continue
}
seen[image] = struct{}{}
if pull {
if t != nil {
t.Log(i18n.GetWithName("PullImageStart", image))
}
if pullErr := dockerClient.PullImageWithProcess(t, image); pullErr != nil {
if exists, _ := dockerClient.ImageExists(image); exists {
if t != nil {
t.Log(i18n.GetMsgByKey("UseExistImage"))
}
continue
}
return buserr.WithNameAndErr("ErrDockerPullImage", "", pullErr)
}
}
exists, inspectErr := dockerClient.ImageExists(image)
if inspectErr != nil || !exists {
return buserr.WithNameAndErr("ErrDockerPullImage", "", fmt.Errorf("image %s is not available locally: %v", image, inspectErr))
}
if pull && t != nil {
t.LogSuccess(i18n.GetMsgByKey("PullImage"))
}
}
return nil
}
func verifyUpgradeImages(images []string) error {
dockerClient, err := docker.NewClient()
if err != nil {
return err
}
defer dockerClient.Close()
for _, image := range images {
exists, inspectErr := dockerClient.ImageExists(image)
if inspectErr != nil || !exists {
return buserr.WithNameAndErr("ErrDockerPullImage", "", fmt.Errorf("image %s is not available locally: %v", image, inspectErr))
}
}
return nil
}
func renderUpgradeEnv(install *model.AppInstall, original []byte) ([]byte, error) {
originalEnv := make(map[string]string)
if len(original) > 0 {
var err error
originalEnv, err = godotenv.UnmarshalBytes(original)
if err != nil {
return nil, err
}
}
params := make(map[string]string, len(originalEnv))
maps.Copy(params, originalEnv)
envs := make(map[string]interface{})
if err := json.Unmarshal([]byte(install.Env), &envs); err != nil {
return nil, err
}
handleMap(envs, params)
if install.App.Key == constant.AppOpenresty {
for _, key := range []string{"CONTAINER_PACKAGE_URL", "RESTY_ADD_PACKAGE_BUILDDEPS", "RESTY_CONFIG_OPTIONS_MORE"} {
if value, ok := originalEnv[key]; ok {
params[key] = value
}
}
if websiteDir := strings.TrimSpace(originalEnv["WEBSITE_DIR"]); websiteDir != "" {
params["WEBSITE_DIR"] = websiteDir
}
websiteDir := strings.TrimSpace(params["WEBSITE_DIR"])
if websiteDir == "" {
websiteDir = NewISettingService().GetWebsiteDir()
}
if !path.IsAbs(websiteDir) {
websiteDir = path.Join(global.Dir.DataDir, websiteDir)
}
params["WEBSITE_DIR"] = websiteDir
envs["WEBSITE_DIR"] = websiteDir
content, marshalErr := json.Marshal(envs)
if marshalErr != nil {
return nil, marshalErr
}
install.Env = string(content)
}
content, err := godotenv.Marshal(params)
if err != nil {
return nil, err
}
return []byte(content), nil
}
func renderUpgradeCompose(install model.AppInstall, detail model.AppDetail, customCompose string) (string, error) {
if customCompose != "" {
return customCompose, nil
}
if install.App.Key == vllmAppKeyForUpgrade {
return install.DockerCompose, nil
}
return getUpgradeCompose(install, detail)
}
func writeUpgradeFile(filePath string, content []byte, mode os.FileMode) error {
tmp, err := os.CreateTemp(path.Dir(filePath), "."+path.Base(filePath)+".*")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if err = tmp.Chmod(mode); err == nil {
_, err = tmp.Write(content)
}
if err == nil {
err = tmp.Sync()
}
if closeErr := tmp.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
return os.Rename(tmpPath, filePath)
}
func copyUpgradeStageFile(sourceRoot, targetRoot, relativePath string) error {
source := path.Join(sourceRoot, relativePath)
if _, err := os.Stat(source); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
target := path.Join(targetRoot, relativePath)
_ = os.RemoveAll(target)
return copyOpenrestyUpgradeSnapshotEntry(source, target)
}
func replaceUpgradePath(sourceRoot, targetRoot, relativePath string) error {
source := path.Join(sourceRoot, relativePath)
if _, err := os.Stat(source); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
target := path.Join(targetRoot, relativePath)
if err := os.RemoveAll(target); err != nil {
return err
}
return copyOpenrestyUpgradeSnapshotEntry(source, target)
}
func createUpgradeFileSnapshot(installPath string, paths []string) (*upgradeFileSnapshot, error) {
backupPath, err := os.MkdirTemp("", "1panel-app-upgrade-*")
if err != nil {
return nil, err
}
snapshot := &upgradeFileSnapshot{
installPath: installPath,
backupPath: backupPath,
paths: paths,
existing: make(map[string]bool, len(paths)),
}
for _, relativePath := range paths {
source := path.Join(installPath, relativePath)
if _, err = os.Stat(source); err != nil {
if os.IsNotExist(err) {
continue
}
snapshot.Cleanup()
return nil, err
}
snapshot.existing[relativePath] = true
if err = copyOpenrestyUpgradeSnapshotEntry(source, path.Join(backupPath, relativePath)); err != nil {
snapshot.Cleanup()
return nil, err
}
}
return snapshot, nil
}
func (s *upgradeFileSnapshot) Restore() error {
for _, relativePath := range s.paths {
target := path.Join(s.installPath, relativePath)
if err := os.RemoveAll(target); err != nil {
return err
}
if !s.existing[relativePath] {
continue
}
if err := copyOpenrestyUpgradeSnapshotEntry(path.Join(s.backupPath, relativePath), target); err != nil {
return err
}
}
return nil
}
func (s *upgradeFileSnapshot) Cleanup() {
if s != nil && s.backupPath != "" {
_ = os.RemoveAll(s.backupPath)
}
}
type appContainerReadinessClient interface {
ContainerList(context.Context, container.ListOptions) ([]container.Summary, error)
ContainerInspect(context.Context, string) (container.InspectResponse, error)
}
func waitAppContainersReady(ctx context.Context, install model.AppInstall) ([]string, error) {
client, err := docker.NewDockerClient()
if err != nil {
return nil, err
}
defer client.Close()
return waitAppContainersReadyWithClient(ctx, client, install)
}
func waitAppContainersReadyWithClient(ctx context.Context, client appContainerReadinessClient, install model.AppInstall) ([]string, error) {
envContent, err := os.ReadFile(install.GetEnvPath())
if err != nil {
envContent, err = renderUpgradeEnv(&install, nil)
if err != nil {
return nil, err
}
}
project, err := docker.GetComposeProject(install.Name, install.GetPath(), []byte(install.DockerCompose), envContent, false)
if err != nil {
return nil, err
}
expectedServices := make(map[string]struct{})
for _, service := range project.Services {
if !skipCheckStatus(service) {
expectedServices[service.Name] = struct{}{}
}
}
if len(expectedServices) == 0 {
return strings.Split(install.ContainerName, ","), nil
}
options := container.ListOptions{
All: true,
Filters: filters.NewArgs(
filters.Arg("label", composeWorkdirLabel+"="+install.GetPath()),
),
}
containers, err := client.ContainerList(ctx, options)
if err != nil {
return nil, err
}
foundServices := make(map[string]bool, len(expectedServices))
containerNames := make([]string, 0, len(containers))
for _, item := range containers {
serviceName := item.Labels[composeServiceLabel]
if _, ok := expectedServices[serviceName]; !ok {
continue
}
if err = waitContainerReady(ctx, client, item.ID); err != nil {
return nil, fmt.Errorf("container %s is not ready: %w", serviceName, err)
}
foundServices[serviceName] = true
if len(item.Names) > 0 {
containerNames = append(containerNames, strings.TrimPrefix(item.Names[0], "/"))
}
}
for serviceName := range expectedServices {
if !foundServices[serviceName] {
return nil, fmt.Errorf("container for service %s was not created", serviceName)
}
}
sort.Strings(containerNames)
return containerNames, nil
}
+238 -361
View File
@@ -1,7 +1,6 @@
package service
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
@@ -10,7 +9,6 @@ import (
"math"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"reflect"
@@ -89,7 +87,15 @@ func checkPort(key string, params map[string]interface{}) (int, error) {
return 0, nil
}
func isPortInUse(port int, protocol string) bool {
return common.ScanPortWithProto(port, normalizeComposeProtocol(protocol))
}
func checkPortExist(port int) error {
return checkPortExistWithProtocol(port, "")
}
func checkPortExistWithProtocol(port int, protocol string) error {
errMap := make(map[string]interface{})
errMap["port"] = port
appInstall, _ := appInstallRepo.GetFirst(appInstallRepo.WithPort(port))
@@ -110,7 +116,7 @@ func checkPortExist(port int) error {
errMap["name"] = domain.Domain
return buserr.WithMap("ErrPortExist", errMap, nil)
}
if common.ScanPort(port) {
if isPortInUse(port, protocol) {
return buserr.WithDetail("ErrPortInUsed", port, nil)
}
return nil
@@ -291,8 +297,8 @@ func createLink(ctx context.Context, installTask *task.Task, app model.App, appI
}
resourceId = oldMysqlDb.ID
if oldMysqlDb.ID > 0 {
if oldMysqlDb.Username != dbConfig.DbUser || oldMysqlDb.Password != dbConfig.Password {
return buserr.New("ErrDbUserNotValid")
if err := ensureAppMysqlDBUser(database, dbConfig); err != nil {
return err
}
} else {
var createMysql dto.MysqlDBCreate
@@ -357,33 +363,13 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
return err
}
if deleteReq.DeleteImage {
delImageStr := i18n.GetMsgByKey("TaskDelete") + i18n.GetMsgByKey("Image")
content, err := op.GetContent(install.GetEnvPath())
if err != nil {
return err
}
images, err := docker.GetImagesFromDockerCompose(content, []byte(install.DockerCompose))
if err != nil {
if err = deleteAppImagesByCompose(t, content, []byte(install.DockerCompose), nil); err != nil {
return err
}
client, err := docker.NewClient()
if err != nil {
return err
}
defer client.Close()
for _, image := range images {
imageID, err := client.GetImageIDByName(image)
if err == nil {
imgStr := delImageStr + image
t.Log(imgStr)
if err = client.DeleteImage(imageID); err != nil {
t.LogFailedWithErr(imgStr, err)
continue
}
t.LogSuccess(delImageStr + image)
}
}
}
}
tx, ctx := helper.GetTxAndContext()
@@ -424,6 +410,20 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
switch install.App.Key {
case constant.AppMysql, constant.AppMariaDB, constant.AppMysqlCluster:
if err = databaseUserGrantRepo.DeleteBy(
ctx,
repo.WithByType(install.App.Key),
databaseUserGrantRepo.WithByDatabase(install.Name),
); err != nil {
return err
}
if err = databaseUserRepo.DeleteBy(
ctx,
repo.WithByType(install.App.Key),
databaseUserRepo.WithByDatabase(install.Name),
); err != nil {
return err
}
_ = mysqlRepo.Delete(ctx, mysqlRepo.WithByMysqlName(install.Name))
case constant.AppMongodb:
_ = mongodbRepo.Delete(ctx, mongodbRepo.WithByMongodbName(install.Name))
@@ -468,6 +468,107 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
return nil
}
type appImageID struct {
name string
id string
}
func getAppImageIDsByCompose(client docker.Client, envContent, composeContent []byte) ([]appImageID, error) {
images, err := docker.GetImagesFromDockerCompose(envContent, composeContent)
if err != nil {
return nil, err
}
imageIDs := make([]appImageID, 0, len(images))
for _, image := range images {
imageID, err := client.GetImageIDByName(image)
if err == nil && imageID != "" {
imageIDs = append(imageIDs, appImageID{name: image, id: imageID})
}
}
return imageIDs, nil
}
func deleteAppImagesByCompose(t *task.Task, envContent, composeContent []byte, excludeImages []string) error {
client, err := docker.NewClient()
if err != nil {
return err
}
defer client.Close()
imageIDs, err := getAppImageIDsByCompose(client, envContent, composeContent)
if err != nil {
return err
}
return deleteAppImagesByIDs(t, client, imageIDs, excludeImages)
}
func deleteAppImagesByIDs(t *task.Task, client docker.Client, imageIDs []appImageID, excludeImages []string) error {
delImageStr := i18n.GetMsgByKey("TaskDelete") + i18n.GetMsgByKey("Image")
excludeImageIDs := make(map[string]struct{}, len(excludeImages))
for _, image := range excludeImages {
imageID, err := client.GetImageIDByName(image)
if err == nil && imageID != "" {
excludeImageIDs[imageID] = struct{}{}
}
}
deletedImageIDs := make(map[string]struct{}, len(imageIDs))
for _, image := range imageIDs {
if _, ok := excludeImageIDs[image.id]; ok {
continue
}
if _, ok := deletedImageIDs[image.id]; ok {
continue
}
deletedImageIDs[image.id] = struct{}{}
imgStr := delImageStr + image.name
t.Log(imgStr)
if err := client.DeleteImage(image.id); err != nil {
t.LogFailedWithErr(imgStr, err)
continue
}
t.LogSuccess(imgStr)
}
return nil
}
func ensureAppMysqlDBUser(database model.Database, dbConfig dto.AppDatabase) error {
const host = "%"
mysqlService := NewIMysqlService()
users, err := mysqlService.ListUsers(dto.MysqlUserSearch{Database: database.Name})
if err != nil {
return err
}
userExists := false
passwordValid := false
for _, user := range users {
if user.Username != dbConfig.DbUser || user.Host != host || user.IsDelete {
continue
}
userExists = true
passwordValid = user.Password == dbConfig.Password
break
}
if !userExists || !passwordValid {
return buserr.New("ErrDbUserNotValid")
}
grants, err := mysqlService.ListGrants(dto.MysqlUserSearch{Database: database.Name})
if err != nil {
return err
}
for _, grant := range grants {
if grant.Database == dbConfig.DbName && grant.Username == dbConfig.DbUser && grant.Host == host {
return nil
}
}
return mysqlService.GrantUser(dto.MysqlGrantCreate{
Database: database.Name,
DB: dbConfig.DbName,
Username: dbConfig.DbUser,
Host: host,
})
}
func deleteLink(del dto.DelAppLink) error {
install := del.Install
resources, _ := appInstallResourceRepo.GetBy(appInstallResourceRepo.WithAppInstallId(install.ID))
@@ -477,19 +578,20 @@ func deleteLink(del dto.DelAppLink) error {
for _, re := range resources {
switch re.Key {
case constant.AppMysql, constant.AppMariaDB:
mysqlService := NewIMysqlService()
database, _ := mysqlRepo.Get(repo.WithByID(re.ResourceId))
if reflect.DeepEqual(database, model.DatabaseMysql{}) {
continue
}
if err := mysqlService.Delete(del.Ctx, dto.MysqlDBDelete{
if err := deleteMysqlDatabaseForResourceOwner(del.Ctx, dto.MysqlDBDelete{
ID: database.ID,
ForceDelete: del.ForceDelete,
DeleteBackup: true,
Type: re.Key,
Database: database.MysqlName,
}); err != nil && !del.ForceDelete {
return err
}, dto.DBResource{Type: constant.TypeApp, Name: install.Name}); err != nil {
if isMysqlDatabaseResourceInUseError(err) || !del.ForceDelete {
return err
}
}
case constant.AppPostgresql:
pgsqlService := NewIPostgresqlService()
@@ -564,11 +666,58 @@ func handleUpgradeCompose(install model.AppInstall, detail model.AppDetail) (map
if oldServiceValue["restart"] != nil {
serviceValue["restart"] = oldServiceValue["restart"]
}
if install.App.Key == constant.AppOpenresty {
mergeOpenrestyModuleVolumes(serviceValue, oldServiceValue)
}
servicesMap[install.ServiceName] = serviceValue
composeMap["services"] = servicesMap
return composeMap, nil
}
// mergeOpenrestyModuleVolumes carries the dynamic module mounts of the old
// compose over to the upgraded one when it does not declare them, so built
// module artifacts and their load configuration stay mounted across upgrades.
func mergeOpenrestyModuleVolumes(serviceValue, oldServiceValue map[string]interface{}) {
oldVolumes, ok := oldServiceValue["volumes"].([]interface{})
if !ok {
return
}
newVolumes, _ := serviceValue["volumes"].([]interface{})
existing := make(map[string]struct{}, len(newVolumes))
for _, volume := range newVolumes {
if containerPath, ok := composeVolumeContainerPath(volume); ok {
existing[containerPath] = struct{}{}
}
}
for _, volume := range oldVolumes {
containerPath, ok := composeVolumeContainerPath(volume)
if !ok {
continue
}
if !strings.Contains(containerPath, nginxModuleEnabledConfDir) && !strings.Contains(containerPath, "nginx/modules/1panel") {
continue
}
if _, ok = existing[containerPath]; ok {
continue
}
newVolumes = append(newVolumes, volume)
existing[containerPath] = struct{}{}
}
serviceValue["volumes"] = newVolumes
}
func composeVolumeContainerPath(volume interface{}) (string, bool) {
volumeStr, ok := volume.(string)
if !ok {
return "", false
}
parts := strings.Split(volumeStr, ":")
if len(parts) < 2 {
return "", false
}
return parts[1], true
}
func getUpgradeCompose(install model.AppInstall, detail model.AppDetail) (string, error) {
if detail.DockerCompose == "" {
return "", nil
@@ -602,331 +751,35 @@ func getUpgradeCompose(install model.AppInstall, detail model.AppDetail) (string
return string(composeByte), nil
}
func buildNginx(parentTask *task.Task) error {
nginxInstall, err := getAppInstallByKey(constant.AppOpenresty)
if err != nil {
return err
}
func buildNginx(parentTask *task.Task, nginxInstall model.AppInstall, catalogPath string) error {
fileOp := files.NewFileOp()
buildPath := path.Join(nginxInstall.GetPath(), "build")
buildPath := path.Join(nginxInstall.GetPath(), nginxModuleBuildDir)
if !fileOp.Stat(buildPath) {
return buserr.New("ErrBuildDirNotFound")
}
moduleConfigPath := path.Join(buildPath, "module.json")
moduleContent, err := fileOp.GetContent(moduleConfigPath)
modules, err := loadNginxModulesWithCatalog(nginxInstall, catalogPath)
if err != nil {
return err
}
var (
modules []dto.NginxModule
addModuleParams []string
addPackages []string
)
if len(moduleContent) > 0 {
_ = json.Unmarshal(moduleContent, &modules)
bashFile, err := os.OpenFile(path.Join(buildPath, "tmp", "pre.sh"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, constant.DirPerm)
if err != nil {
return err
}
defer bashFile.Close()
bashFileWriter := bufio.NewWriter(bashFile)
for _, module := range modules {
if !module.Enable {
continue
}
_, err = bashFileWriter.WriteString(module.Script + "\n")
if err != nil {
return err
}
addModuleParams = append(addModuleParams, module.Params)
addPackages = append(addPackages, module.Packages...)
}
err = bashFileWriter.Flush()
if err != nil {
return err
}
previousModules := cloneNginxModules(modules)
staticBuild := hasEnabledStaticNginxModules(modules)
if err = configureStaticNginxModules(nginxInstall, modules, ""); err != nil {
return err
}
envs, err := gotenv.Read(nginxInstall.GetEnvPath())
if staticBuild {
logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("TaskBuild"), i18n.GetMsgByKey("Image"))
parentTask.LogStart(logStr)
cmdMgr := cmd.NewCommandMgr(cmd.WithTask(*parentTask), cmd.WithTimeout(120*time.Minute))
if err = cmdMgr.Run("docker", "compose", "-f", nginxInstall.GetComposePath(), "build"); err != nil {
return err
}
parentTask.LogSuccess(logStr)
}
modules, err = buildDynamicNginxModules(nginxInstall, modules, nil, false, "", catalogPath, parentTask)
if err != nil {
return err
}
envs["RESTY_CONFIG_OPTIONS_MORE"] = ""
envs["RESTY_ADD_PACKAGE_BUILDDEPS"] = ""
if len(addModuleParams) > 0 {
envs["RESTY_CONFIG_OPTIONS_MORE"] = strings.Join(addModuleParams, " ")
}
if len(addPackages) > 0 {
envs["RESTY_ADD_PACKAGE_BUILDDEPS"] = strings.Join(addPackages, " ")
}
_ = gotenv.Write(envs, nginxInstall.GetEnvPath())
if len(addModuleParams) == 0 && len(addPackages) == 0 {
return nil
}
logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("TaskBuild"), i18n.GetMsgByKey("Image"))
parentTask.LogStart(logStr)
cmdMgr := cmd.NewCommandMgr(cmd.WithTask(*parentTask), cmd.WithTimeout(60*time.Minute))
if err = cmdMgr.Run("docker", "compose", "-f", nginxInstall.GetComposePath(), "build"); err != nil {
return err
}
parentTask.LogSuccess(logStr)
return nil
}
func upgradeInstall(req request.AppInstallUpgrade) error {
install, err := appInstallRepo.GetFirst(repo.WithByID(req.InstallID))
if err != nil {
return err
}
oldVersion := install.Version
detail, err := appDetailRepo.GetFirst(repo.WithByID(req.DetailID))
if err != nil {
return err
}
if install.App.Key == vllmAppKeyForUpgrade && !isVllmUpgradeVersionAllowed(install.Version, detail.Version, loadVllmImageFromEnv(install.Env)) {
return errors.New("vLLM can only upgrade within the same image type")
}
if install.Version == detail.Version {
return errors.New("two version is same")
}
upgradeTask, err := task.NewTaskWithOps(install.Name, task.TaskUpgrade, task.TaskScopeApp, req.TaskID, install.ID)
if err != nil {
return err
}
install.Status = constant.StatusUpgrading
var (
upErr error
backupFile string
)
backUpApp := func(t *task.Task) error {
backupService := NewIBackupService()
backupRecordService := NewIBackupRecordService()
fileName := fmt.Sprintf("upgrade_backup_%s_%s.tar.gz", install.Name, time.Now().Format(constant.DateTimeSlimLayout)+common.RandStrAndNum(5))
backupRecord, err := backupService.AppBackup(dto.CommonBackup{Name: install.App.Key, DetailName: install.Name, FileName: fileName})
if err == nil {
backups, _ := backupRecordService.ListAppRecords(install.App.Key, install.Name, "upgrade_backup")
if len(backups) > 3 {
backupsToDelete := backups[:len(backups)-3]
var deleteIDs []uint
for _, backup := range backupsToDelete {
deleteIDs = append(deleteIDs, backup.ID)
}
_ = backupRecordService.BatchDeleteRecord(deleteIDs)
}
backupFile = path.Join(global.Dir.LocalBackupDir, backupRecord.FileDir, backupRecord.FileName)
} else {
return buserr.WithNameAndErr("ErrAppBackup", install.Name, err)
}
return nil
}
if req.Backup {
upgradeTask.AddSubTask(task.GetTaskName(install.Name, task.TaskBackup, task.TaskScopeApp), backUpApp, nil)
}
upgradeApp := func(t *task.Task) error {
fileOp := files.NewFileOp()
detailDir := path.Join(global.Dir.ResourceDir, "apps", install.App.Resource, install.App.Key, detail.Version)
if install.App.Resource == constant.AppResourceRemote {
if err = downloadApp(install.App, detail, &install, t.Logger); err != nil {
return err
}
if detail.DockerCompose == "" {
composeDetail, err := fileOp.GetContent(path.Join(detailDir, "docker-compose.yml"))
if err != nil {
return err
}
detail.DockerCompose = string(composeDetail)
_ = appDetailRepo.Update(context.Background(), detail)
}
go func() {
RequestDownloadCallBack(detail.DownloadCallBackUrl)
}()
}
if install.App.Resource == constant.AppResourceLocal {
detailDir = path.Join(global.Dir.ResourceDir, "apps", "local", strings.TrimPrefix(install.App.Key, "local"), detail.Version)
}
content, err := fileOp.GetContent(install.GetEnvPath())
if err != nil {
return err
}
if install.App.Key == vllmAppKeyForUpgrade {
envs := make(map[string]interface{})
if err = json.Unmarshal([]byte(install.Env), &envs); err != nil {
return err
}
image := buildVllmUpgradeImage(loadVllmImageFromEnv(install.Env), oldVersion, detail.Version)
envs[vllmImageEnvKey] = image
paramByte, err := json.Marshal(envs)
if err != nil {
return err
}
install.Env = string(paramByte)
content = setVllmImageInEnvContent(content, image)
}
if req.PullImage {
composeContent := []byte(detail.DockerCompose)
if install.App.Key == vllmAppKeyForUpgrade {
composeContent = []byte(install.DockerCompose)
}
if req.DockerCompose != "" {
composeContent = []byte(req.DockerCompose)
}
images, err := docker.GetImagesFromDockerCompose(content, composeContent)
if err != nil {
return err
}
dockerCLi, err := docker.NewClient()
if err != nil {
return err
}
defer dockerCLi.Close()
for _, image := range images {
t.Log(i18n.GetWithName("PullImageStart", image))
if err = dockerCLi.PullImageWithProcess(t, image); err != nil {
err = buserr.WithNameAndErr("ErrDockerPullImage", "", err)
return err
}
t.LogSuccess(i18n.GetMsgByKey("PullImage"))
}
}
_ = copyAppDetailMissing(fileOp, detailDir, install.GetPath())
if install.App.Key == constant.AppOpenresty {
installBuildDir := path.Join(install.GetPath(), "build")
detailBuildDir := path.Join(detailDir, "build")
if !fileOp.Stat(installBuildDir) {
if err := fileOp.CreateDir(installBuildDir, constant.DirPerm); err != nil {
return err
}
}
if err := fileOp.DeleteDir(path.Join(installBuildDir, "tmp")); err != nil {
return err
}
if err := fileOp.CopyDir(path.Join(detailBuildDir, "tmp"), installBuildDir); err != nil {
return err
}
if err := fileOp.CopyFile(path.Join(detailBuildDir, "Dockerfile"), installBuildDir); err != nil {
return err
}
if err := fileOp.CopyFile(path.Join(detailBuildDir, "nginx.conf"), installBuildDir); err != nil {
return err
}
if err := fileOp.CopyFile(path.Join(detailBuildDir, "nginx.vh.default.conf"), installBuildDir); err != nil {
return err
}
}
sourceScripts := path.Join(detailDir, "scripts")
if fileOp.Stat(sourceScripts) {
dstScripts := path.Join(install.GetPath(), "scripts")
_ = fileOp.DeleteDir(dstScripts)
_ = fileOp.CreateDir(dstScripts, constant.DirPerm)
scriptCmd := exec.Command("cp", "-rf", sourceScripts+"/.", dstScripts+"/")
_, _ = scriptCmd.CombinedOutput()
}
var newCompose string
if err = migrateOpenclawProtocolUpgrade(&install, oldVersion, detail.Version); err != nil {
return err
}
if req.DockerCompose == "" {
if install.App.Key == vllmAppKeyForUpgrade {
newCompose = install.DockerCompose
} else {
newCompose, err = getUpgradeCompose(install, detail)
if err != nil {
return err
}
}
} else {
newCompose = req.DockerCompose
}
install.DockerCompose = newCompose
install.Version = detail.Version
install.AppDetailId = req.DetailID
if out, err := compose.Down(install.GetComposePath()); err != nil {
if out != "" {
upErr = errors.New(out)
return upErr
}
return err
}
envs := make(map[string]interface{})
if err = json.Unmarshal([]byte(install.Env), &envs); err != nil {
return err
}
envParams := make(map[string]string, len(envs))
if install.App.Key == constant.AppOpenresty {
packageUrl, _ := env.GetEnvValueByKey(install.GetEnvPath(), "CONTAINER_PACKAGE_URL")
addPackage, _ := env.GetEnvValueByKey(install.GetEnvPath(), "RESTY_ADD_PACKAGE_BUILDDEPS")
options, _ := env.GetEnvValueByKey(install.GetEnvPath(), "RESTY_CONFIG_OPTIONS_MORE")
envParams["CONTAINER_PACKAGE_URL"] = packageUrl
envParams["RESTY_ADD_PACKAGE_BUILDDEPS"] = addPackage
envParams["RESTY_CONFIG_OPTIONS_MORE"] = options
}
handleMap(envs, envParams)
if err = env.Write(envParams, install.GetEnvPath()); err != nil {
return err
}
if err = runScript(t, &install, "upgrade"); err != nil {
return err
}
if err = fileOp.WriteFile(install.GetComposePath(), strings.NewReader(install.DockerCompose), constant.FilePerm); err != nil {
return err
}
if install.App.Key == constant.AppOpenresty {
if err = buildNginx(t); err != nil {
t.Log(err.Error())
return err
}
}
logStr := fmt.Sprintf("%s %s", i18n.GetMsgByKey("Run"), i18n.GetMsgByKey("App"))
t.Log(logStr)
if out, err := compose.Up(install.GetComposePath()); err != nil {
if out != "" {
return errors.New(out)
}
return err
}
t.LogSuccess(logStr)
install.Status = constant.StatusRunning
return appInstallRepo.Save(context.Background(), &install)
}
rollBackApp := func(t *task.Task) {
if req.Backup {
t.Log(i18n.GetWithName("AppRecover", install.Name))
if err := NewIBackupService().AppRecover(dto.CommonRecover{Name: install.App.Key, DetailName: install.Name, Type: "app", DownloadAccountID: 1, File: backupFile}); err != nil {
t.LogFailedWithErr(i18n.GetWithName("AppRecover", install.Name), err)
return
}
t.LogSuccess(i18n.GetWithName("AppRecover", install.Name))
return
}
}
upgradeTask.AddSubTaskWithOps(task.GetTaskName(install.Name, task.TaskUpgrade, task.TaskScopeApp), upgradeApp, rollBackApp, 0, 1*time.Hour)
go func() {
err = upgradeTask.Execute()
if err != nil {
existInstall, _ := appInstallRepo.GetFirst(repo.WithByID(req.InstallID))
if existInstall.ID > 0 && existInstall.Status != constant.StatusRunning {
existInstall.Status = constant.StatusUpgradeErr
existInstall.Message = err.Error()
_ = appInstallRepo.Save(context.Background(), &existInstall)
}
}
}()
return appInstallRepo.Save(context.Background(), &install)
return commitNginxModuleBuilds(nginxInstall, previousModules, modules, false, catalogPath)
}
func skipCheckStatus(service types.ServiceConfig) bool {
@@ -1624,6 +1477,9 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
synAppInstall(containersMap, &installed, false)
}
resourceKeys := getAppInstallResourceKeys(installed.ID)
envMap := make(map[string]interface{})
_ = json.Unmarshal([]byte(installed.Env), &envMap)
installDTO := response.AppInstallDTO{
ID: installed.ID,
Name: installed.Name,
@@ -1645,14 +1501,16 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
Website: installed.App.Website,
Document: installed.App.Document,
},
Favorite: installed.Favorite,
SortOrder: installed.SortOrder,
Container: installed.ContainerName,
ServiceName: strings.ToLower(installed.ServiceName),
Favorite: installed.Favorite,
SortOrder: installed.SortOrder,
Container: installed.ContainerName,
ServiceName: strings.ToLower(installed.ServiceName),
ResourceKeys: resourceKeys,
Env: envMap,
}
if !updated && !checkUpdate {
installDTO.LinkDB = hasLinkDB(installed.ID)
installDTO.LinkDB = hasLinkDBFromKeys(resourceKeys)
res = append(res, installDTO)
continue
}
@@ -1660,7 +1518,7 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
if installed.Version == "latest" {
if checkUpdate {
installDTO.CanUpdate = false
installDTO.LinkDB = hasLinkDB(installed.ID)
installDTO.LinkDB = hasLinkDBFromKeys(resourceKeys)
res = append(res, installDTO)
}
continue
@@ -1689,7 +1547,7 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
if len(versions) == 0 {
if checkUpdate {
installDTO.CanUpdate = false
installDTO.LinkDB = hasLinkDB(installed.ID)
installDTO.LinkDB = hasLinkDBFromKeys(resourceKeys)
res = append(res, installDTO)
}
continue
@@ -1721,7 +1579,7 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
res = append(res, installDTO)
}
} else if checkUpdate {
installDTO.LinkDB = hasLinkDB(installed.ID)
installDTO.LinkDB = hasLinkDBFromKeys(resourceKeys)
res = append(res, installDTO)
}
}
@@ -1964,6 +1822,10 @@ func isHostModel(dockerCompose string) bool {
}
func copyAppDetailMissing(fileOp files.FileOp, srcDir, dstDir string) error {
return copyAppDetailMissingTracked(fileOp, srcDir, dstDir, nil)
}
func copyAppDetailMissingTracked(fileOp files.FileOp, srcDir, dstDir string, createdPaths *[]string) error {
entries, err := os.ReadDir(srcDir)
if err != nil {
return err
@@ -1975,6 +1837,9 @@ func copyAppDetailMissing(fileOp files.FileOp, srcDir, dstDir string) error {
srcPath := path.Join(srcDir, entry.Name())
dstPath := path.Join(dstDir, entry.Name())
if !fileOp.Stat(dstPath) {
if createdPaths != nil {
*createdPaths = append(*createdPaths, dstPath)
}
if entry.IsDir() {
if err := fileOp.CopyDir(srcPath, dstDir); err != nil {
return err
@@ -1989,7 +1854,7 @@ func copyAppDetailMissing(fileOp files.FileOp, srcDir, dstDir string) error {
if !entry.IsDir() {
continue
}
if err := copyAppDetailMissing(fileOp, srcPath, dstPath); err != nil {
if err := copyAppDetailMissingTracked(fileOp, srcPath, dstPath, createdPaths); err != nil {
return err
}
}
@@ -2152,7 +2017,7 @@ func handleOpenrestyFile(appInstall *model.AppInstall) error {
func handleDefaultServer(appInstall *model.AppInstall) error {
installDir := appInstall.GetPath()
defaultConfigPath := path.Join(installDir, "conf", "default", "00.default.conf")
defaultConfigPath := path.Join(installDir, nginxModuleConfDir, "default", "00.default.conf")
fileOp := files.NewFileOp()
content, err := fileOp.GetContent(defaultConfigPath)
if err != nil {
@@ -2166,7 +2031,7 @@ func handleDefaultServer(appInstall *model.AppInstall) error {
}
func handleSSLConfig(appInstall *model.AppInstall, hasDefaultWebsite bool, sslRejectHandshake bool) error {
sslDir := path.Join(appInstall.GetPath(), "conf", "ssl")
sslDir := path.Join(appInstall.GetPath(), nginxModuleConfDir, "ssl")
fileOp := files.NewFileOp()
if !fileOp.Stat(sslDir) {
return errors.New("ssl dir not found")
@@ -2196,7 +2061,7 @@ func handleSSLConfig(appInstall *model.AppInstall, hasDefaultWebsite bool, sslRe
_ = NewIWebsiteSSLService().Delete([]uint{websiteSSL.ID})
}()
}
defaultConfigPath := path.Join(appInstall.GetPath(), "conf", "default", "00.default.conf")
defaultConfigPath := path.Join(appInstall.GetPath(), nginxModuleConfDir, "default", "00.default.conf")
content, err := os.ReadFile(defaultConfigPath)
if err != nil {
return err
@@ -2288,13 +2153,25 @@ func needsUpdate(localTag *model.Tag, remoteTag dto.Tag, translations string) bo
}
func hasLinkDB(installID uint) bool {
return hasLinkDBFromKeys(getAppInstallResourceKeys(installID))
}
func getAppInstallResourceKeys(installID uint) []string {
resources, _ := appInstallResourceRepo.GetBy(appInstallResourceRepo.WithAppInstallId(installID))
keys := make([]string, 0, len(resources))
for _, resource := range resources {
keys = append(keys, resource.Key)
}
return keys
}
func hasLinkDBFromKeys(resourceKeys []string) bool {
hasDB := false
if len(resources) > 0 {
for _, resource := range resources {
if resource.Key == constant.AppPostgres || resource.Key == constant.AppMysql ||
resource.Key == constant.AppMariaDB || resource.Key == constant.AppMysqlCluster ||
resource.Key == constant.AppPostgresql || resource.Key == constant.AppPostgresqlCluster {
if len(resourceKeys) > 0 {
for _, resourceKey := range resourceKeys {
if resourceKey == constant.AppPostgres || resourceKey == constant.AppMysql ||
resourceKey == constant.AppMariaDB || resourceKey == constant.AppMysqlCluster ||
resourceKey == constant.AppPostgresql || resourceKey == constant.AppPostgresqlCluster {
hasDB = true
break
}
+78 -6
View File
@@ -2,6 +2,7 @@ package service
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"fmt"
@@ -372,7 +373,7 @@ func (u *BackupService) checkBackupConn(backup *model.BackupAccount) (bool, erro
targetPath = strings.TrimPrefix(targetPath, "/")
}
if _, err := client.Upload(fileItem, targetPath); err != nil {
if _, err := client.Upload(context.Background(), fileItem, targetPath); err != nil {
return false, err
}
_, _ = client.Delete(path.Join(backup.BackupPath, "test/1panel"))
@@ -437,6 +438,10 @@ type backupClientHelper struct {
}
func NewBackupClientMap(ids []string) map[string]backupClientHelper {
return NewBackupClientMapWithContext(context.Background(), ids)
}
func NewBackupClientMapWithContext(ctx context.Context, ids []string) map[string]backupClientHelper {
var accounts []model.BackupAccount
var idItems []uint
for i := 0; i < len(ids); i++ {
@@ -446,7 +451,7 @@ func NewBackupClientMap(ids []string) map[string]backupClientHelper {
accounts, _ = backupRepo.List(repo.WithByIDs(idItems))
clientMap := make(map[string]backupClientHelper)
for _, item := range accounts {
backClient, err := newClient(&item, true)
backClient, err := newClientWithContext(ctx, &item, true)
itemHelper := backupClientHelper{
client: backClient,
name: item.Name,
@@ -463,7 +468,11 @@ func NewBackupClientMap(ids []string) map[string]backupClientHelper {
return clientMap
}
func uploadWithMap(taskItem task.Task, accountMap map[string]backupClientHelper, src, dst, accountIDs string, downloadAccountID, retry uint) error {
func uploadWithMap(taskItem task.Task, accountMap map[string]backupClientHelper, src, dst, accountIDs string, downloadAccountID, retry uint, cleanOnFailure bool) error {
return uploadWithMapWithContext(context.Background(), taskItem, accountMap, src, dst, accountIDs, downloadAccountID, retry, cleanOnFailure, true)
}
func uploadWithMapWithContext(ctx context.Context, taskItem task.Task, accountMap map[string]backupClientHelper, src, dst, accountIDs string, downloadAccountID, retry uint, cleanOnFailure, removeSrc bool) error {
accounts := strings.Split(accountIDs, ",")
for _, account := range accounts {
if len(account) == 0 {
@@ -489,10 +498,13 @@ func uploadWithMap(taskItem task.Task, accountMap map[string]backupClientHelper,
"backup": name,
}))
for i := 0; i < int(retry)+1; i++ {
_, err := itemBackup.client.Upload(src, path.Join(itemBackup.backupPath, dst))
_, err := itemBackup.client.Upload(ctx, src, path.Join(itemBackup.backupPath, dst))
taskItem.LogWithStatus(i18n.GetMsgByKey("Upload"), err)
if err != nil {
if account == fmt.Sprintf("%d", downloadAccountID) {
if cleanOnFailure {
cleanupCronjobBackupArtifacts(accountMap, src, dst)
}
return err
}
} else {
@@ -502,11 +514,71 @@ func uploadWithMap(taskItem task.Task, accountMap map[string]backupClientHelper,
itemBackup.hasBackup = true
accountMap[account] = itemBackup
}
os.RemoveAll(src)
if removeSrc {
os.RemoveAll(src)
}
return nil
}
func cleanupCronjobBackupArtifacts(accountMap map[string]backupClientHelper, src, dst string) {
if err := os.RemoveAll(src); err != nil {
global.LOG.Errorf("remove failed local cronjob backup file %s failed, err: %v", src, err)
}
for _, account := range accountMap {
if !account.isOk {
continue
}
if _, err := account.client.Delete(path.Join(account.backupPath, dst)); err != nil {
global.LOG.Errorf("remove failed cronjob backup file %s failed, err: %v", dst, err)
}
}
}
func markBackupFailed(recordID uint, backupErr error) {
_ = backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusFailed, "message": backupErr.Error()})
record, err := backupRepo.GetRecord(repo.WithByID(recordID))
if err != nil || record.ID == 0 {
global.LOG.Errorf("load failed backup record %d for cleanup failed, err: %v", recordID, err)
return
}
filePath := path.Join(record.FileDir, record.FileName)
if err := os.Remove(path.Join(global.Dir.LocalBackupDir, filePath)); err != nil && !os.IsNotExist(err) {
global.LOG.Errorf("remove failed local backup file %s failed, err: %v", filePath, err)
}
cleaned := make(map[string]struct{})
for _, accountID := range strings.Split(record.SourceAccountIDs, ",") {
if accountID == "" {
continue
}
if _, ok := cleaned[accountID]; ok {
continue
}
cleaned[accountID] = struct{}{}
id, err := strconv.Atoi(accountID)
if err != nil {
global.LOG.Errorf("parse backup account %s for failed backup cleanup failed, err: %v", accountID, err)
continue
}
account, storageClient, err := NewBackupClientWithID(uint(id))
if err != nil {
global.LOG.Errorf("new backup client for failed backup cleanup failed, err: %v", err)
continue
}
if _, err := storageClient.Delete(path.Join(account.BackupPath, filePath)); err != nil {
global.LOG.Errorf("remove failed backup file %s failed, err: %v", filePath, err)
}
}
}
func newClient(account *model.BackupAccount, isEncrypt bool) (cloud_storage.CloudStorageClient, error) {
return newClientWithContext(context.Background(), account, isEncrypt)
}
func newClientWithContext(ctx context.Context, account *model.BackupAccount, isEncrypt bool) (cloud_storage.CloudStorageClient, error) {
varMap := make(map[string]interface{})
if len(account.Vars) != 0 {
if err := json.Unmarshal([]byte(account.Vars), &varMap); err != nil {
@@ -531,7 +603,7 @@ func newClient(account *model.BackupAccount, isEncrypt bool) (cloud_storage.Clou
varMap["password"] = account.Credential
}
client, err := cloud_storage.NewCloudStorageClient(account.Type, varMap)
client, err := cloud_storage.NewCloudStorageClientWithContext(ctx, account.Type, varMap)
if err != nil {
return nil, err
}
+107 -10
View File
@@ -63,11 +63,58 @@ func (u *BackupService) AppBackup(req dto.CommonBackup) (*model.BackupRecord, er
return nil, err
}
if err = handleAppBackup(&install, nil, record.ID, backupDir, fileName, "", req.Secret, req.TaskID); err != nil {
global.LOG.Errorf("backup app %s failed, err: %v", req.DetailName, err)
return nil, err
if !req.IsImmediate {
if err = handleAppBackup(&install, nil, record.ID, backupDir, fileName, "", req.Secret, req.TaskID); err != nil {
markBackupFailed(record.ID, err)
global.LOG.Errorf("backup app %s failed, err: %v", req.DetailName, err)
return nil, err
}
return record, nil
}
backupTask, err := task.NewTaskWithOps(install.Name, task.TaskBackup, task.TaskScopeBackup, req.TaskID, install.ID)
if err != nil {
markBackupFailed(record.ID, err)
record.Status = constant.StatusFailed
record.Message = err.Error()
return nil, err
}
if err = doAppBackup(&install, backupTask, backupDir, fileName, "", req.Secret); err != nil {
markBackupFailed(record.ID, err)
record.Status = constant.StatusFailed
record.Message = err.Error()
return nil, err
}
backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusSuccess})
record.Status = constant.StatusSuccess
return record, nil
}
func backupAppWithParentTask(install *model.AppInstall, parentTask *task.Task, fileName string) (*model.BackupRecord, error) {
itemDir := fmt.Sprintf("app/%s/%s", install.App.Key, install.Name)
backupDir := path.Join(global.Dir.LocalBackupDir, itemDir)
record := &model.BackupRecord{
Type: "app",
Name: install.App.Key,
DetailName: install.Name,
SourceAccountIDs: "1",
DownloadAccountID: 1,
FileDir: itemDir,
FileName: fileName,
TaskID: parentTask.TaskID,
Status: constant.StatusWaiting,
}
if err := backupRepo.CreateRecord(record); err != nil {
return nil, err
}
if err := handleAppBackup(install, parentTask, record.ID, backupDir, fileName, "", "", parentTask.TaskID); err != nil {
markBackupFailed(record.ID, err)
record.Status = constant.StatusFailed
record.Message = err.Error()
return record, err
}
backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusSuccess})
record.Status = constant.StatusSuccess
return record, nil
}
@@ -143,7 +190,7 @@ func handleAppBackup(install *model.AppInstall, parentTask *task.Task, recordID
backupTask.AddSubTaskWithOps(task.GetTaskName(install.Name, task.TaskBackup, task.TaskScopeBackup), func(t *task.Task) error { return itemHandler() }, nil, 3, time.Hour)
go func() {
if err := backupTask.Execute(); err != nil {
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
markBackupFailed(recordID, err)
return
}
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess})
@@ -184,7 +231,11 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
return err
}
defer func() {
_, _ = compose.Up(install.GetComposePath())
if isRollback {
_, _ = compose.UpWithoutPull(install.GetComposePath())
} else {
_, _ = compose.Up(install.GetComposePath())
}
_ = os.RemoveAll(strings.ReplaceAll(recoverFile, ".tar.gz", ""))
}()
@@ -261,7 +312,7 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
if err != nil {
return err
}
newDB, err := reCreateDB(db.ID, database, backupEnvMap)
newDB, err := reCreateDB(db.ID, database, backupEnvMap, install.Name)
if err != nil {
return err
}
@@ -389,10 +440,16 @@ func doAppBackup(install *model.AppInstall, parentTask *task.Task, backupDir, fi
return nil
}
func reCreateDB(dbID uint, database model.Database, envMap map[string]interface{}) (*model.DatabaseMysql, error) {
func reCreateDB(dbID uint, database model.Database, envMap map[string]interface{}, appInstallName string) (*model.DatabaseMysql, error) {
mysqlService := NewIMysqlService()
ctx := context.Background()
_ = mysqlService.Delete(ctx, dto.MysqlDBDelete{ID: dbID, Database: database.Name, Type: database.Type, DeleteBackup: false, ForceDelete: true})
if err := deleteMysqlDatabaseForResourceOwner(
ctx,
dto.MysqlDBDelete{ID: dbID, Database: database.Name, Type: database.Type, DeleteBackup: false, ForceDelete: true},
dto.DBResource{Type: constant.TypeApp, Name: appInstallName},
); err != nil {
return nil, err
}
dbInfo := getDBCreateInfoFromEnv(envMap, "utf8mb4")
createDB, err := mysqlService.Create(context.Background(), dto.MysqlDBCreate{
@@ -400,17 +457,57 @@ func reCreateDB(dbID uint, database model.Database, envMap map[string]interface{
From: database.From,
Database: database.Name,
Format: dbInfo.Format,
Username: dbInfo.User,
Password: dbInfo.Password,
Permission: "%",
})
if err != nil {
return nil, err
}
if len(dbInfo.User) != 0 {
if err := ensureMysqlDBUser(mysqlService, database, dbInfo); err != nil {
return nil, err
}
}
updateCronjobsDBRef(dbID, createDB.ID)
return createDB, nil
}
func ensureMysqlDBUser(mysqlService IMysqlService, database model.Database, dbInfo dbRecreateInfo) error {
const host = "%"
users, err := mysqlService.ListUsers(dto.MysqlUserSearch{Database: database.Name})
if err != nil {
return err
}
var oldUser dto.MysqlUser
exists := false
for _, user := range users {
if user.Username == dbInfo.User && user.Host == host && !user.IsDelete {
oldUser = user
exists = true
break
}
}
if exists {
if len(oldUser.Password) != 0 && oldUser.Password != dbInfo.Password {
return buserr.New("ErrDbUserNotValid")
}
} else {
if err := mysqlService.CreateUser(dto.MysqlUserCreate{
Database: database.Name,
Username: dbInfo.User,
Host: host,
Password: dbInfo.Password,
}); err != nil {
return err
}
}
return mysqlService.GrantUser(dto.MysqlGrantCreate{
Database: database.Name,
DB: dbInfo.Name,
Username: dbInfo.User,
Host: host,
})
}
func reCreatePostgresqlDB(dbID uint, database model.Database, envMap map[string]interface{}) (*model.DatabasePostgresql, error) {
postgresqlService := NewIPostgresqlService()
_ = postgresqlService.Delete(context.Background(), dto.PostgresqlDBDelete{
+2 -2
View File
@@ -98,7 +98,7 @@ func (u *BackupService) ComposeBackup(req dto.CommonBackup) error {
return err
}
if err := handleComposeBackup(req, nil, record.ID, backupDir, fileName); err != nil {
backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
markBackupFailed(record.ID, err)
return err
}
return nil
@@ -163,7 +163,7 @@ func handleComposeBackup(req dto.CommonBackup, parentTask *task.Task, recordID u
go func() {
defer composeCtx.close()
if err := backupTask.Execute(); err != nil {
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
markBackupFailed(recordID, err)
return
}
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess})
+82 -18
View File
@@ -106,7 +106,7 @@ func (u *BackupService) ContainerBackup(req dto.CommonBackup) error {
return err
}
if err := handleContainerBackup(req.Name, nil, record.ID, backupDir, fileName, req.TaskID, req.Secret, req.StopBefore); err != nil {
backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
markBackupFailed(record.ID, err)
return err
}
return nil
@@ -158,7 +158,7 @@ func handleContainerBackup(containerName string, parentTask *task.Task, recordID
go func() {
defer backupCtx.close()
if err := backupTask.Execute(); err != nil {
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
markBackupFailed(recordID, err)
return
}
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess})
@@ -604,23 +604,40 @@ func stepRecreateContainer(recoverCtx *containerRecoverContext, taskItem *task.T
return nil
}
func removeUnsupportedEndpointStaticIPAM(cli *client.Client, primary *network.NetworkingConfig, extras map[string]*network.EndpointSettings) {
func normalizeContainerEndpointSettings(ctx context.Context, cli *client.Client, primary *network.NetworkingConfig, extras map[string]*network.EndpointSettings) {
if cli.NewVersionError(ctx, "1.44", "specify mac-address per network") != nil {
removeEndpointMacAddresses(primary, extras)
}
endpointGroups := []map[string]*network.EndpointSettings{extras}
if primary != nil {
removeUnsupportedEndpointStaticIPAMFromEndpoints(cli, primary.EndpointsConfig)
endpointGroups = append(endpointGroups, primary.EndpointsConfig)
}
for _, endpoints := range endpointGroups {
for netName, endpoint := range endpoints {
if endpoint == nil || endpoint.IPAMConfig == nil {
continue
}
info, err := cli.NetworkInspect(ctx, netName, network.InspectOptions{})
if err != nil {
continue
}
removeUnsupportedEndpointStaticIP(netName, info, endpoint)
}
}
removeUnsupportedEndpointStaticIPAMFromEndpoints(cli, extras)
}
func removeUnsupportedEndpointStaticIPAMFromEndpoints(cli *client.Client, endpoints map[string]*network.EndpointSettings) {
for netName, endpoint := range endpoints {
if endpoint == nil || endpoint.IPAMConfig == nil {
continue
func removeEndpointMacAddresses(primary *network.NetworkingConfig, extras map[string]*network.EndpointSettings) {
if primary != nil {
for _, endpoint := range primary.EndpointsConfig {
if endpoint != nil {
endpoint.MacAddress = ""
}
}
info, err := cli.NetworkInspect(context.Background(), netName, network.InspectOptions{})
if err != nil {
continue
}
for _, endpoint := range extras {
if endpoint != nil {
endpoint.MacAddress = ""
}
removeUnsupportedEndpointStaticIP(netName, info, endpoint)
}
}
@@ -639,7 +656,7 @@ func removeUnsupportedEndpointStaticIP(netName string, info network.Inspect, end
if endpoint.IPAMConfig.IPv6Address != "" && !networkSupportsStaticIP(info, endpoint.IPAMConfig.IPv6Address, true) {
endpoint.IPAMConfig.IPv6Address = ""
}
if endpoint.IPAMConfig.IPv4Address == "" && endpoint.IPAMConfig.IPv6Address == "" {
if endpoint.IPAMConfig.IPv4Address == "" && endpoint.IPAMConfig.IPv6Address == "" && len(endpoint.IPAMConfig.LinkLocalIPs) == 0 {
endpoint.IPAMConfig = nil
}
}
@@ -776,13 +793,27 @@ func buildContainerRecoverNetworkConfig(networkSettings *container.NetworkSettin
if name == "host" || name == "none" {
continue
}
endpointSetting := &network.EndpointSettings{Aliases: append([]string(nil), endpoint.Aliases...), MacAddress: endpoint.MacAddress}
if endpoint == nil {
if name == primaryName {
config.EndpointsConfig[name] = &network.EndpointSettings{}
} else {
extraNetworks[name] = &network.EndpointSettings{}
}
continue
}
endpointSetting := &network.EndpointSettings{
Links: append([]string(nil), endpoint.Links...),
Aliases: append([]string(nil), endpoint.Aliases...),
DriverOpts: cloneStringMap(endpoint.DriverOpts),
GwPriority: endpoint.GwPriority,
}
if endpoint.IPAMConfig != nil {
endpointSetting.IPAMConfig = &network.EndpointIPAMConfig{
IPv4Address: endpoint.IPAMConfig.IPv4Address,
IPv6Address: endpoint.IPAMConfig.IPv6Address,
IPv4Address: endpoint.IPAMConfig.IPv4Address,
IPv6Address: endpoint.IPAMConfig.IPv6Address,
LinkLocalIPs: append([]string(nil), endpoint.IPAMConfig.LinkLocalIPs...),
}
} else if endpoint.IPAddress != "" || endpoint.GlobalIPv6Address != "" {
} else if name != "bridge" && (endpoint.IPAddress != "" || endpoint.GlobalIPv6Address != "") {
endpointSetting.IPAMConfig = &network.EndpointIPAMConfig{
IPv4Address: endpoint.IPAddress,
IPv6Address: endpoint.GlobalIPv6Address,
@@ -800,6 +831,39 @@ func buildContainerRecoverNetworkConfig(networkSettings *container.NetworkSettin
return config, extraNetworks
}
const unsupportedUserSpecifiedIPAddress = "user specified IP address is supported only when connecting to networks with user configured subnets"
func clearUnsupportedDynamicEndpointIPAM(err error, endpoints map[string]*network.EndpointSettings, networkSettings *container.NetworkSettings) bool {
if err == nil || !strings.Contains(err.Error(), unsupportedUserSpecifiedIPAddress) {
return false
}
for name, endpoint := range endpoints {
if !isDynamicContainerNetwork(networkSettings, name) || endpoint == nil || endpoint.IPAMConfig == nil {
continue
}
if strings.Contains(err.Error(), "network "+name+":") {
endpoint.IPAMConfig = nil
return true
}
}
cleared := false
for name, endpoint := range endpoints {
if isDynamicContainerNetwork(networkSettings, name) && endpoint != nil && endpoint.IPAMConfig != nil {
endpoint.IPAMConfig = nil
cleared = true
}
}
return cleared
}
func isDynamicContainerNetwork(networkSettings *container.NetworkSettings, name string) bool {
if networkSettings == nil || name == "bridge" {
return false
}
endpoint := networkSettings.Networks[name]
return endpoint != nil && endpoint.IPAMConfig == nil && (endpoint.IPAddress != "" || endpoint.GlobalIPv6Address != "")
}
func cloneContainerConfig(config *container.Config) *container.Config {
if config == nil {
return &container.Config{}
+2 -2
View File
@@ -54,7 +54,7 @@ func (u *BackupService) MongodbBackup(req dto.CommonBackup) error {
}
if err := handleMongodbBackup(req, nil, record.ID, targetDir, fileName); err != nil {
backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
markBackupFailed(record.ID, err)
return err
}
return nil
@@ -98,7 +98,7 @@ func handleMongodbBackup(req dto.CommonBackup, parentTask *task.Task, recordID u
)
go func() {
if err := backupTask.Execute(); err != nil {
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
markBackupFailed(recordID, err)
return
}
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess})
+43 -2
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"fmt"
"os"
"path"
@@ -39,6 +40,7 @@ func (u *BackupService) MysqlBackup(req dto.CommonBackup) error {
TaskID: req.TaskID,
Status: constant.StatusWaiting,
Description: req.Description,
Args: encodeBackupArgs(req.Args),
}
if err := backupRepo.CreateRecord(record); err != nil {
global.LOG.Errorf("save backup record failed, err: %v", err)
@@ -47,7 +49,7 @@ func (u *BackupService) MysqlBackup(req dto.CommonBackup) error {
databaseHelper := DatabaseHelper{Database: req.Name, DBType: req.Type, Name: req.DetailName, Args: req.Args}
if err := handleMysqlBackup(databaseHelper, nil, record.ID, targetDir, fileName, req.TaskID, req.Secret); err != nil {
backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
markBackupFailed(record.ID, err)
return err
}
return nil
@@ -100,7 +102,7 @@ func handleMysqlBackup(db DatabaseHelper, parentTask *task.Task, recordID uint,
backupTask.AddSubTaskWithOps(task.GetTaskName(itemName, task.TaskBackup, task.TaskScopeBackup), func(t *task.Task) error { return itemHandler() }, nil, 0, 3*time.Hour)
go func() {
if err := backupTask.Execute(); err != nil {
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
markBackupFailed(recordID, err)
return
}
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess})
@@ -143,6 +145,14 @@ func handleMysqlRecover(req dto.CommonRecover, parentTask *task.Task, isRollback
if !isRollback {
rollbackFile := path.Join(global.Dir.TmpDir, fmt.Sprintf("database/%s/%s_%s.sql.gz", req.Type, req.DetailName, time.Now().Format(constant.DateTimeSlimLayout)))
var rollbackArgs []string
if req.BackupRecordID != 0 {
record, err := backupRepo.GetRecord(repo.WithByID(req.BackupRecordID))
if err != nil {
return err
}
rollbackArgs = decodeBackupArgs(record.Args)
}
if err := cli.Backup(client.BackupInfo{
Name: req.DetailName,
Type: req.Type,
@@ -150,6 +160,7 @@ func handleMysqlRecover(req dto.CommonRecover, parentTask *task.Task, isRollback
Format: dbInfo.Format,
TargetDir: path.Dir(rollbackFile),
FileName: path.Base(rollbackFile),
Args: rollbackArgs,
}); err != nil {
return fmt.Errorf("backup mysql db %s for rollback before recover failed, err: %v", req.DetailName, err)
}
@@ -242,6 +253,36 @@ func doMysqlBackup(db DatabaseHelper, targetDir, fileName, secret string) error
return nil
}
func encodeBackupArgs(args []string) string {
var items []string
for _, arg := range args {
if len(arg) != 0 {
items = append(items, arg)
}
}
if len(items) == 0 {
return ""
}
data, err := json.Marshal(items)
if err != nil {
global.LOG.Warnf("marshal backup args failed: %v", err)
return ""
}
return string(data)
}
func decodeBackupArgs(value string) []string {
if len(value) == 0 {
return nil
}
var args []string
if err := json.Unmarshal([]byte(value), &args); err != nil {
global.LOG.Warnf("unmarshal backup args failed: %v", err)
return nil
}
return args
}
func loadSqlFile(file string) (string, error) {
if !strings.HasSuffix(file, ".tar.gz") && !strings.HasSuffix(file, ".zip") {
return file, nil
+3 -1
View File
@@ -39,10 +39,12 @@ func (u *BackupService) PostgresqlBackup(req dto.CommonBackup) error {
}
if err := backupRepo.CreateRecord(record); err != nil {
global.LOG.Errorf("save backup record failed, err: %v", err)
return err
}
databaseHelper := DatabaseHelper{Database: req.Name, DBType: req.Type, Name: req.DetailName}
if err := handlePostgresqlBackup(databaseHelper, nil, record.ID, targetDir, fileName, req.TaskID, req.Secret); err != nil {
markBackupFailed(record.ID, err)
return err
}
return nil
@@ -88,7 +90,7 @@ func handlePostgresqlBackup(db DatabaseHelper, parentTask *task.Task, recordID u
backupTask.AddSubTaskWithOps(task.GetTaskName(itemName, task.TaskBackup, task.TaskScopeBackup), func(t *task.Task) error { return itemHandler() }, nil, 0, 3*time.Hour)
go func() {
if err := backupTask.Execute(); err != nil {
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
markBackupFailed(recordID, err)
return
}
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess})
+2 -1
View File
@@ -59,6 +59,7 @@ func (u *BackupService) RedisBackup(req dto.CommonBackup) error {
}
if err := handleRedisBackup(redisInfo, nil, record.ID, backupDir, fileName, req.Secret, req.TaskID); err != nil {
markBackupFailed(record.ID, err)
return err
}
return nil
@@ -129,7 +130,7 @@ func handleRedisBackup(redisInfo *repo.RootInfo, parentTask *task.Task, recordID
itemTask.AddSubTaskWithOps(i18n.GetMsgByKey("TaskBackup"), backupDatabase, nil, 3, time.Hour)
go func() {
if err := itemTask.Execute(); err != nil {
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
markBackupFailed(recordID, err)
return
}
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess})
+2 -1
View File
@@ -54,6 +54,7 @@ func (u *BackupService) WebsiteBackup(req dto.CommonBackup) error {
return err
}
if err = handleWebsiteBackup(&website, nil, record.ID, backupDir, fileName, "", req.Secret, req.TaskID); err != nil {
markBackupFailed(record.ID, err)
global.LOG.Errorf("backup website %s failed, err: %v", website.Alias, err)
return err
}
@@ -235,7 +236,7 @@ func handleWebsiteBackup(website *model.Website, parentTask *task.Task, recordID
backupTask.AddSubTaskWithOps(task.GetTaskName(website.Alias, task.TaskBackup, task.TaskScopeBackup), func(t *task.Task) error { return itemHandler() }, nil, 3, time.Hour)
go func() {
if err := backupTask.Execute(); err != nil {
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
markBackupFailed(recordID, err)
return
}
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess})
+141 -238
View File
@@ -15,6 +15,7 @@ import (
"os/exec"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
@@ -32,7 +33,6 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/re"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/build"
"github.com/docker/docker/api/types/container"
@@ -53,6 +53,8 @@ import (
type ContainerService struct{}
var containerLogAnsiRegex = regexp.MustCompile("\x1b\\[[0-9;?]*[A-Za-z]|\x1b=|\x1b>")
type IContainerService interface {
Page(req dto.PageContainer) (int64, interface{}, error)
List() []dto.ContainerOptions
@@ -69,6 +71,7 @@ type IContainerService interface {
ComposeOperation(req dto.ComposeOperation) error
TestCompose(req dto.ComposeCreate) (bool, error)
ComposeUpdate(req dto.ComposeUpdate) error
ComposePin(req dto.ComposePin) error
ComposeLogClean(req dto.ComposeLogClean) error
ContainerCreate(req dto.ContainerOperate, inThread bool) error
@@ -483,15 +486,19 @@ func (u *ContainerService) ContainerCreate(req dto.ContainerOperate, inThread bo
if err != nil {
return err
}
defer client.Close()
unlock := containerOperationLock.lock(req.Name)
ctx := context.Background()
newContainer, _ := client.ContainerInspect(ctx, req.Name)
if newContainer.ContainerJSONBase != nil {
unlock()
_ = client.Close()
return buserr.New("ErrContainerName")
}
taskItem, err := task.NewTaskWithOps(req.Name, task.TaskCreate, task.TaskScopeContainer, req.TaskID, 1)
if err != nil {
unlock()
_ = client.Close()
global.LOG.Errorf("new task for create container failed, err: %v", err)
return err
}
@@ -527,18 +534,20 @@ func (u *ContainerService) ContainerCreate(req dto.ContainerOperate, inThread bo
if err != nil {
return err
}
removeUnsupportedEndpointStaticIPAM(client, networkConf, nil)
normalizeContainerEndpointSettings(ctx, client, networkConf, nil)
con, err := client.ContainerCreate(ctx, config, hostConf, networkConf, &v1.Platform{}, req.Name)
if err != nil {
taskItem.Log(i18n.GetMsgByKey("ContainerCreateFailed"))
_ = client.ContainerRemove(ctx, req.Name, container.RemoveOptions{RemoveVolumes: true, Force: true})
if con.ID != "" {
_ = client.ContainerRemove(ctx, con.ID, container.RemoveOptions{RemoveVolumes: true, Force: true})
}
return err
}
err = client.ContainerStart(ctx, con.ID, container.StartOptions{})
taskItem.LogWithStatus(i18n.GetMsgByKey("ContainerStartCheck"), err)
if err != nil {
taskItem.Log(i18n.GetMsgByKey("ContainerCreateFailed"))
_ = client.ContainerRemove(ctx, req.Name, container.RemoveOptions{RemoveVolumes: true, Force: true})
_ = client.ContainerRemove(ctx, con.ID, container.RemoveOptions{RemoveVolumes: true, Force: true})
return fmt.Errorf("create successful but start failed, err: %v", err)
}
return nil
@@ -546,12 +555,16 @@ func (u *ContainerService) ContainerCreate(req dto.ContainerOperate, inThread bo
if inThread {
go func() {
defer unlock()
defer client.Close()
if err := taskItem.Execute(); err != nil {
global.LOG.Error(err.Error())
}
}()
return nil
}
defer unlock()
defer client.Close()
return taskItem.Execute()
}
@@ -572,21 +585,7 @@ func (u *ContainerService) ContainerInfo(req dto.OperationWithName) (*dto.Contai
data.Image = oldContainer.Config.Image
if oldContainer.NetworkSettings != nil {
for net, val := range oldContainer.NetworkSettings.Networks {
netItem := dto.ContainerNetwork{
Network: net,
MacAddr: val.MacAddress,
}
if val.IPAMConfig != nil {
if netItem.Network != "bridge" {
netItem.Ipv4 = val.IPAMConfig.IPv4Address
netItem.Ipv6 = val.IPAMConfig.IPv6Address
}
} else {
if netItem.Network != "bridge" {
netItem.Ipv4 = val.IPAddress
}
}
data.Networks = append(data.Networks, netItem)
data.Networks = append(data.Networks, loadContainerNetworkInfo(net, val))
}
}
@@ -632,141 +631,40 @@ func (u *ContainerService) ContainerInfo(req dto.OperationWithName) (*dto.Contai
return &data, nil
}
func (u *ContainerService) ContainerUpdate(req dto.ContainerOperate) error {
client, err := docker.NewDockerClient()
if err != nil {
return err
func loadContainerNetworkInfo(name string, endpoint *network.EndpointSettings) dto.ContainerNetwork {
item := dto.ContainerNetwork{Network: name}
if endpoint == nil {
return item
}
defer client.Close()
ctx := context.Background()
oldContainer, err := client.ContainerInspect(ctx, req.Name)
if err != nil {
return err
item.MacAddr = endpoint.MacAddress
item.Links = append([]string(nil), endpoint.Links...)
item.Aliases = append([]string(nil), endpoint.Aliases...)
item.DriverOpts = cloneStringMap(endpoint.DriverOpts)
item.GwPriority = endpoint.GwPriority
if endpoint.IPAMConfig != nil {
item.LinkLocalIPs = append([]string(nil), endpoint.IPAMConfig.LinkLocalIPs...)
}
taskItem, err := task.NewTaskWithOps(req.Name, task.TaskUpdate, task.TaskScopeContainer, req.TaskID, 1)
if err != nil {
global.LOG.Errorf("new task for create container failed, err: %v", err)
return err
}
go func() {
taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error {
if !checkImageExist(client, req.Image) || req.ForcePull {
if err := pullImages(taskItem, client, req.Image); err != nil {
if !req.ForcePull {
return err
}
return fmt.Errorf("pull image %s failed, err: %v", req.Image, err)
}
}
return nil
}, nil)
taskItem.AddSubTask(i18n.GetWithName("ContainerCreate", req.Name), func(t *task.Task) error {
err := client.ContainerRemove(ctx, req.Name, container.RemoveOptions{Force: true})
taskItem.LogWithStatus(i18n.GetWithName("ContainerRemoveOld", req.Name), err)
if err != nil {
return err
}
config, hostConf, networkConf, err := loadConfigInfo(false, req, &oldContainer)
taskItem.LogWithStatus(i18n.GetMsgByKey("ContainerLoadInfo"), err)
if err != nil {
taskItem.Log(i18n.GetMsgByKey("ContainerRecreate"))
reCreateAfterUpdate(req.Name, client, oldContainer.Config, oldContainer.HostConfig, oldContainer.NetworkSettings)
return err
}
removeUnsupportedEndpointStaticIPAM(client, networkConf, nil)
con, err := client.ContainerCreate(ctx, config, hostConf, networkConf, &v1.Platform{}, req.Name)
if err != nil {
taskItem.Log(i18n.GetMsgByKey("ContainerRecreate"))
reCreateAfterUpdate(req.Name, client, oldContainer.Config, oldContainer.HostConfig, oldContainer.NetworkSettings)
return fmt.Errorf("update container failed, err: %v", err)
}
err = client.ContainerStart(ctx, con.ID, container.StartOptions{})
taskItem.LogWithStatus(i18n.GetMsgByKey("ContainerStartCheck"), err)
if err != nil {
return fmt.Errorf("update successful but start failed, err: %v", err)
}
return nil
}, nil)
if err := taskItem.Execute(); err != nil {
global.LOG.Error(err.Error())
if name != "bridge" {
if endpoint.IPAMConfig != nil {
item.Ipv4 = endpoint.IPAMConfig.IPv4Address
item.Ipv6 = endpoint.IPAMConfig.IPv6Address
} else {
item.Ipv4 = endpoint.IPAddress
item.Ipv6 = endpoint.GlobalIPv6Address
}
}()
return nil
}
return item
}
func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
client, err := docker.NewDockerClient()
if err != nil {
return err
func cloneStringMap(source map[string]string) map[string]string {
if len(source) == 0 {
return nil
}
defer client.Close()
ctx := context.Background()
taskItem, err := task.NewTaskWithOps(req.Image, task.TaskUpgrade, task.TaskScopeImage, req.TaskID, 1)
if err != nil {
global.LOG.Errorf("new task for create container failed, err: %v", err)
return err
result := make(map[string]string, len(source))
for key, value := range source {
result[key] = value
}
go func() {
taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error {
taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image))
if !checkImageExist(client, req.Image) || req.ForcePull {
if err := pullImages(taskItem, client, req.Image); err != nil {
if !req.ForcePull {
return err
}
return fmt.Errorf("pull image %s failed, err: %v", req.Image, err)
}
}
return nil
}, nil)
for _, item := range req.Names {
var oldContainer container.InspectResponse
taskItem.AddSubTask(i18n.GetWithName("ContainerLoadInfo", item), func(t *task.Task) error {
taskItem.Logf("----------------- %s -----------------", item)
oldContainer, err = client.ContainerInspect(ctx, item)
if err != nil {
return err
}
return nil
}, nil)
taskItem.AddSubTask(i18n.GetWithName("ContainerCreate", item), func(t *task.Task) error {
config := oldContainer.Config
config.Image = req.Image
hostConf := oldContainer.HostConfig
err := client.ContainerRemove(ctx, item, container.RemoveOptions{Force: true})
taskItem.LogWithStatus(i18n.GetWithName("ContainerRemoveOld", item), err)
if err != nil {
return err
}
con, err := createContainerWithOldNetworks(ctx, client, config, hostConf, oldContainer.NetworkSettings, item)
if err != nil {
taskItem.Log(i18n.GetMsgByKey("ContainerRecreate"))
reCreateAfterUpdate(item, client, oldContainer.Config, oldContainer.HostConfig, oldContainer.NetworkSettings)
return fmt.Errorf("upgrade container failed, err: %v", err)
}
err = client.ContainerStart(ctx, con.ID, container.StartOptions{})
taskItem.LogWithStatus(i18n.GetMsgByKey("ContainerStartCheck"), err)
if err != nil {
return fmt.Errorf("upgrade successful but start failed, err: %v", err)
}
return nil
}, nil)
}
if err := taskItem.Execute(); err != nil {
global.LOG.Error(err.Error())
}
}()
return nil
return result
}
func (u *ContainerService) ContainerRename(req dto.ContainerRename) error {
@@ -776,6 +674,8 @@ func (u *ContainerService) ContainerRename(req dto.ContainerRename) error {
return err
}
defer client.Close()
unlock := containerOperationLock.lock(req.Name, req.NewName)
defer unlock()
newContainer, _ := client.ContainerInspect(ctx, req.NewName)
if newContainer.ContainerJSONBase != nil {
@@ -820,44 +720,48 @@ func (u *ContainerService) ContainerCommit(req dto.ContainerCommit) error {
}
func (u *ContainerService) ContainerOperation(req dto.ContainerOperation) error {
var err error
ctx := context.Background()
client, err := docker.NewDockerClient()
if err != nil {
return err
}
defer client.Close()
taskItem, err := task.NewTaskWithOps(strings.Join(req.Names, " "), req.Operation, task.TaskScopeContainer, req.TaskID, 1)
if err != nil {
_ = client.Close()
return fmt.Errorf("new task for container commit failed, err: %v", err)
}
for _, item := range req.Names {
item := item
taskItem.AddSubTask(item, func(t *task.Task) error {
unlock := containerOperationLock.lock(item)
defer unlock()
var operationErr error
switch req.Operation {
case constant.ContainerOpStart:
err = client.ContainerStart(ctx, item, container.StartOptions{})
operationErr = client.ContainerStart(ctx, item, container.StartOptions{})
case constant.ContainerOpStop:
err = client.ContainerStop(ctx, item, container.StopOptions{})
operationErr = client.ContainerStop(ctx, item, container.StopOptions{})
case constant.ContainerOpRestart:
err = client.ContainerRestart(ctx, item, container.StopOptions{})
operationErr = client.ContainerRestart(ctx, item, container.StopOptions{})
case constant.ContainerOpKill:
err = client.ContainerKill(ctx, item, "SIGKILL")
operationErr = client.ContainerKill(ctx, item, "SIGKILL")
case constant.ContainerOpPause:
err = client.ContainerPause(ctx, item)
operationErr = client.ContainerPause(ctx, item)
case constant.ContainerOpUnpause:
err = client.ContainerUnpause(ctx, item)
operationErr = client.ContainerUnpause(ctx, item)
case constant.ContainerOpRemove:
err = client.ContainerRemove(ctx, item, container.RemoveOptions{RemoveVolumes: true, Force: true})
operationErr = client.ContainerRemove(ctx, item, container.RemoveOptions{RemoveVolumes: true, Force: true})
}
return err
return operationErr
}, nil)
}
go func() {
defer client.Close()
_ = taskItem.Execute()
}()
return err
return nil
}
func (u *ContainerService) ContainerLogClean(req dto.OperationWithName) error {
@@ -866,6 +770,8 @@ func (u *ContainerService) ContainerLogClean(req dto.OperationWithName) error {
return err
}
defer client.Close()
unlock := containerOperationLock.lock(req.Name)
defer unlock()
ctx := context.Background()
containerItem, err := client.ContainerInspect(ctx, req.Name)
if err != nil {
@@ -1036,6 +942,7 @@ func (u *ContainerService) DownloadContainerLogs(containerType, container, since
if cmd.CheckIllegal(container, since, tail) {
return buserr.New("ErrCmdIllegal")
}
ctx := c.Request.Context()
commandArg := []string{"logs", container}
dockerCommand := global.CONF.DockerConfig.Command
if containerType == "compose" {
@@ -1065,10 +972,11 @@ func (u *ContainerService) DownloadContainerLogs(containerType, container, since
}
var dockerCmd *exec.Cmd
if containerType == "compose" && dockerCommand == "docker-compose" {
dockerCmd = exec.Command("docker-compose", commandArg...)
dockerCmd = exec.CommandContext(ctx, "docker-compose", commandArg...)
} else {
dockerCmd = exec.Command("docker", commandArg...)
dockerCmd = exec.CommandContext(ctx, "docker", commandArg...)
}
dockerCmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
stdout, err := dockerCmd.StdoutPipe()
if err != nil {
return err
@@ -1077,15 +985,20 @@ func (u *ContainerService) DownloadContainerLogs(containerType, container, since
if err := dockerCmd.Start(); err != nil {
return err
}
defer func() {
if dockerCmd.Process != nil {
_ = dockerCmd.Process.Kill()
_ = dockerCmd.Wait()
done := make(chan struct{})
go func() {
select {
case <-ctx.Done():
killContainerLogProcess(dockerCmd)
case <-done:
}
}()
defer close(done)
tempFile, err := os.CreateTemp("", "cmd_output_*.txt")
if err != nil {
killContainerLogProcess(dockerCmd)
_ = dockerCmd.Wait()
return err
}
defer tempFile.Close()
@@ -1094,31 +1007,19 @@ func (u *ContainerService) DownloadContainerLogs(containerType, container, since
global.LOG.Errorf("os.Remove() failed: %v", err)
}
}()
errCh := make(chan error, 1)
go func() {
scanner := bufio.NewScanner(stdout)
var ansiRegex = re.GetRegex(re.AnsiEscapePattern)
for scanner.Scan() {
line := scanner.Text()
cleanLine := ansiRegex.ReplaceAllString(line, "")
if _, err := tempFile.WriteString(cleanLine + "\n"); err != nil {
errCh <- err
return
}
copyErr := copyContainerLogOutput(tempFile, stdout)
waitErr := dockerCmd.Wait()
if copyErr != nil {
return copyErr
}
if waitErr != nil {
if ctx.Err() != nil {
return ctx.Err()
}
if err := scanner.Err(); err != nil {
errCh <- err
return
}
errCh <- nil
}()
select {
case err := <-errCh:
if err != nil {
global.LOG.Errorf("Error: %v", err)
}
case <-time.After(40 * time.Second):
global.LOG.Errorf("Download container logs timeout reached")
return waitErr
}
if _, err := tempFile.Seek(0, io.SeekStart); err != nil {
return err
}
info, _ := tempFile.Stat()
@@ -1128,6 +1029,36 @@ func (u *ContainerService) DownloadContainerLogs(containerType, container, since
return nil
}
func copyContainerLogOutput(dst io.Writer, src io.Reader) error {
reader := bufio.NewReader(src)
for {
line, err := reader.ReadString('\n')
if len(line) > 0 {
cleanLine := containerLogAnsiRegex.ReplaceAllString(line, "")
if _, writeErr := io.WriteString(dst, cleanLine); writeErr != nil {
return writeErr
}
}
if err != nil {
if err == io.EOF {
return nil
}
return err
}
}
}
func killContainerLogProcess(command *exec.Cmd) {
if command == nil || command.Process == nil {
return
}
if pgid, err := syscall.Getpgid(command.Process.Pid); err == nil {
_ = syscall.Kill(-pgid, syscall.SIGKILL)
return
}
_ = command.Process.Kill()
}
func (u *ContainerService) ContainerStats(id string) (*dto.ContainerStats, error) {
client, err := docker.NewDockerClient()
if err != nil {
@@ -1805,7 +1736,7 @@ func loadCpuAndMem(client *client.Client, containerItem string) dto.ContainerLis
return data
}
func checkPortStats(ports []dto.PortHelper) (nat.PortMap, error) {
func checkPortStats(ports []dto.PortHelper, checkInUse bool) (nat.PortMap, error) {
portMap := make(nat.PortMap)
if len(ports) == 0 {
return portMap, nil
@@ -1830,7 +1761,7 @@ func checkPortStats(ports []dto.PortHelper) (nat.PortMap, error) {
portMap[nat.Port(fmt.Sprintf("%d/%s", containerStart+i, port.Protocol))] = []nat.PortBinding{bindItem}
}
for i := hostStart; i <= hostEnd; i++ {
if common.ScanPortWithIP(port.HostIP, i) {
if checkInUse && common.ScanPortWithIP(port.HostIP, i) {
return portMap, buserr.WithDetail("ErrPortInUsed", i, nil)
}
}
@@ -1841,7 +1772,7 @@ func checkPortStats(ports []dto.PortHelper) (nat.PortMap, error) {
} else {
portItem, _ = strconv.Atoi(port.HostPort)
}
if common.ScanPortWithIP(port.HostIP, portItem) {
if checkInUse && common.ScanPortWithIP(port.HostIP, portItem) {
return portMap, buserr.WithDetail("ErrPortInUsed", portItem, nil)
}
bindItem := nat.PortBinding{HostPort: strconv.Itoa(portItem), HostIP: port.HostIP}
@@ -1860,7 +1791,7 @@ func loadConfigInfo(isCreate bool, req dto.ContainerOperate, oldContainer *conta
}
var networkConf network.NetworkingConfig
portMap, err := checkPortStats(req.ExposedPorts)
portMap, err := checkPortStats(req.ExposedPorts, isCreate)
if err != nil {
return nil, nil, nil, err
}
@@ -1888,15 +1819,21 @@ func loadConfigInfo(isCreate bool, req dto.ContainerOperate, oldContainer *conta
case "host", "none", "bridge":
hostConf.NetworkMode = container.NetworkMode(item.Network)
}
if item.Ipv4 != "" || item.Ipv6 != "" {
networkConf.EndpointsConfig[item.Network] = &network.EndpointSettings{
IPAMConfig: &network.EndpointIPAMConfig{
IPv4Address: item.Ipv4,
IPv6Address: item.Ipv6,
}, MacAddress: item.MacAddr}
} else {
networkConf.EndpointsConfig[item.Network] = &network.EndpointSettings{}
endpoint := &network.EndpointSettings{
Links: append([]string(nil), item.Links...),
Aliases: append([]string(nil), item.Aliases...),
DriverOpts: cloneStringMap(item.DriverOpts),
GwPriority: item.GwPriority,
MacAddress: item.MacAddr,
}
if item.Ipv4 != "" || item.Ipv6 != "" || len(item.LinkLocalIPs) != 0 {
endpoint.IPAMConfig = &network.EndpointIPAMConfig{
IPv4Address: item.Ipv4,
IPv6Address: item.Ipv6,
LinkLocalIPs: append([]string(nil), item.LinkLocalIPs...),
}
}
networkConf.EndpointsConfig[item.Network] = endpoint
}
} else {
return nil, nil, nil, fmt.Errorf("please set up the network")
@@ -1943,43 +1880,6 @@ func loadConfigInfo(isCreate bool, req dto.ContainerOperate, oldContainer *conta
return &config, &hostConf, &networkConf, nil
}
func reCreateAfterUpdate(name string, client *client.Client, config *container.Config, hostConf *container.HostConfig, networkConf *container.NetworkSettings) {
ctx := context.Background()
oldContainer, err := createContainerWithOldNetworks(ctx, client, config, hostConf, networkConf, name)
if err != nil {
global.LOG.Errorf("recreate after container update failed, err: %v", err)
return
}
if err := client.ContainerStart(ctx, oldContainer.ID, container.StartOptions{}); err != nil {
global.LOG.Errorf("restart after container update failed, err: %v", err)
}
global.LOG.Info("recreate after container update successful")
}
func createContainerWithOldNetworks(ctx context.Context, client *client.Client, config *container.Config, hostConf *container.HostConfig, networkSettings *container.NetworkSettings, name string) (container.CreateResponse, error) {
networkConf, extraNetworks := buildContainerRecoverNetworkConfig(networkSettings, hostConf)
removeUnsupportedEndpointStaticIPAM(client, networkConf, extraNetworks)
created, err := client.ContainerCreate(ctx, config, hostConf, networkConf, nil, name)
if err != nil {
return created, err
}
extraNames := make([]string, 0, len(extraNetworks))
for item := range extraNetworks {
extraNames = append(extraNames, item)
}
sort.Strings(extraNames)
for _, item := range extraNames {
if err := client.NetworkConnect(ctx, item, created.ID, extraNetworks[item]); err != nil {
_ = client.ContainerRemove(ctx, created.ID, container.RemoveOptions{Force: true})
return created, err
}
}
return created, nil
}
func loadVolumeBinds(binds []container.MountPoint) []dto.VolumeHelper {
var datas []dto.VolumeHelper
for _, bind := range binds {
@@ -2126,6 +2026,9 @@ func loadComposeCount(client *client.Client) int {
}
}
for _, compose := range composeCreatedByLocal {
if len(compose.Path) == 0 {
continue
}
if _, has := composeMap[compose.Name]; !has {
composeMap[compose.Name] = struct{}{}
}
+325 -35
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"sort"
@@ -23,8 +24,10 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/compose"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/re"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"gopkg.in/yaml.v3"
)
const composeProjectLabel = "com.docker.compose.project"
@@ -52,7 +55,15 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface
return 0, nil, err
}
composeCreatedByLocal, _ := composeRepo.ListRecord()
composeRecords, _ := composeRepo.ListRecord()
pinnedByName := make(map[string]bool, len(composeRecords))
composeCreatedByLocal := make([]model.Compose, 0, len(composeRecords))
for _, record := range composeRecords {
pinnedByName[record.Name] = record.IsPinned
if len(record.Path) != 0 {
composeCreatedByLocal = append(composeCreatedByLocal, record)
}
}
composeLocalMap := make(map[string]dto.ComposeInfo)
for _, localItem := range composeCreatedByLocal {
composeItemLocal := dto.ComposeInfo{
@@ -136,6 +147,7 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface
for key, value := range mergedMap {
value.Name = key
value.ComposeFileExists = composeFileExists(value.Workdir, value.ConfigFile)
value.IsPinned = pinnedByName[key]
records = append(records, value)
}
if len(req.Info) != 0 {
@@ -149,7 +161,21 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface
}
}
}
if req.ExcludeAppStore {
length, count := len(records), 0
for count < length {
if records[count].CreatedBy == "Apps" {
records = append(records[:count], records[(count+1):]...)
length--
} else {
count++
}
}
}
sort.Slice(records, func(i, j int) bool {
if records[i].IsPinned != records[j].IsPinned {
return records[i].IsPinned
}
return records[i].CreatedAt > records[j].CreatedAt
})
total, start, end := len(records), (req.Page-1)*req.PageSize, req.Page*req.PageSize
@@ -189,53 +215,62 @@ func composeFileExists(workdir, configFile string) bool {
}
func (u *ContainerService) TestCompose(req dto.ComposeCreate) (bool, error) {
if cmd.CheckIllegal(req.Path) {
if err := validateComposeCreateName(req); err != nil {
return false, err
}
if hasIllegalComposeCreateInput(req) {
return false, buserr.New("ErrCmdIllegal")
}
composeItem, _ := composeRepo.GetRecord(repo.WithByName(req.Name))
if composeItem.ID != 0 {
return false, buserr.New("ErrRecordExist")
}
if err := u.loadPath(&req); err != nil {
return false, err
}
if err := newComposeEnv(req.Path, req.Env); err != nil {
return false, err
}
cmd := getComposeCmd(req.Path, "config")
stdout, err := cmd.CombinedOutput()
projectName, err := resolveComposeCreateProjectName(req)
if err != nil {
return false, fmt.Errorf("docker-compose config failed, std: %s, err: %v", string(stdout), err)
return false, err
}
if err := checkComposeCreateDuplicate(req, projectName); err != nil {
return false, err
}
return true, nil
}
func (u *ContainerService) CreateCompose(req dto.ComposeCreate) error {
if cmd.CheckIllegal(req.Name, req.Path) {
if err := validateComposeCreateName(req); err != nil {
return err
}
if hasIllegalComposeCreateInput(req) {
return buserr.New("ErrCmdIllegal")
}
projectName, err := resolveComposeCreateProjectName(req)
if err != nil {
return err
}
if err := checkComposeCreateDuplicate(req, projectName); err != nil {
return err
}
if err := u.loadPath(&req); err != nil {
return err
}
if req.From == "path" {
req.Name = path.Base(path.Dir(req.Path))
if err := newComposeEnv(req.Path, req.Env); err != nil {
return err
}
req.Name = projectName
taskItem, err := task.NewTaskWithOps(req.Name, task.TaskCreate, task.TaskScopeCompose, req.TaskID, 1)
if err != nil {
return fmt.Errorf("new task for image build failed, err: %v", err)
}
if err := newComposeEnv(req.Path, req.Env); err != nil {
return err
}
go func() {
taskItem.AddSubTask(i18n.GetMsgByKey("ComposeCreate"), func(t *task.Task) error {
err := compose.UpWithTask(req.Path, t, req.ForcePull)
err := compose.UpWithTask(req.Path, t, req.ForcePull, req.Name)
t.LogWithStatus(i18n.GetMsgByKey("ComposeCreate"), err)
if err != nil {
_, _ = compose.Down(req.Path)
_, _ = compose.Down(req.Path, req.Name)
return err
}
_ = composeRepo.CreateRecord(&model.Compose{Name: strings.ToLower(req.Name), Path: req.Path})
recordName := strings.ToLower(req.Name)
record, _ := composeRepo.GetRecord(repo.WithByName(recordName))
if record.ID == 0 {
_ = composeRepo.CreateRecord(&model.Compose{Name: recordName, Path: req.Path})
} else {
_ = composeRepo.UpdateRecord(recordName, map[string]interface{}{"path": req.Path})
}
return nil
}, nil)
_ = taskItem.Execute()
@@ -244,6 +279,243 @@ func (u *ContainerService) CreateCompose(req dto.ComposeCreate) error {
return nil
}
func checkComposeRecordName(name string) error {
composeItem, _ := composeRepo.GetRecord(repo.WithByName(name))
if composeItem.ID != 0 && len(composeItem.Path) != 0 {
return buserr.New("ErrRecordExist")
}
return nil
}
func checkComposeCreateDuplicate(req dto.ComposeCreate, projectName string) error {
if err := checkComposeRecordName(projectName); err != nil {
return err
}
if req.From == "path" {
return nil
}
composeItem, _ := composeRepo.GetRecord(repo.WithByPath(composeCreatePath(req)))
if composeItem.ID != 0 && composeItem.Path != "" {
return buserr.New("ErrRecordExist")
}
return nil
}
func validateComposeCreateName(req dto.ComposeCreate) error {
if req.From == "path" {
name := strings.TrimSpace(req.Name)
if name != "" && !re.GetRegex(re.ComposeNamePattern).MatchString(name) {
return buserr.New("ErrComposeNameInvalid")
}
return nil
}
if !re.GetRegex(re.ComposeNamePattern).MatchString(composeCreateDirName(req)) {
return buserr.New("ErrComposeNameInvalid")
}
return nil
}
func hasIllegalComposeCreateInput(req dto.ComposeCreate) bool {
if req.From == "path" {
return cmd.CheckIllegal(req.Name, req.Path)
}
return cmd.CheckIllegal(composeCreateDirName(req))
}
func composeCreateDirName(req dto.ComposeCreate) string {
dirName := strings.TrimSpace(req.DirName)
if dirName == "" {
// Keep compatibility with callers that used name as both the directory and
// Compose project name before dirName was introduced.
return strings.TrimSpace(req.Name)
}
return dirName
}
func composeCreatePath(req dto.ComposeCreate) string {
return filepath.Join(global.Dir.DataDir, "docker", "compose", composeCreateDirName(req), "docker-compose.yml")
}
func resolveComposeCreateProjectName(req dto.ComposeCreate) (string, error) {
if req.From == "path" {
envPath, err := createComposeTempFile(
filepath.Dir(primaryComposePath(req.Path)),
".1panel-compose-*.env",
req.Env,
)
if err != nil {
return "", err
}
defer os.Remove(envPath)
return resolveComposeProjectName(req.Path, req.Name, envPath)
}
dir := filepath.Dir(composeCreatePath(req))
cleanupDir, err := prepareComposeStagingDir(dir)
if err != nil {
return "", err
}
defer cleanupDir()
composePath, err := createComposeTempFile(dir, ".1panel-compose-*.yml", req.File)
if err != nil {
return "", err
}
defer os.Remove(composePath)
envPath, err := createComposeTempFile(dir, ".1panel-compose-*.env", req.Env)
if err != nil {
return "", err
}
defer os.Remove(envPath)
return resolveComposeProjectName(composePath, "", envPath)
}
func createComposeTempFile(dir, pattern, content string) (string, error) {
file, err := os.CreateTemp(dir, pattern)
if err != nil {
return "", err
}
filePath := file.Name()
if _, err := file.WriteString(content); err != nil {
_ = file.Close()
_ = os.Remove(filePath)
return "", err
}
if err := file.Close(); err != nil {
_ = os.Remove(filePath)
return "", err
}
return filePath, nil
}
func prepareComposeStagingDir(dir string) (func(), error) {
if err := os.MkdirAll(filepath.Dir(dir), os.ModePerm); err != nil {
return nil, err
}
created := false
if err := os.Mkdir(dir, os.ModePerm); err != nil {
if !errors.Is(err, os.ErrExist) {
return nil, err
}
} else {
created = true
}
return func() {
if created {
_ = os.Remove(dir)
}
}, nil
}
func resolveComposeProjectName(composePath, fallbackName, envFile string) (string, error) {
// Preserve the name resolved by Compose (including a top-level name) so the
// container label and the local record always use the same project identity.
parentName := normalizeComposeProjectName(path.Base(path.Dir(primaryComposePath(composePath))))
fallbackName = strings.TrimSpace(fallbackName)
stdout, err := runComposeConfig(composePath, "", envFile)
if err == nil {
projectName, parseErr := loadComposeProjectName(stdout)
if parseErr != nil {
return "", parseErr
}
if projectName != "" {
if !re.GetRegex(re.ComposeNamePattern).MatchString(projectName) {
return "", buserr.New("ErrComposeNameInvalid")
}
return projectName, nil
}
if parentName != "" {
return parentName, nil
}
if fallbackName != "" {
if _, fallbackErr := runComposeConfig(composePath, fallbackName, envFile); fallbackErr != nil {
return "", fallbackErr
}
return fallbackName, nil
}
return "", buserr.New("ErrComposeProjectNameEmpty")
}
if !isComposeProjectNameEmptyError(err) {
return "", err
}
resolveErr := err
if parentName != "" {
if _, parentErr := runComposeConfig(composePath, parentName, envFile); parentErr == nil {
return parentName, nil
} else {
resolveErr = parentErr
}
}
if fallbackName != "" && fallbackName != parentName {
if _, fallbackErr := runComposeConfig(composePath, fallbackName, envFile); fallbackErr == nil {
return fallbackName, nil
} else {
return "", fallbackErr
}
}
if parentName == "" && fallbackName == "" {
return "", buserr.New("ErrComposeProjectNameEmpty")
}
return "", resolveErr
}
func runComposeConfig(composePath, projectName, envFile string) ([]byte, error) {
configCmd := getComposeCmdWithEnv(composePath, "config", envFile, projectName)
stdout, err := configCmd.Output()
if err != nil {
var stderr []byte
if exitErr, ok := err.(*exec.ExitError); ok {
stderr = exitErr.Stderr
}
return nil, fmt.Errorf("docker-compose config failed, std: %s, err: %v", mergeComposeOutput(stdout, stderr), err)
}
return stdout, nil
}
func mergeComposeOutput(stdout, stderr []byte) string {
outputs := make([]string, 0, 2)
if output := strings.TrimSpace(string(stdout)); output != "" {
outputs = append(outputs, output)
}
if output := strings.TrimSpace(string(stderr)); output != "" {
outputs = append(outputs, output)
}
return strings.Join(outputs, "\n")
}
func loadComposeProjectName(config []byte) (string, error) {
var project struct {
Name string `yaml:"name"`
}
if err := yaml.Unmarshal(config, &project); err != nil {
return "", buserr.WithDetail("ErrComposeProjectNameParse", err.Error(), err)
}
return strings.TrimSpace(project.Name), nil
}
func primaryComposePath(composePath string) string {
if index := strings.Index(composePath, ","); index >= 0 {
return composePath[:index]
}
return composePath
}
func normalizeComposeProjectName(name string) string {
name = re.GetRegex(re.ComposeDisallowedCharsPattern).
ReplaceAllString(strings.ToLower(strings.TrimSpace(name)), "")
return strings.TrimLeft(name, "_-")
}
func isComposeProjectNameEmptyError(err error) bool {
message := strings.ToLower(err.Error())
return strings.Contains(message, "project name must not be empty") ||
strings.Contains(message, "project name can't be empty")
}
func (u *ContainerService) ComposeOperation(req dto.ComposeOperation) error {
if len(req.Path) == 0 && req.Operation == "delete" {
_ = composeRepo.DeleteRecord(repo.WithByName(req.Name))
@@ -267,11 +539,15 @@ func (u *ContainerService) ComposeOperation(req dto.ComposeOperation) error {
return nil
}
if req.Operation == "up" {
if stdout, err := compose.Up(req.Path); err != nil {
if stdout, err := compose.Up(req.Path, req.Name); err != nil {
return fmt.Errorf("docker-compose up failed, std: %s, err: %v", stdout, err)
}
} else if req.Operation == "rebuild" {
if stdout, err := compose.DownAndUp(req.Path, req.Name); err != nil {
return fmt.Errorf("docker-compose rebuild failed, std: %s, err: %v", stdout, err)
}
} else {
if stdout, err := compose.Operate(req.Path, req.Operation); err != nil {
if stdout, err := compose.Operate(req.Path, req.Operation, req.Name); err != nil {
return fmt.Errorf("docker-compose %s failed, std: %s, err: %v", req.Operation, stdout, err)
}
}
@@ -307,9 +583,9 @@ func (u *ContainerService) ComposeUpdate(req dto.ComposeUpdate) error {
return err
}
if err := compose.UpWithTask(req.Path, t, req.ForcePull); err != nil {
if err := compose.UpWithTask(req.Path, t, req.ForcePull, req.Name); err != nil {
global.LOG.Errorf("update failed when handle compose up, err: %s, now try to recreate the old compose file", err)
if err := recreateCompose(string(oldFile), req.Path); err != nil {
if err := recreateCompose(string(oldFile), req.Path, req.Name); err != nil {
return fmt.Errorf("update failed and recreate old compose file also failed, err: %v", err)
}
return fmt.Errorf("update failed when handle compose up, err: %s", err)
@@ -323,6 +599,20 @@ func (u *ContainerService) ComposeUpdate(req dto.ComposeUpdate) error {
return nil
}
func (u *ContainerService) ComposePin(req dto.ComposePin) error {
record, _ := composeRepo.GetRecord(repo.WithByName(req.Name))
if record.ID == 0 {
if !req.IsPinned {
return nil
}
return composeRepo.CreateRecord(&model.Compose{Name: req.Name, IsPinned: true})
}
if !req.IsPinned && len(record.Path) == 0 {
return composeRepo.DeleteRecord(repo.WithByName(req.Name))
}
return composeRepo.UpdateRecord(req.Name, map[string]interface{}{"is_pinned": req.IsPinned})
}
func (u *ContainerService) ComposeLogClean(req dto.ComposeLogClean) error {
client, err := docker.NewDockerClient()
if err != nil {
@@ -385,15 +675,15 @@ func (u *ContainerService) LoadComposeEnv(name string) (string, error) {
func (u *ContainerService) loadPath(req *dto.ComposeCreate) error {
if req.From == "template" || req.From == "edit" {
dir := fmt.Sprintf("%s/docker/compose/%s", global.Dir.DataDir, req.Name)
composePath := composeCreatePath(*req)
dir := filepath.Dir(composePath)
if _, err := os.Stat(dir); err != nil && os.IsNotExist(err) {
if err = os.MkdirAll(dir, os.ModePerm); err != nil {
return err
}
}
path := fmt.Sprintf("%s/docker-compose.yml", dir)
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, constant.FilePerm)
file, err := os.OpenFile(composePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, constant.FilePerm)
if err != nil {
return err
}
@@ -401,14 +691,14 @@ func (u *ContainerService) loadPath(req *dto.ComposeCreate) error {
write := bufio.NewWriter(file)
_, _ = write.WriteString(string(req.File))
write.Flush()
req.Path = path
req.Path = composePath
}
return nil
}
func removeContainerForCompose(composeName, composePath string) error {
if _, err := os.Stat(composePath); err == nil {
if stdout, err := compose.Operate(composePath, "down"); err != nil {
if stdout, err := compose.Operate(composePath, "down", composeName); err != nil {
return errors.New(stdout)
}
return nil
@@ -433,7 +723,7 @@ func removeContainerForCompose(composeName, composePath string) error {
return nil
}
func recreateCompose(content, path string) error {
func recreateCompose(content, path, projectName string) error {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0640)
if err != nil {
return err
@@ -443,7 +733,7 @@ func recreateCompose(content, path string) error {
_, _ = write.WriteString(content)
write.Flush()
if stdout, err := compose.Up(path); err != nil {
if stdout, err := compose.Up(path, projectName); err != nil {
return errors.New(string(stdout))
}
return nil
+663
View File
@@ -0,0 +1,663 @@
package service
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
)
func (u *ContainerService) ContainerUpdate(req dto.ContainerOperate) error {
client, err := docker.NewDockerClient()
if err != nil {
return err
}
ctx := context.Background()
taskItem, err := task.NewTaskWithOps(req.Name, task.TaskUpdate, task.TaskScopeContainer, req.TaskID, 1)
if err != nil {
_ = client.Close()
global.LOG.Errorf("new task for create container failed, err: %v", err)
return err
}
go func() {
defer client.Close()
taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error {
if !checkImageExist(client, req.Image) || req.ForcePull {
if err := pullImages(taskItem, client, req.Image); err != nil {
if !req.ForcePull {
return err
}
return fmt.Errorf("pull image %s failed, err: %v", req.Image, err)
}
}
return nil
}, nil)
taskItem.AddSubTaskWithOps(task.GetTaskName(req.Name, task.TaskUpdate, task.TaskScopeContainer), func(t *task.Task) error {
t.LogStart(i18n.GetWithName("ContainerAcquireLock", req.Name))
unlock := containerOperationLock.lock(req.Name)
defer unlock()
t.LogWithStatus(i18n.GetWithName("ContainerAcquireLock", req.Name), nil)
oldContainer, err := client.ContainerInspect(ctx, req.Name)
if err != nil {
taskItem.LogWithStatus(i18n.GetMsgByKey("ContainerLoadInfo"), err)
return err
}
config, hostConf, networkConf, err := loadConfigInfo(false, req, &oldContainer)
taskItem.LogWithStatus(i18n.GetMsgByKey("ContainerLoadInfo"), err)
if err != nil {
return err
}
normalizeContainerEndpointSettings(ctx, client, networkConf, nil)
cleanupErr, err := switchContainer(ctx, client, req.Name, oldContainer, func() (container.CreateResponse, error) {
return createContainerWithDynamicIPFallback(func() (container.CreateResponse, error) {
return client.ContainerCreate(ctx, config, hostConf, networkConf, &v1.Platform{}, req.Name)
}, networkConf.EndpointsConfig, oldContainer.NetworkSettings)
}, newContainerSwitchTaskLogger(t))
if err != nil {
return fmt.Errorf("update container failed, err: %v", err)
}
if cleanupErr != nil {
taskItem.Log(i18n.GetWithNameAndErr("ContainerCleanupWarning", containerSwitchBackupName(oldContainer.ID), cleanupErr))
}
return nil
}, nil, 0, 0)
if err := taskItem.Execute(); err != nil {
global.LOG.Error(err.Error())
}
}()
return nil
}
func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
client, err := docker.NewDockerClient()
if err != nil {
return err
}
ctx := context.Background()
taskItem, err := task.NewTaskWithOps(req.Image, task.TaskUpgrade, task.TaskScopeImage, req.TaskID, 1)
if err != nil {
_ = client.Close()
global.LOG.Errorf("new task for create container failed, err: %v", err)
return err
}
go func() {
defer client.Close()
taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error {
taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image))
if !checkImageExist(client, req.Image) || req.ForcePull {
if err := pullImages(taskItem, client, req.Image); err != nil {
if !req.ForcePull {
return err
}
return fmt.Errorf("pull image %s failed, err: %v", req.Image, err)
}
}
return nil
}, nil)
var upgradeErrors []error
for _, item := range req.Names {
item := item
taskItem.AddSubTaskWithIgnoreErr(i18n.GetWithName("ContainerUpgradeItem", item), func(t *task.Task) error {
t.Logf("----------------- %s -----------------", item)
t.LogStart(i18n.GetWithName("ContainerAcquireLock", item))
unlock := containerOperationLock.lock(item)
defer unlock()
t.LogWithStatus(i18n.GetWithName("ContainerAcquireLock", item), nil)
oldContainer, inspectErr := client.ContainerInspect(ctx, item)
t.LogWithStatus(i18n.GetWithName("ContainerLoadInfo", item), inspectErr)
if inspectErr != nil {
err := fmt.Errorf("reload container %s failed: %w", item, inspectErr)
upgradeErrors = append(upgradeErrors, err)
return err
}
config := cloneContainerConfig(oldContainer.Config)
config.Image = req.Image
hostConf := cloneContainerHostConfig(oldContainer.HostConfig)
preserveContainerVolumeMounts(hostConf, oldContainer.Mounts)
cleanupErr, err := switchContainer(ctx, client, item, oldContainer, func() (container.CreateResponse, error) {
return createContainerWithOldNetworks(ctx, client, config, hostConf, oldContainer.NetworkSettings, item)
}, newContainerSwitchTaskLogger(t))
if err != nil {
upgradeErr := fmt.Errorf("upgrade container %s failed: %w", item, err)
upgradeErrors = append(upgradeErrors, upgradeErr)
return upgradeErr
}
if cleanupErr != nil {
t.Log(i18n.GetWithNameAndErr("ContainerCleanupWarning", containerSwitchBackupName(oldContainer.ID), cleanupErr))
}
return nil
})
}
taskItem.AddSubTask(i18n.GetMsgByKey("ContainerUpgradeSummary"), func(t *task.Task) error {
return errors.Join(upgradeErrors...)
}, nil)
if err := taskItem.Execute(); err != nil {
global.LOG.Error(err.Error())
}
}()
return nil
}
type containerSwitchClient interface {
ContainerStop(context.Context, string, container.StopOptions) error
ContainerRename(context.Context, string, string) error
ContainerStart(context.Context, string, container.StartOptions) error
ContainerRemove(context.Context, string, container.RemoveOptions) error
ContainerInspect(context.Context, string) (container.InspectResponse, error)
NetworkConnect(context.Context, string, string, *network.EndpointSettings) error
NetworkDisconnect(context.Context, string, string, bool) error
}
type containerInspectClient interface {
ContainerInspect(context.Context, string) (container.InspectResponse, error)
}
type containerOperationMutex struct {
mutex sync.Mutex
locks map[string]*containerOperationLockEntry
}
var containerOperationLock containerOperationMutex
type containerOperationLockEntry struct {
mutex sync.Mutex
references int
}
func (l *containerOperationMutex) lock(names ...string) func() {
nameSet := make(map[string]struct{}, len(names))
for _, name := range names {
if name != "" {
nameSet[name] = struct{}{}
}
}
orderedNames := make([]string, 0, len(nameSet))
for name := range nameSet {
orderedNames = append(orderedNames, name)
}
sort.Strings(orderedNames)
if len(orderedNames) == 0 {
return func() {}
}
l.mutex.Lock()
if l.locks == nil {
l.locks = make(map[string]*containerOperationLockEntry)
}
entries := make([]*containerOperationLockEntry, 0, len(orderedNames))
for _, name := range orderedNames {
entry := l.locks[name]
if entry == nil {
entry = &containerOperationLockEntry{}
l.locks[name] = entry
}
entry.references++
entries = append(entries, entry)
}
l.mutex.Unlock()
for _, entry := range entries {
entry.mutex.Lock()
}
return func() {
for index := len(entries) - 1; index >= 0; index-- {
entries[index].mutex.Unlock()
}
l.mutex.Lock()
defer l.mutex.Unlock()
for index, name := range orderedNames {
entry := entries[index]
entry.references--
if entry.references == 0 {
delete(l.locks, name)
}
}
}
}
type containerNetworkAttachment struct {
name string
endpoint *network.EndpointSettings
isDynamic bool
}
type containerSwitchLogFunc func(messageKey, containerName string, err error)
func newContainerSwitchTaskLogger(t *task.Task) containerSwitchLogFunc {
return func(messageKey, containerName string, err error) {
t.LogWithStatus(i18n.GetWithName(messageKey, containerName), err)
}
}
func logContainerSwitchStep(logger containerSwitchLogFunc, messageKey, containerName string, err error) {
if logger != nil {
logger(messageKey, containerName, err)
}
}
// switchContainer keeps the stopped original container as the rollback target until the replacement starts.
func switchContainer(
ctx context.Context,
cli containerSwitchClient,
name string,
oldContainer container.InspectResponse,
createNew func() (container.CreateResponse, error),
logger containerSwitchLogFunc,
) (cleanupErr error, err error) {
if oldContainer.ID == "" {
return nil, fmt.Errorf("original container ID is empty")
}
wasRunning := oldContainer.State != nil && oldContainer.State.Running
if wasRunning && oldContainer.HostConfig != nil && oldContainer.HostConfig.AutoRemove {
return nil, fmt.Errorf("cannot safely replace container %s with auto-remove enabled", name)
}
if wasRunning {
if err := cli.ContainerStop(ctx, oldContainer.ID, container.StopOptions{}); err != nil {
logContainerSwitchStep(logger, "ContainerStopOld", name, err)
current, inspectErr := cli.ContainerInspect(ctx, oldContainer.ID)
if inspectErr == nil && current.State != nil && !current.State.Running {
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, restartErr)
return nil, errors.Join(fmt.Errorf("stop original container failed: %w", err), restartErr)
}
return nil, fmt.Errorf("stop original container failed: %w", err)
}
logContainerSwitchStep(logger, "ContainerStopOld", name, nil)
}
backupName := containerSwitchBackupName(oldContainer.ID)
if err := cli.ContainerRename(ctx, oldContainer.ID, backupName); err != nil {
logContainerSwitchStep(logger, "ContainerRenameOld", name, err)
if wasRunning {
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, restartErr)
return nil, errors.Join(fmt.Errorf("rename original container failed: %w", err), restartErr)
}
return nil, fmt.Errorf("rename original container failed: %w", err)
}
logContainerSwitchStep(logger, "ContainerRenameOld", name, nil)
disconnectedNetworks, disconnectErr := disconnectOriginalContainerNetworks(ctx, cli, oldContainer)
logContainerSwitchStep(logger, "ContainerDisconnectOld", backupName, disconnectErr)
if disconnectErr != nil {
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, "", disconnectedNetworks, logger)
return nil, errors.Join(disconnectErr, rollbackErr)
}
created, createErr := createNew()
logContainerSwitchStep(logger, "ContainerCreateReplacement", name, createErr)
if createErr != nil {
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(createErr, rollbackErr)
}
if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
logContainerSwitchStep(logger, "ContainerStartReplacement", name, err)
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(fmt.Errorf("start new container failed: %w", err), rollbackErr)
}
logContainerSwitchStep(logger, "ContainerStartReplacement", name, nil)
if wasRunning {
if err := waitContainerReady(ctx, cli, created.ID); err != nil {
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, err)
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(fmt.Errorf("new container readiness check failed: %w", err), rollbackErr)
}
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, nil)
}
cleanupErr = cli.ContainerRemove(ctx, oldContainer.ID, container.RemoveOptions{Force: true, RemoveVolumes: false})
logContainerSwitchStep(logger, "ContainerRemoveOld", backupName, cleanupErr)
return cleanupErr, nil
}
const (
containerStartStabilization = 10 * time.Second
containerStartPollInterval = time.Second
containerHealthCheckMinWait = 30 * time.Second
containerHealthCheckMaxWait = 10 * time.Minute
)
func waitContainerReady(ctx context.Context, cli containerInspectClient, containerID string) error {
info, err := cli.ContainerInspect(ctx, containerID)
if err != nil {
return err
}
if err := checkContainerRunningState(info); err != nil {
return err
}
if info.State.Health == nil {
return waitContainerStable(ctx, cli, containerID, info)
}
initialRestartCount := info.RestartCount
timeout := containerHealthCheckTimeout(info.Config)
deadline := time.NewTimer(timeout)
ticker := time.NewTicker(time.Second)
defer deadline.Stop()
defer ticker.Stop()
for {
if info.State.Restarting || info.RestartCount != initialRestartCount {
return fmt.Errorf("container restart count changed from %d to %d during startup", initialRestartCount, info.RestartCount)
}
if info.State.Health == nil {
return fmt.Errorf("container health status is unavailable")
}
switch info.State.Health.Status {
case container.Healthy:
return nil
case container.Unhealthy:
return fmt.Errorf("container health status is unhealthy")
}
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
return fmt.Errorf("container health check timed out after %s", timeout)
case <-ticker.C:
info, err = cli.ContainerInspect(ctx, containerID)
if err != nil {
return err
}
if err := checkContainerRunningState(info); err != nil {
return err
}
}
}
}
func waitContainerStable(ctx context.Context, cli containerInspectClient, containerID string, initial container.InspectResponse) error {
startedAt := initial.State.StartedAt
restartCount := initial.RestartCount
if err := checkContainerStableState(initial, startedAt, restartCount); err != nil {
return err
}
deadline := time.NewTimer(containerStartStabilization)
ticker := time.NewTicker(containerStartPollInterval)
defer deadline.Stop()
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
info, err := cli.ContainerInspect(ctx, containerID)
if err != nil {
return err
}
return checkContainerStableState(info, startedAt, restartCount)
case <-ticker.C:
info, err := cli.ContainerInspect(ctx, containerID)
if err != nil {
return err
}
if err := checkContainerStableState(info, startedAt, restartCount); err != nil {
return err
}
}
}
}
func checkContainerStableState(info container.InspectResponse, startedAt string, restartCount int) error {
if err := checkContainerRunningState(info); err != nil {
return err
}
if info.State.Restarting || info.RestartCount != restartCount {
return fmt.Errorf("container restart count changed from %d to %d during startup", restartCount, info.RestartCount)
}
if startedAt != "" && info.State.StartedAt != startedAt {
return fmt.Errorf("container start time changed during startup")
}
return nil
}
func checkContainerRunningState(info container.InspectResponse) error {
if info.State == nil {
return fmt.Errorf("container state is unavailable")
}
if !info.State.Running {
return fmt.Errorf("container exited with code %d: %s", info.State.ExitCode, info.State.Error)
}
return nil
}
func containerHealthCheckTimeout(config *container.Config) time.Duration {
if config == nil || config.Healthcheck == nil {
return containerHealthCheckMinWait
}
health := config.Healthcheck
interval := health.Interval
if interval <= 0 {
interval = 30 * time.Second
}
checkTimeout := health.Timeout
if checkTimeout <= 0 {
checkTimeout = 30 * time.Second
}
retries := health.Retries
if retries <= 0 {
retries = 3
}
timeout := health.StartPeriod + time.Duration(retries)*(interval+checkTimeout)
if timeout < containerHealthCheckMinWait {
return containerHealthCheckMinWait
}
if timeout > containerHealthCheckMaxWait {
return containerHealthCheckMaxWait
}
return timeout
}
func preserveContainerVolumeMounts(hostConfig *container.HostConfig, oldMounts []container.MountPoint) {
if hostConfig == nil {
return
}
for _, oldMount := range oldMounts {
if oldMount.Type != mount.TypeVolume || oldMount.Name == "" || oldMount.Destination == "" {
continue
}
preserved := false
for index := range hostConfig.Mounts {
if hostConfig.Mounts[index].Target == oldMount.Destination {
hostConfig.Mounts[index].Type = mount.TypeVolume
hostConfig.Mounts[index].Source = oldMount.Name
preserved = true
}
}
for index, raw := range hostConfig.Binds {
destination, mode := containerBindDestinationAndMode(raw)
if destination != oldMount.Destination {
continue
}
hostConfig.Binds[index] = oldMount.Name + ":" + oldMount.Destination
if mode != "" {
hostConfig.Binds[index] += ":" + mode
}
preserved = true
}
if !preserved {
hostConfig.Mounts = append(hostConfig.Mounts, mount.Mount{
Type: mount.TypeVolume,
Source: oldMount.Name,
Target: oldMount.Destination,
ReadOnly: !oldMount.RW,
})
}
}
}
func containerBindDestinationAndMode(raw string) (string, string) {
parts := strings.SplitN(raw, ":", 3)
switch len(parts) {
case 1:
return parts[0], ""
case 2:
return parts[1], ""
default:
return parts[1], parts[2]
}
}
func containerSwitchBackupName(containerID string) string {
if len(containerID) > 12 {
containerID = containerID[:12]
}
return "1panel-backup-" + containerID
}
func restartOriginalContainer(ctx context.Context, cli containerSwitchClient, containerID string) error {
if err := cli.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil {
return fmt.Errorf("restart original container failed: %w", err)
}
return nil
}
func disconnectOriginalContainerNetworks(ctx context.Context, cli containerSwitchClient, oldContainer container.InspectResponse) ([]containerNetworkAttachment, error) {
primary, extras := buildContainerRecoverNetworkConfig(oldContainer.NetworkSettings, oldContainer.HostConfig)
endpoints := make(map[string]*network.EndpointSettings, len(extras)+1)
if primary != nil {
for name, endpoint := range primary.EndpointsConfig {
if name != "bridge" && endpoint != nil && endpoint.IPAMConfig != nil {
endpoints[name] = endpoint
}
}
}
for name, endpoint := range extras {
if name != "bridge" && endpoint != nil && endpoint.IPAMConfig != nil {
endpoints[name] = endpoint
}
}
names := make([]string, 0, len(endpoints))
for name := range endpoints {
names = append(names, name)
}
sort.Strings(names)
disconnected := make([]containerNetworkAttachment, 0, len(names))
for _, name := range names {
if err := cli.NetworkDisconnect(ctx, name, oldContainer.ID, true); err != nil {
return disconnected, fmt.Errorf("disconnect original container from network %s failed: %w", name, err)
}
disconnected = append(disconnected, containerNetworkAttachment{
name: name,
endpoint: endpoints[name],
isDynamic: isDynamicContainerNetwork(oldContainer.NetworkSettings, name),
})
}
return disconnected, nil
}
func reconnectOriginalContainerNetworks(ctx context.Context, cli containerSwitchClient, containerID string, attachments []containerNetworkAttachment) error {
var reconnectErr error
for _, attachment := range attachments {
err := cli.NetworkConnect(ctx, attachment.name, containerID, attachment.endpoint)
if err != nil && attachment.isDynamic && strings.Contains(err.Error(), unsupportedUserSpecifiedIPAddress) {
attachment.endpoint.IPAMConfig = nil
err = cli.NetworkConnect(ctx, attachment.name, containerID, attachment.endpoint)
}
if err != nil {
reconnectErr = errors.Join(reconnectErr, fmt.Errorf("reconnect original container to network %s failed: %w", attachment.name, err))
}
}
return reconnectErr
}
func restoreOriginalContainer(ctx context.Context, cli containerSwitchClient, oldContainerID, originalName string, wasRunning bool, newContainer string, disconnectedNetworks []containerNetworkAttachment, logger containerSwitchLogFunc) error {
var rollbackErr error
backupName := containerSwitchBackupName(oldContainerID)
if newContainer != "" {
removeErr := cli.ContainerRemove(ctx, newContainer, container.RemoveOptions{Force: true, RemoveVolumes: true})
if client.IsErrNotFound(removeErr) {
removeErr = nil
}
logContainerSwitchStep(logger, "ContainerRollbackRemoveReplacement", originalName, removeErr)
if removeErr != nil {
rollbackErr = errors.Join(rollbackErr, fmt.Errorf("remove failed replacement container failed: %w", removeErr))
}
}
renameErr := cli.ContainerRename(ctx, oldContainerID, originalName)
logContainerSwitchStep(logger, "ContainerRollbackRenameOld", backupName, renameErr)
if renameErr != nil {
rollbackErr = errors.Join(rollbackErr, fmt.Errorf("restore original container name failed: %w", renameErr))
}
currentName := originalName
if renameErr != nil {
currentName = backupName
}
reconnectErr := reconnectOriginalContainerNetworks(ctx, cli, oldContainerID, disconnectedNetworks)
logContainerSwitchStep(logger, "ContainerRollbackReconnectOld", currentName, reconnectErr)
rollbackErr = errors.Join(rollbackErr, reconnectErr)
if wasRunning {
restartErr := restartOriginalContainer(ctx, cli, oldContainerID)
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", currentName, restartErr)
rollbackErr = errors.Join(rollbackErr, restartErr)
}
return rollbackErr
}
func createContainerWithOldNetworks(ctx context.Context, client *client.Client, config *container.Config, hostConf *container.HostConfig, networkSettings *container.NetworkSettings, name string) (container.CreateResponse, error) {
networkConf, extraNetworks := buildContainerRecoverNetworkConfig(networkSettings, hostConf)
normalizeContainerEndpointSettings(ctx, client, networkConf, extraNetworks)
var primaryEndpoints map[string]*network.EndpointSettings
if networkConf != nil {
primaryEndpoints = networkConf.EndpointsConfig
}
created, err := createContainerWithDynamicIPFallback(func() (container.CreateResponse, error) {
return client.ContainerCreate(ctx, config, hostConf, networkConf, nil, name)
}, primaryEndpoints, networkSettings)
if err != nil {
return created, err
}
extraNames := make([]string, 0, len(extraNetworks))
for item := range extraNetworks {
extraNames = append(extraNames, item)
}
sort.Strings(extraNames)
for _, item := range extraNames {
err := client.NetworkConnect(ctx, item, created.ID, extraNetworks[item])
if clearUnsupportedDynamicEndpointIPAM(err, map[string]*network.EndpointSettings{item: extraNetworks[item]}, networkSettings) {
err = client.NetworkConnect(ctx, item, created.ID, extraNetworks[item])
}
if err != nil {
_ = client.ContainerRemove(ctx, created.ID, container.RemoveOptions{Force: true})
return created, err
}
}
return created, nil
}
func createContainerWithDynamicIPFallback(
create func() (container.CreateResponse, error),
endpoints map[string]*network.EndpointSettings,
networkSettings *container.NetworkSettings,
) (container.CreateResponse, error) {
for {
created, err := create()
if err == nil || created.ID != "" || !clearUnsupportedDynamicEndpointIPAM(err, endpoints, networkSettings) {
return created, err
}
}
}
+7 -4
View File
@@ -538,11 +538,14 @@ func (u *CronjobService) CleanRecord(req dto.CronjobClean) error {
return err
}
for _, del := range delRecords {
if del.Status == constant.StatusWaiting || del.Status == constant.StatusRunning {
continue
}
if err := cronjobRepo.DeleteRecord(repo.WithByID(del.ID)); err != nil {
return err
}
_ = os.RemoveAll(del.Records)
}
if err := cronjobRepo.DeleteRecord(cronjobRepo.WithByJobID(int(req.CronjobID))); err != nil {
return err
}
return nil
}
@@ -644,7 +647,7 @@ func (u *CronjobService) HandleStop(id uint) error {
if len(record.TaskID) == 0 {
return nil
}
if cancel, ok := global.TaskCtxMap[record.TaskID]; ok {
if cancel, ok := global.LoadTaskCancel(record.TaskID); ok {
cancel()
}
return nil
+6 -5
View File
@@ -60,7 +60,7 @@ func (u *CronjobService) handleApp(cronjob model.Cronjob, startTime time.Time, t
src := path.Join(backupDir, record.FileName)
dst := strings.TrimPrefix(src, global.Dir.LocalBackupDir+"/tmp/")
if err := uploadWithMap(*task, accountMap, src, dst, cronjob.SourceAccountIDs, cronjob.DownloadAccountID, cronjob.RetryTimes); err != nil {
if err := uploadWithMap(*task, accountMap, src, dst, cronjob.SourceAccountIDs, cronjob.DownloadAccountID, cronjob.RetryTimes, true); err != nil {
if retry < int(cronjob.RetryTimes) || !cronjob.IgnoreErr {
retry++
return err
@@ -119,7 +119,7 @@ func (u *CronjobService) handleWebsite(cronjob model.Cronjob, startTime time.Tim
src := path.Join(backupDir, record.FileName)
dst := strings.TrimPrefix(src, global.Dir.LocalBackupDir+"/tmp/")
if err := uploadWithMap(*task, accountMap, src, dst, cronjob.SourceAccountIDs, cronjob.DownloadAccountID, cronjob.RetryTimes); err != nil {
if err := uploadWithMap(*task, accountMap, src, dst, cronjob.SourceAccountIDs, cronjob.DownloadAccountID, cronjob.RetryTimes, true); err != nil {
if retry < int(cronjob.RetryTimes) || !cronjob.IgnoreErr {
retry++
return err
@@ -163,6 +163,7 @@ func (u *CronjobService) handleDatabase(cronjob model.Cronjob, startTime time.Ti
record.Name = dbInfo.Database
record.DetailName = dbInfo.Name
record.DownloadAccountID, record.SourceAccountIDs = cronjob.DownloadAccountID, cronjob.SourceAccountIDs
record.Args = encodeBackupArgs(dbInfo.Args)
backupDir := path.Join(global.Dir.LocalBackupDir, fmt.Sprintf("tmp/database/%s/%s/%s", dbInfo.DBType, record.Name, dbInfo.Name))
switch dbInfo.DBType {
@@ -216,7 +217,7 @@ func (u *CronjobService) handleDatabase(cronjob model.Cronjob, startTime time.Ti
src := path.Join(backupDir, record.FileName)
dst := strings.TrimPrefix(src, global.Dir.LocalBackupDir+"/tmp/")
if err := uploadWithMap(*task, accountMap, src, dst, cronjob.SourceAccountIDs, cronjob.DownloadAccountID, cronjob.RetryTimes); err != nil {
if err := uploadWithMap(*task, accountMap, src, dst, cronjob.SourceAccountIDs, cronjob.DownloadAccountID, cronjob.RetryTimes, true); err != nil {
if retry < int(cronjob.RetryTimes) || !cronjob.IgnoreErr {
retry++
return err
@@ -274,7 +275,7 @@ func (u *CronjobService) handleDirectory(cronjob model.Cronjob, startTime time.T
src := path.Join(backupDir, fileName)
dst := strings.TrimPrefix(src, global.Dir.LocalBackupDir+"/tmp/")
if err := uploadWithMap(*task, accountMap, src, dst, cronjob.SourceAccountIDs, cronjob.DownloadAccountID, cronjob.RetryTimes); err != nil {
if err := uploadWithMap(*task, accountMap, src, dst, cronjob.SourceAccountIDs, cronjob.DownloadAccountID, cronjob.RetryTimes, true); err != nil {
return err
}
record.FileDir = path.Dir(dst)
@@ -310,7 +311,7 @@ func (u *CronjobService) handleSystemLog(cronjob model.Cronjob, startTime time.T
src := path.Join(path.Dir(backupDir), fileName)
dst := strings.TrimPrefix(src, global.Dir.LocalBackupDir+"/tmp/")
if err := uploadWithMap(*task, accountMap, src, dst, cronjob.SourceAccountIDs, cronjob.DownloadAccountID, cronjob.RetryTimes); err != nil {
if err := uploadWithMap(*task, accountMap, src, dst, cronjob.SourceAccountIDs, cronjob.DownloadAccountID, cronjob.RetryTimes, true); err != nil {
return err
}
record.FileDir = path.Dir(dst)
+40 -11
View File
@@ -184,9 +184,30 @@ func (u *DashboardService) LoadBaseInfo(ioOption string, netOption string) (*dto
func (u *DashboardService) LoadCurrentInfo(ioOption string, netOption string) *dto.DashboardCurrent {
var currentInfo dto.DashboardCurrent
hostInfo, _ := psutil.HOST.GetHostInfo(false)
currentInfo.Uptime = hostInfo.Uptime
currentInfo.TimeSinceUptime = time.Unix(int64(hostInfo.BootTime), 0).Format(constant.DateTimeLayout)
shotTime := time.Now()
hostInfo, err := psutil.HOST.GetHostInfo(false)
if err != nil {
global.LOG.Errorf("load host info failed: %v", err)
currentInfo.ShotTime = shotTime
return &currentInfo
}
uptime := hostInfo.Uptime
var bootTime uint64
if now := shotTime.Unix(); now > 0 {
nowUnix := uint64(now)
if hostInfo.BootTime > 0 && hostInfo.BootTime <= nowUnix {
bootTime = hostInfo.BootTime
uptime = nowUnix - bootTime
} else if uptime <= nowUnix {
bootTime = nowUnix - uptime
}
}
currentInfo.Uptime = uptime
currentInfo.RunningTime = loadRunningTime(uptime)
if bootTime > 0 {
currentInfo.TimeSinceUptime = time.Unix(int64(bootTime), 0).Format(constant.DateTimeLayout)
}
currentInfo.Procs = hostInfo.Procs
currentInfo.CPUTotal, _ = psutil.CPUInfo.GetLogicalCores(false)
@@ -228,13 +249,12 @@ func (u *DashboardService) LoadCurrentInfo(ioOption string, netOption string) *d
if ioOption == "all" {
diskInfo, _ := disk.IOCounters()
for _, state := range diskInfo {
currentInfo.IOReadBytes += state.ReadBytes
currentInfo.IOWriteBytes += state.WriteBytes
currentInfo.IOCount += (state.ReadCount + state.WriteCount)
currentInfo.IOReadTime += state.ReadTime
currentInfo.IOWriteTime += state.WriteTime
}
state := sumDiskIOCounters(diskInfo)
currentInfo.IOReadBytes = state.ReadBytes
currentInfo.IOWriteBytes = state.WriteBytes
currentInfo.IOCount = state.ReadCount + state.WriteCount
currentInfo.IOReadTime = state.ReadTime
currentInfo.IOWriteTime = state.WriteTime
} else {
diskInfo, _ := disk.IOCounters(ioOption)
for _, state := range diskInfo {
@@ -263,10 +283,19 @@ func (u *DashboardService) LoadCurrentInfo(ioOption string, netOption string) *d
}
}
currentInfo.ShotTime = time.Now()
currentInfo.ShotTime = shotTime
return &currentInfo
}
func loadRunningTime(uptime uint64) dto.RunningTime {
return dto.RunningTime{
Days: uptime / 86400,
Hours: (uptime % 86400) / 3600,
Minutes: (uptime % 3600) / 60,
Seconds: uptime % 60,
}
}
func (u *DashboardService) LoadTopCPU() []dto.Process {
return loadTopCPU()
}
File diff suppressed because it is too large Load Diff
+69 -20
View File
@@ -28,10 +28,11 @@ import (
)
const (
rollbackPath = "1panel/tmp"
upgradePath = "1panel/tmp/upgrade"
uploadPath = "1panel/uploads"
downloadPath = "1panel/download"
rollbackPath = "1panel/tmp"
communityRestorePath = "1panel/tmp/community-restore"
upgradePath = "1panel/tmp/upgrade"
uploadPath = "1panel/uploads"
downloadPath = "1panel/download"
)
func (u *DeviceService) Scan() dto.CleanData {
@@ -58,7 +59,7 @@ func (u *DeviceService) Scan() dto.CleanData {
SystemClean.BackupClean = loadBackupTree(fileOp)
rollBackTree := loadRollBackTree(fileOp)
rollBackTree := loadRollBackTree()
rollbackSize := uint64(0)
for _, rollback := range rollBackTree {
rollbackSize += rollback.Size
@@ -113,12 +114,15 @@ func (u *DeviceService) Clean(req []dto.Clean) {
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "app"))
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "database"))
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "website"))
dropFileOrDir(path.Join(global.Dir.BaseDir, communityRestorePath))
case "rollback_app":
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "app", item.Name))
case "rollback_database":
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "database", item.Name))
case "rollback_website":
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "website", item.Name))
case "rollback_community_restore":
dropFileOrDir(path.Join(global.Dir.BaseDir, communityRestorePath, item.Name))
case "upload":
dropFileOrDir(path.Join(global.Dir.BaseDir, uploadPath, item.Name))
@@ -214,6 +218,7 @@ func doSystemClean(taskItem *task.Task) func(t *task.Task) error {
dropWithTask(path.Join(global.Dir.BaseDir, rollbackPath, "app"), taskItem, &size, &fileCount)
dropWithTask(path.Join(global.Dir.BaseDir, rollbackPath, "website"), taskItem, &size, &fileCount)
dropWithTask(path.Join(global.Dir.BaseDir, rollbackPath, "database"), taskItem, &size, &fileCount)
dropWithTask(path.Join(global.Dir.BaseDir, communityRestorePath), taskItem, &size, &fileCount)
upgrades := path.Join(global.Dir.BaseDir, upgradePath)
oldUpgradeFiles, _ := os.ReadDir(upgrades)
@@ -606,20 +611,21 @@ func isExactPathMatch(path string, excludePaths []string) bool {
return false
}
func loadRollBackTree(fileOp fileUtils.FileOp) []dto.CleanTree {
func loadRollBackTree() []dto.CleanTree {
var treeData []dto.CleanTree
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, rollbackPath, "app"), "rollback_app", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, rollbackPath, "website"), "rollback_website", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, rollbackPath, "database"), "rollback_database", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, rollbackPath, "app"), "rollback_app")
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, rollbackPath, "website"), "rollback_website")
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, rollbackPath, "database"), "rollback_database")
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, communityRestorePath), "rollback_community_restore")
return treeData
}
func loadUploadTree(fileOp fileUtils.FileOp) []dto.CleanTree {
var treeData []dto.CleanTree
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "app"), "upload_app", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "website"), "upload_website", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "database"), "upload_database", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "app"), "upload_app")
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "website"), "upload_website")
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "database"), "upload_database")
path5 := path.Join(global.Dir.BaseDir, uploadPath)
uploadTreeData := loadTreeWithAllFile(true, path5, "upload", path5, fileOp)
@@ -630,9 +636,9 @@ func loadUploadTree(fileOp fileUtils.FileOp) []dto.CleanTree {
func loadDownloadTree(fileOp fileUtils.FileOp) []dto.CleanTree {
var treeData []dto.CleanTree
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, downloadPath, "app"), "download_app", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, downloadPath, "website"), "download_website", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, downloadPath, "database"), "download_database", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, downloadPath, "app"), "download_app")
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, downloadPath, "website"), "download_website")
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, downloadPath, "database"), "download_database")
path5 := path.Join(global.Dir.BaseDir, downloadPath)
uploadTreeData := loadTreeWithAllFile(true, path5, "download", path5, fileOp)
@@ -814,16 +820,59 @@ func loadContainerTree() []dto.CleanTree {
return treeData
}
func loadTreeWithCheck(treeData []dto.CleanTree, pathItem, treeType string, fileOp fileUtils.FileOp) []dto.CleanTree {
size, _ := fileOp.GetDirSize(pathItem)
if size == 0 {
func loadTreeWithCheck(treeData []dto.CleanTree, pathItem, treeType string) []dto.CleanTree {
list, size := loadTreeWithFileSize(true, pathItem, treeType, pathItem)
if len(list) == 0 || size == 0 {
return treeData
}
list := loadTreeWithAllFile(true, pathItem, treeType, pathItem, fileOp)
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: treeType, Size: uint64(size), IsCheck: size > 0, Children: list, Type: treeType, IsRecommend: true, CanDelete: false})
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: treeType, Size: size, IsCheck: size > 0, Children: list, Type: treeType, IsRecommend: true, CanDelete: false})
return treeData
}
func loadTreeWithFileSize(isCheck bool, originalPath, treeType, pathItem string) ([]dto.CleanTree, uint64) {
var (
lists []dto.CleanTree
total uint64
)
entries, err := os.ReadDir(pathItem)
if err != nil {
return lists, total
}
for _, entry := range entries {
item := dto.CleanTree{
ID: uuid.NewString(),
Label: entry.Name(),
Type: treeType,
Name: strings.TrimPrefix(path.Join(pathItem, entry.Name()), originalPath+"/"),
IsCheck: isCheck,
IsRecommend: isCheck,
CanDelete: true,
}
entryPath := path.Join(pathItem, entry.Name())
if entry.IsDir() {
children, size := loadTreeWithFileSize(isCheck, originalPath, treeType, entryPath)
if len(children) == 0 {
continue
}
item.Children = children
item.Size = size
} else {
info, err := entry.Info()
if err != nil {
continue
}
item.Size = uint64(info.Size())
}
if item.Size == 0 {
continue
}
total += item.Size
lists = append(lists, item)
}
return lists, total
}
func loadTreeWithDir(isCheck bool, treeType, pathItem string, fileOp fileUtils.FileOp) []dto.CleanTree {
var lists []dto.CleanTree
files, err := os.ReadDir(pathItem)
@@ -0,0 +1,84 @@
package service
import (
"fmt"
"strconv"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
)
func composePortEnvKeys(index int) (containerPort, hostPort, hostIP, protocol string) {
return fmt.Sprintf("CONTAINER_PORT_%d", index),
fmt.Sprintf("HOST_PORT_%d", index),
fmt.Sprintf("HOST_IP_%d", index),
fmt.Sprintf("PORT_PROTOCOL_%d", index)
}
func isComposePortEnvKey(key string) bool {
return strings.HasPrefix(key, "CONTAINER_PORT_") ||
strings.HasPrefix(key, "HOST_PORT_") ||
strings.HasPrefix(key, "HOST_IP_") ||
strings.HasPrefix(key, "PORT_PROTOCOL_")
}
func formatComposePortMapping(hostIP, hostPort, containerPort, protocol string) string {
return fmt.Sprintf("${%s}:${%s}:${%s}/%s", hostIP, hostPort, containerPort, normalizeComposeProtocol(protocol))
}
func normalizeComposeProtocol(protocol string) string {
switch strings.ToLower(strings.TrimSpace(protocol)) {
case "udp":
return "udp"
default:
return "tcp"
}
}
func formatComposeVolume(source, target, mode string) string {
return fmt.Sprintf("%s:%s:%s", source, target, normalizeComposeVolumeMode(mode))
}
func normalizeComposeVolumeMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "ro":
return "ro"
default:
return "rw"
}
}
func loadComposeExposedPortsFromEnv(envs map[string]string, defaultHostIP string, strict bool) ([]request.ExposedPort, error) {
var ports []request.ExposedPort
for key, value := range envs {
if !strings.HasPrefix(key, "CONTAINER_PORT_") {
continue
}
index := strings.TrimPrefix(key, "CONTAINER_PORT_")
containerPort, err := strconv.Atoi(value)
if err != nil {
if strict {
return nil, err
}
continue
}
hostPort, err := strconv.Atoi(envs["HOST_PORT_"+index])
if err != nil {
if strict {
return nil, err
}
continue
}
hostIP := envs["HOST_IP_"+index]
if hostIP == "" {
hostIP = defaultHostIP
}
ports = append(ports, request.ExposedPort{
ContainerPort: containerPort,
HostPort: hostPort,
HostIP: hostIP,
Protocol: normalizeComposeProtocol(envs["PORT_PROTOCOL_"+index]),
})
}
return ports, nil
}
+14 -10
View File
@@ -19,10 +19,12 @@ var (
agentAccountRepo = repo.NewIAgentAccountRepo()
agentAccountModelRepo = repo.NewIAgentAccountModelRepo()
mysqlRepo = repo.NewIMysqlRepo()
postgresqlRepo = repo.NewIPostgresqlRepo()
mongodbRepo = repo.NewIMongodbRepo()
databaseRepo = repo.NewIDatabaseRepo()
mysqlRepo = repo.NewIMysqlRepo()
postgresqlRepo = repo.NewIPostgresqlRepo()
mongodbRepo = repo.NewIMongodbRepo()
databaseRepo = repo.NewIDatabaseRepo()
databaseUserRepo = repo.NewIDatabaseUserRepo()
databaseUserGrantRepo = repo.NewIDatabaseUserGrantRepo()
imageRepoRepo = repo.NewIImageRepoRepo()
composeRepo = repo.NewIComposeTemplateRepo()
@@ -38,12 +40,14 @@ var (
settingRepo = repo.NewISettingRepo()
backupRepo = repo.NewIBackupRepo()
websiteRepo = repo.NewIWebsiteRepo()
websiteDomainRepo = repo.NewIWebsiteDomainRepo()
websiteDnsRepo = repo.NewIWebsiteDnsAccountRepo()
websiteSSLRepo = repo.NewISSLRepo()
websiteAcmeRepo = repo.NewIAcmeAccountRepo()
websiteCARepo = repo.NewIWebsiteCARepo()
websiteRepo = repo.NewIWebsiteRepo()
websiteDomainRepo = repo.NewIWebsiteDomainRepo()
websiteDnsRepo = repo.NewIWebsiteDnsAccountRepo()
websiteSSLRepo = repo.NewISSLRepo()
websiteAcmeRepo = repo.NewIAcmeAccountRepo()
websiteCARepo = repo.NewIWebsiteCARepo()
websiteTemplateRepo = repo.NewIWebsiteTemplateRepo()
websiteTemplateOutputRepo = repo.NewIWebsiteTemplateOutputRepo()
snapshotRepo = repo.NewISnapshotRepo()
+66 -12
View File
@@ -16,6 +16,7 @@ import (
"sort"
"strconv"
"strings"
"syscall"
"time"
"unicode/utf8"
@@ -466,7 +467,7 @@ func (f *FileService) Compress(c request.FileCompress) error {
func preflightCompressTool(compressType files.CompressType) error {
switch compressType {
case files.TarGz, files.Rar, files.X7z:
case files.Tar, files.Gz, files.Bz2, files.TarBz2, files.Tgz, files.TarGz, files.Xz, files.TarXz, files.Rar, files.X7z:
_, err := files.NewShellArchiver(compressType)
return err
default:
@@ -476,7 +477,7 @@ func preflightCompressTool(compressType files.CompressType) error {
func preflightDecompressTool(decompressType files.CompressType) error {
switch decompressType {
case files.Rar, files.X7z:
case files.Rar:
_, err := files.NewExtractShellArchiver(decompressType)
return err
default:
@@ -485,7 +486,7 @@ func preflightDecompressTool(decompressType files.CompressType) error {
}
func (f *FileService) StopCompress(taskID string) error {
if cancel, ok := global.TaskCtxMap[taskID]; ok {
if cancel, ok := global.LoadTaskCancel(taskID); ok {
cancel()
return nil
}
@@ -493,7 +494,7 @@ func (f *FileService) StopCompress(taskID string) error {
}
func (f *FileService) StopDeCompress(taskID string) error {
if cancel, ok := global.TaskCtxMap[taskID]; ok {
if cancel, ok := global.LoadTaskCancel(taskID); ok {
cancel()
return nil
}
@@ -533,7 +534,10 @@ func (f *FileService) DeCompress(c request.FileDeCompress) error {
_ = os.RemoveAll(c.Dst)
}
}()
if err := fo.Decompress(t.TaskCtx, c.Path, tempDst, files.CompressType(c.Type), c.Secret); err != nil {
if err := fo.DecompressWithOptions(t.TaskCtx, c.Path, tempDst, files.CompressType(c.Type), c.Secret, files.DecompressOptions{
PreserveOwner: true,
AllowCLIReextract: true,
}); err != nil {
return err
}
if err := fo.CreateDir(c.Dst, constant.DirPerm); err != nil {
@@ -551,19 +555,42 @@ func (f *FileService) DeCompress(c request.FileDeCompress) error {
}
func copyDecompressTree(ctx context.Context, srcDir, dstDir string) error {
state := decompressCopyState{hardlinks: make(map[decompressFileIdentity]string)}
entries, err := os.ReadDir(srcDir)
if err != nil {
return err
}
for _, entry := range entries {
if err := copyDecompressEntry(ctx, filepath.Join(srcDir, entry.Name()), filepath.Join(dstDir, entry.Name())); err != nil {
if err := copyDecompressEntryWithState(ctx, filepath.Join(srcDir, entry.Name()), filepath.Join(dstDir, entry.Name()), &state); err != nil {
return err
}
}
return nil
}
type decompressFileIdentity struct {
device uint64
inode uint64
}
type decompressCopyState struct {
hardlinks map[decompressFileIdentity]string
}
func decompressHardlinkIdentity(info os.FileInfo) (decompressFileIdentity, bool) {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || stat.Nlink < 2 {
return decompressFileIdentity{}, false
}
return decompressFileIdentity{device: uint64(stat.Dev), inode: uint64(stat.Ino)}, true
}
func copyDecompressEntry(ctx context.Context, srcPath, dstPath string) (retErr error) {
state := decompressCopyState{hardlinks: make(map[decompressFileIdentity]string)}
return copyDecompressEntryWithState(ctx, srcPath, dstPath, &state)
}
func copyDecompressEntryWithState(ctx context.Context, srcPath, dstPath string, state *decompressCopyState) (retErr error) {
if err := ctx.Err(); err != nil {
return err
}
@@ -605,13 +632,16 @@ func copyDecompressEntry(ctx context.Context, srcPath, dstPath string) (retErr e
if err := applyDecompressOwnership(srcPath, dstPath); err != nil {
return err
}
if err := os.Chmod(dstPath, info.Mode().Perm()); err != nil {
return err
}
}
entries, err := os.ReadDir(srcPath)
if err != nil {
return err
}
for _, entry := range entries {
if err := copyDecompressEntry(ctx, filepath.Join(srcPath, entry.Name()), filepath.Join(dstPath, entry.Name())); err != nil {
if err := copyDecompressEntryWithState(ctx, filepath.Join(srcPath, entry.Name()), filepath.Join(dstPath, entry.Name()), state); err != nil {
return err
}
}
@@ -621,12 +651,28 @@ func copyDecompressEntry(ctx context.Context, srcPath, dstPath string) (retErr e
return os.Chtimes(dstPath, info.ModTime(), info.ModTime())
}
if err := os.RemoveAll(dstPath); err != nil {
dstInfo, err := os.Lstat(dstPath)
keepExistingFile := err == nil && dstInfo.Mode().IsRegular()
if err != nil && !os.IsNotExist(err) {
return err
}
if !keepExistingFile {
if err := os.RemoveAll(dstPath); err != nil {
return err
}
}
if err := os.MkdirAll(filepath.Dir(dstPath), constant.DirPerm); err != nil {
return err
}
identity, isHardlink := decompressHardlinkIdentity(info)
if !keepExistingFile && isHardlink {
if existingPath, ok := state.hardlinks[identity]; ok {
if err := os.Link(existingPath, dstPath); err != nil {
return err
}
return os.Chtimes(dstPath, info.ModTime(), info.ModTime())
}
}
srcFile, err := os.Open(srcPath)
if err != nil {
@@ -647,8 +693,16 @@ func copyDecompressEntry(ctx context.Context, srcPath, dstPath string) (retErr e
if _, err := io.Copy(dstFile, srcFile); err != nil {
return err
}
if err := applyDecompressOwnership(srcPath, dstPath); err != nil {
return err
if !keepExistingFile {
if err := applyDecompressOwnership(srcPath, dstPath); err != nil {
return err
}
if err := os.Chmod(dstPath, info.Mode().Perm()); err != nil {
return err
}
if isHardlink {
state.hardlinks[identity] = dstPath
}
}
return os.Chtimes(dstPath, info.ModTime(), info.ModTime())
}
@@ -658,7 +712,7 @@ func applyDecompressOwnership(srcPath, dstPath string) error {
if err != nil {
return err
}
stat, ok := info.Sys().(*unix.Stat_t)
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return nil
}
@@ -1116,7 +1170,7 @@ func (f *FileService) ReadLogByLine(req request.FileReadByLineReq) (*response.Fi
}
logFilePath = path.Join(global.Dir.LogDir, "ai", safeName)
default:
return nil, buserr.New("ErrNotSupportType")
return nil, buserr.WithName("ErrNotSupportType", req.Type)
}
file, err := os.Open(logFilePath)
+36 -99
View File
@@ -3,14 +3,12 @@ package service
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/common"
@@ -28,7 +26,6 @@ type IFirewallService interface {
SearchWithPage(search dto.RuleSearch) (int64, interface{}, error)
OperateFirewall(req dto.FirewallOperation) error
OperatePortRule(req dto.PortRuleOperate, reload bool) error
OperateForwardRule(req dto.ForwardRuleOperate) error
OperateAddressRule(req dto.AddrRuleOperate, reload bool) error
UpdatePortRule(req dto.PortRuleUpdate) error
UpdateAddrRule(req dto.AddrRuleUpdate) error
@@ -84,8 +81,6 @@ func (u *FirewallService) SearchWithPage(req dto.RuleSearch) (int64, interface{}
switch req.Type {
case "port":
rules, err = client.ListPort()
case "forward":
rules, err = client.ListForward()
case "address":
rules, err = client.ListAddress()
}
@@ -166,6 +161,12 @@ func (u *FirewallService) OperateFirewall(req dto.FirewallOperation) error {
if err != nil {
return err
}
fail2BanState := newFirewallFail2BanState()
if client.Name() == "firewalld" && req.Operation == "stop" {
if err := fail2BanState.rememberBeforeFirewallStop(); err != nil {
return err
}
}
needRestartDocker := false
switch req.Operation {
case "start":
@@ -210,6 +211,11 @@ func (u *FirewallService) OperateFirewall(req dto.FirewallOperation) error {
return fmt.Errorf("failed to restart Docker: %v", err)
}
}
if client.Name() == "firewalld" && req.Operation == "start" {
if err := fail2BanState.restoreAfterFirewallStart(); err != nil {
return err
}
}
return nil
}
@@ -222,7 +228,7 @@ func (u *FirewallService) OperatePortRule(req dto.PortRuleOperate, reload bool)
req.Chain = iptables.Chain1PanelBasic
}
protos := strings.Split(req.Protocol, "/")
itemAddress := strings.Split(strings.TrimSuffix(req.Address, ","), ",")
itemAddress := splitFirewallRuleAddresses(req.Address)
if client.Name() == "ufw" {
if strings.Contains(req.Port, ",") || strings.Contains(req.Port, "-") {
@@ -306,96 +312,6 @@ func (u *FirewallService) OperatePortRule(req dto.PortRuleOperate, reload bool)
return nil
}
func (u *FirewallService) OperateForwardRule(req dto.ForwardRuleOperate) error {
client, err := firewall.NewFirewallClient()
if err != nil {
return err
}
rules, _ := client.ListForward()
i := 0
for _, rule := range rules {
shouldKeep := true
for i := range req.Rules {
reqRule := &req.Rules[i]
if reqRule.TargetIP == "" {
reqRule.TargetIP = "127.0.0.1"
}
if reqRule.Operation == "remove" {
for _, proto := range strings.Split(reqRule.Protocol, "/") {
if reqRule.Port == rule.Port &&
reqRule.TargetPort == rule.TargetPort &&
reqRule.TargetIP == rule.TargetIP &&
proto == rule.Protocol &&
reqRule.Interface == rule.Interface {
shouldKeep = false
break
}
}
}
}
if shouldKeep {
rules[i] = rule
i++
}
}
rules = rules[:i]
for _, rule := range rules {
for _, reqRule := range req.Rules {
if reqRule.Operation == "remove" {
continue
}
for _, proto := range strings.Split(reqRule.Protocol, "/") {
if reqRule.Port == rule.Port &&
reqRule.TargetPort == rule.TargetPort &&
reqRule.TargetIP == rule.TargetIP &&
proto == rule.Protocol &&
reqRule.Interface == rule.Interface {
return buserr.New("ErrRecordExist")
}
}
}
}
sort.SliceStable(req.Rules, func(i, j int) bool {
if req.Rules[i].Operation == "remove" && req.Rules[j].Operation != "remove" {
return true
}
if req.Rules[i].Operation != "remove" && req.Rules[j].Operation == "remove" {
return false
}
n1, _ := strconv.Atoi(req.Rules[i].Num)
n2, _ := strconv.Atoi(req.Rules[j].Num)
return n1 > n2
})
for _, r := range req.Rules {
for _, p := range strings.Split(r.Protocol, "/") {
if r.TargetIP == "" {
r.TargetIP = "127.0.0.1"
}
if err = client.PortForward(fireClient.Forward{
Num: r.Num,
Protocol: p,
Port: r.Port,
TargetIP: r.TargetIP,
TargetPort: r.TargetPort,
Interface: r.Interface,
}, r.Operation); err != nil {
if req.ForceDelete {
global.LOG.Error(err)
continue
}
return err
}
}
}
return nil
}
func (u *FirewallService) OperateAddressRule(req dto.AddrRuleOperate, reload bool) error {
client, err := firewall.NewFirewallClient()
if err != nil {
@@ -510,11 +426,12 @@ func OperateFirewallPort(oldPorts, newPorts []int) error {
return client.Reload()
}
func (u *FirewallService) operatePort(client firewall.FirewallClient, req dto.PortRuleOperate) error {
func (u *FirewallService) operatePort(client firewall.FilterClient, req dto.PortRuleOperate) error {
var fireInfo fireClient.FireInfo
if err := copier.Copy(&fireInfo, &req); err != nil {
return err
}
fireInfo.Address = normalizeFirewallRuleAddress(fireInfo.Address)
if client.Name() == "ufw" {
if len(fireInfo.Address) != 0 && !strings.EqualFold(fireInfo.Address, "Anywhere") {
@@ -529,6 +446,26 @@ func (u *FirewallService) operatePort(client firewall.FirewallClient, req dto.Po
return client.Port(fireInfo, req.Operation)
}
func splitFirewallRuleAddresses(address string) []string {
parts := strings.Split(strings.TrimSuffix(address, ","), ",")
addresses := make([]string, 0, len(parts))
for _, part := range parts {
addresses = append(addresses, normalizeFirewallRuleAddress(part))
}
if len(addresses) == 0 {
return []string{""}
}
return addresses
}
func normalizeFirewallRuleAddress(address string) string {
address = strings.TrimSpace(address)
if strings.EqualFold(address, "Anywhere") {
return ""
}
return address
}
type portOfApp struct {
AppName string
HttpPort string
@@ -557,7 +494,7 @@ func (u *FirewallService) loadPortByApp() []portOfApp {
return datas
}
func (u *FirewallService) cleanUnUsedData(client firewall.FirewallClient) {
func (u *FirewallService) cleanUnUsedData(client firewall.FilterClient) {
list, _ := client.ListPort()
addressList, _ := client.ListAddress()
list = append(list, addressList...)
@@ -581,7 +518,7 @@ func (u *FirewallService) cleanUnUsedData(client firewall.FirewallClient) {
}
}
func (u *FirewallService) addPortsBeforeStart(client firewall.FirewallClient) error {
func (u *FirewallService) addPortsBeforeStart(client firewall.FilterClient) error {
if client.Name() == "iptables" {
isInit, _ := iptables.LoadInitStatus("iptables", "base")
if !isInit {

Some files were not shown because too many files have changed in this diff Show More