chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
*.js linguist-language=java
|
||||
*.css linguist-language=java
|
||||
*.html linguist-language=java
|
||||
@@ -0,0 +1,28 @@
|
||||
<!-- Here is for bug reports and feature requests ONLY!
|
||||
|
||||
If you're looking for help, please check our mail list、WeChat group and the Gitter room.
|
||||
|
||||
Please try to use English to describe your issue, or at least provide a snippet of English translation.
|
||||
我们鼓励使用英文,如果不能直接使用,可以使用翻译软件,您仍旧可以保留中文原文。
|
||||
-->
|
||||
|
||||
## Issue Description
|
||||
|
||||
Type: *bug report* or *feature request*
|
||||
|
||||
### Describe what happened (or what feature you want)
|
||||
|
||||
|
||||
### Describe what you expected to happen
|
||||
|
||||
|
||||
### How to reproduce it (as minimally and precisely as possible)
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Tell us your environment
|
||||
|
||||
|
||||
### Anything else we need to know?
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!-- Here is for bug reports and feature requests ONLY!
|
||||
|
||||
If you're looking for help, please check our mail list、WeChat group and the Gitter room.
|
||||
|
||||
Please try to use English to describe your issue, or at least provide a snippet of English translation.
|
||||
我们鼓励使用英文,如果不能直接使用,可以使用翻译软件,您仍旧可以保留中文原文。
|
||||
-->
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Actually behavior**
|
||||
A clear and concise description of what you actually to happen.
|
||||
|
||||
**How to Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See errors
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. Centos]
|
||||
- Version [e.g. nacos-server 1.3.1, nacos-client 1.3.1]
|
||||
- Module [e.g. naming/config]
|
||||
- SDK [e.g. original, spring-cloud-alibaba-nacos, dubbo]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions
|
||||
url: https://github.com/alibaba/nacos/discussions
|
||||
about: For questions and discussions. Bug reports and feature requests should use the templates above.
|
||||
- name: Security Vulnerabilities
|
||||
url: https://security.alibaba.com
|
||||
about: Report security vulnerabilities to ASRC (Alibaba Security Response Center).
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!-- Here is for bug reports and feature requests ONLY!
|
||||
|
||||
If you're looking for help, please check our mail list、WeChat group and the Gitter room.
|
||||
|
||||
Please try to use English to describe your issue, or at least provide a snippet of English translation.
|
||||
我们鼓励使用英文,如果不能直接使用,可以使用翻译软件,您仍旧可以保留中文原文。
|
||||
-->
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,22 @@
|
||||
Please do not create a Pull Request without creating an issue first.
|
||||
|
||||
## What is the purpose of the change
|
||||
|
||||
XXXXX
|
||||
|
||||
## Brief changelog
|
||||
|
||||
XX
|
||||
|
||||
## Verifying this change
|
||||
|
||||
XXXX
|
||||
|
||||
Follow this checklist to help us incorporate your contribution quickly and easily:
|
||||
|
||||
* [ ] Make sure there is a Github issue filed for the change (usually before you start working on it). Trivial changes like typos do not require a Github issue. Your pull request should address just this issue, without pulling in other changes - one PR resolves one issue.
|
||||
* [ ] Format the pull request title like `[ISSUE #123] Fix UnknownException when host config not exist`. Each commit in the pull request should have a meaningful subject line and body.
|
||||
* [ ] Write a pull request description that is detailed enough to understand what the pull request does, how, and why.
|
||||
* [ ] Write necessary unit-test to verify your logic correction, more mock a little better when cross module dependency exist. If the new feature or significant change is committed, please remember to add integration-test in [test module](https://github.com/alibaba/nacos/tree/master/test).
|
||||
* [ ] Run `mvn -B clean package apache-rat:check spotbugs:check -DskipTests` to make sure basic checks pass. Run `mvn clean install` to make sure unit-test pass. Run `mvn clean test-compile failsafe:integration-test` to make sure integration-test pass.
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
name: Anti-Spam Protection
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
spam-detection:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check for spam
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const title = issue.title.toLowerCase();
|
||||
const body = (issue.body || '').toLowerCase();
|
||||
const author = issue.user.login;
|
||||
const authorAssociation = issue.author_association;
|
||||
|
||||
// Skip if author is a member, collaborator, or owner
|
||||
if (['MEMBER', 'COLLABORATOR', 'OWNER'].includes(authorAssociation)) {
|
||||
console.log(`Skipping spam check for ${authorAssociation}: ${author}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user details via API to calculate account age
|
||||
let accountAgeDays = 365; // Default to old account if API fails
|
||||
try {
|
||||
const { data: userData } = await github.rest.users.getByUsername({
|
||||
username: author
|
||||
});
|
||||
const authorCreatedAt = new Date(userData.created_at);
|
||||
const now = new Date();
|
||||
accountAgeDays = (now - authorCreatedAt) / (1000 * 60 * 60 * 24);
|
||||
console.log(`Account ${author} created at ${userData.created_at}, age: ${accountAgeDays.toFixed(1)} days`);
|
||||
} catch (error) {
|
||||
console.log(`Failed to get user info for ${author}: ${error.message}`);
|
||||
}
|
||||
|
||||
// Spam keywords - airlines and travel-related spam
|
||||
const spamKeywords = [
|
||||
// Airlines
|
||||
'lufthansa', 'emirates', 'klm', 'turkish airlines', 'singapore airlines',
|
||||
'aer lingus', 'sas airlines', 'qatar airways', 'british airways',
|
||||
'american airlines', 'united airlines', 'delta airlines', 'air france',
|
||||
'swiss air', 'austrian airlines', 'tap portugal', 'air canada',
|
||||
'air europa', 'ita airways',
|
||||
// Italian terms
|
||||
'telefono', 'rimborso', 'volo', 'biglietto', 'prenotazione',
|
||||
'annullare', 'cancellare', 'modifica', 'gestire', 'chiamare',
|
||||
'numero di telefono', 'contattare', 'assistenza clienti',
|
||||
// German terms
|
||||
'kontakt', 'buchen', 'kundenservice', 'hotline', 'kundendienst',
|
||||
'buchung', 'stornieren', 'umbuchung', 'erreichen', 'telefonnummer',
|
||||
// French terms
|
||||
'billet', 'réservation', 'annuler', 'rembours', 'vol',
|
||||
'contacter', 'numéro', 'téléphone', 'modifier', 'payer',
|
||||
// Common spam patterns
|
||||
'customer service number', 'booking number', 'flight cancel',
|
||||
'refund process', 'how to contact', 'toll free', 'helpline',
|
||||
'1-800', '1-888', '1-877', '1-866'
|
||||
];
|
||||
|
||||
// Check content for spam keywords
|
||||
const contentToCheck = title + ' ' + body;
|
||||
const matchedKeywords = spamKeywords.filter(kw => contentToCheck.includes(kw));
|
||||
|
||||
// Check for phone number patterns (3+ phone numbers is suspicious)
|
||||
const phonePatterns = [
|
||||
/\+\d{1,3}[\s-]?\d{2,4}[\s-]?\d{3,4}[\s-]?\d{3,4}/g, // International format
|
||||
/\d{3}[\s.-]?\d{3}[\s.-]?\d{4}/g // US/common format
|
||||
];
|
||||
let phoneCount = 0;
|
||||
for (const pattern of phonePatterns) {
|
||||
const matches = contentToCheck.match(pattern) || [];
|
||||
phoneCount += matches.length;
|
||||
}
|
||||
const hasExcessivePhones = phoneCount >= 2;
|
||||
|
||||
// Spam detection rules:
|
||||
// 1. Match 2+ spam keywords
|
||||
// 2. New account (< 7 days) + 1 spam keyword
|
||||
// 3. 2+ phone numbers in content
|
||||
const isSpam = matchedKeywords.length >= 2 ||
|
||||
(accountAgeDays < 7 && matchedKeywords.length >= 1) ||
|
||||
hasExcessivePhones;
|
||||
|
||||
if (isSpam) {
|
||||
console.log(`Spam detected in issue #${issue.number}`);
|
||||
console.log(`Author: ${author}, Account age: ${accountAgeDays.toFixed(1)} days`);
|
||||
console.log(`Matched keywords: ${matchedKeywords.join(', ')}`);
|
||||
console.log(`Phone numbers found: ${phoneCount}`);
|
||||
|
||||
// Add spam label
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['spam']
|
||||
});
|
||||
|
||||
// Add comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `This issue has been automatically detected as spam and will be closed.\n\n` +
|
||||
`If this is a legitimate issue, please create a new issue using the [issue template](https://github.com/${context.repo.owner}/${context.repo.repo}/issues/new/choose).\n\n` +
|
||||
`---\n` +
|
||||
`此 Issue 被自动检测为垃圾信息,将被关闭。如果这是一个合法的问题,请使用 [Issue 模板](https://github.com/${context.repo.owner}/${context.repo.repo}/issues/new/choose) 重新创建。`
|
||||
});
|
||||
|
||||
// Close issue
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned'
|
||||
});
|
||||
|
||||
// Lock issue
|
||||
await github.rest.issues.lock({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
lock_reason: 'spam'
|
||||
});
|
||||
|
||||
console.log(`Issue #${issue.number} closed and locked as spam`);
|
||||
} else {
|
||||
console.log(`No spam detected in issue #${issue.number}`);
|
||||
console.log(`Matched keywords: ${matchedKeywords.length}, Phone numbers: ${phoneCount}, Account age: ${accountAgeDays.toFixed(1)} days`);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
# This workflow will build a Java project with Maven
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven
|
||||
|
||||
name: "Continuous Integration"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ develop, v2.x-develop ]
|
||||
pull_request:
|
||||
branches: [ develop, v2.x-develop ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: [self-hosted, Linux, X64, nacos-ci, nacos-java]
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: "Cache Maven Repos"
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.m2/repository
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v4
|
||||
- name: "Set up JDK 17"
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: 'zulu'
|
||||
- name: Set up Maven Daemon
|
||||
shell: bash
|
||||
env:
|
||||
MVND_VERSION: '1.0.6'
|
||||
# The download speed from downloads.apache.org and archive.apache.org can sometimes be very slow.
|
||||
# MVND_BASE_URL: 'https://downloads.apache.org/maven/mvnd'
|
||||
# MVND_BASE_URL: 'https://archive.apache.org/dist/maven/mvnd'
|
||||
# GitHub releases do not have a .sha256 file.
|
||||
MVND_BASE_URL: 'https://github.com/apache/maven-mvnd/releases/download'
|
||||
run: |
|
||||
case "${{ runner.os }}" in
|
||||
Linux) MVND_OS=linux ;;
|
||||
macOS) MVND_OS=darwin ;;
|
||||
Windows) MVND_OS=windows ;;
|
||||
*) echo "Unsupported OS: ${{ runner.os }}"; exit 1 ;;
|
||||
esac
|
||||
case "${{ runner.arch }}" in
|
||||
X64) MVND_ARCH=amd64 ;;
|
||||
ARM64) MVND_ARCH=aarch64 ;;
|
||||
*) echo "Unsupported arch: ${{ runner.arch }}"; exit 1 ;;
|
||||
esac
|
||||
MVND_DIR="maven-mvnd-${MVND_VERSION}-${MVND_OS}-${MVND_ARCH}"
|
||||
MVND_ZIP="${MVND_DIR}.zip"
|
||||
MVND_URL="${MVND_BASE_URL}/${MVND_VERSION}/${MVND_ZIP}"
|
||||
echo "Downloading mvnd ${MVND_VERSION} for ${MVND_OS}/${MVND_ARCH}..."
|
||||
echo "Download URL: $MVND_URL"
|
||||
curl -fsSL --retry 3 --retry-delay 5 --retry-connrefused "$MVND_URL" -o "$RUNNER_TEMP/mvnd.zip"
|
||||
# echo "Checksum URL: ${MVND_URL}.sha256"
|
||||
# curl -fsSL --retry 3 --retry-delay 5 --retry-connrefused "${MVND_URL}.sha256" -o "$RUNNER_TEMP/mvnd.zip.sha256"
|
||||
RELEASE_API="https://api.github.com/repos/apache/maven-mvnd/releases/tags/${MVND_VERSION}"
|
||||
echo "Fetching expected checksum from GitHub API..."
|
||||
EXPECTED=$(curl -fsSL --retry 3 --retry-delay 5 -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "$RELEASE_API" | jq -r --arg name "$MVND_ZIP" '.assets[] | select(.name == $name) | .digest' | sed 's/^sha256://')
|
||||
echo "Verifying download..."
|
||||
# EXPECTED=$(cut -d' ' -f1 "$RUNNER_TEMP/mvnd.zip.sha256")
|
||||
ACTUAL=$(sha256sum "$RUNNER_TEMP/mvnd.zip" | cut -d' ' -f1 | tr -d '\\')
|
||||
if [ "$EXPECTED" != "$ACTUAL" ]; then
|
||||
echo "Checksum mismatch! Expected: $EXPECTED, got: $ACTUAL"
|
||||
exit 1
|
||||
fi
|
||||
echo "Checksum verified."
|
||||
unzip -q "$RUNNER_TEMP/mvnd.zip" -d "$RUNNER_TEMP/mvnd-cache"
|
||||
echo "$RUNNER_TEMP/mvnd-cache/${MVND_DIR}/bin" >> "$GITHUB_PATH"
|
||||
- name: "Print Maven Daemon version"
|
||||
run: mvnd -version
|
||||
- name: "Check with Maven Daemon"
|
||||
run: mvnd -B clean compile apache-rat:check checkstyle:check spotbugs:check spotless:check -e -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn
|
||||
- name: "Build and Test with Maven Daemon"
|
||||
run: mvnd -B '-Prelease-nacos,!dev' clean install -Drat.skip=true -Dspotbugs.skip=true -DtrimStackTrace=false -U -e -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn
|
||||
- name: "Upload coverage to Codecov"
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
verbose: true
|
||||
files: ./core/target/site/jacoco/jacoco.xml,./plugin-default-impl/nacos-default-auth-plugin/target/site/jacoco/jacoco.xml,./plugin-default-impl/nacos-default-control-plugin/target/site/jacoco/jacoco.xml,./plugin-default-impl/nacos-default-datasource-plugin/nacos-datasource-plugin-base/target/site/jacoco/jacoco.xml,./plugin-default-impl/nacos-default-datasource-plugin/nacos-datasource-plugin-mysql/target/site/jacoco/jacoco.xml,./plugin-default-impl/nacos-default-datasource-plugin/nacos-datasource-plugin-derby/target/site/jacoco/jacoco.xml,./plugin-default-impl/nacos-default-datasource-plugin/nacos-datasource-plugin-postgresql/target/site/jacoco/jacoco.xml,./plugin-default-impl/nacos-default-datasource-plugin/nacos-datasource-plugin-oracle/target/site/jacoco/jacoco.xml,./config/target/site/jacoco/jacoco.xml,./auth/target/site/jacoco/jacoco.xml,./plugin/encryption/target/site/jacoco/jacoco.xml,./plugin/datasource/target/site/jacoco/jacoco.xml,./plugin/trace/target/site/jacoco/jacoco.xml,./plugin/config/target/site/jacoco/jacoco.xml,./plugin/auth/target/site/jacoco/jacoco.xml,./plugin/environment/target/site/jacoco/jacoco.xml,./plugin/control/target/site/jacoco/jacoco.xml,./lock/target/site/jacoco/jacoco.xml,./logger-adapter-impl/log4j2-adapter/target/site/jacoco/jacoco.xml,./logger-adapter-impl/logback-adapter-12/target/site/jacoco/jacoco.xml,./consistency/target/site/jacoco/jacoco.xml,./common/target/site/jacoco/jacoco.xml,./sys/target/site/jacoco/jacoco.xml,./ai/target/site/jacoco/jacoco.xml,./naming/target/site/jacoco/jacoco.xml,./client-basic/target/site/jacoco/jacoco.xml,./address/target/site/jacoco/jacoco.xml,./persistence/target/site/jacoco/jacoco.xml,./api/target/site/jacoco/jacoco.xml,./maintainer-client/target/site/jacoco/jacoco.xml,./prometheus/target/site/jacoco/jacoco.xml,./client/target/site/jacoco/jacoco.xml,./console/target/site/jacoco/jacoco.xml
|
||||
@@ -0,0 +1,70 @@
|
||||
name: "Frontend Continuous Integration"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ develop, v2.x-develop ]
|
||||
pull_request:
|
||||
branches: [ develop, v2.x-develop ]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
frontend-ci:
|
||||
runs-on: [self-hosted, Linux, X64, nacos-ci, nacos-nodejs]
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v4
|
||||
- name: "Set up Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: "Build and check console-ui"
|
||||
working-directory: console-ui
|
||||
run: npm ci && npm run build
|
||||
- name: "Build and check console-ui-next"
|
||||
working-directory: console-ui-next
|
||||
run: npm ci && npm run build
|
||||
|
||||
check-min-release-age:
|
||||
runs-on: [self-hosted, Linux, X64, nacos-ci, nacos-nodejs]
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v4
|
||||
- name: "Set up Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
# node 24: npm >= 11
|
||||
node-version: 24
|
||||
- name: "Check npm >= 11"
|
||||
run: |
|
||||
NPM_VERSION=$(npm -v)
|
||||
echo $NPM_VERSION
|
||||
NPM_MAJOR=$(echo "$NPM_VERSION" | cut -d'.' -f1)
|
||||
echo $NPM_MAJOR
|
||||
if [ "$NPM_MAJOR" -lt 11 ]; then
|
||||
echo "::error::npm version must be >= 11 for min-release-age, but got npm ${NPM_VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
echo "npm ${NPM_VERSION} >= 11, min-release-age is supported"
|
||||
- name: "Check min-release-age in console-ui-next/.npmrc"
|
||||
run: |
|
||||
if ! grep -q '^min-release-age=' console-ui-next/.npmrc; then
|
||||
echo "::error::min-release-age is not configured in console-ui-next/.npmrc"
|
||||
exit 1
|
||||
fi
|
||||
VALUE=$(grep '^min-release-age=' console-ui-next/.npmrc | cut -d'=' -f2)
|
||||
if [ -z "$VALUE" ]; then
|
||||
echo "::error::min-release-age has no value set in console-ui-next/.npmrc"
|
||||
exit 1
|
||||
fi
|
||||
echo "min-release-age=${VALUE} is configured"
|
||||
- name: "Verify min-release-age takes effect during npm install"
|
||||
run: |
|
||||
cd console-ui-next
|
||||
npm install --legacy-peer-deps $(node -e "const p=require('./package-lock.json'); const root=p.packages['']; const all={...root.dependencies,...root.devDependencies}; console.log(Object.entries(all).map(([k])=>k+'@'+((p.packages['node_modules/'+k]||{}).version||'?')).join(' '))")
|
||||
@@ -0,0 +1,77 @@
|
||||
name: "Issue Assignment"
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
handle-command:
|
||||
if: github.event_name == 'issue_comment' && !github.event.issue.pull_request
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const comment = context.payload.comment;
|
||||
const issue = context.payload.issue;
|
||||
const commenter = comment.user.login;
|
||||
const body = comment.body.trim();
|
||||
|
||||
// /assign command
|
||||
if (/^\/assign\s*$/.test(body)) {
|
||||
const assignees = issue.assignees.map(a => a.login);
|
||||
|
||||
if (assignees.length === 0) {
|
||||
// No assignee — assign commenter
|
||||
await github.rest.issues.addAssignees({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
assignees: [commenter]
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `@${commenter} has been assigned to this issue / 已被分配到此 Issue.`
|
||||
});
|
||||
} else if (assignees.includes(commenter)) {
|
||||
// Already assigned to commenter — ignore silently
|
||||
} else {
|
||||
// Assigned to someone else — reject
|
||||
const currentAssignee = assignees[0];
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `This issue is currently assigned to @${currentAssignee} / 此 Issue 当前已分配给 @${currentAssignee}. Please wait or discuss in the comments / 请等待其释放或在评论中沟通。`
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// /unassign command
|
||||
if (/^\/unassign\s*$/.test(body)) {
|
||||
const assignees = issue.assignees.map(a => a.login);
|
||||
|
||||
if (assignees.includes(commenter)) {
|
||||
await github.rest.issues.removeAssignees({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
assignees: [commenter]
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `@${commenter} has been unassigned from this issue / 已取消认领此 Issue. It is now available for others / 现在可供其他人认领。`
|
||||
});
|
||||
}
|
||||
// If commenter is not assignee — ignore
|
||||
return;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
# This workflow will build a Java project with Maven
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven
|
||||
|
||||
name: Integration Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ develop ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
integration-test:
|
||||
name: OpenAPI, Java SDK and Maintainer SDK Integration Test
|
||||
runs-on: [self-hosted, Linux, X64, nacos-ci, nacos-java]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
cache: 'maven'
|
||||
|
||||
- name: Build with Maven
|
||||
run: mvn -B clean install -Prelease-nacos -DskipTests=true
|
||||
|
||||
- name: Start Server Without Auth
|
||||
run: |
|
||||
echo "Configuring application.properties..."
|
||||
PROP_FILE=$(ls distribution/target/nacos-server-*/nacos/conf/application.properties)
|
||||
echo "Updating file: $PROP_FILE"
|
||||
sed 's|nacos.core.auth.plugin.nacos.token.secret.key=.*|nacos.core.auth.plugin.nacos.token.secret.key=VGhpc0lzTXlDdXN0b21TZWNyZXRLZXkwMTIzNDU2Nzg=|' "$PROP_FILE" > "${PROP_FILE}.tmp" && mv "${PROP_FILE}.tmp" "$PROP_FILE"
|
||||
sed 's|nacos.core.auth.server.identity.key=.*|nacos.core.auth.server.identity.key=testKey|' "$PROP_FILE" > "${PROP_FILE}.tmp" && mv "${PROP_FILE}.tmp" "$PROP_FILE"
|
||||
sed 's|nacos.core.auth.server.identity.value=.*|nacos.core.auth.server.identity.value=testValue|' "$PROP_FILE" > "${PROP_FILE}.tmp" && mv "${PROP_FILE}.tmp" "$PROP_FILE"
|
||||
sed 's|nacos.core.auth.enabled=.*|nacos.core.auth.enabled=false|' "$PROP_FILE" > "${PROP_FILE}.tmp" && mv "${PROP_FILE}.tmp" "$PROP_FILE"
|
||||
sed 's|nacos.core.auth.admin.enabled=.*|nacos.core.auth.admin.enabled=false|' "$PROP_FILE" > "${PROP_FILE}.tmp" && mv "${PROP_FILE}.tmp" "$PROP_FILE"
|
||||
sed 's|nacos.core.auth.console.enabled=.*|nacos.core.auth.console.enabled=false|' "$PROP_FILE" > "${PROP_FILE}.tmp" && mv "${PROP_FILE}.tmp" "$PROP_FILE"
|
||||
|
||||
echo "Starting server..."
|
||||
bash distribution/target/nacos-server-*/nacos/bin/startup.sh -m standalone
|
||||
|
||||
- name: Wait for Server Startup
|
||||
run: |
|
||||
echo "Waiting for server to launch on port 8080..."
|
||||
if ! timeout 60s bash -c 'until curl -s localhost:8080 > /dev/null; do sleep 2; done'; then
|
||||
echo "Server failed to start"
|
||||
LOG_DIR=$(ls -d distribution/target/nacos-server-*/nacos/logs 2>/dev/null | head -n 1 || true)
|
||||
if [ -n "$LOG_DIR" ]; then
|
||||
echo "::group::Nacos startup.log"
|
||||
if [ -f "$LOG_DIR/startup.log" ]; then
|
||||
tail -300 "$LOG_DIR/startup.log"
|
||||
else
|
||||
echo "startup.log not found"
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
echo "::group::Nacos nacos.log"
|
||||
if [ -f "$LOG_DIR/nacos.log" ]; then
|
||||
tail -300 "$LOG_DIR/nacos.log"
|
||||
else
|
||||
echo "nacos.log not found"
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
echo "::group::Nacos log directory"
|
||||
ls -la "$LOG_DIR"
|
||||
echo "::endgroup::"
|
||||
else
|
||||
echo "Nacos log directory not found"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
echo "Server is up"
|
||||
|
||||
- name: Run IT Tests
|
||||
run: |
|
||||
cd test
|
||||
mvn -B clean verify -Pintegration-test
|
||||
|
||||
- name: Run Java SDK IT Tests
|
||||
run: mvn -B -pl test/java-sdk-test clean verify -Pjava-sdk-integration-test -DskipTests=false
|
||||
|
||||
- name: Run Maintainer SDK IT Tests
|
||||
run: mvn -B -pl test/maintainer-sdk-test clean verify -Pmaintainer-sdk-integration-test -DskipTests=false
|
||||
|
||||
- name: Run Java SDK IT Tests With Jackson 3
|
||||
run: mvn -B -pl test/java-sdk-test clean verify -Pjava-sdk-integration-test,jackson3-sdk-test -DskipTests=false
|
||||
|
||||
- name: Run Maintainer SDK IT Tests With Jackson 3
|
||||
run: mvn -B -pl test/maintainer-sdk-test clean verify -Pmaintainer-sdk-integration-test,jackson3-sdk-test -DskipTests=false
|
||||
@@ -0,0 +1,31 @@
|
||||
name: "Comment on PR"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
thanks-and-hint-to-document:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
name: Say thanks for the PR and hint to document
|
||||
steps:
|
||||
- name: comment on the pull request
|
||||
uses: hasura/comment-progress@v2.3.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: ${{ github.repository }}
|
||||
number: ${{ github.event.number }}
|
||||
id: thanks-and-hint-to-document
|
||||
recreate: true
|
||||
message: |
|
||||
Thanks for your this PR. :pray:
|
||||
Please check again for your PR changes whether contains any usage/api/configuration change such as `Add new API `, `Add new configuration`, `Change default value of configuration`.
|
||||
If so, please add or update documents(markdown type) in `docs/next/` for repository [nacos-group/nacos-group.github.io](https://github.com/nacos-group/nacos-group.github.io/tree/develop-astro-nacos/src/content/docs/next)
|
||||
|
||||
---
|
||||
|
||||
感谢您提交的PR。 :pray:
|
||||
请再次查看您的PR内容,确认是否包含任何使用方式/API/配置参数的变更,如:`新增API`、`新增配置参数`、`修改默认配置`等操作。
|
||||
如果是,请确保在提交之前,在仓库[nacos-group/nacos-group.github.io](https://github.com/nacos-group/nacos-group.github.io/tree/develop-astro-nacos/src/content/docs/next)中的`docs/next/`目录下添加或更新文档(markdown格式)。
|
||||
@@ -0,0 +1,593 @@
|
||||
name: E2E test for pull request
|
||||
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["PR-CI"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
DOCKER_REPO: wuyfeedocker/nacos-ci
|
||||
DOCKER_REPO_B: wuyfeehub/nacos-ci
|
||||
TEST_REPO_NAME: nacos-group/nacos-e2e
|
||||
HEADER_PARAMS: "-H \"Accept: application/vnd.github+json\" -H \"Authorization: Bearer ${{ secrets.ACTION_TOKEN }}\" -H \"X-GitHub-Api-Version: 2022-11-28\""
|
||||
HEADER_PARAMS_WRITE_COMMENT: "-H \"Accept: application/vnd.github+json\" -H \"Authorization: Bearer ${{ secrets.ACTION_TOKEN }}\" -H \"X-GitHub-Api-Version: 2022-11-28\""
|
||||
BASE_URL_NACOS: https://api.github.com/repos/${GITHUB_REPOSITORY}
|
||||
BASE_URL_ROBOT: https://api.github.com/repos/wuyfee/nacos
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
matrix:
|
||||
base-image: ["ubuntu"]
|
||||
java-version: ["17"]
|
||||
outputs:
|
||||
version-json: ${{ steps.show_versions.outputs.version-json }}
|
||||
steps:
|
||||
- name: 'Download artifact'
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
let artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{ github.event.workflow_run.id }},
|
||||
});
|
||||
let matchArtifactNacos = artifacts.data.artifacts.filter((artifact) => {
|
||||
return artifact.name == "nacos"
|
||||
})[0];
|
||||
let download = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: matchArtifactNacos.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
var fs = require('fs');
|
||||
fs.writeFileSync('${{github.workspace}}/nacos.zip', Buffer.from(download.data));
|
||||
- run: |
|
||||
unzip nacos.zip
|
||||
mkdir nacos
|
||||
cp -r nacos-* nacos/
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
repository: nacos-group/nacos-e2e.git
|
||||
ref: main
|
||||
path: nacos-e2e
|
||||
- name: Generate image tag
|
||||
id: build-images
|
||||
run: |
|
||||
mv nacos-server-*.tar.gz nacos-e2e/cicd/build
|
||||
cd nacos-e2e/cicd/build
|
||||
version=${{ github.event.pull_request.number || github.ref_name }}-$(uuidgen)
|
||||
mkdir versionlist
|
||||
touch versionlist/"${version}-`echo ${{ matrix.java-version }} | sed -e "s/:/-/g"`"
|
||||
ls versionlist/
|
||||
echo TAG=${version}-$(echo ${{ matrix.java-version }} | sed -e "s/:/-/g") >> $GITHUB_ENV
|
||||
- name: docker-login-1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USER }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: save docker_1 images
|
||||
run: |
|
||||
mkdir build_backup
|
||||
cp -rf nacos-e2e/cicd/build/* ./build_backup/
|
||||
cd nacos-e2e/cicd/build
|
||||
docker build --no-cache -f Dockerfile -t ${DOCKER_REPO}:${{ env.TAG }} .
|
||||
docker push ${DOCKER_REPO}:${{ env.TAG }}
|
||||
- name: docker-login-2
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USER_B }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN_B }}
|
||||
- name: save docker_2 images
|
||||
run: |
|
||||
rm -rf nacos-e2e/cicd/build/*
|
||||
mv ./build_backup/* nacos-e2e/cicd/build/
|
||||
cd nacos-e2e/cicd/build
|
||||
docker build --no-cache -f Dockerfile -t ${DOCKER_REPO_B}:${{ env.TAG }} .
|
||||
docker push ${DOCKER_REPO_B}:${{ env.TAG }}
|
||||
- name: Show versions
|
||||
id: show_versions
|
||||
run: |
|
||||
a=(`ls nacos-e2e/cicd/build/versionlist`)
|
||||
printf '%s\n' "${a[@]}" | jq -R . | jq -s .
|
||||
echo version-json=`printf '%s\n' "${a[@]}" | jq -R . | jq -s .` >> $GITHUB_OUTPUT
|
||||
|
||||
deploy:
|
||||
if: ${{ success() }}
|
||||
name: Deploy nacos
|
||||
needs: [docker]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
REPLICA_COUNT: 3
|
||||
DATABASE: mysql
|
||||
NODE_PORT: 30000
|
||||
AUTH_ENABLED: false
|
||||
ACTUAL_MODE: cluster
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
mode: ["cluster","standalone","standalone_auth"]
|
||||
version: ${{ fromJSON(needs.docker.outputs.version-json) }}
|
||||
steps:
|
||||
- name: set nodeport
|
||||
run: |
|
||||
echo "NODE_PORT=$(expr $(expr $(expr $(expr ${{ strategy.job-index }} + 1) * ${{ github.run_number }}) % 30000) + 30000)" >> $GITHUB_ENV
|
||||
- name: set params values
|
||||
run: |
|
||||
if [[ ${{ matrix.mode }} == "standalone"* ]];then
|
||||
if [[ ${{ matrix.mode }} = "standalone_auth" ]]; then
|
||||
echo "AUTH_ENABLED=true" >> $GITHUB_ENV
|
||||
fi
|
||||
echo "ACTUAL_MODE=standalone" >> $GITHUB_ENV
|
||||
echo "REPLICA_COUNT=1" >> $GITHUB_ENV
|
||||
echo "DATABASE=embedded" >> $GITHUB_ENV
|
||||
echo ${{ matrix.mode }}-nacos-${{ github.run_id }}-${{ strategy.job-index }}
|
||||
fi
|
||||
- name: allocate docker repo
|
||||
run: |
|
||||
if [[ $(expr $(expr ${{ github.run_id }} + ${{ strategy.job-index }} ) % 2 ) -eq 1 ]]; then
|
||||
echo "DOCKER_REPO_ACTUAL=${{ env.DOCKER_REPO }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "DOCKER_REPO_ACTUAL=${{ env.DOCKER_REPO_B }}" >> $GITHUB_ENV
|
||||
fi
|
||||
- uses: apache/rocketmq-test-tool@java-dev
|
||||
name: Deploy nacos
|
||||
with:
|
||||
yamlString: |
|
||||
action: deploy
|
||||
namespace: nacos-${{ github.run_id }}-${{ strategy.job-index }}
|
||||
askConfig: ${{ secrets.ASK_CONFIG_VIRGINA }}
|
||||
waitTimes: 2000
|
||||
velaAppDescription: nacos-${{ env.GITHUB_WORKFLOW }}-${{ github.run_id }}@${{ matrix.version }}
|
||||
repoName: nacos
|
||||
helm:
|
||||
chart: ./cicd/helm
|
||||
git:
|
||||
branch: main
|
||||
repoType: git
|
||||
retries: 3
|
||||
url: https://github.com/nacos-group/nacos-e2e.git
|
||||
values:
|
||||
namespace: nacos-${{ github.run_id }}-${{ strategy.job-index }}
|
||||
global:
|
||||
mode: ${{ env.ACTUAL_MODE }}
|
||||
nacos:
|
||||
replicaCount: ${{ env.REPLICA_COUNT }}
|
||||
image:
|
||||
repository: ${{ env.DOCKER_REPO_ACTUAL }}
|
||||
tag: ${{ matrix.version }}
|
||||
auth:
|
||||
enabled: ${{ env.AUTH_ENABLED }}
|
||||
storage:
|
||||
type: ${{ env.DATABASE }}
|
||||
db:
|
||||
port: 3306
|
||||
username: nacos
|
||||
password: nacos
|
||||
param: characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useSSL=false
|
||||
service:
|
||||
nodePort: ${{ env.NODE_PORT }}
|
||||
type: ClusterIP
|
||||
|
||||
e2e-java-test:
|
||||
if: ${{ success() }}
|
||||
name: Java e2e Test
|
||||
needs: [docker, deploy]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CODE_PATH: java/nacos-2X
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
mode: ["cluster","standalone","standalone_auth"]
|
||||
version: ${{ fromJSON(needs.docker.outputs.version-json) }}
|
||||
steps:
|
||||
- name: set code path
|
||||
run: |
|
||||
if [[ ${{ matrix.mode }} = "standalone_auth" ]]; then
|
||||
echo "CODE_PATH=java/auth" >> $GITHUB_ENV
|
||||
echo ${{ matrix.mode }}-nacos-${{ github.run_id }}-${{ strategy.job-index }}
|
||||
fi
|
||||
- uses: apache/rocketmq-test-tool@java-dev
|
||||
name: java e2e test
|
||||
with:
|
||||
yamlString: |
|
||||
action: test
|
||||
namespace: nacos-${{ github.run_id }}-${{ strategy.job-index }}
|
||||
askConfig: ${{ secrets.ASK_CONFIG_VIRGINA }}
|
||||
API_VERSION: v1
|
||||
KIND: Pod
|
||||
RESTART_POLICY: Never
|
||||
ENV:
|
||||
WAIT_TIME: 900
|
||||
REPO_NAME: ${{ env.TEST_REPO_NAME }}
|
||||
CODE: https://github.com/${{ env.TEST_REPO_NAME }}
|
||||
BRANCH: main
|
||||
CODE_PATH: ${{ env.CODE_PATH }}
|
||||
CMD: mvn clean test -B
|
||||
ALL_IP: null
|
||||
CONTAINER:
|
||||
IMAGE: cloudnativeofalibabacloud/test-runner:v0.0.4
|
||||
RESOURCE_LIMITS:
|
||||
cpu: 2
|
||||
memory: 2Gi
|
||||
RESOURCE_REQUIRE:
|
||||
cpu: 2
|
||||
memory: 2Gi
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
name: Upload test log
|
||||
with:
|
||||
name: testlog-${{ matrix.mode }}-java.txt
|
||||
path: testlog.txt
|
||||
- name: add markdown
|
||||
if: always()
|
||||
run: |
|
||||
cat result.md >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
e2e-go-test:
|
||||
if: ${{ success() }}
|
||||
name: GO E2E Test
|
||||
needs: [docker, deploy]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
mode: ["cluster","standalone"]
|
||||
version: ${{ fromJSON(needs.docker.outputs.version-json) }}
|
||||
steps:
|
||||
- uses: apache/rocketmq-test-tool@java-dev
|
||||
name: go e2e test
|
||||
with:
|
||||
yamlString: |
|
||||
action: test
|
||||
namespace: nacos-${{ github.run_id }}-${{ strategy.job-index }}
|
||||
askConfig: ${{ secrets.ASK_CONFIG_VIRGINA }}
|
||||
API_VERSION: v1
|
||||
KIND: Pod
|
||||
RESTART_POLICY: Never
|
||||
ENV:
|
||||
WAIT_TIME: 900
|
||||
REPO_NAME: ${{ env.TEST_REPO_NAME }}
|
||||
CODE: https://github.com/${{ env.TEST_REPO_NAME }}
|
||||
BRANCH: main
|
||||
CODE_PATH: golang
|
||||
CMD: |
|
||||
cd /root/code/golang && go mod init nacos_go_test && go mod tidy
|
||||
gotestsum --junitfile ./target/surefire-reports/TEST-report.xml ./nacosgotest
|
||||
ALL_IP: null
|
||||
CONTAINER:
|
||||
IMAGE: cloudnativeofalibabacloud/test-runner:v0.0.4
|
||||
RESOURCE_LIMITS:
|
||||
cpu: 2
|
||||
memory: 2Gi
|
||||
RESOURCE_REQUIRE:
|
||||
cpu: 2
|
||||
memory: 2Gi
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
name: Upload test log
|
||||
with:
|
||||
name: testlog-${{ matrix.mode }}-go.txt
|
||||
path: testlog.txt
|
||||
- name: add markdown
|
||||
if: always()
|
||||
run: |
|
||||
cat result.md >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
e2e-cpp-test:
|
||||
if: ${{ success() }}
|
||||
name: Cpp E2E Test
|
||||
needs: [docker, deploy]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
mode: ["cluster","standalone"]
|
||||
version: ${{ fromJSON(needs.docker.outputs.version-json) }}
|
||||
steps:
|
||||
- uses: apache/rocketmq-test-tool@java-dev
|
||||
name: cpp e2e test
|
||||
with:
|
||||
yamlString: |
|
||||
action: test
|
||||
namespace: nacos-${{ github.run_id }}-${{ strategy.job-index }}
|
||||
askConfig: ${{ secrets.ASK_CONFIG_VIRGINA }}
|
||||
API_VERSION: v1
|
||||
KIND: Pod
|
||||
RESTART_POLICY: Never
|
||||
ENV:
|
||||
WAIT_TIME: 900
|
||||
REPO_NAME: ${{ env.TEST_REPO_NAME }}
|
||||
CODE: https://github.com/${{ env.TEST_REPO_NAME }}
|
||||
BRANCH: main
|
||||
CODE_PATH: cpp
|
||||
CMD: |
|
||||
yum-config-manager remove centos-sclo-rh
|
||||
cd /root/code/cpp && make install
|
||||
echo "export LD_LIBRARY_PATH=/usr/local/lib" >> ~/.bashrc && source ~/.bashrc
|
||||
cd /root/code/cpp/nacoscpptest
|
||||
g++ nacos_test.cpp -o nacos_test -lgtest -lpthread -I/usr/local/include/nacos/ -L/usr/local/lib/ -lnacos-cli
|
||||
chmod 777 nacos_test && ./nacos_test --gtest_output="xml:../target/surefire-reports/TEST-gtestresults.xml"
|
||||
ALL_IP: null
|
||||
CONTAINER:
|
||||
IMAGE: cloudnativeofalibabacloud/test-runner:v0.0.4
|
||||
RESOURCE_LIMITS:
|
||||
cpu: 2
|
||||
memory: 2Gi
|
||||
RESOURCE_REQUIRE:
|
||||
cpu: 2
|
||||
memory: 2Gi
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
name: Upload test log
|
||||
with:
|
||||
name: testlog-${{ matrix.mode }}-cpp.txt
|
||||
path: testlog.txt
|
||||
- name: add markdown
|
||||
if: always()
|
||||
run: |
|
||||
cat result.md >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
e2e-csharp-test:
|
||||
if: ${{ success() }}
|
||||
name: Csharp E2E Test
|
||||
needs: [docker, deploy]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
mode: ["cluster","standalone"]
|
||||
version: ${{ fromJSON(needs.docker.outputs.version-json) }}
|
||||
steps:
|
||||
- uses: apache/rocketmq-test-tool@java-dev
|
||||
name: csharp e2e test
|
||||
with:
|
||||
yamlString: |
|
||||
action: test
|
||||
namespace: nacos-${{ github.run_id }}-${{ strategy.job-index }}
|
||||
askConfig: ${{ secrets.ASK_CONFIG_VIRGINA }}
|
||||
API_VERSION: v1
|
||||
KIND: Pod
|
||||
RESTART_POLICY: Never
|
||||
ENV:
|
||||
WAIT_TIME: 900
|
||||
REPO_NAME: ${{ env.TEST_REPO_NAME }}
|
||||
CODE: https://github.com/${{ env.TEST_REPO_NAME }}
|
||||
BRANCH: main
|
||||
CODE_PATH: csharp
|
||||
CMD: |
|
||||
rpm -Uvh https://packages.microsoft.com/config/centos/7/packages-microsoft-prod.rpm
|
||||
yum -y install dotnet-sdk-3.1 && yum -y install aspnetcore-runtime-7.0
|
||||
cd /root/code/csharp/nacos-csharp-sdk-test && dotnet restore
|
||||
dotnet test --logger:"junit;LogFilePath=../target/surefire-reports/TEST-result.xml"
|
||||
ALL_IP: null
|
||||
CONTAINER:
|
||||
IMAGE: cloudnativeofalibabacloud/test-runner:v0.0.4
|
||||
RESOURCE_LIMITS:
|
||||
cpu: 2
|
||||
memory: 2Gi
|
||||
RESOURCE_REQUIRE:
|
||||
cpu: 2
|
||||
memory: 2Gi
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
name: Upload test log
|
||||
with:
|
||||
name: testlog-${{ matrix.mode }}-csharp.txt
|
||||
path: testlog.txt
|
||||
- name: add markdown
|
||||
if: always()
|
||||
run: |
|
||||
cat result.md >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
e2e-nodejs-test:
|
||||
if: ${{ success() }}
|
||||
name: Nodejs E2E Test
|
||||
needs: [docker, deploy]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
mode: ["cluster","standalone"]
|
||||
version: ${{ fromJSON(needs.docker.outputs.version-json) }}
|
||||
steps:
|
||||
- uses: apache/rocketmq-test-tool@java-dev
|
||||
name: nodejs e2e test
|
||||
with:
|
||||
yamlString: |
|
||||
action: test
|
||||
namespace: nacos-${{ github.run_id }}-${{ strategy.job-index }}
|
||||
askConfig: ${{ secrets.ASK_CONFIG_VIRGINA }}
|
||||
API_VERSION: v1
|
||||
KIND: Pod
|
||||
RESTART_POLICY: Never
|
||||
ENV:
|
||||
WAIT_TIME: 900
|
||||
REPO_NAME: ${{ env.TEST_REPO_NAME }}
|
||||
CODE: https://github.com/${{ env.TEST_REPO_NAME }}
|
||||
BRANCH: main
|
||||
CODE_PATH: nodejs
|
||||
CMD: |
|
||||
cd /root/code/nodejs/nacosnodejstest && npm install
|
||||
mocha test --reporter mocha-junit-reporter --reporter-options mochaFile=../target/surefire-reports/TEST-report.xml
|
||||
ALL_IP: null
|
||||
CONTAINER:
|
||||
IMAGE: cloudnativeofalibabacloud/test-runner:v0.0.4
|
||||
RESOURCE_LIMITS:
|
||||
cpu: 2
|
||||
memory: 2Gi
|
||||
RESOURCE_REQUIRE:
|
||||
cpu: 2
|
||||
memory: 2Gi
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
name: Upload test log
|
||||
with:
|
||||
name: testlog-${{ matrix.mode }}-nodejs.txt
|
||||
path: testlog.txt
|
||||
- name: add markdown
|
||||
if: always()
|
||||
run: |
|
||||
cat result.md >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
e2e-python-test:
|
||||
if: ${{ success() }}
|
||||
name: Python E2E Test
|
||||
needs: [docker, deploy]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
mode: ["cluster","standalone"]
|
||||
version: ${{ fromJSON(needs.docker.outputs.version-json) }}
|
||||
steps:
|
||||
- uses: apache/rocketmq-test-tool@java-dev
|
||||
name: python e2e test
|
||||
with:
|
||||
yamlString: |
|
||||
action: test
|
||||
namespace: nacos-${{ github.run_id }}-${{ strategy.job-index }}
|
||||
askConfig: ${{ secrets.ASK_CONFIG_VIRGINA }}
|
||||
API_VERSION: v1
|
||||
KIND: Pod
|
||||
RESTART_POLICY: Never
|
||||
ENV:
|
||||
WAIT_TIME: 900
|
||||
REPO_NAME: ${{ env.TEST_REPO_NAME }}
|
||||
CODE: https://github.com/${{ env.TEST_REPO_NAME }}
|
||||
BRANCH: main
|
||||
CODE_PATH: python
|
||||
CMD: |
|
||||
cd /root/code/python
|
||||
pip3 install -r requirements.txt
|
||||
source ~/.bashrc
|
||||
cd nacospythontest && pytest --junitxml ../target/surefire-reports/TEST-report.xml test/*_test.py --log-cli-level=DEBUG
|
||||
ALL_IP: null
|
||||
CONTAINER:
|
||||
IMAGE: cloudnativeofalibabacloud/test-runner:v0.0.4
|
||||
RESOURCE_LIMITS:
|
||||
cpu: 2
|
||||
memory: 2Gi
|
||||
RESOURCE_REQUIRE:
|
||||
cpu: 2
|
||||
memory: 2Gi
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
name: Upload test log
|
||||
with:
|
||||
name: testlog-${{ matrix.mode }}-python.txt
|
||||
path: testlog.txt
|
||||
- name: add markdown
|
||||
if: always()
|
||||
run: |
|
||||
cat result.md >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
clean:
|
||||
if: ${{ always() }}
|
||||
name: Clean
|
||||
needs: [docker, e2e-java-test, e2e-go-test, e2e-cpp-test, e2e-csharp-test, e2e-nodejs-test, e2e-python-test]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
mode: ["cluster","standalone","standalone_auth"]
|
||||
version: ${{ fromJSON(needs.docker.outputs.version-json) }}
|
||||
steps:
|
||||
- uses: apache/rocketmq-test-tool@java-dev
|
||||
name: clean
|
||||
with:
|
||||
yamlString: |
|
||||
action: clean
|
||||
namespace: nacos-${{ github.run_id }}-${{ strategy.job-index }}
|
||||
askConfig: ${{ secrets.ASK_CONFIG_VIRGINA }}
|
||||
write-comment:
|
||||
if: ${{ always() }}
|
||||
name: write comment to pr
|
||||
needs: [docker, deploy, e2e-java-test, e2e-go-test, e2e-cpp-test, e2e-csharp-test, e2e-nodejs-test, e2e-python-test, clean]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: 'Download artifact'
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
let artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{ github.event.workflow_run.id }},
|
||||
});
|
||||
|
||||
let matchArtifactPR = artifacts.data.artifacts.filter((artifact) => {
|
||||
return artifact.name == "pr"
|
||||
})[0];
|
||||
let download = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: matchArtifactPR.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
var fs = require('fs');
|
||||
fs.writeFileSync('${{github.workspace}}/pr.zip', Buffer.from(download.data));
|
||||
- name: unzip pr
|
||||
run: |
|
||||
unzip pr.zip
|
||||
cat pr.txt
|
||||
pr_number=`cat pr.txt`
|
||||
echo "PR_NUMBER=${pr_number}" >> $GITHUB_ENV
|
||||
- name: write issue comment
|
||||
run: |
|
||||
echo ${{ env.PR_NUMBER }}
|
||||
job_status=""
|
||||
if [ ${{ needs.docker.result }} = 'success' ] && [ ${{ needs.deploy.result }} = 'success' ] && [ ${{ needs.e2e-java-test.result }} = 'success' ] && [ ${{ needs.e2e-go-test.result }} = 'success' ] && [ ${{ needs.e2e-cpp-test.result }} = 'success' ] && [ ${{ needs.e2e-csharp-test.result }} = 'success' ] && [ ${{ needs.e2e-nodejs-test.result }} = 'success' ] && [ ${{ needs.e2e-python-test.result }} = 'success' ]; then
|
||||
jobs_status='$\\color{green}{SUCCESS}$'
|
||||
jobs_status+="\n[DETAILS](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${{ github.run_id }})"
|
||||
else
|
||||
jobs_status='$\\color{red}{FAILURE}$'
|
||||
jobs_status+="\n[DETAILS](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${{ github.run_id }})"
|
||||
fi
|
||||
echo ${jobs_status}
|
||||
markdown_content="${jobs_status}"
|
||||
generate_content() {
|
||||
if [ $1 = "success" ];then
|
||||
echo "\n ✅ $2 $1 "
|
||||
else
|
||||
echo "\n ❌ $2 $1 "
|
||||
fi
|
||||
}
|
||||
markdown_content+=$(generate_content ${{ needs.docker.result }} "- docker: ")
|
||||
markdown_content+=$(generate_content ${{ needs.deploy.result }} "- deploy (standalone & cluster & standalone_auth): ")
|
||||
markdown_content+=$(generate_content ${{ needs.e2e-java-test.result }} "- e2e-java-test (standalone & cluster & standalone_auth): ")
|
||||
markdown_content+=$(generate_content ${{ needs.e2e-go-test.result }} "- e2e-go-test (standalone & cluster): ")
|
||||
markdown_content+=$(generate_content ${{ needs.e2e-cpp-test.result }} "- e2e-cpp-test (standalone & cluster): ")
|
||||
markdown_content+=$(generate_content ${{ needs.e2e-csharp-test.result }} "- e2e-csharp-test (standalone & cluster): ")
|
||||
markdown_content+=$(generate_content ${{ needs.e2e-nodejs-test.result }} "- e2e-nodejs-test (standalone & cluster): ")
|
||||
markdown_content+=$(generate_content ${{ needs.e2e-python-test.result }} "- e2e-python-test (standalone & cluster): ")
|
||||
markdown_content+=$(generate_content ${{ needs.clean.result }} "- clean (standalone & cluster & standalone_auth): ")
|
||||
echo "markdown_content: \n ${markdown_content}"
|
||||
payload=`echo "{\"body\": \"${markdown_content}\"}" | jq .`
|
||||
curl -L -X POST ${{ env.HEADER_PARAMS_WRITE_COMMENT }} ${{ env.BASE_URL_NACOS }}/issues/${{ env.PR_NUMBER }}/comments -d "${payload}"
|
||||
@@ -0,0 +1,93 @@
|
||||
name: "PR Validation"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check-commit-authors:
|
||||
runs-on: ubuntu-latest
|
||||
name: Verify commit author emails
|
||||
steps:
|
||||
- name: Check commit authors linked to GitHub
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
|
||||
const commits = await github.rest.pulls.listCommits({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const unlinked = commits.data.filter(c => !c.author);
|
||||
if (unlinked.length === 0) {
|
||||
core.info('All commit authors are linked to GitHub accounts.');
|
||||
return;
|
||||
}
|
||||
|
||||
const details = unlinked.map(c =>
|
||||
`| \`${c.sha.slice(0, 7)}\` | ${c.commit.author.name} | ${c.commit.author.email} |`
|
||||
).join('\n');
|
||||
|
||||
const body = [
|
||||
'### ⚠️ Commit Author Verification Failed',
|
||||
'',
|
||||
'The following commits have author emails **not linked to any GitHub account**.',
|
||||
'This will prevent CLA signing and block your PR from being merged.',
|
||||
'',
|
||||
'| Commit | Author | Email |',
|
||||
'|--------|--------|-------|',
|
||||
details,
|
||||
'',
|
||||
'**How to fix:**',
|
||||
'1. Add your commit email to your GitHub account: https://github.com/settings/emails',
|
||||
'2. Or update your local git config to use an email already linked to GitHub:',
|
||||
' ```',
|
||||
' git config user.name "Your GitHub Username"',
|
||||
' git config user.email "your-github-email@example.com"',
|
||||
' ```',
|
||||
'3. Amend your commits and force-push:',
|
||||
' ```',
|
||||
' git rebase -i HEAD~' + unlinked.length + ' # mark commits as "edit" and amend author',
|
||||
' git push --force-with-lease',
|
||||
' ```',
|
||||
'',
|
||||
'*This check will re-run automatically after you push.*',
|
||||
].join('\n');
|
||||
|
||||
// Post or update comment
|
||||
const marker = '<!-- pr-validation-author-check -->';
|
||||
const commentBody = marker + '\n' + body;
|
||||
const existingComments = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
});
|
||||
const existing = existingComments.data.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body: commentBody,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: commentBody,
|
||||
});
|
||||
}
|
||||
|
||||
core.setFailed(
|
||||
`${unlinked.length} commit(s) have author emails not linked to a GitHub account. ` +
|
||||
'See the PR comment for fix instructions.'
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Close inactive issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 17 * * *" # UTC+8 01:00(UTC 17:00)
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
operations-per-run: 50
|
||||
days-before-issue-stale: 7
|
||||
days-before-issue-close: 7
|
||||
stale-issue-label: "expired"
|
||||
stale-issue-message: "This issue has been open 7 days with no activity. This will be closed in 7 days."
|
||||
close-issue-message: "This issue was closed because it has been inactive for 7 days since being marked as expired."
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
exempt-all-milestones: true
|
||||
exempt-issue-labels: "kind/bug,kind/code quality,kind/discussion,kind/enhancement,kind/feature,kind/notice,kind/performance,kind/proposal,kind/question,kind/refactor,kind/user experience,good first issue,contribution welcome,dependencies"
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Except this file !.gitignore
|
||||
.classpath
|
||||
.project
|
||||
.settings
|
||||
target
|
||||
.idea
|
||||
.vscode
|
||||
.DS_Store
|
||||
.factorypath
|
||||
/logs
|
||||
*.iml
|
||||
*.log
|
||||
node_modules
|
||||
test/derby.log
|
||||
derby.log
|
||||
work
|
||||
test/logs
|
||||
derby.log
|
||||
yarn.lock
|
||||
.flattened-pom.xml
|
||||
lefthook.yml
|
||||
distribution/conf/*.sql
|
||||
distribution/plugins/*.jar
|
||||
dependency-reduced-pom.xml
|
||||
.jqwik-database
|
||||
.qoder/
|
||||
openspec/
|
||||
.codegraph
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
wrapperVersion=3.3.2
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
notifications:
|
||||
email:
|
||||
recipients:
|
||||
- dev-nacos@googlegroups.com
|
||||
- mw_configcenter@list.alibaba-inc.com
|
||||
on_success: change
|
||||
on_failure: always
|
||||
|
||||
language: java
|
||||
|
||||
matrix:
|
||||
include:
|
||||
# On OSX, run with default JDK only.
|
||||
# - os: osx
|
||||
# On Linux, run with specific JDKs only.
|
||||
- os: linux
|
||||
env: CUSTOM_JDK="oraclejdk8"
|
||||
- name: Linux aarch64
|
||||
dist: focal
|
||||
arch: arm64-graviton2
|
||||
group: edge
|
||||
virt: vm
|
||||
|
||||
|
||||
jdk:
|
||||
- openjdk11
|
||||
- openjdk8
|
||||
|
||||
before_install:
|
||||
- echo 'MAVEN_OPTS="$MAVEN_OPTS -Xmx1024m -XX:MaxPermSize=512m -XX:+BytecodeVerificationLocal"' >> ~/.mavenrc
|
||||
- cat ~/.mavenrc
|
||||
# - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then export JAVA_HOME=$(/usr/libexec/java_home); fi
|
||||
# - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then jdk_switcher use "$CUSTOM_JDK"; fi
|
||||
|
||||
script:
|
||||
- mvn -B clean package apache-rat:check findbugs:findbugs -Dmaven.test.skip=true
|
||||
- mvn clean -Premove-test-data
|
||||
- mvn -Prelease-nacos -Dmaven.test.skip=true clean install -U
|
||||
- mvn clean -Premove-test-data
|
||||
- mvn clean package -Pcit-test
|
||||
- mvn clean -Premove-test-data
|
||||
- mvn clean package -Pnit-test
|
||||
- mvn clean -Premove-test-data
|
||||
after_success:
|
||||
- mvn clean package -Pit-test
|
||||
- mvn sonar:sonar -Psonar-apache
|
||||
@@ -0,0 +1,450 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to AI coding agents (Claude Code, Cursor, GitHub Copilot, etc.) when working with the Nacos repository. For human contributors, see [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
|
||||
## AI Contribution Guidelines
|
||||
|
||||
- **Do NOT post AI-generated comments** on issues or PRs. Discussions are for humans only.
|
||||
- **Discuss before implementing**: Ensure the implementation direction is agreed upon with maintainers in the issue comments before starting work.
|
||||
- **Spec-first coding is mandatory**: Before changing behavior, APIs, SDKs,
|
||||
plugins, storage, runtime flow, or domain semantics, AI agents MUST read the
|
||||
relevant specs under [`specs/`](./specs/README.md) and treat them as the rule
|
||||
source for the implementation.
|
||||
- **Discuss spec-impacting changes before coding**: If a change touches behavior
|
||||
covered by an existing spec, or exposes a gap between the code and the spec,
|
||||
AI agents MUST discuss the implementation direction and the required spec
|
||||
update with maintainers before starting the code change.
|
||||
- **Update specs with the design**: Any design proposal that changes or
|
||||
clarifies spec-covered behavior MUST include the corresponding spec updates
|
||||
in the same change set. When the design is large or controversial, prefer a
|
||||
spec/design-only PR first, then follow with implementation PRs.
|
||||
- **API IT impact comes first**: Before adding, changing, deleting, or
|
||||
deprecating any HTTP API, AI agents MUST analyze the affected
|
||||
`test/openapi-test` coverage and update the API IT scenario matrix, test
|
||||
cases, and coverage registry in the same change set. If the functional path
|
||||
cannot be exercised in standalone IT, cover boundary/error scenarios and
|
||||
document the reason.
|
||||
- **Java SDK IT impact comes first**: Before adding, changing, deleting, or
|
||||
deprecating any public Java SDK interface, factory, model, listener behavior,
|
||||
lifecycle behavior, or exception mapping, AI agents MUST analyze and update
|
||||
`test/java-sdk-test` coverage, including scenario documentation. If the
|
||||
end-to-end success path is impractical, cover SDK parameter validation,
|
||||
boundary behavior, and controlled exceptions.
|
||||
- **Disclose AI usage**: When a significant part of a commit is AI-generated, add a trailer to your commit message:
|
||||
```
|
||||
Assisted-by: Claude Code
|
||||
```
|
||||
- **Follow [CONTRIBUTING.md](./CONTRIBUTING.md)** for all contribution processes.
|
||||
|
||||
## Repository Overview
|
||||
|
||||
Nacos (Dynamic Naming and Configuration Service) is an easy-to-use platform designed for dynamic service discovery, configuration management, and AI agent management. It helps you build cloud-native applications and AI Agent applications easily. Key capabilities: service discovery, dynamic configuration, dynamic DNS service, service/metadata management, and AI registry (Prompt, MCP, A2A).
|
||||
|
||||
**Current Version**: 3.2.1-SNAPSHOT | **Main Branch**: `develop` | **Java**: JDK 17+ (client modules: JDK 8+) | **Build**: Maven 3.2.5+
|
||||
|
||||
## Core Architecture
|
||||
|
||||
Key modules and their roles:
|
||||
|
||||
- **api / client / client-basic**: Client-facing APIs, gRPC definitions, SDK (Java 8 compatible)
|
||||
- **common**: Shared utilities, HTTP client, notify center, executor
|
||||
- **config**: Configuration management server
|
||||
- **naming**: Service discovery and registration server
|
||||
- **core**: Core server infrastructure (cluster, distributed consensus)
|
||||
- **consistency**: JRaft-based CP protocol + custom Distro AP protocol
|
||||
- **auth**: Authentication and authorization
|
||||
- **plugin / plugin-default-impl**: Extensible plugin system (Java SPI). Types: auth, visibility, datasource dialect, config change, encryption, trace, environment, control, AI pipeline, AI storage
|
||||
- **console / console-ui**: Web UI backend (Spring Boot) and frontend (React)
|
||||
- **ai / copilot / ai-registry-adaptor**: AI Agent support, Copilot integration, and AI registry adaptor
|
||||
- **sys**: System environment utilities and JVM parameter management
|
||||
- **bootstrap / server**: Server startup and aggregation
|
||||
- **persistence**: Data persistence with multi-database support (Derby, MySQL, PostgreSQL)
|
||||
- **maintainer-client**: Internal maintenance client
|
||||
- **lock**: Distributed lock support
|
||||
|
||||
Communication: **gRPC** (primary) + **HTTP/REST** (legacy compatibility). Protobuf definitions in `api/src/main/proto/`.
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
```bash
|
||||
# Full build (skip tests)
|
||||
mvn '-Prelease-nacos,!dev' -Dmaven.test.skip=true clean install -U
|
||||
|
||||
# Run all unit tests
|
||||
mvn test
|
||||
|
||||
# Run standalone-server integration tests
|
||||
mvn -pl test/openapi-test -Pintegration-test -DskipTests=false verify
|
||||
mvn -pl test/java-sdk-test -Pjava-sdk-integration-test -DskipTests=false verify
|
||||
mvn -pl test/maintainer-sdk-test -Pmaintainer-sdk-integration-test -DskipTests=false verify
|
||||
|
||||
# Format code (run before commit)
|
||||
mvn spotless:apply
|
||||
|
||||
# Pre-submission checks (MUST pass before PR)
|
||||
mvn -B clean compile apache-rat:check checkstyle:check spotbugs:check spotless:check -DskipTests
|
||||
```
|
||||
|
||||
### Mandatory Formatting Before Commit
|
||||
|
||||
Before committing Java code or tests, AI agents MUST run Spotless for the
|
||||
affected module or nearest aggregator:
|
||||
|
||||
1. Run `mvn spotless:apply` first.
|
||||
2. Run `mvn spotless:check` for the same scope.
|
||||
3. Then run the relevant compile/check/test command.
|
||||
4. Commit only after Spotless and the relevant validation pass.
|
||||
|
||||
Do not rely on `checkstyle:check`, `spotbugs:check`, or `git diff --check` as a
|
||||
substitute for Spotless. Spotless uses the project formatter and may accept
|
||||
formatting that generic whitespace checks report differently.
|
||||
|
||||
## Code Style
|
||||
|
||||
Follows **Alibaba Java Coding Guidelines**.
|
||||
|
||||
- Checkstyle config: [`style/NacosCheckStyle.xml`](style/NacosCheckStyle.xml)
|
||||
- IDEA code style: [`style/nacos-code-style-for-idea.xml`](style/nacos-code-style-for-idea.xml)
|
||||
|
||||
### Key Rules for AI Agents
|
||||
|
||||
| Rule | Value |
|
||||
|------|-------|
|
||||
| Indentation | **4 spaces** (basic offset), 4 spaces (case indent) |
|
||||
| Line length | **100 characters** max (enforced by Spotless + Checkstyle) |
|
||||
| Star imports | **Forbidden** — always use explicit imports |
|
||||
| Unused imports | **Forbidden** |
|
||||
| Javadoc | Required for API methods (exemptions: `@Override`, `@Test`, `@Before`, `@After`, `@BeforeClass`, `@AfterClass`, `@Parameterized`, `@Parameters`, `@Bean`) |
|
||||
| Braces | Required for all `if/else/for/while/do-while` blocks, even single-line |
|
||||
| Switch | Must have `default` case; fall-through must be commented |
|
||||
| Naming | `camelCase` for methods/variables, `PascalCase` for classes, `UPPER_SNAKE_CASE` for constants |
|
||||
| Abbreviations | Max 1 consecutive capital letter in names (exception: `VO`) |
|
||||
|
||||
### License Header
|
||||
|
||||
Every new source file **must** include the Apache License 2.0 header. CI enforces this via `apache-rat:check`.
|
||||
|
||||
```java
|
||||
/*
|
||||
* Copyright 1999-${year} Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
```
|
||||
|
||||
## API Standards
|
||||
|
||||
Nacos v3 APIs follow strict conventions. AI agents **must** comply with these
|
||||
standards when generating controller code.
|
||||
|
||||
Authoritative API and SDK specs live under [`specs/`](./specs/README.md).
|
||||
Before coding any API, SDK, plugin, storage, runtime, or domain change, AI
|
||||
agents **MUST** consult the relevant specs below. If the intended behavior
|
||||
differs from the existing spec, do not silently implement the code first:
|
||||
discuss the change with maintainers and update the affected specs as part of
|
||||
the design. For broad or uncertain changes, submit a spec/design PR first so
|
||||
the contract is reviewed before implementation.
|
||||
|
||||
English:
|
||||
|
||||
- Design foundation:
|
||||
[Nacos Design Spec](./specs/en/design/nacos-design-spec.md),
|
||||
[Resource Model Spec](./specs/en/design/resource-model-spec.md),
|
||||
[Compatibility And Deprecation Spec](./specs/en/design/compatibility-deprecation-spec.md),
|
||||
[Foundation Capabilities Spec](./specs/en/design/foundation-capabilities-spec.md),
|
||||
[Server Lifecycle And Environment Configuration Spec](./specs/en/design/foundation-server-lifecycle-env-spec.md),
|
||||
[Cluster Membership Spec](./specs/en/design/foundation-cluster-membership-spec.md),
|
||||
[Remote Connection Lifecycle Spec](./specs/en/design/foundation-remote-connection-spec.md),
|
||||
[Request Filtering And Runtime Context Spec](./specs/en/design/foundation-request-context-spec.md),
|
||||
[Internal RPC And Cluster Request Spec](./specs/en/design/foundation-internal-rpc-spec.md),
|
||||
[AP Consistency Spec](./specs/en/design/foundation-ap-consistency-spec.md),
|
||||
[CP Consistency Spec](./specs/en/design/foundation-cp-consistency-spec.md),
|
||||
[Persistence And Dump Spec](./specs/en/design/foundation-persistence-dump-spec.md),
|
||||
[Task Execution Spec](./specs/en/design/foundation-task-execution-spec.md),
|
||||
[Event Dispatch And NotifyCenter Spec](./specs/en/design/foundation-event-dispatch-spec.md),
|
||||
[Observability Hooks Spec](./specs/en/design/foundation-observability-hooks-spec.md),
|
||||
[Core Capabilities Spec](./specs/en/design/core-capabilities-spec.md)
|
||||
- Interface model:
|
||||
[HTTP API Spec](./specs/en/http-api/api-spec.md),
|
||||
[Authorization Spec](./specs/en/http-api/authorization-spec.md),
|
||||
[Response And Error Spec](./specs/en/http-api/response-error-spec.md),
|
||||
[V3 API Surface](./specs/en/http-api/v3-api-surface.md),
|
||||
[gRPC API Spec](./specs/en/grpc-api/api-spec.md),
|
||||
[SDK Spec](./specs/en/sdk/sdk-spec.md),
|
||||
[Java SDK Implementation Spec](./specs/en/sdk/sdk-java-impl-spec.md),
|
||||
[Client Runtime Spec](./specs/en/client/client-runtime-spec.md),
|
||||
[Client Connection And Failover Spec](./specs/en/client/client-connection-failover-spec.md),
|
||||
[Client Ability Negotiation Spec](./specs/en/client/client-ability-negotiation-spec.md),
|
||||
[Client Local Cache And Redo Spec](./specs/en/client/client-local-cache-redo-spec.md),
|
||||
[Runtime Push And Reconnect Spec](./specs/en/client/runtime-push-reconnect-spec.md)
|
||||
- Domain model:
|
||||
[Config Spec](./specs/en/config/config-spec.md),
|
||||
[Config Resource Spec](./specs/en/config/config-resource-spec.md),
|
||||
[Config Publish And Query Spec](./specs/en/config/config-publish-query-spec.md),
|
||||
[Config Listener And Watch Spec](./specs/en/config/config-listener-watch-spec.md),
|
||||
[Config Gray Release Spec](./specs/en/config/config-gray-release-spec.md),
|
||||
[Config Persistence And History Spec](./specs/en/config/config-persistence-history-spec.md),
|
||||
[Config Consistency, Dump, And Visibility Spec](./specs/en/config/config-consistency-dump-visibility-spec.md),
|
||||
[Config Capacity And Ops Spec](./specs/en/config/config-capacity-ops-spec.md),
|
||||
[Naming Spec](./specs/en/naming/naming-spec.md),
|
||||
[Naming Resource Spec](./specs/en/naming/naming-resource-spec.md),
|
||||
[Naming Instance Lifecycle Spec](./specs/en/naming/naming-instance-lifecycle-spec.md),
|
||||
[Naming Discovery And Subscription Spec](./specs/en/naming/naming-discovery-subscription-spec.md),
|
||||
[Naming Health And Protection Spec](./specs/en/naming/naming-health-protection-spec.md),
|
||||
[Naming Metadata And Selector Spec](./specs/en/naming/naming-metadata-selector-spec.md),
|
||||
[Naming Consistency And Client State Spec](./specs/en/naming/naming-consistency-client-spec.md),
|
||||
[Naming Ephemeral Distro Consistency Spec](./specs/en/naming/naming-ephemeral-distro-consistency-spec.md),
|
||||
[Naming Persistent CP Consistency Spec](./specs/en/naming/naming-persistent-cp-consistency-spec.md),
|
||||
[Naming Ops Spec](./specs/en/naming/naming-ops-spec.md),
|
||||
[AI Registry Spec](./specs/en/ai/ai-registry-spec.md),
|
||||
[AI Resource Model Spec](./specs/en/ai/ai-resource-model-spec.md),
|
||||
[AI Resource Lifecycle Spec](./specs/en/ai/ai-resource-lifecycle-spec.md),
|
||||
[AI Registry Adaptor Spec](./specs/en/ai/ai-registry-adaptor-spec.md),
|
||||
[MCP Server Spec](./specs/en/ai/mcp-server-spec.md),
|
||||
[A2A Agent Spec](./specs/en/ai/a2a-agent-spec.md),
|
||||
[Prompt Spec](./specs/en/ai/prompt-spec.md),
|
||||
[Skill Spec](./specs/en/ai/skill-spec.md),
|
||||
[AgentSpec Spec](./specs/en/ai/agentspec-spec.md),
|
||||
[Core Operations Spec](./specs/en/core/core-operations-spec.md),
|
||||
[Console Spec](./specs/en/console/console-spec.md),
|
||||
[Distributed Lock Spec](./specs/en/lock/lock-spec.md)
|
||||
- Extension model:
|
||||
[Integration And Adapter Spec](./specs/en/integration/integration-adapter-spec.md),
|
||||
[Plugin Specs](./specs/en/plugin/README.md)
|
||||
- Security model:
|
||||
[Auth And Permission Spec](./specs/en/auth/auth-permission-spec.md),
|
||||
[Auth Plugin Spec](./specs/en/auth/auth-plugin-spec.md),
|
||||
[RAM Auth Plugin Spec](./specs/en/auth/ram-auth-plugin-spec.md),
|
||||
[OIDC Auth Plugin Spec](./specs/en/auth/oidc-auth-plugin-spec.md),
|
||||
[Visibility Plugin Spec](./specs/en/auth/visibility-plugin-spec.md),
|
||||
[Default Auth Plugin Implementation Spec](./specs/en/auth/default-auth-plugin-spec.md)
|
||||
- Testing model:
|
||||
[API Integration Test Spec](./specs/en/testing/api-integration-test-spec.md),
|
||||
[Java SDK Integration Test Spec](./specs/en/testing/java-sdk-integration-test-spec.md)
|
||||
|
||||
Simplified Chinese:
|
||||
|
||||
- 设计基础:
|
||||
[Nacos 设计规范](./specs/zh-cn/design/nacos-design-spec.md),
|
||||
[资源模型规范](./specs/zh-cn/design/resource-model-spec.md),
|
||||
[兼容与废弃策略规范](./specs/zh-cn/design/compatibility-deprecation-spec.md),
|
||||
[基础能力规范](./specs/zh-cn/design/foundation-capabilities-spec.md),
|
||||
[服务端生命周期与环境配置规范](./specs/zh-cn/design/foundation-server-lifecycle-env-spec.md),
|
||||
[集群成员规范](./specs/zh-cn/design/foundation-cluster-membership-spec.md),
|
||||
[远程连接生命周期规范](./specs/zh-cn/design/foundation-remote-connection-spec.md),
|
||||
[请求过滤与运行时上下文规范](./specs/zh-cn/design/foundation-request-context-spec.md),
|
||||
[内部 RPC 与集群请求规范](./specs/zh-cn/design/foundation-internal-rpc-spec.md),
|
||||
[AP 一致性规范](./specs/zh-cn/design/foundation-ap-consistency-spec.md),
|
||||
[CP 一致性规范](./specs/zh-cn/design/foundation-cp-consistency-spec.md),
|
||||
[持久化与 Dump 规范](./specs/zh-cn/design/foundation-persistence-dump-spec.md),
|
||||
[任务执行规范](./specs/zh-cn/design/foundation-task-execution-spec.md),
|
||||
[事件分发与 NotifyCenter 规范](./specs/zh-cn/design/foundation-event-dispatch-spec.md),
|
||||
[可观测钩子规范](./specs/zh-cn/design/foundation-observability-hooks-spec.md),
|
||||
[核心功能规范](./specs/zh-cn/design/core-capabilities-spec.md)
|
||||
- 接口模型:
|
||||
[HTTP API 规范](./specs/zh-cn/http-api/api-spec.md),
|
||||
[鉴权规范](./specs/zh-cn/http-api/authorization-spec.md),
|
||||
[响应与错误规范](./specs/zh-cn/http-api/response-error-spec.md),
|
||||
[V3 API 范围](./specs/zh-cn/http-api/v3-api-surface.md),
|
||||
[gRPC API 规范](./specs/zh-cn/grpc-api/api-spec.md),
|
||||
[SDK 规范](./specs/zh-cn/sdk/sdk-spec.md),
|
||||
[Java SDK 实现规范](./specs/zh-cn/sdk/sdk-java-impl-spec.md),
|
||||
[客户端运行时规范](./specs/zh-cn/client/client-runtime-spec.md),
|
||||
[客户端连接与故障切换规范](./specs/zh-cn/client/client-connection-failover-spec.md),
|
||||
[客户端能力协商规范](./specs/zh-cn/client/client-ability-negotiation-spec.md),
|
||||
[客户端本地缓存与 Redo 规范](./specs/zh-cn/client/client-local-cache-redo-spec.md),
|
||||
[运行时推送与重连规范](./specs/zh-cn/client/runtime-push-reconnect-spec.md)
|
||||
- 领域模型:
|
||||
[Config 规范](./specs/zh-cn/config/config-spec.md),
|
||||
[Config 资源规范](./specs/zh-cn/config/config-resource-spec.md),
|
||||
[Config 发布与查询规范](./specs/zh-cn/config/config-publish-query-spec.md),
|
||||
[Config 监听与 Watch 规范](./specs/zh-cn/config/config-listener-watch-spec.md),
|
||||
[Config 灰度发布规范](./specs/zh-cn/config/config-gray-release-spec.md),
|
||||
[Config 持久化与历史规范](./specs/zh-cn/config/config-persistence-history-spec.md),
|
||||
[Config 一致性、Dump 与可见性规范](./specs/zh-cn/config/config-consistency-dump-visibility-spec.md),
|
||||
[Config 容量与运维规范](./specs/zh-cn/config/config-capacity-ops-spec.md),
|
||||
[Naming 规范](./specs/zh-cn/naming/naming-spec.md),
|
||||
[Naming 资源规范](./specs/zh-cn/naming/naming-resource-spec.md),
|
||||
[Naming 实例生命周期规范](./specs/zh-cn/naming/naming-instance-lifecycle-spec.md),
|
||||
[Naming 发现与订阅规范](./specs/zh-cn/naming/naming-discovery-subscription-spec.md),
|
||||
[Naming 健康检查与保护规范](./specs/zh-cn/naming/naming-health-protection-spec.md),
|
||||
[Naming 元数据与 Selector 规范](./specs/zh-cn/naming/naming-metadata-selector-spec.md),
|
||||
[Naming 一致性与客户端状态规范](./specs/zh-cn/naming/naming-consistency-client-spec.md),
|
||||
[Naming 临时服务 Distro 一致性规范](./specs/zh-cn/naming/naming-ephemeral-distro-consistency-spec.md),
|
||||
[Naming 持久服务 CP 一致性规范](./specs/zh-cn/naming/naming-persistent-cp-consistency-spec.md),
|
||||
[Naming 运维规范](./specs/zh-cn/naming/naming-ops-spec.md),
|
||||
[AI Registry 规范](./specs/zh-cn/ai/ai-registry-spec.md),
|
||||
[AI 资源模型规范](./specs/zh-cn/ai/ai-resource-model-spec.md),
|
||||
[AI 资源生命周期规范](./specs/zh-cn/ai/ai-resource-lifecycle-spec.md),
|
||||
[AI Registry 适配器规范](./specs/zh-cn/ai/ai-registry-adaptor-spec.md),
|
||||
[MCP Server 规范](./specs/zh-cn/ai/mcp-server-spec.md),
|
||||
[A2A Agent 规范](./specs/zh-cn/ai/a2a-agent-spec.md),
|
||||
[Prompt 规范](./specs/zh-cn/ai/prompt-spec.md),
|
||||
[Skill 规范](./specs/zh-cn/ai/skill-spec.md),
|
||||
[AgentSpec 规范](./specs/zh-cn/ai/agentspec-spec.md),
|
||||
[Core 运维规范](./specs/zh-cn/core/core-operations-spec.md),
|
||||
[Console 规范](./specs/zh-cn/console/console-spec.md),
|
||||
[分布式锁规范](./specs/zh-cn/lock/lock-spec.md)
|
||||
- 扩展模型:
|
||||
[集成与适配器规范](./specs/zh-cn/integration/integration-adapter-spec.md),
|
||||
[插件规范](./specs/zh-cn/plugin/README.md)
|
||||
- 安全模型:
|
||||
[鉴权与权限规范](./specs/zh-cn/auth/auth-permission-spec.md),
|
||||
[鉴权插件规范](./specs/zh-cn/auth/auth-plugin-spec.md),
|
||||
[RAM 鉴权插件规范](./specs/zh-cn/auth/ram-auth-plugin-spec.md),
|
||||
[OIDC 鉴权插件规范](./specs/zh-cn/auth/oidc-auth-plugin-spec.md),
|
||||
[可见性插件规范](./specs/zh-cn/auth/visibility-plugin-spec.md),
|
||||
[默认鉴权插件实现规范](./specs/zh-cn/auth/default-auth-plugin-spec.md)
|
||||
- 测试模型:
|
||||
[API 集成测试规范](./specs/zh-cn/testing/api-integration-test-spec.md),
|
||||
[Java SDK 集成测试规范](./specs/zh-cn/testing/java-sdk-integration-test-spec.md)
|
||||
|
||||
This section is a quick implementation checklist for agents. If it conflicts
|
||||
with the specs, follow the specs and update this checklist.
|
||||
|
||||
### URL Path Patterns
|
||||
|
||||
| API Type | Base Path | Purpose | Example |
|
||||
|----------|-----------|---------|---------|
|
||||
| **Open API** | `/v3/client/{module}/...` | Client-facing operations | `/v3/client/ns/instance` |
|
||||
| **Admin API** | `/v3/admin/{module}/...` | Administrative operations | `/v3/admin/ns/service` |
|
||||
| **Console API** | `/v3/console/{module}/...` | Web console operations | `/v3/console/cs/config` |
|
||||
| **Auth API** | `/v3/auth/{resource}/...` | Plugin-provided auth operations | `/v3/auth/user` |
|
||||
|
||||
### Module Names
|
||||
|
||||
| Module | Abbreviation | Scope |
|
||||
|--------|-------------|-------|
|
||||
| Config Service | `cs` | Configuration management |
|
||||
| Naming Service | `ns` | Service discovery |
|
||||
| Core | `core` | Cluster, namespace management |
|
||||
| AI | `ai` | AI resource management |
|
||||
| Plugin | `plugin` | Plugin management |
|
||||
|
||||
> **Note**: Auth APIs (`/v3/auth/user`, `/v3/auth/role`, `/v3/auth/permission`) are defined in `plugin-default-impl` module, not in core.
|
||||
|
||||
### HTTP Method Semantics
|
||||
|
||||
| Method | Usage | Idempotent |
|
||||
|--------|-------|:----------:|
|
||||
| `GET` | Query / Retrieve | Yes |
|
||||
| `POST` | Create / Register | No |
|
||||
| `PUT` | Update / Modify | Yes |
|
||||
| `DELETE` | Remove / Deregister | Yes |
|
||||
|
||||
### Response Format
|
||||
|
||||
**Always** wrap responses in `com.alibaba.nacos.api.model.v2.Result<T>`:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": { }
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
**Always** add `@Secured` annotation (`com.alibaba.nacos.auth.annotation.Secured`):
|
||||
|
||||
```java
|
||||
@Secured(action = ActionTypes.READ, // READ or WRITE
|
||||
signType = SignType.CONFIG, // CONFIG, NAMING, or CONSOLE
|
||||
apiType = ApiType.ADMIN_API) // OPEN_API, ADMIN_API, or CONSOLE_API
|
||||
```
|
||||
|
||||
### API Integration Tests
|
||||
|
||||
For every HTTP API addition, modification, deletion, or deprecation, handle
|
||||
`test/openapi-test` before the API change is considered complete:
|
||||
|
||||
1. Read the affected controller, form/request model, validators, service path,
|
||||
response model, exception handling, and matching specs.
|
||||
2. Build or update the scenario matrix for expected capability,
|
||||
boundary/validation behavior, and exception/error handling.
|
||||
3. Add, update, or remove API IT cases for the changed contract. The goal is API
|
||||
scenario coverage, not line or branch coverage.
|
||||
4. Update `test/openapi-test/API_TEST_COVERAGE.md` and the matching
|
||||
`*_API_TEST_SCENARIOS.md` document.
|
||||
5. If the functional success path is hard to exercise in standalone IT, at
|
||||
least cover boundary and error scenarios and document the uncovered path.
|
||||
|
||||
### Java SDK Integration Tests
|
||||
|
||||
For every Java SDK public contract change, handle `test/java-sdk-test` before
|
||||
the SDK change is considered complete:
|
||||
|
||||
1. Read the public interface, implementation, request/response model, listener
|
||||
path, lifecycle code, exception mapping, and matching SDK/client specs.
|
||||
2. Build or update the scenario matrix for factory/lifecycle behavior,
|
||||
expected capability, boundary/validation behavior, listener/subscription
|
||||
behavior, and exception handling.
|
||||
3. Add, update, or remove Java SDK IT cases for the changed SDK contract. Assert
|
||||
SDK return values, callbacks, remote side effects, and typed exceptions.
|
||||
4. Update `test/java-sdk-test/JAVA_SDK_IT_COVERAGE.md`.
|
||||
5. Keep SDK ITs as external-client tests against a standalone Nacos server; do
|
||||
not start Spring or Nacos inside the test class.
|
||||
|
||||
### Controller Example
|
||||
|
||||
```java
|
||||
import com.alibaba.nacos.api.model.v2.Result;
|
||||
import com.alibaba.nacos.auth.annotation.Secured;
|
||||
import com.alibaba.nacos.plugin.auth.constant.ActionTypes;
|
||||
import com.alibaba.nacos.plugin.auth.constant.SignType;
|
||||
import com.alibaba.nacos.api.common.ApiType;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/v3/admin/ns/service")
|
||||
public class ServiceControllerV3 {
|
||||
|
||||
@PostMapping
|
||||
@Secured(action = ActionTypes.WRITE, apiType = ApiType.ADMIN_API)
|
||||
public Result<String> create(ServiceForm serviceForm) throws Exception {
|
||||
serviceForm.validate();
|
||||
// business logic ...
|
||||
return Result.success("ok");
|
||||
}
|
||||
|
||||
@GetMapping("/list")
|
||||
@Secured(action = ActionTypes.READ, apiType = ApiType.ADMIN_API)
|
||||
public Result<Page<ServiceDetailInfo>> list(ServiceListForm serviceListForm) throws NacosException {
|
||||
serviceListForm.validate();
|
||||
// business logic ...
|
||||
return Result.success(result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Java Version Targeting
|
||||
|
||||
- **Server modules** (config, naming, core, console, etc.): Java 17+
|
||||
- **Client/API/Plugin modules** (api, client, plugin): Java 8+ — ensure backwards compatibility when modifying
|
||||
|
||||
## PR Convention
|
||||
|
||||
All PRs must target the `develop` branch. Follow the [PR template](.github/PULL_REQUEST_TEMPLATE.md).
|
||||
|
||||
**Title format**: `[ISSUE #14122] Add JVM --add-opens options for JDK 17+ compatibility`
|
||||
|
||||
**Pre-submission checklist**:
|
||||
```bash
|
||||
mvn -B clean package apache-rat:check spotbugs:check -DskipTests
|
||||
mvn clean install
|
||||
mvn clean test-compile failsafe:integration-test
|
||||
```
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
Do NOT report security vulnerabilities via GitHub Issues. Use [ASRC (Alibaba Security Response Center)](https://security.alibaba.com) instead.
|
||||
@@ -0,0 +1,40 @@
|
||||
Build Instructions for NACOS
|
||||
|
||||
====================================================
|
||||
|
||||
(1) Prerequisites
|
||||
|
||||
JDK 17+ is required in order to compile and run Nacos.
|
||||
|
||||
nacos utilizes Maven as a distribution management and packaging tool. Version 3.6.3 or later is required.
|
||||
The plugin org.apache.maven.plugins:maven-enforcer-plugin:3.5.0 requires Maven version 3.6.3 .
|
||||
Maven installation and configuration instructions can be found here:
|
||||
|
||||
http://maven.apache.org/run-maven/index.html
|
||||
|
||||
|
||||
(2) Run test cases
|
||||
|
||||
Execute the following command in order to compile and run test cases of each components:
|
||||
|
||||
$ mvn test
|
||||
|
||||
|
||||
(3) Import projects to Eclipse IDE
|
||||
|
||||
First, generate eclipse project files:
|
||||
|
||||
$ mvn -U eclipse:eclipse
|
||||
|
||||
Then, import to eclipse by specifying the root directory of the project via:
|
||||
|
||||
[File] > [Import] > [Existing Projects into Workspace].
|
||||
|
||||
|
||||
(4) Build distribution packages
|
||||
|
||||
Execute the following command in order to build the tar.gz packages and install JAR into local repository:
|
||||
|
||||
#build nacos
|
||||
$ mvn -Prelease-nacos -Dmaven.test.skip=true clean install -U
|
||||
$ ./mvnw -Prelease-nacos -Dmaven.test.skip=true clean install -U
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
## 1.0.0-RC4(Mar 22, 2019)
|
||||
* [#923] Nacos 1.0.0 compatible with nacos-client 0.6.2
|
||||
* [#938] Client beat processor task lost
|
||||
* [#946] Change default server mode to AP
|
||||
|
||||
|
||||
|
||||
## 1.0.0-RC1(Mar 15, 2019)
|
||||
|
||||
* [#870] About Nacos's namespace and tenant design
|
||||
* [#869] Client exception message is confusing
|
||||
* [#866] BeatInfo scheduled property may have the memory visibility issue
|
||||
* [#865] checksum value is not correct
|
||||
* [#839] Refactor API URLs
|
||||
* [#811] ApiCommands.updateIpPublish countDownLatch timeout issue
|
||||
* [#809] Instance field 'valid' should be deprecated and replaced by 'healthy'
|
||||
* [#803] Nacos front-end function regression plan and landing
|
||||
* [#801] Nacos uses nignx as a best practice article for current limiting.
|
||||
* [#757] The word 'domain' should be replaced by 'service'
|
||||
* [#745] Support server running mode in CP, AP or mixed
|
||||
* [#744] The exact status of server should be stored and controlled
|
||||
* [#725] Will the nacos registry be how to do multi-environment deployment?
|
||||
* [#677] Support ephemeral instances and persistent instances
|
||||
* [#651] Remove old API entry 'APICommands'
|
||||
* [#650] Refactor server list management to make it irrelevant to consistency protocol
|
||||
* [#634] Add global push enable switch and data query enable switch
|
||||
* [#629] Server data needs warm up before open traffic
|
||||
* [#502] Registering ephemeral instance as well as persistent instances
|
||||
* [#501] Health check mode confict when building muilt clusters whit nacos sync + nacos
|
||||
* [#479] Metadata should be displayed and edited using standard property syntax
|
||||
* [#327] Inform the ACM SDK of the RAM role name and access the configuration ? ?
|
||||
* [#269] need to support service group in naming module
|
||||
|
||||
## 0.9.0(Feb 28, 2019)
|
||||
|
||||
* [#840] Nacos server adds startup mode to distinguish between config and naming.
|
||||
* [#762] Register instance returns failed when the health check mode is 'server' in standalone mode.
|
||||
* [#473] Nacos Cluster Mode kubernate Startup nacos.log error Log.
|
||||
* [#240] Log strong dependence problem.
|
||||
* [#824] getServicesOfServer throws exception if service list is empty.
|
||||
* [#802] Nacos server multi-boot mode support.
|
||||
* [#800] Nacos's client-to-server addressing mode document introduction.
|
||||
* [#768] The interval at which the heartbeat is sent in BeatReactor is not controlled by the server return value.
|
||||
* [#759] why instance can't auto-delete.
|
||||
* [#756] Format of instance and service should be validated.
|
||||
* [#720] Memory leak in PushService.
|
||||
* [#653] IoUtils under nacos-common-0.2.1-RC1.jar lacks "" judgment on encoding.
|
||||
* [#588] Client compatible to jdk1.6.
|
||||
|
||||
## 0.8.0(Jan 22, 2019) PRE-GA
|
||||
|
||||
* [#162] Support open metrics and prometheus
|
||||
* [#268] Health check is performed in the Nacos startup script
|
||||
* [#320] Nacos supports multiple configuration files, configuration template abstraction and inheritance
|
||||
* [#333] Use nacos in k8s to get hostname exception
|
||||
* [#335] update nacos.io docker img priority/low
|
||||
* [#339] Project language problem identified in github
|
||||
* [#381] Discuss:How to support Login
|
||||
* [#397] Some questions for Nacos
|
||||
* [#402] When the configuration is added or edited, the edit box will not come out
|
||||
* [#462] Nacos monitor discuss (0.8 version)
|
||||
* [#496] Warning log printing when quering a nonexistent service
|
||||
* [#497] Make subscription of service triggered by getInstance method optional
|
||||
* [#498] Support namespace for service discovery
|
||||
* [#499] When the configuration is newly created (if data-id and group already exist), the original configuration will be overwritten
|
||||
* [#512] nacos-logs start.out always print 8848 (but port can be changed)
|
||||
* [#514] Nacos 0.7 not support namespace
|
||||
* [#523] Add a switch to control server detection and client reporting heartbeat switching
|
||||
* [#526] Possible data loss in server side health check mode
|
||||
* [#527] Many repeat client beat tasks can be generated
|
||||
* [#558] Enable access log recording by default
|
||||
* [#560] Nacos server startup issues
|
||||
* [#579] New API support - “update health in none health check mode through api”
|
||||
* [#587] Client sends request concurrently
|
||||
* [#592] Service restful interface put/post is reversed
|
||||
* [#599] getSubscribeServices method gets services that were deregistered
|
||||
* [#603] Format log of naming module
|
||||
* [#609] Always print a NPE log at start
|
||||
* [#663] Nacos update instance info NPE
|
||||
* [#668] 0.8.0-SNAPSHOT naming heartbeat not compatible with lower version client
|
||||
* [#672] Startup.cmd bug
|
||||
|
||||
## 0.7.0(Dec, 2018)
|
||||
|
||||
* [ #461 ] Registration failed when instance port is set to 0
|
||||
* [ #455 ] The console can't change the change code
|
||||
* [ #447 ] 集群模式server挂掉一台后,提供方注册失败
|
||||
* [ #445 ] 0.6.1控制台创建配置发布提交时,提示信息有问题
|
||||
* [ #442 ] Typos in class names and variables.
|
||||
* [ #413 ] The console has some uncaught exceptions
|
||||
* [ #395 ] nacos surport mysql in the case of stand-alone mode
|
||||
* [ #393 ] Support operation of selector on console
|
||||
* [ #365 ] NodeJs SDK support
|
||||
* [ #362 ] The metadata will lost when online or offline instance through web ui
|
||||
* [ #187 ] Provide Label ability for Naming Service into NACOS for complex multi-DC scenario.
|
||||
|
||||
## 0.6.1(Dec, 2018)
|
||||
|
||||
* [#421] NamingService's serivce name can't use colon(:) in Windows
|
||||
* [#432] When packing nacos-core, ${user.home} is replaced in the logback configuration file (nacos.xml)
|
||||
|
||||
## 0.6.0(Dec, 2018)
|
||||
|
||||
* [#388] Cluster name should be provided in the Instance
|
||||
* [#377] Clean up messy code in Naming module
|
||||
* [#369] Support instance list persisted on disk
|
||||
* [#366] findbugs-maven-plugin version
|
||||
* [#362] The metadata will lost when online or offline instance through web ui
|
||||
* [#352] Refactoring internationalization Nacos console
|
||||
* [#278] Nacos docker img
|
||||
* [#243] optimize the efficiency of integration testing, it’s taking too long now
|
||||
|
||||
## 0.5.0(Nov, 2018)
|
||||
|
||||
* [#148] Naming write performace.
|
||||
* [#175] Support deregistering instance automatically.
|
||||
* [#176] Naming client query instance method should bypass local cache at client start.
|
||||
* [#177] Console supports registering new empty service and delete empty service.
|
||||
* [#181] NPE when adding an instance if no leader in the raft cluster.
|
||||
* [#193] Configure host domain name cause nacos server cluster is unavailable.
|
||||
* [#209] Disable service and cluster level customization in client registerInstance method.
|
||||
* [#214] Please support Java 11.
|
||||
* [#222] print more nacos server start status info in start.log.
|
||||
* [#231] Refactoring: Parsing the Nacos home directory and the cluster.conf file.
|
||||
* [#246] "mvn -B clean apache-rat:check findbugs:findbugs" did not work as expected.
|
||||
* [#251] Console Editor Optimization.
|
||||
* [#254] DataId and group are required in historical version and listener query.
|
||||
* [#256] Whether the service discovery data needs to add a newline link symbol.
|
||||
* [#257] Listening query switching query dimension data is not refreshed.
|
||||
* [#258] Remove the Balloon of DataId/Group.
|
||||
* [#259] Listening query paging size problem.
|
||||
* [#272] "#it is ip" is also parsed into an instance IP.
|
||||
* [#275] nacos coredns plugin to support DNS.
|
||||
* [#281] We should lint the console code.
|
||||
* [#302] Maven build project supports java 11.
|
||||
* [#316] In stand alone mode, Nacos still checks the cluster.conf.
|
||||
|
||||
## 0.4.0(Nov 7, 2018)
|
||||
|
||||
* [#216] Fix tenant dir problem
|
||||
* [#197] Service update ignored some properties
|
||||
* [#190] Client beat lose weight info and metadata info
|
||||
* [#188] Console delete data cannot be updated in time
|
||||
* [#179] Listening query fail when namespace is not blank
|
||||
* [#157] Lack information in readme.md to describe the related project repositories for Nacos echosystem
|
||||
* [#144] There have a error and something are not clear
|
||||
* [#106] Snapshot file create error
|
||||
* [#92] Eliminate warnings, refactor code, show start.log detail
|
||||
|
||||
|
||||
## 0.3.0(Oct 26, 2018)
|
||||
|
||||
* [#171] UI debug errors
|
||||
* [#156] Web UI 404 problem
|
||||
* [#155] use local resource
|
||||
* [#145] nacos-example not found :org.apache.logging.log4j.core.Logger
|
||||
* [#142] UI console show Group
|
||||
* [#149] Fix naming client beat process failed bug.
|
||||
* [#150] Fix naming service registration hangs bug.
|
||||
|
||||
## 0.3.0-RC1(Oct 19, 2018)
|
||||
|
||||
* [#33] Support console for config management.
|
||||
* [#51] Support console for naming service.
|
||||
* [#121] Fix get instance method hanging bug.
|
||||
* [#138] Add a flag to indicate if instance is offline.
|
||||
* [#130] Fix health check disabled if machine has one CPU core bug.
|
||||
* [#139] Fix still get instance with zero weight bug.
|
||||
* [#128] Fix console layout bug.
|
||||
|
||||
|
||||
|
||||
## 0.2.1-release(Sept 28, 2018)
|
||||
|
||||
* Fix deregister last instance failed error.
|
||||
* Fix url pattern error.
|
||||
* Fully integrate with and seamlessly support Spring framework, Spring Boot and Spring Cloud
|
||||
* Separate nacos-api from nacos client implementation
|
||||
* Support high available cluster mode
|
||||
* Fix cluster node health check abnormality
|
||||
* Fix stand-alone mode gets the change history list exception
|
||||
* Fix Pulling does not exist configuration print io exception
|
||||
* Optimized log framework
|
||||
* Service Discovery: Client support getting server status.
|
||||
* Service Discovery: Client support get all service names of server.
|
||||
* Service Discovery: Client support get all subscribed services.
|
||||
|
||||
## 0.2.0 (Sept 17, 2018)
|
||||
|
||||
#### FEATURES:
|
||||
|
||||
* separate nacos-api from nacos client implementation
|
||||
* Cluster node health check abnormality
|
||||
* Stand-alone mode gets the change history list exception
|
||||
* Pulling does not exist configuration print io exception
|
||||
* Optimized log framework
|
||||
* Service Discovery: Client support getting server status.
|
||||
* Service Discovery: Client support get all service names of server.
|
||||
* Service Discovery: Client support get all subscribed services.
|
||||
|
||||
|
||||
#### IMPROVEMENTS:
|
||||
|
||||
#### BUG FIXES:
|
||||
|
||||
#### BREAKING CHANGES:
|
||||
|
||||
|
||||
|
||||
## 0.1.0 (July 18, 2018)
|
||||
|
||||
#### FEATURES:
|
||||
|
||||
* Creating, deleting, modifying, and querying configurations: the core functionalities.
|
||||
* Multiple languages support: supports Java/Shell/HTTP OpenAPI.
|
||||
* Service Discovery: Basic service registry and discovery.
|
||||
* Service Discovery: Service load balancing using instance weights, protect threshold and instance health statuses.
|
||||
* Service Discovery: Supports four ways for health check: http, tcp, mysql and client heartbeat.
|
||||
* Service Discovery: CRUD operations on service instances through Java client and open API.
|
||||
* Service Discovery: Service subscribtion and push through Java client.
|
||||
* Nacos official website is coming. https://nacos.io/
|
||||
|
||||
|
||||
|
||||
#### IMPROVEMENTS:
|
||||
|
||||
#### BUG FIXES:
|
||||
|
||||
#### BREAKING CHANGES:
|
||||
@@ -0,0 +1,73 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, gender identity and expression, level of experience,
|
||||
education, socio-economic status, nationality, personal appearance, race,
|
||||
religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at users-nacos@googlegroups.com. All
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
# Contributing to Nacos
|
||||
|
||||
[中文版](./CONTRIBUTING_zh.md)
|
||||
|
||||
Welcome to Nacos! This document is a guideline about how to contribute to Nacos.
|
||||
|
||||
Nacos is released under the non-restrictive Apache 2.0 license, and follows a very standard Github development process, using Github tracker for issues and merging pull requests into develop. If you want to contribute even something trivial, please do not hesitate, but follow the guidelines below.
|
||||
|
||||
We are always very happy to have contributions, whether for trivial cleanups or big new features. We want to have high quality, well documented codes for each programming language. Nor is code the only way to contribute to the project. We strongly value documentation, integration with other projects, and gladly accept improvements for these aspects.
|
||||
|
||||
## Before You Get Started
|
||||
|
||||
### Code of Conduct
|
||||
|
||||
Please make sure to read and observe our [Code of Conduct](./CODE_OF_CONDUCT.md).
|
||||
|
||||
### Code Style
|
||||
|
||||
Please read and set up the Nacos code style correctly before contributing:
|
||||
|
||||
- Checkstyle config: [`style/NacosCheckStyle.xml`](style/NacosCheckStyle.xml)
|
||||
- IDEA code style: [`style/nacos-code-style-for-idea.xml`](style/nacos-code-style-for-idea.xml)
|
||||
- Code style guide: [`style/codeStyle.md`](style/codeStyle.md)
|
||||
|
||||
Nacos uses [Spotless](https://github.com/diffplug/spotless) with Eclipse JDT Formatter for automated code formatting. The formatter configuration is at [`style/nacos-eclipse-formatter.xml`](style/nacos-eclipse-formatter.xml). Run `mvn spotless:apply` before committing to auto-format your code.
|
||||
|
||||
## Contact Us
|
||||
|
||||
- **Nacos Gitter**: [https://gitter.im/alibaba/nacos](https://gitter.im/alibaba/nacos)
|
||||
- **Nacos Weibo**: [https://weibo.com/u/6574374908](https://weibo.com/u/6574374908)
|
||||
- **Nacos SegmentFault**: [https://segmentfault.com/t/nacos](https://segmentfault.com/t/nacos)
|
||||
|
||||
### Mailing List
|
||||
|
||||
Mailing list is recommended for discussing almost anything related to Nacos:
|
||||
|
||||
- [dev-nacos@googlegroups.com](mailto:dev-nacos%2Bsubscribe@googlegroups.com): The develop mailing list. You can ask questions here if you encounter any problem when using or developing Nacos.
|
||||
- [commits-nacos@googlegroups.com](mailto:commits-nacos%2Bsubscribe@googlegroups.com): All commits will be sent to this mailing list. You can subscribe to it if you are interested in Nacos' development.
|
||||
- [users-nacos@googlegroups.com](mailto:users-nacos%2Bsubscribe@googlegroups.com): All Github issue updates and pull request updates will be sent to this mailing list.
|
||||
- [nacos_dev@linux.alibaba.com](mailto:nacos_dev@linux.alibaba.com)
|
||||
|
||||
## Reporting Bugs
|
||||
|
||||
If any part of the Nacos project has bugs or documentation mistakes, please let us know by [opening an issue](https://github.com/alibaba/nacos/issues/new). We treat bugs and mistakes very seriously and believe no issue is too small. Before creating a bug report, please check that an issue reporting the same problem does not already exist.
|
||||
|
||||
To make the bug report accurate and easy to understand, please try to create bug reports that are:
|
||||
|
||||
- **Specific**: Include as much details as possible: which version, what environment, what configuration, etc. If the bug is related to running the Nacos server, please attach the Nacos log (the starting log with Nacos configuration is especially important).
|
||||
|
||||
- **Reproducible**: Include the steps to reproduce the problem. We understand some issues might be hard to reproduce, please include the steps that might lead to the problem. If possible, please attach the affected Nacos data dir and stack trace to the bug report.
|
||||
|
||||
- **Unique**: Do not duplicate existing bug reports.
|
||||
|
||||
It may be worthwhile to read [Elika Etemad's article on filing good bug reports](http://fantasai.inkedblade.net/style/talks/filing-good-bugs/) before creating a bug report.
|
||||
|
||||
### Reporting Security Bugs
|
||||
|
||||
Do **NOT** report security vulnerabilities via GitHub Issues. Please report them through [ASRC (Alibaba Security Response Center)](https://security.alibaba.com).
|
||||
|
||||
## Contributing Code
|
||||
|
||||
Nacos welcomes new participants of any role, including user, contributor, committer and PMC.
|
||||
|
||||

|
||||
|
||||
We encourage newcomers actively joining in Nacos projects and involving from user roles to committer roles, and even PMC roles.
|
||||
|
||||
### Finding Issues to Work On
|
||||
|
||||
If you find a typo in a document, find a bug in code or want new features, or want to give suggestions, you can [open an issue on GitHub](https://github.com/alibaba/nacos/issues/new) to report it.
|
||||
|
||||
If you just want to contribute directly you can choose the issues below:
|
||||
|
||||
- [Contribution Welcome](https://github.com/alibaba/nacos/labels/contribution%20welcome): Heavily needed issues, but currently short of hands.
|
||||
- [Good First Issue](https://github.com/alibaba/nacos/labels/good%20first%20issue): Good for newcomers, newcomers can pick up one for warm-up.
|
||||
|
||||
**Please note that any PR must be associated with a valid issue. Otherwise, the PR will be rejected.**
|
||||
|
||||
### Issue Assignment
|
||||
|
||||
To avoid duplicate work and ensure efficient collaboration, please follow this process when claiming an issue:
|
||||
|
||||
#### How to claim an issue
|
||||
|
||||
1. Comment `/assign` on the issue to claim it — you will be automatically assigned
|
||||
2. If the issue is already assigned, please wait or discuss in the comments
|
||||
3. To give up a claimed issue, comment `/unassign`
|
||||
|
||||
#### Tips
|
||||
|
||||
- Check if an issue already has an assignee before claiming
|
||||
- For complex issues, consider discussing your approach in the issue before starting
|
||||
- If you need more time, comment on the issue to let others know your progress
|
||||
- If you can't continue, comment `/unassign` to release the issue for others
|
||||
|
||||
### Contribution Notice
|
||||
|
||||
Before submitting a change, please ensure:
|
||||
|
||||
1. Read and follow the Nacos [Code Style](style/codeStyle.md), and make sure your IDE has the code style configured and required plugins installed.
|
||||
|
||||
2. If the change is non-trivial, please include unit tests that cover the new functionality.
|
||||
|
||||
3. If you are introducing a completely new feature or API, it is a good idea to start a discussion and get consensus on the basic design first.
|
||||
|
||||
### Contribution Flow (Step by Step)
|
||||
|
||||
This contribution flow is applicable to all Nacos community content, including but not limited to `Nacos`, `Nacos wiki/doc`, `Nacos SDK`.
|
||||
|
||||
#### 1. Fork the Repository
|
||||
|
||||
Fork [alibaba/nacos](https://github.com/alibaba/nacos) repository to your Github account.
|
||||
|
||||
#### 2. Clone Your Fork to Local
|
||||
|
||||
```bash
|
||||
git clone ${your_fork_nacos_repo_address}
|
||||
cd nacos
|
||||
```
|
||||
|
||||
#### 3. Add Upstream Repository
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/alibaba/nacos.git
|
||||
|
||||
git remote -v
|
||||
# origin ${your_fork_nacos_repo_address} (fetch)
|
||||
# origin ${your_fork_nacos_repo_address} (push)
|
||||
# upstream https://github.com/alibaba/nacos.git (fetch)
|
||||
# upstream https://github.com/alibaba/nacos.git (push)
|
||||
|
||||
git fetch origin
|
||||
git fetch upstream
|
||||
```
|
||||
|
||||
#### 4. Create a Development Branch
|
||||
|
||||
We use the `develop` branch as the development branch, which indicates that this is an unstable branch. Create a new branch based on `upstream/develop`:
|
||||
|
||||
```bash
|
||||
# Checkout branch from remote repo to local
|
||||
git checkout -b upstream-develop upstream/develop
|
||||
|
||||
# Create a development branch (usually using the issue number as branch name)
|
||||
git checkout -b develop-issue#${issue-number}
|
||||
```
|
||||
|
||||
#### 5. Make Your Changes
|
||||
|
||||
When making changes, please ensure:
|
||||
|
||||
- Changes on this branch are **only relevant to the issue**
|
||||
- Try to be as small as possible - **one branch for one thing, one PR for one issue**
|
||||
- Use English descriptions for your commits, mainly using **predicate + object** format, such as: `Fix xxx problem/bug`
|
||||
- Simple commits can be described using `For xxx`, such as: `For codestyle`
|
||||
- If the commit is related to an ISSUE, add the ISSUE number as prefix, such as: `For #10000, Fix xxx problem/bug`
|
||||
|
||||
#### 6. Run Pre-submission Checks
|
||||
|
||||
Before pushing your commits, run the following command locally to catch issues early:
|
||||
|
||||
```bash
|
||||
mvn -B clean compile apache-rat:check checkstyle:check spotbugs:check spotless:check -DskipTests
|
||||
```
|
||||
|
||||
| Check | What it verifies |
|
||||
|-------|-----------------|
|
||||
| `compile` | Code compiles without errors |
|
||||
| `apache-rat:check` | All source files have the required Apache License header |
|
||||
| `checkstyle:check` | Code style complies with [Alibaba Java Coding Guidelines](style/NacosCheckStyle.xml) |
|
||||
| `spotbugs:check` | No high-priority bug patterns detected by [SpotBugs](https://spotbugs.github.io/) |
|
||||
| `spotless:check` | Code formatting matches Nacos Eclipse JDT style ([config](style/nacos-eclipse-formatter.xml)). Run `mvn spotless:apply` to auto-fix |
|
||||
|
||||
To run unit tests:
|
||||
|
||||
```bash
|
||||
mvn clean test
|
||||
```
|
||||
|
||||
#### 7. Rebase Your Branch
|
||||
|
||||
When you make changes, other people's changes may have been committed and merged. At this time, there may be conflicts. Please use the rebase command to merge and resolve:
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase -i upstream/develop
|
||||
```
|
||||
|
||||
OR
|
||||
|
||||
```bash
|
||||
git checkout upstream-develop
|
||||
git pull
|
||||
git checkout develop-issue#${issue-number}
|
||||
git rebase -i upstream-develop
|
||||
```
|
||||
|
||||
Benefits of rebasing:
|
||||
1. Your submission record will be clean, without `Merge xxxx branch` messages
|
||||
2. After rebase, the commit log of your branch is a single chain, making it easier to review
|
||||
|
||||
**If you are using IntelliJ IDEA**, it is recommended to use the IDE's version control panel for a more convenient visual interface to resolve conflicts and perform squash operations.
|
||||
|
||||
#### 8. Push to Your Fork Repository
|
||||
|
||||
```bash
|
||||
git push origin develop-issue#${issue-number}
|
||||
```
|
||||
|
||||
**Note**: If you are prompted that there are conflicts when pushing again after a rebase, force push to your fork branch: `git push -f origin develop-issue#${issue-number}`. This happens because the commit ID has changed after rebasing.
|
||||
|
||||
#### 9. Create a Pull Request
|
||||
|
||||
Create a pull request to the **develop** branch and follow the [pull request template](./.github/PULL_REQUEST_TEMPLATE.md).
|
||||
|
||||
##### Pull Request Checklist
|
||||
|
||||
- [ ] Make sure there is a Github issue filed for the change (usually before you start working on it). Trivial changes like typos do not require a Github issue. Your pull request should address just this issue, without pulling in other changes - one PR resolves one issue.
|
||||
- [ ] Format the pull request title like `[ISSUE #123] Fix UnknownException when host config not exist`. Each commit in the pull request should have a meaningful subject line and body.
|
||||
- [ ] Write a pull request description that is detailed enough to understand what the pull request does, how, and why.
|
||||
- [ ] Write necessary unit tests to verify your logic correction. Use mocking when cross-module dependencies exist. If a new feature or significant change is committed, please remember to add integration tests.
|
||||
- [ ] Run `mvn -B clean apache-rat:check checkstyle:check spotbugs:check spotless:check -DskipTests` to make sure basic checks pass.
|
||||
- [ ] Run `mvn clean install` to make sure unit tests pass.
|
||||
- [ ] Run `mvn clean test-compile failsafe:integration-test` to make sure integration tests pass.
|
||||
- [ ] If this contribution is large, please file an [Apache Individual Contributor License Agreement](http://www.apache.org/licenses/#clas).
|
||||
|
||||
##### Pull Request Guidelines
|
||||
|
||||
1. Please create the request to the **develop** branch.
|
||||
2. Please make sure the PR has a corresponding issue.
|
||||
3. If your PR contains large changes, e.g. component refactor or new components, please write detailed documents about its design and usage.
|
||||
4. Note that a single PR should not be too large. If heavy changes are required, it's better to separate the changes into individual PRs.
|
||||
5. After creating a PR, one or more reviewers will be assigned to the pull request.
|
||||
6. Before merging a PR, squash any fix review feedback, typo, merged and rebased sorts of commits. The final commit message should be clear and concise.
|
||||
|
||||
#### 10. Wait for Review and Merge
|
||||
|
||||
The Nacos community will review your Pull Request and may propose comments. You can return to step 5 to modify code according to the comments and use step 7 to resubmit.
|
||||
|
||||
If there are no more issues, the Nacos community will merge your PR. Congratulations on becoming an official contributor of Nacos!
|
||||
|
||||
## License Header
|
||||
|
||||
Every new source file (`.java`, `.xml`, etc.) **must** include the Apache License 2.0 header. CI enforces this via `apache-rat:check` and your PR will fail without it.
|
||||
|
||||
Copy the header below into every new file (adjust the comment style for non-Java files):
|
||||
|
||||
```java
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
```
|
||||
|
||||
## Contributing Documentation
|
||||
|
||||
When contributing documents, please confirm and check the following:
|
||||
|
||||
1. The document is indeed incorrect or missing.
|
||||
2. You are familiar with [Markdown](https://www.markdownguide.org/getting-started).
|
||||
3. You are familiar with the documentation site and can complete local debugging according to the [documentation README](https://github.com/nacos-group/nacos-group.github.io).
|
||||
|
||||
## Code Review Guidance
|
||||
|
||||
Committers will rotate reviewing the code to make sure all PRs are reviewed timely and by at least one committer before merge. If we aren't doing our job (sometimes we drop things), please feel free to ping us. And as always, we welcome volunteers for code review.
|
||||
|
||||
### Review Principles
|
||||
|
||||
- **Readability**: Important code should be well-documented. APIs should have Javadoc. Code style should comply with the existing standards.
|
||||
- **Elegance**: New functions, classes or components should be well-designed.
|
||||
- **Testability**: 80% of the new code should be covered by unit test cases.
|
||||
- **Maintainability**: Comply with our [code style spec](style/codeStyle.md), and maintain at least a 3-month update frequency.
|
||||
|
||||
## Becoming a Committer
|
||||
|
||||
We are always interested in adding new contributors. What we look for are series of contributions, good taste and ongoing interest in the project. If you are interested in becoming a committer, please let one of the existing committers know and they can help you walk through the process.
|
||||
|
||||
### Important Contribution Areas
|
||||
|
||||
- Wiki & JavaDoc
|
||||
- Nacos Console
|
||||
- Nacos SDK (C++, .NET, PHP, Python, Go, Node.js)
|
||||
|
||||
### Prerequisites for Committer
|
||||
|
||||
- **Readability**: APIs as well as important methods must have Javadoc.
|
||||
- **Testability**: Ensure over 80% unit test coverage for main processes.
|
||||
- **Maintainability**: Comply with our [Code Style](style/codeStyle.md), with an update frequency at least once every 3 months.
|
||||
- **Deployability**: We encourage you to deploy into [Maven Repository](http://search.maven.org/).
|
||||
|
||||
### Nomination Process
|
||||
|
||||
Generally speaking, contribute 8 non-trivial patches and get at least three different people to review them (you'll need three people to support you). Then ask someone to nominate you. You're demonstrating your:
|
||||
|
||||
- At least 8 PRs and the associated issues to the project
|
||||
- Ability to collaborate with the team
|
||||
- Understanding of the project's code base and coding style
|
||||
- Ability to write good code (last but certainly not least)
|
||||
|
||||
A current committer nominates you by creating a Nacos issue with the label "nomination" containing:
|
||||
|
||||
- Your first and last name
|
||||
- A link to your Git profile
|
||||
- An explanation of why you should be a committer
|
||||
- Elaboration on the top 3 PRs and the associated issues that demonstrate your ability
|
||||
|
||||
Two other committers need to second your nomination. If no one objects in 5 working days (China), you're a committer. If anyone objects or wants more information, the committers discuss and usually come to a consensus (within the 5 working days). If issues cannot be resolved, there's a vote among current committers.
|
||||
|
||||

|
||||
|
||||
In the worst case, this can drag out for two weeks. Keep contributing! Even in the rare cases where a nomination fails, the objection is usually something easy to address like "more patches" or "not enough people are familiar with this person's work."
|
||||
@@ -0,0 +1,322 @@
|
||||
# 贡献指南
|
||||
|
||||
[English](./CONTRIBUTING.md)
|
||||
|
||||
欢迎来到 Nacos!本文档是关于如何为 Nacos 做贡献的指南。
|
||||
|
||||
Nacos 采用宽松的 Apache 2.0 许可证发布,遵循标准的 Github 开发流程,使用 Github Issue 跟踪问题并将 Pull Request 合并到 develop 分支。如果您想要贡献,无论是简单的修复还是重大的新功能,请不要犹豫,但请遵循以下指南。
|
||||
|
||||
我们始终非常欢迎各种贡献,无论是简单的代码清理还是重大的新功能。我们希望每种编程语言都有高质量、文档完善的代码。代码并不是贡献项目的唯一方式,我们同样重视文档和与其他项目的集成,并乐于接受这些方面的改进。
|
||||
|
||||
## 开始之前
|
||||
|
||||
### 行为准则
|
||||
|
||||
请务必阅读并遵守我们的[行为准则](./CODE_OF_CONDUCT.md)。
|
||||
|
||||
### 代码规范
|
||||
|
||||
在贡献之前,请阅读并正确配置 Nacos 代码规范:
|
||||
|
||||
- Checkstyle 配置:[`style/NacosCheckStyle.xml`](style/NacosCheckStyle.xml)
|
||||
- IDEA 代码风格:[`style/nacos-code-style-for-idea.xml`](style/nacos-code-style-for-idea.xml)
|
||||
- 代码规范指南:[`style/codeStyle.md`](style/codeStyle.md)
|
||||
|
||||
Nacos 使用 [Spotless](https://github.com/diffplug/spotless) 配合 Eclipse JDT Formatter 进行自动代码格式化。格式化配置文件位于 [`style/nacos-eclipse-formatter.xml`](style/nacos-eclipse-formatter.xml)。提交代码前请运行 `mvn spotless:apply` 自动格式化代码。
|
||||
|
||||
## 联系我们
|
||||
|
||||
- **Nacos Gitter**:[https://gitter.im/alibaba/nacos](https://gitter.im/alibaba/nacos)
|
||||
- **Nacos 微博**:[https://weibo.com/u/6574374908](https://weibo.com/u/6574374908)
|
||||
- **Nacos SegmentFault**:[https://segmentfault.com/t/nacos](https://segmentfault.com/t/nacos)
|
||||
|
||||
### 邮件列表
|
||||
|
||||
邮件列表是讨论 Nacos 相关任何事项的推荐方式:
|
||||
|
||||
- [dev-nacos@googlegroups.com](mailto:dev-nacos%2Bsubscribe@googlegroups.com):开发邮件列表。如果您在使用或开发 Nacos 时遇到任何问题,可以在这里提问。
|
||||
- [commits-nacos@googlegroups.com](mailto:commits-nacos%2Bsubscribe@googlegroups.com):所有提交都会发送到此邮件列表。如果您对 Nacos 的开发感兴趣,可以订阅此列表。
|
||||
- [users-nacos@googlegroups.com](mailto:users-nacos%2Bsubscribe@googlegroups.com):所有 Github Issue 更新和 Pull Request 更新都会发送到此邮件列表。
|
||||
- [nacos_dev@linux.alibaba.com](mailto:nacos_dev@linux.alibaba.com)
|
||||
|
||||
## 报告 Bug
|
||||
|
||||
如果您在 Nacos 项目中发现 bug 或文档错误,请通过[创建 Issue](https://github.com/alibaba/nacos/issues/new) 告知我们。我们非常重视 bug 和错误,认为任何问题都不算小问题。在创建 bug 报告之前,请先检查是否已存在报告相同问题的 Issue。
|
||||
|
||||
为了使 bug 报告准确且易于理解,请尽量创建符合以下要求的 bug 报告:
|
||||
|
||||
- **具体**:尽可能包含详细信息:哪个版本、什么环境、什么配置等。如果 bug 与运行 Nacos 服务器相关,请附上 Nacos 日志(启动日志和 Nacos 配置信息尤为重要)。
|
||||
|
||||
- **可复现**:包含重现问题的步骤。我们理解某些问题可能难以复现,请包含可能导致问题的步骤。如有可能,请将受影响的 Nacos 数据目录和堆栈跟踪附加到 bug 报告中。
|
||||
|
||||
- **唯一**:不要重复已有的 bug 报告。
|
||||
|
||||
在创建 bug 报告之前,阅读 [Elika Etemad 关于如何提交好的 bug 报告的文章](http://fantasai.inkedblade.net/style/talks/filing-good-bugs/)可能会有所帮助。
|
||||
|
||||
### 报告安全漏洞
|
||||
|
||||
**请勿**通过 GitHub Issues 报告安全漏洞。请通过 [ASRC(阿里巴巴安全响应中心)](https://security.alibaba.com)进行报告。
|
||||
|
||||
## 代码贡献
|
||||
|
||||
Nacos 欢迎任何角色的新参与者,包括用户、贡献者、Committer 和 PMC。
|
||||
|
||||

|
||||
|
||||
我们鼓励新人积极参与 Nacos 项目,从用户角色发展到贡献者、Committer,甚至 PMC。
|
||||
|
||||
### 寻找要参与的 Issue
|
||||
|
||||
如果您发现文档中的拼写错误、代码中的 bug,或者想要新功能、提出建议,可以在 [GitHub 上创建 Issue](https://github.com/alibaba/nacos/issues/new) 进行反馈。
|
||||
|
||||
如果您想直接参与贡献,可以选择以下标签的 Issue:
|
||||
|
||||
- [Contribution Welcome](https://github.com/alibaba/nacos/labels/contribution%20welcome):急需解决但人手不足的 Issue。
|
||||
- [Good First Issue](https://github.com/alibaba/nacos/labels/good%20first%20issue):适合新手的 Issue,可以作为入门热身。
|
||||
|
||||
**请注意,每个 PR 必须关联一个有效的 Issue,否则 PR 将被拒绝。**
|
||||
|
||||
### Issue 认领
|
||||
|
||||
为避免重复工作并提高协作效率,请在认领 Issue 时遵循以下流程:
|
||||
|
||||
#### 如何认领 Issue
|
||||
|
||||
1. 在 Issue 下评论 `/assign` 即可自动认领
|
||||
2. 如果 Issue 已被认领,请等待或在评论中沟通
|
||||
3. 如需放弃认领,评论 `/unassign` 即可
|
||||
|
||||
#### 提示
|
||||
|
||||
- 在认领之前,请先检查 Issue 是否已有 Assignee
|
||||
- 对于复杂的 Issue,建议先在 Issue 中讨论实现方案再开始
|
||||
- 如果需要更多时间,请在 Issue 中留言说明进展
|
||||
- 如果无法继续完成,评论 `/unassign` 即可释放给其他贡献者
|
||||
|
||||
### 贡献须知
|
||||
|
||||
在提交更改之前,请确保:
|
||||
|
||||
1. 阅读并遵循 Nacos [代码规范](style/codeStyle.md),确保您的 IDE 已配置代码风格并安装了必要的插件。
|
||||
|
||||
2. 如果更改是非琐碎的,请包含覆盖新功能的单元测试。
|
||||
|
||||
3. 如果您正在引入全新的功能或 API,最好先发起讨论并在基本设计上达成共识。
|
||||
|
||||
### 贡献流程(详细步骤)
|
||||
|
||||
此贡献流程适用于所有 Nacos 社区内容,包括但不限于 `Nacos`、`Nacos wiki/doc`、`Nacos SDK`。
|
||||
|
||||
#### 1. Fork 仓库
|
||||
|
||||
将 [alibaba/nacos](https://github.com/alibaba/nacos) 仓库 Fork 到您的 Github 账号。
|
||||
|
||||
#### 2. 克隆到本地
|
||||
|
||||
```bash
|
||||
git clone ${您的_fork_nacos_仓库地址}
|
||||
cd nacos
|
||||
```
|
||||
|
||||
#### 3. 添加上游仓库
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/alibaba/nacos.git
|
||||
|
||||
git remote -v
|
||||
# origin ${您的_fork_nacos_仓库地址} (fetch)
|
||||
# origin ${您的_fork_nacos_仓库地址} (push)
|
||||
# upstream https://github.com/alibaba/nacos.git (fetch)
|
||||
# upstream https://github.com/alibaba/nacos.git (push)
|
||||
|
||||
git fetch origin
|
||||
git fetch upstream
|
||||
```
|
||||
|
||||
#### 4. 创建开发分支
|
||||
|
||||
我们使用 `develop` 分支作为开发分支,这是一个不稳定的分支。基于 `upstream/develop` 创建新分支:
|
||||
|
||||
```bash
|
||||
# 从远程仓库检出分支到本地
|
||||
git checkout -b upstream-develop upstream/develop
|
||||
|
||||
# 创建开发分支(通常使用 issue 编号作为分支名)
|
||||
git checkout -b develop-issue#${issue编号}
|
||||
```
|
||||
|
||||
#### 5. 进行修改
|
||||
|
||||
进行修改时,请确保:
|
||||
|
||||
- 此分支上的更改**仅与该 Issue 相关**
|
||||
- 尽量保持更改最小化 - **一个分支做一件事,一个 PR 解决一个 Issue**
|
||||
- 使用英文描述您的提交,主要使用**谓语 + 宾语**格式,例如:`Fix xxx problem/bug`
|
||||
- 简单的提交可以使用 `For xxx` 描述,例如:`For codestyle`
|
||||
- 如果提交与某个 ISSUE 相关,可以添加 ISSUE 编号作为前缀,例如:`For #10000, Fix xxx problem/bug`
|
||||
|
||||
#### 6. 运行提交前检查
|
||||
|
||||
在推送代码之前,请在本地运行以下命令以尽早发现问题:
|
||||
|
||||
```bash
|
||||
mvn -B clean compile apache-rat:check checkstyle:check spotbugs:check spotless:check -DskipTests
|
||||
```
|
||||
|
||||
| 检查项 | 说明 |
|
||||
|-------|------|
|
||||
| `compile` | 代码是否能正常编译 |
|
||||
| `apache-rat:check` | 所有源文件是否包含 Apache License 头 |
|
||||
| `checkstyle:check` | 代码风格是否符合[阿里巴巴 Java 开发规约](style/NacosCheckStyle.xml) |
|
||||
| `spotbugs:check` | 是否存在 [SpotBugs](https://spotbugs.github.io/) 检测到的高优先级 bug |
|
||||
| `spotless:check` | 代码格式是否符合 Nacos Eclipse JDT 风格([配置文件](style/nacos-eclipse-formatter.xml))。运行 `mvn spotless:apply` 自动修复 |
|
||||
|
||||
运行单元测试:
|
||||
|
||||
```bash
|
||||
mvn clean test
|
||||
```
|
||||
|
||||
#### 7. 变基(Rebase)分支
|
||||
|
||||
在您进行修改的同时,其他人的更改可能已经被提交和合并。此时可能会有冲突,请使用 rebase 命令进行合并和解决:
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase -i upstream/develop
|
||||
```
|
||||
|
||||
或者
|
||||
|
||||
```bash
|
||||
git checkout upstream-develop
|
||||
git pull
|
||||
git checkout develop-issue#${issue编号}
|
||||
git rebase -i upstream-develop
|
||||
```
|
||||
|
||||
变基的好处:
|
||||
1. 您的提交记录将很干净,没有 `Merge xxxx branch` 的信息
|
||||
2. 变基后,您分支的提交日志是单链的,更容易回溯
|
||||
|
||||
**如果您使用 IntelliJ IDEA**,建议使用 IDE 的版本控制面板,它有更方便的可视化界面来解决冲突和执行压缩操作。
|
||||
|
||||
#### 8. 推送到 Fork 仓库
|
||||
|
||||
```bash
|
||||
git push origin develop-issue#${issue编号}
|
||||
```
|
||||
|
||||
**注意**:如果在变基后再次推送时提示有冲突,可以强制推送到您的 fork 分支:`git push -f origin develop-issue#${issue编号}`。这是因为变基后提交 ID 发生了变化。
|
||||
|
||||
#### 9. 创建 Pull Request
|
||||
|
||||
向 **develop** 分支创建 Pull Request,并遵循 [PR 模板](./.github/PULL_REQUEST_TEMPLATE.md)。
|
||||
|
||||
##### Pull Request 检查清单
|
||||
|
||||
- [ ] 确保已为此更改创建了 Github Issue(通常在开始工作之前)。拼写错误等琐碎更改不需要 Github Issue。您的 Pull Request 应该只解决这个 Issue,不要引入其他更改 - 一个 PR 解决一个 Issue。
|
||||
- [ ] PR 标题格式如 `[ISSUE #123] Fix UnknownException when host config not exist`。PR 中的每个提交都应有有意义的主题和正文。
|
||||
- [ ] 编写详细的 PR 描述,足以说明 PR 做了什么、如何做的以及为什么这样做。
|
||||
- [ ] 编写必要的单元测试来验证您的逻辑正确性。当存在跨模块依赖时,尽量使用 mock。如果提交了新功能或重大更改,请记得添加集成测试。
|
||||
- [ ] 运行 `mvn -B clean apache-rat:check checkstyle:check spotbugs:check spotless:check -DskipTests` 确保基本检查通过。
|
||||
- [ ] 运行 `mvn clean install` 确保单元测试通过。
|
||||
- [ ] 运行 `mvn clean test-compile failsafe:integration-test` 确保集成测试通过。
|
||||
- [ ] 如果此贡献较大,请签署 [Apache 个人贡献者许可协议](http://www.apache.org/licenses/#clas)。
|
||||
|
||||
##### Pull Request 指南
|
||||
|
||||
1. 请将 PR 提交到 **develop** 分支。
|
||||
2. 请确保 PR 关联了对应的 Issue。
|
||||
3. 如果 PR 包含较大的改动(如组件重构或新组件),请编写详细的设计和使用文档。
|
||||
4. 注意单个 PR 不要过大。如果需要大量改动,最好将其拆分为多个独立的 PR。
|
||||
5. 创建 PR 后,一名或多名审核者会被分配到该 PR。
|
||||
6. 合并前,请将修复审查意见、拼写错误、合并和变基等提交压缩为有意义的提交。最终的 commit message 应当清晰简洁。
|
||||
|
||||
#### 10. 等待审核和合并
|
||||
|
||||
Nacos 社区将审核您的 Pull Request 并可能提出意见。您可以返回步骤 5 根据意见修改代码,并使用步骤 7 重新提交。
|
||||
|
||||
如果没有更多问题,Nacos 社区将合并您的 PR。恭喜您成为 Nacos 的正式贡献者!
|
||||
|
||||
## License 头
|
||||
|
||||
每个新的源文件(`.java`、`.xml` 等)**必须**包含 Apache License 2.0 头。CI 会通过 `apache-rat:check` 自动检查,缺少 License 头的 PR 将无法通过。
|
||||
|
||||
请将以下头信息复制到每个新文件中(非 Java 文件请调整注释风格):
|
||||
|
||||
```java
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
```
|
||||
|
||||
## 文档贡献
|
||||
|
||||
在贡献文档时,请确认并检查以下内容:
|
||||
|
||||
1. 文档确实存在错误或缺失。
|
||||
2. 您熟悉 [Markdown](https://www.markdownguide.org/getting-started)。
|
||||
3. 您熟悉文档站点,能够根据[文档 README](https://github.com/nacos-group/nacos-group.github.io) 完成本地调试。
|
||||
|
||||
## 代码审查指南
|
||||
|
||||
Committer 会轮流审查代码,确保所有 PR 在合并前至少经过一名 Committer 的及时审核。如果我们有所疏漏,欢迎随时提醒。同时,我们也欢迎志愿者参与代码审查。
|
||||
|
||||
### 审查原则
|
||||
|
||||
- **可读性**:重要的代码应有完善的文档。API 应有 Javadoc。代码风格应与现有代码保持一致。
|
||||
- **优雅性**:新的函数、类或组件应当设计良好。
|
||||
- **可测试性**:新代码应有 80% 的单元测试覆盖率。
|
||||
- **可维护性**:遵守我们的[代码规范](style/codeStyle.md),至少每 3 个月更新一次。
|
||||
|
||||
## 成为 Committer
|
||||
|
||||
我们始终欢迎新的贡献者加入。我们看重的是持续的贡献、良好的品味和对项目的持续兴趣。如果您有兴趣成为 Committer,请联系现有的 Committer,他们可以帮助您完成这个过程。
|
||||
|
||||
### 重要贡献领域
|
||||
|
||||
- Wiki 和 JavaDoc
|
||||
- Nacos Console
|
||||
- Nacos SDK(C++、.NET、PHP、Python、Go、Node.js)
|
||||
|
||||
### 成为 Committer 的前提条件
|
||||
|
||||
- **可读性**:API 和重要方法必须有 Javadoc。
|
||||
- **可测试性**:确保主要流程的单元测试覆盖率超过 80%。
|
||||
- **可维护性**:遵守我们的[代码规范](style/codeStyle.md),至少每 3 个月更新一次。
|
||||
- **可部署性**:我们鼓励您部署到 [Maven 仓库](http://search.maven.org/)。
|
||||
|
||||
### 提名流程
|
||||
|
||||
一般来说,需要贡献 8 个非琐碎的补丁,并获得至少三个不同的人来审核(您需要三个人的支持)。然后请人提名您。您需要展示:
|
||||
|
||||
- 至少为项目贡献了 8 个 PR 和对应的 Issue
|
||||
- 能够与团队协作
|
||||
- 了解项目代码库和编码风格
|
||||
- 能够编写高质量的代码
|
||||
|
||||
Committer 通过在带有 "nomination" 标签的 Nacos Issue 中通知团队来提名您,需要包含:
|
||||
|
||||
- 您的姓名
|
||||
- 您的 Git 主页链接
|
||||
- 解释您为何应该成为 Committer
|
||||
- 详细说明提名者与您合作过的前 3 个 PR 和对应 Issue,以证明您的能力
|
||||
|
||||
需要另外两名 Committer 附议您的提名。如果 5 个工作日(中国时间)内没有人反对,您就是 Committer 了。如果有人反对或需要更多信息,Committer 们会进行讨论并通常在 5 个工作日内达成共识。如果问题无法解决,将在现有 Committer 中进行投票。
|
||||
|
||||

|
||||
|
||||
在最坏的情况下,这个过程可能会持续两周。请继续贡献!即使在提名失败的罕见情况下,反对意见通常也是容易解决的,比如"需要更多补丁"或"没有足够的人熟悉此人的工作"。
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,132 @@
|
||||
# ----------------------------------------------------------------------------
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# Makefile usage guide: https://github.com/alibaba/nacos/issues/15338
|
||||
|
||||
# Set shell to bash for better compatibility
|
||||
SHELL := /usr/bin/env bash
|
||||
|
||||
# Mark targets as phony (not actual files)
|
||||
.PHONY: help
|
||||
# Set default target when running 'make' without arguments
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
# Display help information with colored output
|
||||
# Parses comments with '##' and formats them nicely
|
||||
help: ## Show help information
|
||||
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-48s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
# Maven command configuration
|
||||
# Prefer using system mvn, fall back to ./mvnw wrapper if mvn does not exist
|
||||
MVN ?= $(shell command -v mvn >/dev/null 2>&1 && echo "mvn" || echo "./mvnw")
|
||||
# Maven arguments: -T 4C (parallel build with 4 threads per CPU core)
|
||||
# -e (show error stack traces)
|
||||
# -B (batch mode, non-interactive)
|
||||
# -V (show version information)
|
||||
MAVEN_ARGS ?= -T 4C -e -B -V
|
||||
|
||||
JVM_BASE_ARGS := --add-opens java.base/java.util=ALL-UNNAMED
|
||||
AUTH_DISABLED_ARGS := -Dnacos.core.auth.enabled=false \
|
||||
-Dnacos.core.auth.admin.enabled=false \
|
||||
-Dnacos.core.auth.console.enabled=false
|
||||
AUTH_ARGS := -Dnacos.core.auth.server.identity.key=testKey \
|
||||
-Dnacos.core.auth.server.identity.value=testValue \
|
||||
-Dnacos.core.auth.plugin.nacos.token.secret.key=VGhpc0lzTXlDdXN0b21TZWNyZXRLZXkwMTIzNDU2Nzg= \
|
||||
$(AUTH_DISABLED_ARGS)
|
||||
|
||||
# Mark additional targets as phony
|
||||
.PHONY: clean test check-maven build-maven-test build-frontend build-maven build \
|
||||
install-and-run-bootstrap \
|
||||
install-and-run-bootstrap-config \
|
||||
install-and-run-bootstrap-naming \
|
||||
install-and-run-bootstrap-microservice \
|
||||
install-and-run-bootstrap-ai \
|
||||
install-and-run-bootstrap-extension-ai-enabled \
|
||||
install-and-run-bootstrap-extension-ai-disabled \
|
||||
run-it-tests \
|
||||
run-java-sdk-it-tests \
|
||||
run-maintainer-sdk-it-tests
|
||||
|
||||
# Clean all build artifacts and generated files
|
||||
clean: ## Clean the project
|
||||
$(MVN) $(MAVEN_ARGS) clean
|
||||
|
||||
test: ## Run unit tests
|
||||
$(MVN) $(MAVEN_ARGS) test
|
||||
|
||||
build-frontend: ## Build frontend (console-ui and console-ui-next)
|
||||
@echo "Building console-ui..."
|
||||
cd console-ui && npm i && npm run build
|
||||
@echo "Building console-ui-next..."
|
||||
cd console-ui-next && npm i && npm run build
|
||||
|
||||
build-maven: ## Build Maven project (skip tests, activation profile release-nacos)
|
||||
@echo "Building and Install..."
|
||||
$(MVN) $(MAVEN_ARGS) clean install -Prelease-nacos -DskipTests
|
||||
|
||||
check-maven: ## Run pre-submission checks (compile, rat, checkstyle, spotbugs, spotless)
|
||||
$(MVN) $(MAVEN_ARGS) clean compile apache-rat:check checkstyle:check spotbugs:check spotless:check -e -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn
|
||||
|
||||
build-maven-test: ## Build Maven project with tests (skip rat, spotbugs checks)
|
||||
$(MVN) $(MAVEN_ARGS) '-Prelease-nacos,!dev' clean install -Drat.skip=true -Dspotbugs.skip=true -DtrimStackTrace=false -U -e -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn
|
||||
|
||||
build: build-frontend build-maven ## Build both frontend and Maven project
|
||||
|
||||
# Build and run Nacos in development mode
|
||||
# Steps:
|
||||
# 1. Build console-ui (old React UI)
|
||||
# 2. Build console-ui-next (new React UI)
|
||||
# 3. Maven clean install (skip tests for faster build)
|
||||
# 4. Run bootstrap module with Spring Boot
|
||||
install-and-run-bootstrap: build ## Build and run Nacos bootstrap module
|
||||
cd bootstrap && $(MVN) $(MAVEN_ARGS) spring-boot:run -Prelease-nacos -DskipTests \
|
||||
-Dspring-boot.run.jvmArguments="$(JVM_BASE_ARGS) $(AUTH_ARGS) -Dnacos.standalone=true"
|
||||
|
||||
install-and-run-bootstrap-config: build ## Build and run Nacos in config mode
|
||||
cd bootstrap && $(MVN) $(MAVEN_ARGS) spring-boot:run -Prelease-nacos -DskipTests \
|
||||
-Dspring-boot.run.jvmArguments="$(JVM_BASE_ARGS) $(AUTH_ARGS) -Dnacos.standalone=true -Dnacos.functionMode=config"
|
||||
|
||||
install-and-run-bootstrap-naming: build ## Build and run Nacos in naming mode
|
||||
cd bootstrap && $(MVN) $(MAVEN_ARGS) spring-boot:run -Prelease-nacos -DskipTests \
|
||||
-Dspring-boot.run.jvmArguments="$(JVM_BASE_ARGS) $(AUTH_ARGS) -Dnacos.standalone=true -Dnacos.functionMode=naming"
|
||||
|
||||
install-and-run-bootstrap-microservice: build ## Build and run Nacos in microservice mode
|
||||
cd bootstrap && $(MVN) $(MAVEN_ARGS) spring-boot:run -Prelease-nacos -DskipTests \
|
||||
-Dspring-boot.run.jvmArguments="$(JVM_BASE_ARGS) $(AUTH_ARGS) -Dnacos.standalone=true -Dnacos.functionMode=microservice"
|
||||
|
||||
install-and-run-bootstrap-ai: build ## Build and run Nacos in AI mode
|
||||
cd bootstrap && $(MVN) $(MAVEN_ARGS) spring-boot:run -Prelease-nacos -DskipTests \
|
||||
-Dspring-boot.run.jvmArguments="$(JVM_BASE_ARGS) $(AUTH_ARGS) -Dnacos.standalone=true -Dnacos.functionMode=ai"
|
||||
|
||||
install-and-run-bootstrap-extension-ai-enabled: build ## Build and run Nacos with nacos.extension.ai.enabled=true
|
||||
cd bootstrap && $(MVN) $(MAVEN_ARGS) spring-boot:run -Prelease-nacos -DskipTests \
|
||||
-Dspring-boot.run.jvmArguments="$(JVM_BASE_ARGS) $(AUTH_ARGS) -Dnacos.standalone=true -Dnacos.extension.ai.enabled=true"
|
||||
|
||||
install-and-run-bootstrap-extension-ai-disabled: build ## Build and run Nacos with nacos.extension.ai.enabled=false
|
||||
cd bootstrap && $(MVN) $(MAVEN_ARGS) spring-boot:run -Prelease-nacos -DskipTests \
|
||||
-Dspring-boot.run.jvmArguments="$(JVM_BASE_ARGS) $(AUTH_ARGS) -Dnacos.standalone=true -Dnacos.extension.ai.enabled=false"
|
||||
|
||||
run-it-tests: ## Run IT Tests
|
||||
cd test && $(MVN) $(MAVEN_ARGS) clean verify -Pintegration-test
|
||||
|
||||
run-java-sdk-it-tests: ## Run Java SDK IT Tests
|
||||
$(MVN) $(MAVEN_ARGS) -pl test/java-sdk-test clean verify -Pjava-sdk-integration-test -DskipTests=false
|
||||
|
||||
run-maintainer-sdk-it-tests: ## Run Maintainer SDK IT Tests
|
||||
$(MVN) $(MAVEN_ARGS) -pl test/maintainer-sdk-test clean verify -Pmaintainer-sdk-integration-test -DskipTests=false
|
||||
@@ -0,0 +1,108 @@
|
||||
Nacos
|
||||
Copyright 2018-2020
|
||||
|
||||
This product includes software developed at
|
||||
The Alibaba MiddleWare Group.
|
||||
|
||||
------
|
||||
This product has a bundle Spring Boot:
|
||||
The Spring Boot Project
|
||||
=================
|
||||
|
||||
Please visit the Spring Boot web site for more information:
|
||||
|
||||
* https://spring.io/projects/spring-boot
|
||||
|
||||
Copyright 2014 The Spring Boot Project
|
||||
|
||||
The Spring Boot Project licenses this file to you under the Apache License,
|
||||
version 2.0 (the "License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at:
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
License for the specific language governing permissions and limitations
|
||||
under the License.
|
||||
|
||||
Also, please refer to each LICENSE.<component>.txt file, which is located in
|
||||
the 'license' directory of the distribution file, for the license terms of the
|
||||
components that this product depends on.
|
||||
|
||||
------
|
||||
This product has a bundle Spring Framework:
|
||||
Spring Framework
|
||||
=================
|
||||
|
||||
Please visit the git for more information:
|
||||
|
||||
* https://github.com/spring-projects/spring-framework.git
|
||||
|
||||
Copyright 2002-2020 the original author or authors.
|
||||
|
||||
The Spring Framework licenses this file to you under the Apache License,
|
||||
version 2.0 (the "License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at:
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
License for the specific language governing permissions and limitations
|
||||
under the License.
|
||||
|
||||
Also, please refer to each LICENSE.<component>.txt file, which is located in
|
||||
the 'license' directory of the distribution file, for the license terms of the
|
||||
components that this product depends on.
|
||||
|
||||
------
|
||||
com.alibaba.nacos.common.utils.InetAddressValidator.java in this product is
|
||||
copied from com.dynatrace.openkit.core.util.InetAddressValidator.java of openkit-java project.
|
||||
https://github.com/Dynatrace/openkit-java
|
||||
openkit-java
|
||||
======================
|
||||
|
||||
Copyright 2018-2021 Dynatrace LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
------
|
||||
This product has a bundle Protocol Buffers��
|
||||
Protocol Buffers
|
||||
=======================
|
||||
Protocol Buffers for Go with Gadgets
|
||||
|
||||
Copyright (c) 2013, The GoGo Authors. All rights reserved.
|
||||
http://github.com/gogo/protobuf
|
||||
|
||||
------
|
||||
This product has a bundle Istio��
|
||||
Istio
|
||||
=======================
|
||||
|
||||
Copyright 2018 Istio Authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,257 @@
|
||||
|
||||
<img src="doc/Nacos_Logo.png" width="50%" syt height="50%" />
|
||||
|
||||
# Nacos: Dynamic *Na*ming and *Co*nfiguration *S*ervice
|
||||
|
||||
[](https://gitter.im/alibaba/nacos?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [](https://www.apache.org/licenses/LICENSE-2.0.html)
|
||||
[](https://github.com/alibaba/nacos/actions/workflows/ci.yml)
|
||||
[](https://app.codecov.io/gh/alibaba/nacos/tree/develop)
|
||||
[](https://opensource.alibaba.com/contribution_leaderboard/details?projectValue=nacos)
|
||||
[](https://deepwiki.com/alibaba/nacos)
|
||||
|
||||
-------
|
||||
|
||||
## What does it do
|
||||
|
||||
Nacos (official site: [nacos.io](https://nacos.io)) is an easy-to-use platform designed for dynamic service discovery and configuration and service management. It helps you to build cloud native applications and microservices platform easily.
|
||||
|
||||
Service is a first-class citizen in Nacos. Nacos supports almost all type of services,for example,[Dubbo/gRPC service](https://nacos.io/docs/latest/ecology/use-nacos-with-dubbo/), [Spring Cloud RESTFul service](https://nacos.io/docs/latest/ecology/use-nacos-with-spring-cloud/) or [Kubernetes service](https://nacos.io/docs/latest/quickstart/quick-start-kubernetes/).
|
||||
|
||||
Nacos provides four major functions.
|
||||
|
||||
* **Service Discovery and Service Health Check**
|
||||
|
||||
Nacos makes it simple for services to register themselves and to discover other services via a DNS or HTTP interface. Nacos also provides real-time health checks of services to prevent sending requests to unhealthy hosts or service instances.
|
||||
|
||||
* **Dynamic Configuration Management**
|
||||
|
||||
Dynamic Configuration Service allows you to manage configurations of all services in a centralized and dynamic manner across all environments. Nacos eliminates the need to redeploy applications and services when configurations are updated, which makes configuration changes more efficient and agile.
|
||||
|
||||
* **Dynamic DNS Service**
|
||||
|
||||
Nacos supports weighted routing, making it easier for you to implement mid-tier load balancing, flexible routing policies, flow control, and simple DNS resolution services in the production environment within your data center. It helps you to implement DNS-based service discovery easily and prevent applications from coupling to vendor-specific service discovery APIs.
|
||||
|
||||
* **Service and MetaData Management**
|
||||
|
||||
Nacos provides an easy-to-use service dashboard to help you manage your services metadata, configuration, kubernetes DNS, service health and metrics statistics.
|
||||
|
||||
|
||||
## Quick Start
|
||||
It is super easy to get started with your first project.
|
||||
|
||||
### Deploying Nacos on cloud
|
||||
|
||||
You can deploy Nacos on cloud, which is the easiest and most convenient way to start Nacos.
|
||||
|
||||
Use the following [Nacos deployment guide](https://cn.aliyun.com/product/aliware/mse?spm=nacos-website.topbar.0.0.0) to see more information and deploy a stable and out-of-the-box Nacos server.
|
||||
|
||||
|
||||
### Start by the provided startup package
|
||||
|
||||
#### Step 1: Download the binary package
|
||||
|
||||
You can download the package from the [latest stable release](https://github.com/alibaba/nacos/releases).
|
||||
|
||||
Take release `nacos-server-1.0.0.zip` for example:
|
||||
```sh
|
||||
unzip nacos-server-1.0.0.zip
|
||||
cd nacos/bin
|
||||
```
|
||||
|
||||
#### Step 2: Start Server
|
||||
|
||||
On the **Linux/Unix/Mac** platform, run the following command to start server with standalone mode:
|
||||
```sh
|
||||
sh startup.sh -m standalone
|
||||
```
|
||||
|
||||
On the **Windows** platform, run the following command to start server with standalone mode. Alternatively, you can also double-click the `startup.cmd` to run NacosServer.
|
||||
```
|
||||
startup.cmd -m standalone
|
||||
```
|
||||
|
||||
For more details, see [quick-start.](https://nacos.io/docs/latest/quickstart/quick-start/)
|
||||
|
||||
## Quick start for other open-source projects:
|
||||
* [Quick start with Nacos command and console](https://nacos.io/docs/latest/quickstart/quick-start/)
|
||||
|
||||
* [Quick start with dubbo](https://nacos.io/docs/latest/ecology/use-nacos-with-dubbo/)
|
||||
|
||||
* [Quick start with spring cloud](https://nacos.io/docs/latest/ecology/use-nacos-with-spring-cloud/)
|
||||
|
||||
* [Quick start with kubernetes](https://nacos.io/docs/latest/quickstart/quick-start-kubernetes/)
|
||||
|
||||
|
||||
## Documentation
|
||||
|
||||
You can view the full documentation from the [Nacos website](https://nacos.io/docs/latest/overview/).
|
||||
|
||||
You can also read this online eBook from the [NACOS ARCHITECTURE & PRINCIPLES](https://nacos.io/docs/ebook/kbyo6n/).
|
||||
|
||||
All the latest and long-term notice can also be found here from [GitHub notice issue](https://github.com/alibaba/nacos/labels/notice).
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributors are welcomed to join Nacos project. Please check [CONTRIBUTING](./CONTRIBUTING.md) about how to contribute to this project.
|
||||
|
||||
### How can I contribute?
|
||||
|
||||
* Take a look at issues with tags marked [`good first issue`](https://github.com/alibaba/nacos/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) or [`contribution welcome`](https://github.com/alibaba/nacos/issues?q=is%3Aopen+is%3Aissue+label%3A%22contribution+welcome%22).
|
||||
* Answer questions on [issues](https://github.com/alibaba/nacos/issues).
|
||||
* Fix bugs reported on [issues](https://github.com/alibaba/nacos/issues), and send us a pull request.
|
||||
* Review the existing [pull request](https://github.com/alibaba/nacos/pulls).
|
||||
* Improve the [website](https://github.com/nacos-group/nacos-group.github.io), typically we need
|
||||
* blog post
|
||||
* translation on documentation
|
||||
* use cases around the integration of Nacos in enterprise systems.
|
||||
|
||||
## Other Related Project Repositories
|
||||
|
||||
* [nacos-spring-project](https://github.com/nacos-group/nacos-spring-project) provides the integration functionality for Spring.
|
||||
* [nacos-group](https://github.com/nacos-group) is the repository that hosts the eco tools for Nacos, such as SDK, synchronization tool, etc.
|
||||
* [spring-cloud-alibaba](https://github.com/spring-cloud-incubator/spring-cloud-alibaba) provides the one-stop solution for application development over Alibaba middleware which includes Nacos.
|
||||
|
||||
## Contact
|
||||
|
||||
* [Gitter](https://gitter.im/alibaba/nacos): Nacos's IM tool for community messaging, collaboration and discovery.
|
||||
* [Twitter](https://twitter.com/nacos2): Follow along for latest nacos news on Twitter.
|
||||
* [Weibo](https://weibo.com/u/6574374908): Follow along for latest nacos news on Weibo (Twitter of China version).
|
||||
* [Nacos Segmentfault](https://segmentfault.com/t/nacos): Get latest notice and prompt help from Segmentfault.
|
||||
* Email Group:
|
||||
* users-nacos@googlegroups.com: Nacos usage general discussion.
|
||||
* dev-nacos@googlegroups.com: Nacos developer discussion (APIs, feature design, etc).
|
||||
* commits-nacos@googlegroups.com: Commits notice, very high frequency.
|
||||
* Join us from DingDing(Group 1: 21708933(full), Group 2: 30438813(full), Group 3: 31222241(full), Group 4: 12810027056).
|
||||
|
||||
### DingDing Group QR Code
|
||||
|
||||

|
||||
|
||||
### DingDing MCP Group QR Code
|
||||
|
||||

|
||||
|
||||
### WeChat Group QR Code
|
||||
|
||||

|
||||
|
||||
## Enterprise Service
|
||||
If you need Nacos enterprise service support, or purchase cloud product services, you can join the discussion by scanning the following DingTalk group. It can also be directly activated and used through the microservice engine (MSE) provided by Alibaba Cloud.
|
||||
https://cn.aliyun.com/product/aliware/mse?spm=nacos-website.topbar.0.0.0
|
||||
|
||||
<img src="https://img.alicdn.com/imgextra/i3/O1CN01RTfN7q1KUzX4TcH08_!!6000000001168-2-tps-864-814.png" width="500">
|
||||
|
||||
|
||||
## Download
|
||||
|
||||
- [Nacos Official Website](https://nacos.io/download/nacos-server)
|
||||
- [GitHub Release](https://github.com/alibaba/nacos/releases)
|
||||
|
||||
## Who is using
|
||||
|
||||
These are only part of the companies using Nacos, for reference only. If you are using Nacos, please [add your company here](https://github.com/alibaba/nacos/issues/273) to tell us your scenario to make Nacos better.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="https://data.alibabagroup.com/ecms-files/886024452/296d05a1-c52a-4f5e-abf2-0d49d4c0d6b3.png" alt="Alibaba Group" width="180" height="120"></td>
|
||||
<td><img src="https://a.msstatic.com/huya/main/img/logo.png" alt="虎牙直播" width="180" height="120"></td>
|
||||
<td><img src="https://v.icbc.com.cn/userfiles/Resources/ICBC/shouye/images/2017/logo.png" alt="ICBC" width="180" height="120"></td>
|
||||
<td><img src="https://pic2.iqiyipic.com/lequ/20220422/e7fe69c75e2541f2a931c9e538e2ab9d.jpg" alt="爱奇艺" width="180" height="120"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1pwi9EwHqK1RjSZJnXXbNLpXa-479-59.png" alt="平安科技" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1MZWSEzDpK1RjSZFrXXa78VXa-269-69.png" alt="华夏信财" width="180" height="120"></td>
|
||||
<td><img src="https://www.urwork.cn/public/images/ui/logo.png" alt="优客工场" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1ebu.EAvoK1RjSZFwXXciCFXa-224-80.png" alt="贝壳找房" width="180" height="120"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1lxu7EBLoK1RjSZFuXXXn0XXa-409-74.png" alt="瑞安农村商业银行" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1L16eEzTpK1RjSZKPXXa3UpXa-302-50.png" alt="司法大数据" width="180" height="120"></td>
|
||||
<td><img src="https://www.souyidai.com/www-style/images/logo.gif" alt="搜易贷" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1OigyDyLaK1RjSZFxXXamPFXa-168-70.png" alt="平行云" width="180" height="120"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1gJ4vIhTpK1RjSZR0XXbEwXXa-462-60.jpg" alt="甘肃紫光" width="180" height="120"></td>
|
||||
<td><img src="http://www.seaskylight.com/cn/uploadfiles/image/logo.png" alt="海云天" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1DZWSEzDpK1RjSZFrXXa78VXa-240-62.png" alt="Acmedcare+" width="180" height="120"></td>
|
||||
<td><img src="https://14605854.s21i.faiusr.com/4/ABUIABAEGAAg4OvkzwUo8b-qlwUwxQ449gM!300x300.png" alt="北京天合互联信息有限公司" width="180" height="120"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="http://www.mwclg.com/static-resource/front/images/home/img_logo_nav.png" alt="上海密尔克卫化工" width="180" height="120"></td>
|
||||
<td><img src="https://www.synwe.com/logo-full.png" alt="大连新唯" width="180" height="120"></td>
|
||||
<td><img src="https://user-images.githubusercontent.com/10215557/51593180-7563af00-1f2c-11e9-95b1-ec2c645d6a0b.png" alt="立思辰" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1zWW2EpYqK1RjSZLeXXbXppXa-262-81.png" alt="东家" width="180" height="120"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="http://www.sh-guiyao.com/images/logo.jpg" alt="上海克垚" width="180" height="120"></td>
|
||||
<td><img src="http://www.lckjep.com:80//theme/img/logoTop.png" alt="联采科技" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1G216EsbpK1RjSZFyXXX_qFXa-325-53.jpg" alt="南京28研究所" width="180" height="120"></td>
|
||||
<td><img src="https://p1.ifengimg.com/auto/image/2017/0922/auto_logo.png" alt="凤凰网-汽车" width="180" height="120"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="http://www.sinochemitech.com/zhxx/lib/images/-logo.png" alt="中化信息" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1DXerNgDqK1RjSZSyXXaxEVXa-333-103.png" alt="一点车" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1VfOANgHqK1RjSZFPXXcwapXa-313-40.png" alt="明传无线" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1lvCyNhTpK1RjSZFMXXbG_VXa-130-60.png" alt="妙优车" width="180" height="120"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1kY9qNgTqK1RjSZPhXXXfOFXa-120-50.png" alt="蜂巢" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1G.GBNbrpK1RjSZTEXXcWAVXa-234-65.png" alt="华存数据" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1qsurNgDqK1RjSZSyXXaxEVXa-300-90.png" alt="数云" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB13aywNhTpK1RjSZR0XXbEwXXa-98-38.png" alt="广通软件" width="180" height="120"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1xqmBNjTpK1RjSZKPXXa3UpXa-162-70.png" alt="菜菜" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB18DmINcfpK1RjSZFOXXa6nFXa-200-200.png" alt="科蓝公司" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB15uqANXzqK1RjSZFoXXbfcXXa-188-86.png" alt="浩鲸" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1mvmyNkvoK1RjSZPfXXXPKFXa-238-46.png" alt="未名天日语" width="180" height="120"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1PSWsNmrqK1RjSZK9XXXyypXa-195-130.jpg" alt="金联创" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1k1qzNbvpK1RjSZFqXXcXUVXa-160-69.png" alt="同窗链" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1HdyvNmzqK1RjSZFLXXcn2XXa-143-143.jpg" alt="顺能" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1UdaGNgHqK1RjSZJnXXbNLpXa-277-62.png" alt="百世快递" width="180" height="120"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB17OqENbrpK1RjSZTEXXcWAVXa-240-113.jpg" alt="汽车之家" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1q71ANkvoK1RjSZPfXXXPKFXa-257-104.png" alt="鲸打卡" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1UzuyNhTpK1RjSZR0XXbEwXXa-201-86.jpg" alt="时代光华" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB19RCANgHqK1RjSZFPXXcwapXa-180-180.jpg" alt="康美" width="180" height="120"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1iCGyNb2pK1RjSZFsXXaNlXXa-143-143.jpg" alt="环球易购" width="180" height="120"></td>
|
||||
<td><img src="https://avatars0.githubusercontent.com/u/16344119?s=200&v=4" alt="Nepxion" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1aUe5EpzqK1RjSZSgXXcpAVXa-248-124.png" alt="chigua" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1H9O5EAvoK1RjSZFNXXcxMVXa-221-221.jpg" alt="宅无限" width="180" height="120"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1rNq4EwHqK1RjSZFgXXa7JXXa-200-200.jpg" alt="天阙" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1CRAxDxYaK1RjSZFnXXa80pXa-190-190.jpg" alt="联合永道" width="180" height="120"></td>
|
||||
<td><img src="https://img.alicdn.com/tfs/TB1.q14ErrpK1RjSZTEXXcWAVXa-219-219.jpg" alt="明源云" width="180" height="120"></td>
|
||||
<td><img src="https://www.daocloud.io/static/Logo-Light.png" alt="DaoCloud" width="180" height="120"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://www.meicai.cn/img/logo.9210b6eb.jpg" alt="美菜" width="180" height="120"></td>
|
||||
<td><img src="https://img5.tianyancha.com/logo/lll/3aad34039972b57e70874df8c919ae8b.png@!f_200x200" alt="松格科技" width="180" height="120"></td>
|
||||
<td><img src="https://www.jsic-tech.com/Public/uploads/20191206/5de9b9baac696.jpg" alt="集萃智能" width="180" height="120"></td>
|
||||
<td><img src="https://www.wuuxiang.com/theme/images/common/logo1.png" alt="吾享" width="180" height="120"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="http://www.tpson.cn/static/upload/image/20230111/1673427385140440.png" alt="拓深科技" width="180" height="120"></td>
|
||||
<td><img src="https://www.sunline.cn/u_file/fileUpload/2021-06/25/2021062586431.png" alt="长亮科技" width="180" height="120"></td>
|
||||
<td><img src="http://pmt2f499f.pic44.websiteonline.cn/upload/wv0c.png" alt="深圳易停车库" width="180" height="120"></td>
|
||||
<td><img src="http://www.dragonwake.cn/static/css/default/img/logo.png" alt="武汉日创科技" width="180" height="120"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://i4im-web.oss-cn-shanghai.aliyuncs.com/images/logo.png" alt="易管智能" width="180" height="120"></td>
|
||||
<td><img src="https://www.yunzhangfang.com/assets/img/logo.4096cf52.png" alt="云帐房" width="180" height="120"></td>
|
||||
<td><img src="https://www.sinocare.com/sannuo/templates/web/img/bocweb-logo.svg" alt="三诺生物" width="180" height="120"></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>郑州山水</td>
|
||||
<td>知氏教育</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`alibaba/nacos`
|
||||
- 原始仓库:https://github.com/alibaba/nacos
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1,39 @@
|
||||
# How to report bugs
|
||||
|
||||
If any part of the Nacos project has bugs or documentation mistakes, please let us know by [opening an issue][Nacos-issue]. We treat bugs and mistakes very seriously and believe no issue is too small, anyone is implement. Before creating a bug report, please check that an issue reporting the same problem does not already exist.
|
||||
|
||||
To make the bug report accurate and easy to understand, please try to create bug reports that are:
|
||||
|
||||
- Specific. Include as many details as possible: which version, what environment, what configuration, etc. If the bug is related to running the Nacos server, please attach the Nacos log (the starting log with Nacos configuration is especially important).
|
||||
|
||||
- Reproducible. Include the steps to reproduce the problem. We understand some issues might be hard to reproduce, please includes the steps that might lead to the problem. If possible, please attach the affected Nacos data dir and stack strace to the bug report.
|
||||
|
||||
- Unique. Do not duplicate the existing bug report.
|
||||
|
||||
|
||||
It may be worthwhile to read [Elika Etemad’s article on filing good bug reports][filing-good-bugs] before creating a bug report.
|
||||
|
||||
We might ask for further information to locate a bug. A duplicated bug report will be closed.
|
||||
|
||||
[etcd-issue]: https://github.com/etcd-io/etcd/issues/new
|
||||
[filing-good-bugs]: http://fantasai.inkedblade.net/style/talks/filing-good-bugs/
|
||||
|
||||
|
||||
# 如何提交错误报告
|
||||
|
||||
如果Nacos项目的任何部分存在问题或文档问题,请通过[opening an issue][Nacos-issue]告诉我们。我们非常认真地对待错误和缺陷,在产品面前没有不重要的问题。不过在创建错误报告之前,请检查是否存在报告相同问题的issues。
|
||||
|
||||
为了使错误报告准确且易于理解,请尝试创建以下错误报告:
|
||||
|
||||
- 具体到细节。包括尽可能多的细节:哪个版本,什么环境,什么配置等。如果错误与运行Nacos服务器有关,请附加Nacos日志(具有Nacos配置的起始日志尤为重要)。
|
||||
|
||||
- 可复现。包括重现问题的步骤。我们理解某些问题可能难以重现,请包括可能导致问题的步骤。如果可能,请将受影响的Nacos数据目录和堆栈strace附加到错误报告中。
|
||||
|
||||
- 不重复。不要复制现有的错误报告。
|
||||
|
||||
在创建错误报告之前,最好阅读下[Elika Etemad关于提交好错误报告的文章] [归档好错误],相信 会给你启发。
|
||||
|
||||
我们可能会要求您提供更多信息以查找错误。将关闭重复的错误报告。
|
||||
|
||||
[etcd-issue]:https://github.com/etcd-io/etcd/issues/new
|
||||
[filing-good-bugs]:http://fantasai.inkedblade.net/style/talks/filing-good-bugs/
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ Copyright 1999-2018 Alibaba Group Holding Ltd.
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<parent>
|
||||
<artifactId>nacos-all</artifactId>
|
||||
<groupId>com.alibaba.nacos</groupId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<artifactId>nacos-address</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>nacos-address ${project.version}</name>
|
||||
<url>https://nacos.io</url>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>nacos-naming</artifactId>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>nacos-cmdb</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
</resource>
|
||||
</resources>
|
||||
</build>
|
||||
|
||||
<reporting>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>com.github.spotbugs</groupId>
|
||||
<artifactId>spotbugs-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</reporting>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>release-address</id>
|
||||
<build>
|
||||
<finalName>nacos-address</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifest>
|
||||
<addDefaultImplementationEntries>true</addDefaultImplementationEntries>
|
||||
<addDefaultSpecificationEntries>true</addDefaultSpecificationEntries>
|
||||
</manifest>
|
||||
</archive>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<mainClass>com.alibaba.nacos.address.AddressServer</mainClass>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 1999-2018 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.address;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* support address server.
|
||||
*
|
||||
* @author nacos
|
||||
* @since 1.1.0
|
||||
*/
|
||||
@SpringBootApplication(scanBasePackages = "com.alibaba.nacos")
|
||||
public class AddressServer {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
SpringApplication.run(AddressServer.class, args);
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright 1999-2018 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.address.component;
|
||||
|
||||
import com.alibaba.nacos.address.constant.AddressServerConstants;
|
||||
import com.alibaba.nacos.api.common.Constants;
|
||||
import com.alibaba.nacos.api.naming.pojo.Instance;
|
||||
import com.alibaba.nacos.common.utils.InternetAddressUtil;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* will generator some result by the input parameter.
|
||||
*
|
||||
* @author pbting
|
||||
* @date 2019-07-01 8:53 PM
|
||||
* @since 1.1.0
|
||||
*/
|
||||
@Component
|
||||
public class AddressServerGeneratorManager {
|
||||
|
||||
/**
|
||||
* Generate product name.
|
||||
*
|
||||
* @param name name
|
||||
* @return product
|
||||
*/
|
||||
public String generateProductName(String name) {
|
||||
|
||||
if (StringUtils.isBlank(name) || AddressServerConstants.DEFAULT_PRODUCT.equals(name)) {
|
||||
|
||||
return AddressServerConstants.ALIWARE_NACOS_DEFAULT_PRODUCT_NAME;
|
||||
}
|
||||
|
||||
return String.format(AddressServerConstants.ALIWARE_NACOS_PRODUCT_DOM_TEMPLATE, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: if the parameter inputted is empty then will return the empty list.
|
||||
*
|
||||
* @param serviceName service name
|
||||
* @param clusterName cluster name
|
||||
* @param ipArray array of ips
|
||||
* @return instance list
|
||||
*/
|
||||
public List<Instance> generateInstancesByIps(String serviceName, String rawProductName,
|
||||
String clusterName,
|
||||
String[] ipArray) {
|
||||
if (StringUtils.isEmpty(serviceName) || StringUtils.isEmpty(clusterName) || ipArray == null
|
||||
|| ipArray.length == 0) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<Instance> instanceList = new ArrayList<>(ipArray.length);
|
||||
for (String ip : ipArray) {
|
||||
String[] ipAndPort = generateIpAndPort(ip);
|
||||
Instance instance = new Instance();
|
||||
instance.setIp(ipAndPort[0]);
|
||||
instance.setPort(Integer.parseInt(ipAndPort[1]));
|
||||
instance.setClusterName(clusterName);
|
||||
instance.setServiceName(serviceName);
|
||||
instance.setEphemeral(false);
|
||||
instance.getMetadata().put("app", rawProductName);
|
||||
instance.getMetadata().put("tenant", Constants.DEFAULT_NAMESPACE_ID);
|
||||
instanceList.add(instance);
|
||||
}
|
||||
|
||||
return instanceList;
|
||||
}
|
||||
|
||||
private String[] generateIpAndPort(String ip) {
|
||||
String[] result = InternetAddressUtil.splitIpPortStr(ip);
|
||||
if (result.length != InternetAddressUtil.SPLIT_IP_PORT_RESULT_LENGTH) {
|
||||
return new String[] {result[0],
|
||||
String.valueOf(AddressServerConstants.DEFAULT_SERVER_PORT)};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate response ips.
|
||||
*
|
||||
* @param instanceList an instance set will generate string response to client.
|
||||
* @return the result of response to client
|
||||
*/
|
||||
public String generateResponseIps(
|
||||
List<com.alibaba.nacos.api.naming.pojo.Instance> instanceList) {
|
||||
|
||||
StringBuilder ips = new StringBuilder();
|
||||
instanceList.forEach(instance -> {
|
||||
ips.append(instance.getIp()).append(':').append(instance.getPort());
|
||||
ips.append('\n');
|
||||
});
|
||||
|
||||
return ips.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate nacos service name.
|
||||
*
|
||||
* @param rawServiceName the raw service name will not contain the {@link Constants#DEFAULT_GROUP}.
|
||||
* @return the nacos service name
|
||||
*/
|
||||
public String generateNacosServiceName(String rawServiceName) {
|
||||
|
||||
if (rawServiceName.contains(Constants.DEFAULT_GROUP)) {
|
||||
return rawServiceName;
|
||||
}
|
||||
|
||||
return Constants.DEFAULT_GROUP + AddressServerConstants.GROUP_SERVICE_NAME_SEP
|
||||
+ rawServiceName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 1999-2018 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.address.component;
|
||||
|
||||
import com.alibaba.nacos.address.constant.AddressServerConstants;
|
||||
import com.alibaba.nacos.naming.misc.UtilsAndCommons;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* This class holds the IP list of the CAI's address service.
|
||||
*
|
||||
* @author deshao
|
||||
* @date 2016/4/28 20:58
|
||||
* @since 1.1.0
|
||||
*/
|
||||
@Component
|
||||
public class AddressServerManager {
|
||||
|
||||
public String getRawProductName(String name) {
|
||||
|
||||
if (StringUtils.isBlank(name)) {
|
||||
|
||||
return AddressServerConstants.DEFAULT_PRODUCT;
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the name is empty then return the default {@link UtilsAndCommons#DEFAULT_CLUSTER_NAME}, or return the source
|
||||
* name by input.
|
||||
*
|
||||
* @param name name
|
||||
* @return default cluster name
|
||||
*/
|
||||
public String getDefaultClusterNameIfEmpty(String name) {
|
||||
|
||||
if (StringUtils.isEmpty(name)) {
|
||||
return AddressServerConstants.DEFAULT_GET_CLUSTER;
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getRawClusterName(String name) {
|
||||
|
||||
return getDefaultClusterNameIfEmpty(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split ips.
|
||||
*
|
||||
* @param ips multi ip will separator by the ','
|
||||
* @return array of ip
|
||||
*/
|
||||
public String[] splitIps(String ips) {
|
||||
|
||||
if (StringUtils.isBlank(ips)) {
|
||||
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
return ips.split(AddressServerConstants.MULTI_IPS_SEPARATOR);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 1999-2022 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.address.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* nacos web security configuration.
|
||||
*
|
||||
* @author onewe
|
||||
*/
|
||||
@Configuration
|
||||
public class AddressServerSecurityConfiguration {
|
||||
|
||||
@Bean
|
||||
@Order(99)
|
||||
public SecurityFilterChain addressServerSecurityFilterChain(HttpSecurity http)
|
||||
throws Exception {
|
||||
http.authorizeHttpRequests(
|
||||
requestMatcherRegistry -> requestMatcherRegistry
|
||||
.requestMatchers("/nacos/v1/as/**").authenticated())
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.httpBasic(Customizer.withDefaults());
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 1999-2018 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.address.constant;
|
||||
|
||||
import com.alibaba.nacos.naming.misc.UtilsAndCommons;
|
||||
|
||||
/**
|
||||
* Uniform constant parameter naming for address servers and default values for related parameters.
|
||||
*
|
||||
* @author pbting
|
||||
* @date 2019-06-17 7:23 PM
|
||||
* @since 1.1.0
|
||||
*/
|
||||
public interface AddressServerConstants {
|
||||
|
||||
/**
|
||||
* the default server port when create the Instance object.
|
||||
*/
|
||||
int DEFAULT_SERVER_PORT = 8848;
|
||||
|
||||
/**
|
||||
* when post ips is not given the product,then use the default.
|
||||
*/
|
||||
String DEFAULT_PRODUCT = "nacos";
|
||||
|
||||
/**
|
||||
* the separator for service name between raw service name and group.
|
||||
*/
|
||||
String GROUP_SERVICE_NAME_SEP = "@@";
|
||||
|
||||
/**
|
||||
* when post ips is not given the cluster,then use the default.
|
||||
*/
|
||||
String DEFAULT_GET_CLUSTER = "serverlist";
|
||||
|
||||
/**
|
||||
* post multi ip will use the "," to separator.
|
||||
*/
|
||||
String MULTI_IPS_SEPARATOR = ",";
|
||||
|
||||
/**
|
||||
* the default product name when deploy nacos with naming and config.
|
||||
*/
|
||||
String ALIWARE_NACOS_DEFAULT_PRODUCT_NAME = "nacos.as.default";
|
||||
|
||||
/**
|
||||
* when the config and naming will separate deploy,then must specify product name by the client.
|
||||
*/
|
||||
String ALIWARE_NACOS_PRODUCT_DOM_TEMPLATE = "nacos.as.%s";
|
||||
|
||||
/**
|
||||
* the url for address server prefix.
|
||||
*/
|
||||
String ADDRESS_SERVER_REQUEST_URL =
|
||||
UtilsAndCommons.NACOS_SERVER_CONTEXT + UtilsAndCommons.NACOS_SERVER_VERSION + "/as";
|
||||
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* Copyright 1999-2018 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.address.controller;
|
||||
|
||||
import com.alibaba.nacos.api.annotation.Since;
|
||||
import com.alibaba.nacos.address.component.AddressServerGeneratorManager;
|
||||
import com.alibaba.nacos.address.component.AddressServerManager;
|
||||
import com.alibaba.nacos.address.constant.AddressServerConstants;
|
||||
import com.alibaba.nacos.address.misc.Loggers;
|
||||
import com.alibaba.nacos.api.common.Constants;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.api.naming.pojo.Instance;
|
||||
import com.alibaba.nacos.api.naming.pojo.healthcheck.AbstractHealthChecker;
|
||||
import com.alibaba.nacos.api.naming.utils.NamingUtils;
|
||||
import com.alibaba.nacos.common.utils.InternetAddressUtil;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
import com.alibaba.nacos.naming.core.ClusterOperator;
|
||||
import com.alibaba.nacos.naming.core.InstanceOperator;
|
||||
import com.alibaba.nacos.naming.core.v2.ServiceManager;
|
||||
import com.alibaba.nacos.naming.core.v2.metadata.ClusterMetadata;
|
||||
import com.alibaba.nacos.naming.core.v2.metadata.NamingMetadataManager;
|
||||
import com.alibaba.nacos.naming.core.v2.metadata.ServiceMetadata;
|
||||
import com.alibaba.nacos.naming.core.v2.pojo.Service;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Address server cluster controller.
|
||||
*
|
||||
* @author pbting
|
||||
* @since 1.1.0
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping({AddressServerConstants.ADDRESS_SERVER_REQUEST_URL + "/nodes"})
|
||||
public class AddressServerClusterController {
|
||||
|
||||
private final InstanceOperator instanceOperator;
|
||||
|
||||
private final NamingMetadataManager metadataManager;
|
||||
|
||||
private final ClusterOperator clusterOperator;
|
||||
|
||||
private final AddressServerManager addressServerManager;
|
||||
|
||||
private final AddressServerGeneratorManager addressServerGeneratorManager;
|
||||
|
||||
public AddressServerClusterController(InstanceOperator instanceOperator,
|
||||
NamingMetadataManager metadataManager,
|
||||
ClusterOperator clusterOperator, AddressServerManager addressServerManager,
|
||||
AddressServerGeneratorManager addressServerGeneratorManager) {
|
||||
this.instanceOperator = instanceOperator;
|
||||
this.metadataManager = metadataManager;
|
||||
this.clusterOperator = clusterOperator;
|
||||
this.addressServerManager = addressServerManager;
|
||||
this.addressServerGeneratorManager = addressServerGeneratorManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new cluster.
|
||||
*
|
||||
* @param product Ip list of products to be associated
|
||||
* @param cluster Ip list of product cluster to be associated
|
||||
* @param ips will post ip list.
|
||||
* @return result of create new cluster
|
||||
*/
|
||||
@Since("1.1.0")
|
||||
@RequestMapping(value = "", method = RequestMethod.POST)
|
||||
public ResponseEntity<String> postCluster(@RequestParam(required = false) String product,
|
||||
@RequestParam(required = false) String cluster, @RequestParam(name = "ips") String ips) {
|
||||
|
||||
//1. prepare the storage name for product and cluster
|
||||
String productName = addressServerGeneratorManager.generateProductName(product);
|
||||
String clusterName = addressServerManager.getDefaultClusterNameIfEmpty(cluster);
|
||||
|
||||
//2. prepare the response name for product and cluster to client
|
||||
String rawProductName = addressServerManager.getRawProductName(product);
|
||||
String rawClusterName = addressServerManager.getRawClusterName(cluster);
|
||||
Loggers.ADDRESS_LOGGER.info(
|
||||
"put cluster node,the cluster name is " + cluster + "; the product name=" + product
|
||||
+ "; the ip list=" + ips);
|
||||
ResponseEntity<String> responseEntity;
|
||||
try {
|
||||
String serviceName =
|
||||
addressServerGeneratorManager.generateNacosServiceName(productName);
|
||||
|
||||
Result result = registerCluster(serviceName, rawProductName, clusterName, ips);
|
||||
if (InternetAddressUtil.checkOk(result.getCheckResult())) {
|
||||
responseEntity = ResponseEntity
|
||||
.ok("product=" + rawProductName + ",cluster=" + rawClusterName
|
||||
+ "; put success with size="
|
||||
+ result.getSize());
|
||||
} else {
|
||||
responseEntity =
|
||||
ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result.getCheckResult());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
responseEntity =
|
||||
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage());
|
||||
}
|
||||
|
||||
return responseEntity;
|
||||
}
|
||||
|
||||
private Result registerCluster(String serviceName, String productName, String clusterName,
|
||||
String ips)
|
||||
throws NacosException {
|
||||
String serviceWithoutGroup = NamingUtils.getServiceName(serviceName);
|
||||
String groupName = NamingUtils.getGroupName(serviceName);
|
||||
Service service = Service.newService(Constants.DEFAULT_NAMESPACE_ID, groupName,
|
||||
serviceWithoutGroup, false);
|
||||
service = ServiceManager.getInstance().getSingleton(service);
|
||||
if (service.isEphemeral()) {
|
||||
return new Result(
|
||||
String.format("Service %s is ephemeral service, can't use as address server",
|
||||
serviceName),
|
||||
0);
|
||||
}
|
||||
ServiceMetadata serviceMetadata =
|
||||
metadataManager.getServiceMetadata(service).orElse(new ServiceMetadata());
|
||||
if (!serviceMetadata.getClusters().containsKey(clusterName)) {
|
||||
ClusterMetadata metadata = new ClusterMetadata();
|
||||
metadata.setHealthyCheckType(AbstractHealthChecker.None.TYPE);
|
||||
metadata.setHealthChecker(new AbstractHealthChecker.None());
|
||||
clusterOperator.updateClusterMetadata(Constants.DEFAULT_NAMESPACE_ID, serviceName,
|
||||
clusterName, metadata);
|
||||
}
|
||||
String[] ipArray = addressServerManager.splitIps(ips);
|
||||
String checkResult = InternetAddressUtil.checkIps(ipArray);
|
||||
if (InternetAddressUtil.checkOk(checkResult)) {
|
||||
List<Instance> instanceList = addressServerGeneratorManager
|
||||
.generateInstancesByIps(serviceName, productName, clusterName, ipArray);
|
||||
for (Instance instance : instanceList) {
|
||||
instanceOperator.registerInstance(Constants.DEFAULT_NAMESPACE_ID, serviceName,
|
||||
instance);
|
||||
}
|
||||
}
|
||||
return new Result(checkResult, ipArray.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cluster.
|
||||
*
|
||||
* @param product Ip list of products to be associated
|
||||
* @param cluster Ip list of product cluster to be associated
|
||||
* @param ips will delete ips.
|
||||
* @return delete result (the cluster information is return if success, exception information is return if fail)
|
||||
*/
|
||||
@Since("1.1.0")
|
||||
@RequestMapping(value = "", method = RequestMethod.DELETE)
|
||||
public ResponseEntity<String> deleteCluster(@RequestParam(required = false) String product,
|
||||
@RequestParam(required = false) String cluster, @RequestParam String ips) {
|
||||
//1. prepare the storage name for product and cluster
|
||||
String productName = addressServerGeneratorManager.generateProductName(product);
|
||||
String clusterName = addressServerManager.getDefaultClusterNameIfEmpty(cluster);
|
||||
|
||||
//2. prepare the response name for product and cluster to client
|
||||
String rawProductName = addressServerManager.getRawProductName(product);
|
||||
String rawClusterName = addressServerManager.getRawClusterName(cluster);
|
||||
ResponseEntity responseEntity = ResponseEntity.status(HttpStatus.OK)
|
||||
.body("product=" + rawProductName + ", cluster=" + rawClusterName + " delete success.");
|
||||
try {
|
||||
|
||||
String serviceName =
|
||||
addressServerGeneratorManager.generateNacosServiceName(productName);
|
||||
String serviceWithoutGroup = NamingUtils.getServiceName(serviceName);
|
||||
String groupName = NamingUtils.getGroupName(serviceName);
|
||||
Optional<com.alibaba.nacos.naming.core.v2.pojo.Service> service =
|
||||
com.alibaba.nacos.naming.core.v2.ServiceManager
|
||||
.getInstance().getSingletonIfExist(Constants.DEFAULT_NAMESPACE_ID, groupName,
|
||||
serviceWithoutGroup);
|
||||
|
||||
if (!service.isPresent()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body("product=" + rawProductName + " not found.");
|
||||
}
|
||||
if (StringUtils.isBlank(ips)) {
|
||||
// delete all ips from the cluster
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("ips must not be empty.");
|
||||
}
|
||||
// delete specified ip list
|
||||
String[] ipArray = addressServerManager.splitIps(ips);
|
||||
String checkResult = InternetAddressUtil.checkIps(ipArray);
|
||||
if (InternetAddressUtil.checkOk(checkResult)) {
|
||||
List<Instance> instanceList = addressServerGeneratorManager
|
||||
.generateInstancesByIps(serviceName, rawProductName, clusterName, ipArray);
|
||||
for (Instance each : instanceList) {
|
||||
instanceOperator.removeInstance(Constants.DEFAULT_NAMESPACE_ID, serviceName,
|
||||
each);
|
||||
}
|
||||
} else {
|
||||
responseEntity = ResponseEntity.status(HttpStatus.BAD_REQUEST).body(checkResult);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
|
||||
responseEntity =
|
||||
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getCause());
|
||||
}
|
||||
|
||||
return responseEntity;
|
||||
}
|
||||
|
||||
private class Result {
|
||||
|
||||
private final String checkResult;
|
||||
|
||||
private final int size;
|
||||
|
||||
public Result(String checkResult, int size) {
|
||||
this.checkResult = checkResult;
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
public String getCheckResult() {
|
||||
return checkResult;
|
||||
}
|
||||
|
||||
public int getSize() {
|
||||
return size;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright 1999-2018 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.address.controller;
|
||||
|
||||
import com.alibaba.nacos.api.annotation.Since;
|
||||
import com.alibaba.nacos.address.component.AddressServerGeneratorManager;
|
||||
import com.alibaba.nacos.api.common.Constants;
|
||||
import com.alibaba.nacos.api.naming.pojo.ServiceInfo;
|
||||
import com.alibaba.nacos.api.naming.utils.NamingUtils;
|
||||
import com.alibaba.nacos.naming.core.v2.ServiceManager;
|
||||
import com.alibaba.nacos.naming.core.v2.index.ServiceStorage;
|
||||
import com.alibaba.nacos.naming.core.v2.metadata.ClusterMetadata;
|
||||
import com.alibaba.nacos.naming.core.v2.metadata.NamingMetadataManager;
|
||||
import com.alibaba.nacos.naming.core.v2.metadata.ServiceMetadata;
|
||||
import com.alibaba.nacos.naming.core.v2.pojo.Service;
|
||||
import com.alibaba.nacos.naming.utils.ServiceUtil;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Server list controller.
|
||||
*
|
||||
* @author pbting
|
||||
* @since 1.1.0
|
||||
*/
|
||||
@RestController
|
||||
public class ServerListController {
|
||||
|
||||
private final AddressServerGeneratorManager addressServerBuilderManager;
|
||||
|
||||
private final NamingMetadataManager metadataManager;
|
||||
|
||||
private final ServiceStorage serviceStorage;
|
||||
|
||||
public ServerListController(AddressServerGeneratorManager addressServerBuilderManager,
|
||||
NamingMetadataManager metadataManager, ServiceStorage serviceStorage) {
|
||||
this.addressServerBuilderManager = addressServerBuilderManager;
|
||||
this.metadataManager = metadataManager;
|
||||
this.serviceStorage = serviceStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cluster.
|
||||
*
|
||||
* @param product will get Ip list of that products to be associated
|
||||
* @param cluster will get Ip list of that product cluster to be associated
|
||||
* @return result of get
|
||||
*/
|
||||
@Since("1.1.0")
|
||||
@RequestMapping(value = "/{product}/{cluster}", method = RequestMethod.GET)
|
||||
public ResponseEntity<String> getCluster(@PathVariable String product,
|
||||
@PathVariable String cluster) {
|
||||
|
||||
String productName = addressServerBuilderManager.generateProductName(product);
|
||||
String serviceName = addressServerBuilderManager.generateNacosServiceName(productName);
|
||||
String serviceWithoutGroup = NamingUtils.getServiceName(serviceName);
|
||||
String groupName = NamingUtils.getGroupName(serviceName);
|
||||
Optional<Service> service = ServiceManager.getInstance()
|
||||
.getSingletonIfExist(Constants.DEFAULT_NAMESPACE_ID, groupName, serviceWithoutGroup);
|
||||
if (!service.isPresent()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body("product=" + product + " not found.");
|
||||
}
|
||||
ClusterMetadata metadata =
|
||||
metadataManager.getServiceMetadata(service.get()).orElse(new ServiceMetadata())
|
||||
.getClusters().get(cluster);
|
||||
if (null == metadata) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body("product=" + product + ",cluster=" + cluster + " not found.");
|
||||
}
|
||||
ServiceInfo serviceInfo = serviceStorage.getData(service.get());
|
||||
serviceInfo = ServiceUtil.selectInstances(serviceInfo, cluster, false);
|
||||
return ResponseEntity.status(HttpStatus.OK)
|
||||
.body(addressServerBuilderManager.generateResponseIps(serviceInfo.getHosts()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 1999-2018 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.address.misc;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Loggers holder.
|
||||
*
|
||||
* @author pbting
|
||||
* @date 2019-07-04 4:34 PM
|
||||
*/
|
||||
public class Loggers {
|
||||
|
||||
public static final Logger ADDRESS_LOGGER =
|
||||
LoggerFactory.getLogger("com.alibaba.nacos.address.main");
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ Copyright 1999-2018 Alibaba Group Holding Ltd.
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<included>
|
||||
|
||||
<springProperty scope="context" name="logPath" source="nacos.logs.path" defaultValue="${user.home}/nacos/logs"/>
|
||||
<property name="LOG_HOME" value="${logPath}"/>
|
||||
|
||||
<appender name="nacos-address"
|
||||
class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_HOME}/nacos-address.log</file>
|
||||
<append>true</append>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_HOME}/nacos-address.log.%d{yyyy-MM-dd}.%i</fileNamePattern>
|
||||
<maxFileSize>2GB</maxFileSize>
|
||||
<maxHistory>15</maxHistory>
|
||||
<totalSizeCap>7GB</totalSizeCap>
|
||||
<cleanHistoryOnStart>true</cleanHistoryOnStart>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<Pattern>%date %level %msg%n%n</Pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<logger name="com.alibaba.nacos.address.main" additivity="false" level="INFO">
|
||||
<appender-ref ref="nacos-address"/>
|
||||
</logger>
|
||||
</included>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#
|
||||
# Copyright 1999-2018 Alibaba Group Holding Ltd.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
server.port=8080
|
||||
server.servlet.context-path=/
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright 1999-2018 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.address.component;
|
||||
|
||||
import com.alibaba.nacos.address.constant.AddressServerConstants;
|
||||
import com.alibaba.nacos.api.naming.pojo.Instance;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class AddressServerGeneratorManagerTest {
|
||||
|
||||
@Test
|
||||
void testGenerateProductName() {
|
||||
AddressServerGeneratorManager manager = new AddressServerGeneratorManager();
|
||||
final String blankName = manager.generateProductName("");
|
||||
assertEquals(AddressServerConstants.ALIWARE_NACOS_DEFAULT_PRODUCT_NAME, blankName);
|
||||
|
||||
final String defaultName =
|
||||
manager.generateProductName(AddressServerConstants.DEFAULT_PRODUCT);
|
||||
assertEquals(AddressServerConstants.ALIWARE_NACOS_DEFAULT_PRODUCT_NAME, defaultName);
|
||||
|
||||
final String testName = manager.generateProductName("test");
|
||||
assertEquals("nacos.as.test", testName);
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateInstancesByIps() {
|
||||
AddressServerGeneratorManager manager = new AddressServerGeneratorManager();
|
||||
final List<Instance> empty = manager.generateInstancesByIps(null, null, null, null);
|
||||
assertNotNull(empty);
|
||||
assertTrue(empty.isEmpty());
|
||||
|
||||
String[] ipArray =
|
||||
new String[] {"192.168.3.1:8848", "192.168.3.2:8848", "192.168.3.3:8848"};
|
||||
final List<Instance> instanceList =
|
||||
manager.generateInstancesByIps("DEFAULT_GROUP@@nacos.as.test", "test", "test",
|
||||
ipArray);
|
||||
assertNotNull(instanceList);
|
||||
assertFalse(instanceList.isEmpty());
|
||||
assertEquals(3, instanceList.size());
|
||||
|
||||
final Instance instance1 = instanceList.get(0);
|
||||
assertEquals("192.168.3.1", instance1.getIp());
|
||||
|
||||
final Instance instance2 = instanceList.get(1);
|
||||
assertEquals("192.168.3.2", instance2.getIp());
|
||||
|
||||
final Instance instance3 = instanceList.get(2);
|
||||
assertEquals("192.168.3.3", instance3.getIp());
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateResponseIps() {
|
||||
final List<com.alibaba.nacos.api.naming.pojo.Instance> instanceList = new ArrayList<>();
|
||||
Instance instance1 = new Instance();
|
||||
instance1.setIp("192.168.3.1");
|
||||
instance1.setPort(8848);
|
||||
|
||||
Instance instance2 = new Instance();
|
||||
instance2.setIp("192.168.3.2");
|
||||
instance2.setPort(8848);
|
||||
|
||||
Instance instance3 = new Instance();
|
||||
instance3.setIp("192.168.3.3");
|
||||
instance3.setPort(8848);
|
||||
|
||||
instanceList.add(instance1);
|
||||
instanceList.add(instance2);
|
||||
instanceList.add(instance3);
|
||||
|
||||
AddressServerGeneratorManager manager = new AddressServerGeneratorManager();
|
||||
final String ipListStr = manager.generateResponseIps(instanceList);
|
||||
|
||||
StringBuilder expectStr = new StringBuilder();
|
||||
final StringBuilder ret = expectStr
|
||||
.append("192.168.3.1:8848").append('\n')
|
||||
.append("192.168.3.2:8848").append('\n')
|
||||
.append("192.168.3.3:8848").append('\n');
|
||||
assertEquals(ret.toString(), ipListStr);
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateNacosServiceName() {
|
||||
AddressServerGeneratorManager manager = new AddressServerGeneratorManager();
|
||||
|
||||
final String containDefault = manager.generateNacosServiceName("DEFAULT_GROUP@@test");
|
||||
assertEquals("DEFAULT_GROUP@@test", containDefault);
|
||||
|
||||
final String product = manager.generateNacosServiceName("product");
|
||||
assertEquals("DEFAULT_GROUP@@product", product);
|
||||
}
|
||||
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 1999-2018 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.address.component;
|
||||
|
||||
import com.alibaba.nacos.address.constant.AddressServerConstants;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class AddressServerManagerTests {
|
||||
|
||||
private static final AddressServerManager ADDRESS_SERVER_MANAGER = new AddressServerManager();
|
||||
|
||||
@Test
|
||||
void getRawProductName() {
|
||||
assertEquals(AddressServerConstants.DEFAULT_PRODUCT,
|
||||
ADDRESS_SERVER_MANAGER.getRawProductName(""));
|
||||
assertEquals(AddressServerConstants.DEFAULT_PRODUCT,
|
||||
ADDRESS_SERVER_MANAGER.getRawProductName(AddressServerConstants.DEFAULT_PRODUCT));
|
||||
assertEquals("otherProduct", ADDRESS_SERVER_MANAGER.getRawProductName("otherProduct"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDefaultClusterNameIfEmpty() {
|
||||
assertEquals(AddressServerConstants.DEFAULT_GET_CLUSTER,
|
||||
ADDRESS_SERVER_MANAGER.getDefaultClusterNameIfEmpty(""));
|
||||
assertEquals(AddressServerConstants.DEFAULT_GET_CLUSTER,
|
||||
ADDRESS_SERVER_MANAGER
|
||||
.getDefaultClusterNameIfEmpty(AddressServerConstants.DEFAULT_GET_CLUSTER));
|
||||
assertEquals("otherServerList",
|
||||
ADDRESS_SERVER_MANAGER.getDefaultClusterNameIfEmpty("otherServerList"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetRawClusterName() {
|
||||
assertEquals("serverList", ADDRESS_SERVER_MANAGER.getRawClusterName("serverList"));
|
||||
assertEquals(AddressServerConstants.DEFAULT_GET_CLUSTER,
|
||||
ADDRESS_SERVER_MANAGER.getRawClusterName(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSplitIps() {
|
||||
final String[] emptyArr = ADDRESS_SERVER_MANAGER.splitIps("");
|
||||
assertEquals(0, emptyArr.length);
|
||||
final String[] one = ADDRESS_SERVER_MANAGER.splitIps("192.168.1.12:8848");
|
||||
assertEquals(1, one.length);
|
||||
assertEquals("192.168.1.12:8848", one[0]);
|
||||
final String[] two = ADDRESS_SERVER_MANAGER.splitIps("192.168.1.12:8848,192.268.3.33:8848");
|
||||
assertEquals(2, two.length);
|
||||
assertEquals("192.168.1.12:8848", two[0]);
|
||||
assertEquals("192.268.3.33:8848", two[1]);
|
||||
}
|
||||
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Copyright 1999-2018 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.address.controller;
|
||||
|
||||
import com.alibaba.nacos.address.component.AddressServerGeneratorManager;
|
||||
import com.alibaba.nacos.address.component.AddressServerManager;
|
||||
import com.alibaba.nacos.address.constant.AddressServerConstants;
|
||||
import com.alibaba.nacos.api.common.Constants;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.naming.core.ClusterOperator;
|
||||
import com.alibaba.nacos.naming.core.InstanceOperator;
|
||||
import com.alibaba.nacos.naming.core.v2.ServiceManager;
|
||||
import com.alibaba.nacos.naming.core.v2.metadata.NamingMetadataManager;
|
||||
import com.alibaba.nacos.naming.core.v2.pojo.Service;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AddressServerClusterControllerTest {
|
||||
|
||||
@Mock
|
||||
private InstanceOperator instanceOperator;
|
||||
|
||||
@Mock
|
||||
private NamingMetadataManager metadataManager;
|
||||
|
||||
@Mock
|
||||
private ClusterOperator clusterOperator;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void before() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(
|
||||
new AddressServerClusterController(instanceOperator, metadataManager, clusterOperator,
|
||||
new AddressServerManager(), new AddressServerGeneratorManager()))
|
||||
.build();
|
||||
Service service = Service
|
||||
.newService(Constants.DEFAULT_NAMESPACE_ID, Constants.DEFAULT_GROUP, "nacos.as.default",
|
||||
false);
|
||||
ServiceManager.getInstance().getSingleton(service);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
Service service = Service
|
||||
.newService(Constants.DEFAULT_NAMESPACE_ID, Constants.DEFAULT_GROUP, "nacos.as.default",
|
||||
false);
|
||||
ServiceManager.getInstance().removeSingleton(service);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPostCluster() throws Exception {
|
||||
|
||||
mockMvc.perform(
|
||||
post("/nacos/v1/as/nodes").param("product", "default").param("cluster", "serverList")
|
||||
.param("ips", "192.168.3.1,192.168.3.2"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPostClusterWithErrorIps() throws Exception {
|
||||
mockMvc.perform(
|
||||
post("/nacos/v1/as/nodes").param("product", "default").param("cluster", "serverList")
|
||||
.param("ips", "192.168.1"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPostClusterThrowException() throws Exception {
|
||||
|
||||
Mockito.doThrow(new NacosException(500, "create service error")).when(clusterOperator)
|
||||
.updateClusterMetadata(Mockito.eq(Constants.DEFAULT_NAMESPACE_ID), Mockito.eq(
|
||||
Constants.DEFAULT_GROUP + AddressServerConstants.GROUP_SERVICE_NAME_SEP
|
||||
+ "nacos.as.default"),
|
||||
Mockito.eq("serverList"), Mockito.any());
|
||||
|
||||
mockMvc.perform(
|
||||
post("/nacos/v1/as/nodes").param("product", "default").param("cluster", "serverList")
|
||||
.param("ips", "192.168.1"))
|
||||
.andExpect(status().isInternalServerError());
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteCluster() throws Exception {
|
||||
mockMvc.perform(
|
||||
delete("/nacos/v1/as/nodes").param("product", "default").param("cluster", "serverList")
|
||||
.param("ips", "192.168.3.1,192.168.3.2"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteClusterCannotFindService() throws Exception {
|
||||
tearDown();
|
||||
mockMvc.perform(
|
||||
delete("/nacos/v1/as/nodes").param("product", "default").param("cluster", "serverList")
|
||||
.param("ips", "192.168.3.1,192.168.3.2"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteClusterEmptyIps() throws Exception {
|
||||
mockMvc.perform(
|
||||
delete("/nacos/v1/as/nodes").param("product", "default").param("cluster", "serverList")
|
||||
.param("ips", ""))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteClusterErrorIps() throws Exception {
|
||||
mockMvc.perform(
|
||||
delete("/nacos/v1/as/nodes").param("product", "default").param("cluster", "serverList")
|
||||
.param("ips", "192.168.1"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteClusterThrowException() throws Exception {
|
||||
Mockito.doThrow(new NacosException(500, "remove service error")).when(instanceOperator)
|
||||
.removeInstance(Mockito.eq(Constants.DEFAULT_NAMESPACE_ID), Mockito.eq(
|
||||
Constants.DEFAULT_GROUP + AddressServerConstants.GROUP_SERVICE_NAME_SEP
|
||||
+ "nacos.as.default"),
|
||||
Mockito.any());
|
||||
|
||||
mockMvc
|
||||
.perform(delete("/nacos/v1/as/nodes").param("product", "default")
|
||||
.param("cluster", "serverList")
|
||||
.param("ips", "192.168.3.1,192.168.3.2"))
|
||||
.andExpect(status().isInternalServerError());
|
||||
}
|
||||
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright 1999-2018 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.address.controller;
|
||||
|
||||
import com.alibaba.nacos.address.component.AddressServerGeneratorManager;
|
||||
import com.alibaba.nacos.api.common.Constants;
|
||||
import com.alibaba.nacos.api.naming.pojo.Instance;
|
||||
import com.alibaba.nacos.api.naming.pojo.ServiceInfo;
|
||||
import com.alibaba.nacos.naming.core.v2.ServiceManager;
|
||||
import com.alibaba.nacos.naming.core.v2.index.ServiceStorage;
|
||||
import com.alibaba.nacos.naming.core.v2.metadata.ClusterMetadata;
|
||||
import com.alibaba.nacos.naming.core.v2.metadata.NamingMetadataManager;
|
||||
import com.alibaba.nacos.naming.core.v2.metadata.ServiceMetadata;
|
||||
import com.alibaba.nacos.naming.core.v2.pojo.Service;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ServerListControllerTest {
|
||||
|
||||
@Mock
|
||||
private NamingMetadataManager metadataManager;
|
||||
|
||||
@Mock
|
||||
private ServiceStorage serviceStorage;
|
||||
|
||||
private Service service;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void before() {
|
||||
this.mockMvc = MockMvcBuilders.standaloneSetup(
|
||||
new ServerListController(new AddressServerGeneratorManager(), metadataManager,
|
||||
serviceStorage))
|
||||
.build();
|
||||
service = Service
|
||||
.newService(Constants.DEFAULT_NAMESPACE_ID, Constants.DEFAULT_GROUP, "nacos.as.default",
|
||||
false);
|
||||
ServiceManager.getInstance().getSingleton(service);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
ServiceManager.getInstance().removeSingleton(service);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetCluster() throws Exception {
|
||||
|
||||
final Service service = Service
|
||||
.newService(Constants.DEFAULT_NAMESPACE_ID, Constants.DEFAULT_GROUP, "nacos.as.default",
|
||||
false);
|
||||
ServiceMetadata serviceMetadata = new ServiceMetadata();
|
||||
serviceMetadata.getClusters().put("serverList", new ClusterMetadata());
|
||||
when(metadataManager.getServiceMetadata(service)).thenReturn(Optional.of(serviceMetadata));
|
||||
List<Instance> list = new ArrayList<>(2);
|
||||
list.add(new Instance());
|
||||
list.add(new Instance());
|
||||
ServiceInfo serviceInfo = new ServiceInfo();
|
||||
serviceInfo.setHosts(list);
|
||||
when(serviceStorage.getData(service)).thenReturn(serviceInfo);
|
||||
mockMvc.perform(get("/nacos/serverList")).andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetClusterCannotFindService() throws Exception {
|
||||
tearDown();
|
||||
mockMvc.perform(get("/default/serverList")).andExpect(status().isNotFound());
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetClusterCannotFindCluster() throws Exception {
|
||||
mockMvc.perform(get("/nacos/serverList")).andExpect(status().isNotFound());
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.alibaba.nacos</groupId>
|
||||
<artifactId>nacos-all</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<name>nacos-ai-registry-adaptor ${project.version}</name>
|
||||
<url>https://nacos.io</url>
|
||||
|
||||
<artifactId>nacos-ai-registry-adaptor</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.nacos</groupId>
|
||||
<artifactId>nacos-ai</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>commons-codec</groupId>
|
||||
<artifactId>commons-codec</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-test-autoconfigure</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.PropertySource;
|
||||
|
||||
/**
|
||||
* Nacos Mcp Registry.
|
||||
* This server implement the official mcp registry api.
|
||||
* @author xinluo
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@PropertySource("classpath:nacos-ai-registry.properties")
|
||||
public class NacosAiRegistry {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(NacosAiRegistry.class, args);
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry;
|
||||
|
||||
import com.alibaba.nacos.core.listener.startup.AbstractNacosStartUp;
|
||||
import com.alibaba.nacos.core.listener.startup.NacosStartUp;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
/**
|
||||
* NacosAiRegistryStartUp.
|
||||
* @author xinluo
|
||||
*/
|
||||
public class NacosAiRegistryStartUp extends AbstractNacosStartUp {
|
||||
|
||||
public NacosAiRegistryStartUp() {
|
||||
super(NacosStartUp.AI_REGISTRY_START_UP_PHASE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get phase name in starting info.
|
||||
*
|
||||
* @return phase name
|
||||
*/
|
||||
@Override
|
||||
protected String getPhaseNameInStartingInfo() {
|
||||
return "Nacos AI Registry";
|
||||
}
|
||||
|
||||
/**
|
||||
* Log started info for current Nacos Server.
|
||||
*
|
||||
* @param logger logger for print info
|
||||
*/
|
||||
@Override
|
||||
public void logStarted(Logger logger) {
|
||||
logger.info("Nacos AI Registry Started.");
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.config;
|
||||
|
||||
import com.alibaba.nacos.airegistry.NacosAiRegistry;
|
||||
import com.alibaba.nacos.sys.filter.NacosPackageExcludeFilter;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* McpRegistry module package exclude filter. Only Basic and Web contexts use NacosTypeExcludeFilter with
|
||||
* basePackages = "com.alibaba.nacos"; McpRegistry context uses default scan (airegistry package only) and does not
|
||||
* apply this filter. So when this filter runs, it is always Basic/Web context and airegistry package should be excluded.
|
||||
*
|
||||
* @author xiweng.yy
|
||||
*/
|
||||
public class AiRegistryPackageExcludeFilter implements NacosPackageExcludeFilter {
|
||||
|
||||
@Override
|
||||
public String getResponsiblePackagePrefix() {
|
||||
return NacosAiRegistry.class.getPackage().getName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isExcluded(String className, Set<String> annotationNames) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.config;
|
||||
|
||||
import org.apache.tomcat.util.buf.EncodedSolidusHandling;
|
||||
import org.springframework.boot.tomcat.TomcatConnectorCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
|
||||
import org.springframework.security.web.firewall.StrictHttpFirewall;
|
||||
|
||||
/**
|
||||
* In the MCP community API definition, the MCP server name is passed as a path parameter.
|
||||
* Because the server name can contain '/', we need to configure Tomcat and WebSecurity
|
||||
* to allow '/' in the path.
|
||||
*
|
||||
* @author xinluo
|
||||
*/
|
||||
@Configuration
|
||||
public class HttpPathConfiguration {
|
||||
|
||||
@Bean
|
||||
public TomcatConnectorCustomizer connectorCustomizer() {
|
||||
return (connector) -> connector
|
||||
.setEncodedSolidusHandling(EncodedSolidusHandling.PASS_THROUGH.getValue());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public WebSecurityCustomizer webSecurityCustomizer() {
|
||||
StrictHttpFirewall firewall = new StrictHttpFirewall();
|
||||
firewall.setAllowUrlEncodedSlash(true);
|
||||
firewall.setAllowUrlEncodedPercent(true);
|
||||
return (web) -> web.httpFirewall(firewall);
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.controller;
|
||||
|
||||
import com.alibaba.nacos.api.annotation.Since;
|
||||
import com.alibaba.nacos.airegistry.form.GetServerForm;
|
||||
import com.alibaba.nacos.airegistry.form.ListServerForm;
|
||||
import com.alibaba.nacos.airegistry.form.ListServersNacosForm;
|
||||
import com.alibaba.nacos.ai.param.McpHttpParamExtractor;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.registry.McpErrorResponse;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.registry.McpRegistryServerList;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.registry.ServerResponse;
|
||||
import com.alibaba.nacos.api.annotation.NacosApi;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.api.exception.api.NacosApiException;
|
||||
import com.alibaba.nacos.common.constant.HttpHeaderConsts;
|
||||
import com.alibaba.nacos.core.paramcheck.ExtractorManager;
|
||||
import com.alibaba.nacos.airegistry.service.NacosMcpRegistryService;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Collections;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* McpRegistryController.
|
||||
*
|
||||
* @author xinluo
|
||||
*/
|
||||
@NacosApi
|
||||
@RestController
|
||||
@ExtractorManager.Extractor(httpExtractor = McpHttpParamExtractor.class)
|
||||
@ConditionalOnProperty(name = "nacos.ai.mcp.registry.enabled", havingValue = "true")
|
||||
public class McpRegistryController {
|
||||
|
||||
private final NacosMcpRegistryService nacosMcpRegistryService;
|
||||
|
||||
public McpRegistryController(NacosMcpRegistryService nacosMcpRegistryService) {
|
||||
this.nacosMcpRegistryService = nacosMcpRegistryService;
|
||||
}
|
||||
|
||||
/**
|
||||
* List mcp servers.
|
||||
* All server info is related to the latest version of the server.
|
||||
*
|
||||
* @param form list mcp servers request form
|
||||
* Support blur and accurate search
|
||||
* mode.
|
||||
* default offset is 0
|
||||
* default limit is 30
|
||||
* @return mcp server list {@link McpRegistryServerList}
|
||||
* @throws NacosApiException if request parameter is invalid or handle error
|
||||
*/
|
||||
@Since("3.2.1")
|
||||
@GetMapping(value = "/v0/servers")
|
||||
public McpRegistryServerList listMcpServers(ListServersNacosForm form)
|
||||
throws NacosApiException, NacosException {
|
||||
form.validate();
|
||||
int offset = form.resolveOffset();
|
||||
int limit = form.getLimit();
|
||||
String namespaceId = form.getNamespaceId();
|
||||
// reuse internal service with converted form
|
||||
ListServerForm internal = new ListServerForm();
|
||||
internal.setOffset(offset);
|
||||
internal.setLimit(limit);
|
||||
internal.setNamespaceId(namespaceId);
|
||||
internal.setServerName(form.getSearch());
|
||||
McpRegistryServerList internalList = nacosMcpRegistryService.listMcpServers(internal);
|
||||
// Null-safe server list handling; service has enriched items already
|
||||
List<ServerResponse> details = internalList.getServers();
|
||||
if (details == null) {
|
||||
details = Collections.emptyList();
|
||||
}
|
||||
McpRegistryServerList response = new McpRegistryServerList();
|
||||
response.setServers(details);
|
||||
int returned = details.size();
|
||||
String nextCursor = String.valueOf(offset + returned);
|
||||
response.setMetadata(new McpRegistryServerList.Metadata(nextCursor, returned));
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mcp server details.
|
||||
* If version is not provided, this api will return the latest version of the
|
||||
* server.
|
||||
*
|
||||
* @param name server name
|
||||
* @param form get server request form
|
||||
* @param response HTTP response
|
||||
* @return mcp server detail or McpErrorResponse when server not found.
|
||||
* @throws NacosApiException if request parameter is invalid or handle error
|
||||
*/
|
||||
@Since("3.2.1")
|
||||
@GetMapping(value = "/v0/servers/{name}/versions")
|
||||
public Object getServerVersions(@PathVariable String name, GetServerForm form,
|
||||
HttpServletResponse response)
|
||||
throws NacosException {
|
||||
McpRegistryServerList server =
|
||||
nacosMcpRegistryService.getServerVersions(form.getNamespaceId(), name);
|
||||
if (Objects.isNull(server)) {
|
||||
response.setStatus(404);
|
||||
response.setHeader(HttpHeaderConsts.CONTENT_TYPE, "application/json");
|
||||
McpErrorResponse errorResponse = new McpErrorResponse();
|
||||
errorResponse.setError("Server not found");
|
||||
return errorResponse;
|
||||
}
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific MCP server version.
|
||||
* Returns detailed information about a specific version of an MCP server.
|
||||
* Use the special version `latest` to get the latest version.
|
||||
*
|
||||
* @param serverName URL-encoded server name (e.g., "com.example%2Fmy-server")
|
||||
* @param version URL-encoded version to retrieve (e.g., "1.0.0" or
|
||||
* "1.0.0%2B20130313144700")
|
||||
* @param form get server request form
|
||||
* @param response HTTP response
|
||||
* @return mcp server detail or McpErrorResponse when server or version not
|
||||
* found.
|
||||
* @throws NacosException if handle error
|
||||
*/
|
||||
@Since("3.2.1")
|
||||
@GetMapping(value = "/v0/servers/{serverName}/versions/{version}")
|
||||
public Object getVersionedServer(@PathVariable String serverName, @PathVariable String version,
|
||||
GetServerForm form, HttpServletResponse response)
|
||||
throws NacosException {
|
||||
ServerResponse server =
|
||||
nacosMcpRegistryService.getServer(serverName, form.getNamespaceId(), version);
|
||||
if (Objects.isNull(server)) {
|
||||
response.setStatus(404);
|
||||
response.setHeader(HttpHeaderConsts.CONTENT_TYPE, "application/json");
|
||||
McpErrorResponse errorResponse = new McpErrorResponse();
|
||||
errorResponse.setError("Server not found");
|
||||
return errorResponse;
|
||||
}
|
||||
return server;
|
||||
}
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.controller;
|
||||
|
||||
import com.alibaba.nacos.api.annotation.Since;
|
||||
import com.alibaba.nacos.api.annotation.NacosApi;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
import com.alibaba.nacos.airegistry.form.SkillsFileQueryForm;
|
||||
import com.alibaba.nacos.airegistry.form.SkillsSearchForm;
|
||||
import com.alibaba.nacos.airegistry.model.skills.SkillsSearchResponse;
|
||||
import com.alibaba.nacos.airegistry.model.skills.WellKnownSkillsIndex;
|
||||
import com.alibaba.nacos.airegistry.service.NacosSkillsRegistryService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.AntPathMatcher;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.HandlerMapping;
|
||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* Skills CLI compatible registry endpoints hosted by the MCP registry adaptor.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
@NacosApi
|
||||
@RestController
|
||||
@ConditionalOnProperty(name = "nacos.ai.skill.registry.enabled", havingValue = "true")
|
||||
public class SkillsRegistryController {
|
||||
|
||||
private static final String BASE_PATH = "/registry";
|
||||
|
||||
private static final String WELL_KNOWN_AGENT_SKILLS =
|
||||
BASE_PATH + "/{namespaceId}/.well-known/agent-skills";
|
||||
|
||||
private static final String WELL_KNOWN_SKILLS = BASE_PATH + "/{namespaceId}/.well-known/skills";
|
||||
|
||||
private static final String APPLICATION_ZIP_VALUE = "application/zip";
|
||||
|
||||
private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();
|
||||
|
||||
private final NacosSkillsRegistryService nacosSkillsRegistryService;
|
||||
|
||||
public SkillsRegistryController(NacosSkillsRegistryService nacosSkillsRegistryService) {
|
||||
this.nacosSkillsRegistryService = nacosSkillsRegistryService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose well-known index.json for the skills CLI.
|
||||
*
|
||||
* @param namespaceId namespace to query
|
||||
* @return well-known index payload
|
||||
* @throws NacosException if query fails
|
||||
*/
|
||||
@Since("3.2.2")
|
||||
@GetMapping(value = WELL_KNOWN_AGENT_SKILLS + "/index.json",
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public WellKnownSkillsIndex getAgentSkillsIndex(@PathVariable String namespaceId)
|
||||
throws NacosException {
|
||||
return nacosSkillsRegistryService.buildAgentSkillsIndex(namespaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose legacy well-known index.json for v0.1-compatible clients.
|
||||
*
|
||||
* @param namespaceId namespace to query
|
||||
* @return legacy well-known index payload
|
||||
* @throws NacosException if query fails
|
||||
*/
|
||||
@Since("3.2.2")
|
||||
@GetMapping(value = WELL_KNOWN_SKILLS + "/index.json",
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public WellKnownSkillsIndex getLegacySkillsIndex(@PathVariable String namespaceId)
|
||||
throws NacosException {
|
||||
return nacosSkillsRegistryService.buildLegacySkillsIndex(namespaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose CLI-compatible search results under the adaptor endpoint.
|
||||
*
|
||||
* @param namespaceId namespace to query
|
||||
* @param form search query form
|
||||
* @param request current HTTP request
|
||||
* @return search response with CLI-compatible shape
|
||||
* @throws NacosException if query fails
|
||||
*/
|
||||
@Since("3.2.1")
|
||||
@GetMapping(value = BASE_PATH + "/{namespaceId}/api/search",
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public SkillsSearchResponse search(@PathVariable String namespaceId, SkillsSearchForm form,
|
||||
HttpServletRequest request)
|
||||
throws NacosException {
|
||||
form.setNamespaceId(namespaceId);
|
||||
form.validate();
|
||||
return nacosSkillsRegistryService.search(namespaceId, form.getQ(), form.getLimit(),
|
||||
buildSourceBaseUrl(request,
|
||||
namespaceId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the exported SKILL.md for a namespace skill.
|
||||
*
|
||||
* @param namespaceId namespace to query
|
||||
* @param skillName skill name
|
||||
* @return skill markdown when the skill is exportable, otherwise 404
|
||||
* @throws NacosException if query fails
|
||||
*/
|
||||
@Since("3.2.1")
|
||||
@GetMapping(value = {
|
||||
WELL_KNOWN_AGENT_SKILLS + "/{skillName}/SKILL.md",
|
||||
WELL_KNOWN_SKILLS + "/{skillName}/SKILL.md"
|
||||
}, produces = MediaType.TEXT_PLAIN_VALUE)
|
||||
public ResponseEntity<String> getSkillMarkdown(@PathVariable String namespaceId,
|
||||
@PathVariable String skillName)
|
||||
throws NacosException {
|
||||
SkillsFileQueryForm form = new SkillsFileQueryForm();
|
||||
form.setNamespaceId(namespaceId);
|
||||
form.setSkillName(skillName);
|
||||
form.setFilePath("SKILL.md");
|
||||
form.validate();
|
||||
String content = nacosSkillsRegistryService.getSkillFileContent(namespaceId, skillName,
|
||||
form.getFilePath());
|
||||
return content == null ? ResponseEntity.notFound().build()
|
||||
: ResponseEntity.ok().contentType(MediaType.TEXT_PLAIN).body(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an exported skill archive for v0.2 well-known discovery.
|
||||
*
|
||||
* @param namespaceId namespace to query
|
||||
* @param skillName skill name
|
||||
* @return skill ZIP archive when exportable, otherwise 404
|
||||
* @throws NacosException if query fails
|
||||
*/
|
||||
@Since("3.2.2")
|
||||
@GetMapping(value = {
|
||||
WELL_KNOWN_AGENT_SKILLS + "/{skillName}.zip",
|
||||
WELL_KNOWN_SKILLS + "/{skillName}.zip"
|
||||
}, produces = APPLICATION_ZIP_VALUE)
|
||||
public ResponseEntity<byte[]> getSkillArchive(@PathVariable String namespaceId,
|
||||
@PathVariable String skillName)
|
||||
throws NacosException {
|
||||
byte[] content = nacosSkillsRegistryService.getSkillArchiveContent(namespaceId,
|
||||
skillName);
|
||||
return content == null ? ResponseEntity.notFound().build()
|
||||
: ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment;filename=" + skillName + ".zip")
|
||||
.contentType(MediaType.parseMediaType(APPLICATION_ZIP_VALUE))
|
||||
.body(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an exported text resource for a namespace skill.
|
||||
*
|
||||
* @param namespaceId namespace to query
|
||||
* @param skillName skill name
|
||||
* @param request current HTTP request
|
||||
* @return file content when the skill and file are exportable, otherwise 404
|
||||
* @throws NacosException if query fails
|
||||
*/
|
||||
@Since("3.2.1")
|
||||
@GetMapping(value = {
|
||||
WELL_KNOWN_AGENT_SKILLS + "/{skillName}/**",
|
||||
WELL_KNOWN_SKILLS + "/{skillName}/**"
|
||||
}, produces = MediaType.TEXT_PLAIN_VALUE)
|
||||
public ResponseEntity<String> getSkillFile(@PathVariable String namespaceId,
|
||||
@PathVariable String skillName,
|
||||
HttpServletRequest request) throws NacosException {
|
||||
String filePath = extractFilePath(request);
|
||||
SkillsFileQueryForm form = new SkillsFileQueryForm();
|
||||
form.setNamespaceId(namespaceId);
|
||||
form.setSkillName(skillName);
|
||||
form.setFilePath(filePath);
|
||||
form.validate();
|
||||
String content = nacosSkillsRegistryService.getSkillFileContent(namespaceId, skillName,
|
||||
form.getFilePath());
|
||||
return content == null ? ResponseEntity.notFound().build()
|
||||
: ResponseEntity.ok().contentType(MediaType.TEXT_PLAIN).body(content);
|
||||
}
|
||||
|
||||
private String buildSourceBaseUrl(HttpServletRequest request, String namespaceId) {
|
||||
return ServletUriComponentsBuilder.fromRequestUri(request)
|
||||
.replacePath(BASE_PATH + "/{namespaceId}")
|
||||
.replaceQuery(null)
|
||||
.buildAndExpand(namespaceId)
|
||||
.toUriString();
|
||||
}
|
||||
|
||||
private String extractFilePath(HttpServletRequest request) {
|
||||
String pathWithinMapping =
|
||||
(String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE);
|
||||
String bestMatchingPattern =
|
||||
(String) request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
|
||||
String extracted =
|
||||
PATH_MATCHER.extractPathWithinPattern(bestMatchingPattern, pathWithinMapping);
|
||||
if (StringUtils.isBlank(extracted)) {
|
||||
return extracted;
|
||||
}
|
||||
return extracted.replaceFirst("^/+", "");
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.form;
|
||||
|
||||
import com.alibaba.nacos.api.exception.api.NacosApiException;
|
||||
import com.alibaba.nacos.api.model.NacosForm;
|
||||
|
||||
/**
|
||||
* Get mcp server form.
|
||||
* @author xinluo
|
||||
*/
|
||||
public class GetServerForm implements NacosForm {
|
||||
|
||||
private String namespaceId;
|
||||
|
||||
public String getNamespaceId() {
|
||||
return namespaceId;
|
||||
}
|
||||
|
||||
public void setNamespaceId(String namespaceId) {
|
||||
this.namespaceId = namespaceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* check form parameters while valid.
|
||||
*
|
||||
* @throws NacosApiException when form parameters is invalid.
|
||||
*/
|
||||
@Override
|
||||
public void validate() throws NacosApiException {
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.form;
|
||||
|
||||
import com.alibaba.nacos.ai.constant.Constants;
|
||||
import com.alibaba.nacos.api.exception.api.NacosApiException;
|
||||
import com.alibaba.nacos.api.model.NacosForm;
|
||||
import com.alibaba.nacos.api.model.v2.ErrorCode;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* List mcp server form.
|
||||
* @author xinluo
|
||||
*/
|
||||
public class ListServerForm implements NacosForm {
|
||||
|
||||
private String namespaceId;
|
||||
|
||||
private int offset = 0;
|
||||
|
||||
private int limit = 30;
|
||||
|
||||
private String serverName;
|
||||
|
||||
private String searchMode;
|
||||
|
||||
public int getLimit() {
|
||||
return limit;
|
||||
}
|
||||
|
||||
public void setLimit(int limit) {
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
public int getOffset() {
|
||||
return offset;
|
||||
}
|
||||
|
||||
public void setOffset(int offset) {
|
||||
this.offset = offset;
|
||||
}
|
||||
|
||||
public String getNamespaceId() {
|
||||
return namespaceId;
|
||||
}
|
||||
|
||||
public void setNamespaceId(String namespaceId) {
|
||||
this.namespaceId = namespaceId;
|
||||
}
|
||||
|
||||
public String getServerName() {
|
||||
return serverName;
|
||||
}
|
||||
|
||||
public void setServerName(String serverName) {
|
||||
this.serverName = serverName;
|
||||
}
|
||||
|
||||
public String getSearchMode() {
|
||||
return searchMode;
|
||||
}
|
||||
|
||||
public void setSearchMode(String searchMode) {
|
||||
this.searchMode = searchMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* check form parameters while valid.
|
||||
*
|
||||
* @throws NacosApiException when form parameters is invalid.
|
||||
*/
|
||||
@Override
|
||||
public void validate() throws NacosApiException {
|
||||
if (offset < 0) {
|
||||
throw new NacosApiException(HttpStatus.BAD_REQUEST.value(),
|
||||
ErrorCode.PARAMETER_VALIDATE_ERROR,
|
||||
"Parameter 'offset' must >= 0");
|
||||
}
|
||||
|
||||
if (limit > Constants.MAX_LIST_SIZE) {
|
||||
throw new NacosApiException(HttpStatus.BAD_REQUEST.value(),
|
||||
ErrorCode.PARAMETER_VALIDATE_ERROR,
|
||||
"Parameter 'limit' must <= 100");
|
||||
}
|
||||
|
||||
if (StringUtils.isNotEmpty(searchMode)) {
|
||||
if (!Constants.MCP_LIST_SEARCH_BLUR.equals(searchMode)
|
||||
&& !Constants.MCP_LIST_SEARCH_ACCURATE.equals(searchMode)) {
|
||||
throw new NacosApiException(HttpStatus.BAD_REQUEST.value(),
|
||||
ErrorCode.PARAMETER_MISMATCH,
|
||||
"Parameter 'searchMode' must be " + Constants.MCP_LIST_SEARCH_BLUR + " or "
|
||||
+ Constants.MCP_LIST_SEARCH_ACCURATE);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.form;
|
||||
|
||||
/**
|
||||
* Nacos list servers form extending official form with namespaceId.
|
||||
*
|
||||
* @author xinluo
|
||||
*/
|
||||
public class ListServersNacosForm extends ListServersOfficialForm {
|
||||
|
||||
private String namespaceId;
|
||||
|
||||
public String getNamespaceId() {
|
||||
return namespaceId;
|
||||
}
|
||||
|
||||
public void setNamespaceId(String namespaceId) {
|
||||
this.namespaceId = namespaceId;
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.form;
|
||||
|
||||
import com.alibaba.nacos.api.exception.api.NacosApiException;
|
||||
import com.alibaba.nacos.api.model.v2.ErrorCode;
|
||||
import com.alibaba.nacos.api.model.NacosForm;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* Official list servers form matching MCP Registry OpenAPI spec.
|
||||
* cursor: 分页游标, 为空表示第一页;实现上使用 offset 的字符串表示。
|
||||
* limit: 返回数量,默认 30,最大 100。
|
||||
*
|
||||
* @author xinluo
|
||||
*/
|
||||
public class ListServersOfficialForm implements NacosForm {
|
||||
|
||||
/** 默认 limit 数值. */
|
||||
public static final int DEFAULT_LIMIT = 30;
|
||||
|
||||
/** 最大 limit 数值. */
|
||||
public static final int MAX_LIMIT = 100;
|
||||
|
||||
/** offset 的字符串表示. */
|
||||
private String cursor;
|
||||
|
||||
private Integer limit = DEFAULT_LIMIT;
|
||||
|
||||
private String search;
|
||||
|
||||
private String updatedSince;
|
||||
|
||||
public String getCursor() {
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public void setCursor(String cursor) {
|
||||
this.cursor = cursor;
|
||||
}
|
||||
|
||||
public Integer getLimit() {
|
||||
return limit;
|
||||
}
|
||||
|
||||
public void setLimit(Integer limit) {
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
public String getSearch() {
|
||||
return search;
|
||||
}
|
||||
|
||||
public void setSearch(String search) {
|
||||
this.search = search;
|
||||
}
|
||||
|
||||
public String getUpdatedSince() {
|
||||
return updatedSince;
|
||||
}
|
||||
|
||||
public void setUpdatedSince(String updatedSince) {
|
||||
this.updatedSince = updatedSince;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 cursor 字段为 offset 数值.
|
||||
* 当 cursor 为空时返回 0;当为非法数字时抛出 NacosApiException.
|
||||
*
|
||||
* @return offset 数值
|
||||
* @throws NacosApiException 当 cursor 非法或为负数
|
||||
*/
|
||||
public int resolveOffset() throws NacosApiException {
|
||||
if (cursor == null || cursor.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
int off = Integer.parseInt(cursor);
|
||||
if (off < 0) {
|
||||
throw new NacosApiException(HttpStatus.BAD_REQUEST.value(),
|
||||
ErrorCode.PARAMETER_VALIDATE_ERROR, "cursor must be >= 0");
|
||||
}
|
||||
return off;
|
||||
} catch (NumberFormatException e) {
|
||||
throw new NacosApiException(HttpStatus.BAD_REQUEST.value(),
|
||||
ErrorCode.PARAMETER_VALIDATE_ERROR, "cursor must be numeric");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate() throws NacosApiException {
|
||||
if (limit == null) {
|
||||
limit = DEFAULT_LIMIT;
|
||||
}
|
||||
if (limit < 0) {
|
||||
throw new NacosApiException(HttpStatus.BAD_REQUEST.value(),
|
||||
ErrorCode.PARAMETER_VALIDATE_ERROR, "limit must be >= 0");
|
||||
}
|
||||
if (limit > MAX_LIMIT) {
|
||||
throw new NacosApiException(HttpStatus.BAD_REQUEST.value(),
|
||||
ErrorCode.PARAMETER_VALIDATE_ERROR, "limit must <= " + MAX_LIMIT);
|
||||
}
|
||||
// validate cursor numeric if present
|
||||
resolveOffset();
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.form;
|
||||
|
||||
import com.alibaba.nacos.api.exception.api.NacosApiException;
|
||||
import com.alibaba.nacos.api.model.NacosForm;
|
||||
import com.alibaba.nacos.api.model.v2.ErrorCode;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
|
||||
/**
|
||||
* Query form for skills CLI compatible file fetch endpoint.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
public class SkillsFileQueryForm implements NacosForm {
|
||||
|
||||
private String namespaceId;
|
||||
|
||||
private String skillName;
|
||||
|
||||
private String filePath;
|
||||
|
||||
@Override
|
||||
public void validate() throws NacosApiException {
|
||||
if (StringUtils.isBlank(namespaceId)) {
|
||||
throw new NacosApiException(NacosApiException.INVALID_PARAM,
|
||||
ErrorCode.PARAMETER_MISSING,
|
||||
"namespaceId is required");
|
||||
}
|
||||
if (StringUtils.isBlank(skillName)) {
|
||||
throw new NacosApiException(NacosApiException.INVALID_PARAM,
|
||||
ErrorCode.PARAMETER_MISSING,
|
||||
"skillName is required");
|
||||
}
|
||||
if (StringUtils.isBlank(filePath)) {
|
||||
throw new NacosApiException(NacosApiException.INVALID_PARAM,
|
||||
ErrorCode.PARAMETER_MISSING,
|
||||
"filePath is required");
|
||||
}
|
||||
if (filePath.startsWith("/") || filePath.startsWith("\\") || filePath.contains("..")) {
|
||||
throw new NacosApiException(NacosApiException.INVALID_PARAM,
|
||||
ErrorCode.PARAMETER_VALIDATE_ERROR,
|
||||
"filePath is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
public String getNamespaceId() {
|
||||
return namespaceId;
|
||||
}
|
||||
|
||||
public void setNamespaceId(String namespaceId) {
|
||||
this.namespaceId = namespaceId;
|
||||
}
|
||||
|
||||
public String getSkillName() {
|
||||
return skillName;
|
||||
}
|
||||
|
||||
public void setSkillName(String skillName) {
|
||||
this.skillName = skillName;
|
||||
}
|
||||
|
||||
public String getFilePath() {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
public void setFilePath(String filePath) {
|
||||
this.filePath = filePath;
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.form;
|
||||
|
||||
import com.alibaba.nacos.api.exception.api.NacosApiException;
|
||||
import com.alibaba.nacos.api.model.NacosForm;
|
||||
import com.alibaba.nacos.api.model.v2.ErrorCode;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
|
||||
/**
|
||||
* Query form for skills CLI compatible search endpoint.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
public class SkillsSearchForm implements NacosForm {
|
||||
|
||||
private static final int DEFAULT_LIMIT = 10;
|
||||
|
||||
private static final int MAX_LIMIT = 10;
|
||||
|
||||
private String namespaceId;
|
||||
|
||||
private String q;
|
||||
|
||||
private Integer limit;
|
||||
|
||||
@Override
|
||||
public void validate() throws NacosApiException {
|
||||
if (StringUtils.isBlank(namespaceId)) {
|
||||
throw new NacosApiException(NacosApiException.INVALID_PARAM,
|
||||
ErrorCode.PARAMETER_MISSING,
|
||||
"namespaceId is required");
|
||||
}
|
||||
if (limit == null || limit <= 0) {
|
||||
limit = DEFAULT_LIMIT;
|
||||
}
|
||||
if (limit > MAX_LIMIT) {
|
||||
limit = MAX_LIMIT;
|
||||
}
|
||||
}
|
||||
|
||||
public String getNamespaceId() {
|
||||
return namespaceId;
|
||||
}
|
||||
|
||||
public void setNamespaceId(String namespaceId) {
|
||||
this.namespaceId = namespaceId;
|
||||
}
|
||||
|
||||
public String getQ() {
|
||||
return q;
|
||||
}
|
||||
|
||||
public void setQ(String q) {
|
||||
this.q = q;
|
||||
}
|
||||
|
||||
public Integer getLimit() {
|
||||
return limit;
|
||||
}
|
||||
|
||||
public void setLimit(Integer limit) {
|
||||
this.limit = limit;
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.model.skills;
|
||||
|
||||
/**
|
||||
* Search result item for the skills CLI.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
public class SkillsSearchItem {
|
||||
|
||||
private String id;
|
||||
|
||||
private String name;
|
||||
|
||||
private long installs;
|
||||
|
||||
private String source;
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public long getInstalls() {
|
||||
return installs;
|
||||
}
|
||||
|
||||
public void setInstalls(long installs) {
|
||||
this.installs = installs;
|
||||
}
|
||||
|
||||
public String getSource() {
|
||||
return source;
|
||||
}
|
||||
|
||||
public void setSource(String source) {
|
||||
this.source = source;
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.model.skills;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Search response for the skills CLI.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
public class SkillsSearchResponse {
|
||||
|
||||
private List<SkillsSearchItem> skills;
|
||||
|
||||
public List<SkillsSearchItem> getSkills() {
|
||||
return skills;
|
||||
}
|
||||
|
||||
public void setSkills(List<SkillsSearchItem> skills) {
|
||||
this.skills = skills;
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.model.skills;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Well-known skill entry for the skills CLI.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class WellKnownSkillEntry {
|
||||
|
||||
private String name;
|
||||
|
||||
private String type;
|
||||
|
||||
private String description;
|
||||
|
||||
private String url;
|
||||
|
||||
private String digest;
|
||||
|
||||
private String version;
|
||||
|
||||
private List<String> files;
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public void setUrl(String url) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
public String getDigest() {
|
||||
return digest;
|
||||
}
|
||||
|
||||
public void setDigest(String digest) {
|
||||
this.digest = digest;
|
||||
}
|
||||
|
||||
public String getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public void setVersion(String version) {
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public List<String> getFiles() {
|
||||
return files;
|
||||
}
|
||||
|
||||
public void setFiles(List<String> files) {
|
||||
this.files = files;
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.model.skills;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Well-known skills index response for the skills CLI.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class WellKnownSkillsIndex {
|
||||
|
||||
@JsonProperty("$schema")
|
||||
private String schema;
|
||||
|
||||
private List<WellKnownSkillEntry> skills;
|
||||
|
||||
public String getSchema() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
public void setSchema(String schema) {
|
||||
this.schema = schema;
|
||||
}
|
||||
|
||||
public List<WellKnownSkillEntry> getSkills() {
|
||||
return skills;
|
||||
}
|
||||
|
||||
public void setSkills(List<WellKnownSkillEntry> skills) {
|
||||
this.skills = skills;
|
||||
}
|
||||
}
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.service;
|
||||
|
||||
import com.alibaba.nacos.ai.constant.Constants;
|
||||
import com.alibaba.nacos.ai.index.McpServerIndex;
|
||||
import com.alibaba.nacos.ai.model.mcp.McpServerIndexData;
|
||||
import com.alibaba.nacos.ai.service.McpServerOperationService;
|
||||
import com.alibaba.nacos.api.ai.constant.AiConstants;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.McpEndpointInfo;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.McpServerBasicInfo;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.McpServerDetailInfo;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.McpToolSpecification;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.registry.McpRegistryServerDetail;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.registry.McpRegistryServerList;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.registry.ServerResponse;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.registry.ServerVersionDetail;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.registry.OfficialMeta;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.registry.Remote;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.api.model.Page;
|
||||
import com.alibaba.nacos.api.model.response.Namespace;
|
||||
import com.alibaba.nacos.common.utils.CollectionUtils;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
import com.alibaba.nacos.core.service.NamespaceOperationService;
|
||||
import com.alibaba.nacos.airegistry.form.ListServerForm;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static com.alibaba.nacos.ai.constant.Constants.MCP_LIST_SEARCH_BLUR;
|
||||
|
||||
/**
|
||||
* a service for mcp registry api implementation.
|
||||
*
|
||||
* @author xinluo
|
||||
*/
|
||||
@Service
|
||||
@ConditionalOnProperty(name = "nacos.ai.mcp.registry.enabled", havingValue = "true")
|
||||
public class NacosMcpRegistryService {
|
||||
|
||||
private static final int DEFAULT_HTTP_PORT = 80;
|
||||
|
||||
private static final int DEFAULT_HTTPS_PORT = 443;
|
||||
|
||||
private final McpServerOperationService mcpServerOperationService;
|
||||
|
||||
private final NamespaceOperationService namespaceOperationService;
|
||||
|
||||
private final McpServerIndex mcpServerIndex;
|
||||
|
||||
public NacosMcpRegistryService(McpServerOperationService mcpServerOperationService,
|
||||
NamespaceOperationService namespaceOperationService, McpServerIndex mcpServerIndex) {
|
||||
this.mcpServerOperationService = mcpServerOperationService;
|
||||
this.namespaceOperationService = namespaceOperationService;
|
||||
this.mcpServerIndex = mcpServerIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* List mcp server from mcpServerOperationService and convert the result to
|
||||
* {@link McpRegistryServerList}.
|
||||
*
|
||||
* @param listServerForm listServerParams
|
||||
* @return {@link McpRegistryServerList}
|
||||
*/
|
||||
public McpRegistryServerList listMcpServers(ListServerForm listServerForm) {
|
||||
int limit = listServerForm.getLimit();
|
||||
int offset = listServerForm.getOffset();
|
||||
String namespaceId = listServerForm.getNamespaceId();
|
||||
String serverName = listServerForm.getServerName();
|
||||
Collection<String> namespaceIdList = StringUtils.isNotEmpty(namespaceId)
|
||||
? Collections.singletonList(namespaceId)
|
||||
: fetchOrderedNamespaceList();
|
||||
|
||||
List<McpServerBasicInfo> servers =
|
||||
listMcpServerByNamespaceList(namespaceIdList, serverName, offset, limit);
|
||||
|
||||
// Build detail list by fetching per-item detail via getServer for consistency
|
||||
List<ServerResponse> finalServers = servers.stream().map((item) -> {
|
||||
try {
|
||||
return getServer(item.getName(), item.getNamespaceId(), null);
|
||||
} catch (Exception ignore) {
|
||||
return null;
|
||||
}
|
||||
}).filter(Objects::nonNull).collect(Collectors.toList());
|
||||
McpRegistryServerList serverList = new McpRegistryServerList();
|
||||
serverList.setServers(finalServers);
|
||||
return serverList;
|
||||
}
|
||||
|
||||
private List<String> fetchOrderedNamespaceList() {
|
||||
return namespaceOperationService.getNamespaceList().stream()
|
||||
.sorted(Comparator.comparing(Namespace::getNamespace)).map(Namespace::getNamespace)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<McpServerBasicInfo> listMcpServerByNamespaceList(
|
||||
Collection<String> namespaceIdList, String serverName,
|
||||
int offset, int limit) {
|
||||
List<McpServerBasicInfo> result = new ArrayList<>();
|
||||
|
||||
// 如果 limit <= 0,直接返回空
|
||||
if (limit <= 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
int remainOffset = offset;
|
||||
|
||||
for (String namespaceId : namespaceIdList) {
|
||||
if (result.size() >= limit) {
|
||||
break;
|
||||
}
|
||||
Page<McpServerBasicInfo> countPage =
|
||||
mcpServerOperationService.listMcpServerWithPage(namespaceId, serverName,
|
||||
MCP_LIST_SEARCH_BLUR, 1, 1);
|
||||
int totalCount = countPage.getTotalCount();
|
||||
if (totalCount == 0) {
|
||||
continue;
|
||||
}
|
||||
if (remainOffset >= totalCount) {
|
||||
remainOffset -= totalCount;
|
||||
continue;
|
||||
}
|
||||
int remaining = limit - result.size();
|
||||
int pageSize = limit;
|
||||
int pageNum = remainOffset / pageSize + 1;
|
||||
int pageOffset = remainOffset % pageSize;
|
||||
while (remaining > 0) {
|
||||
Page<McpServerBasicInfo> dataPage =
|
||||
mcpServerOperationService.listMcpServerWithPage(namespaceId, serverName,
|
||||
MCP_LIST_SEARCH_BLUR, pageNum, pageSize);
|
||||
if (CollectionUtils.isEmpty(dataPage.getPageItems())) {
|
||||
break;
|
||||
}
|
||||
int startIdx = (pageNum == remainOffset / pageSize + 1) ? pageOffset : 0;
|
||||
int endIdx = Math.min(startIdx + remaining, dataPage.getPageItems().size());
|
||||
if (startIdx < dataPage.getPageItems().size()) {
|
||||
result.addAll(dataPage.getPageItems().subList(startIdx, endIdx));
|
||||
remaining -= (endIdx - startIdx);
|
||||
}
|
||||
if (endIdx < dataPage.getPageItems().size()) {
|
||||
break;
|
||||
}
|
||||
pageNum++;
|
||||
}
|
||||
remainOffset = 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mcp server detail for the specified name and version.
|
||||
* if namespaceId is null, search in all namespaces and return the first mcp server found.
|
||||
* @param name mcp server name
|
||||
* @param namespaceId namespace id
|
||||
* @param version mcp server version
|
||||
* @return mcp server detail
|
||||
* @throws NacosException if nacos operation fails
|
||||
*/
|
||||
public ServerResponse getServer(String name, String namespaceId, String version)
|
||||
throws NacosException {
|
||||
try {
|
||||
McpServerDetailInfo mcpServerDetail =
|
||||
mcpServerOperationService.getMcpServerDetail(namespaceId, null, name,
|
||||
version);
|
||||
return buildServerResponse(mcpServerDetail);
|
||||
} catch (NacosException e) {
|
||||
if (e.getErrCode() == NacosException.NOT_FOUND) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all versions of the specified MCP server.
|
||||
* @param namespaceId the namespaceId of mcp server, if not specified, search in all namespaces.
|
||||
* @param serverName the server name of mcp server.
|
||||
* @return all versions of the found MCP server as {@link McpRegistryServerList}
|
||||
*/
|
||||
public McpRegistryServerList getServerVersions(String namespaceId, String serverName)
|
||||
throws NacosException {
|
||||
try {
|
||||
McpServerDetailInfo mcpServerDetail =
|
||||
mcpServerOperationService.getMcpServerDetail(namespaceId, null, serverName, null);
|
||||
List<ServerVersionDetail> allVersions = mcpServerDetail.getAllVersions();
|
||||
allVersions.sort(Comparator.comparing(ServerVersionDetail::getVersion));
|
||||
List<ServerResponse> serverResponses = allVersions.stream().map((server) -> {
|
||||
try {
|
||||
return mcpServerOperationService.getMcpServerDetail(namespaceId, null,
|
||||
serverName, server.getVersion());
|
||||
} catch (NacosException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}).map(this::buildServerResponse).collect(Collectors.toList());
|
||||
McpRegistryServerList registryServerList = new McpRegistryServerList();
|
||||
registryServerList.setServers(serverResponses);
|
||||
McpRegistryServerList.Metadata metadata = new McpRegistryServerList.Metadata();
|
||||
metadata.setCount(registryServerList.getServers().size());
|
||||
registryServerList.setMetadata(metadata);
|
||||
return registryServerList;
|
||||
} catch (NacosException e) {
|
||||
if (e.getErrCode() == NacosException.NOT_FOUND) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tools info about the given version of the mcp server.
|
||||
*
|
||||
* @param serverId mcp server id.
|
||||
* @param version the version of the mcp server.
|
||||
* @return tools info about the mcp server.
|
||||
* @throws NacosException if request parameter is invalid or handle error
|
||||
*/
|
||||
public McpToolSpecification getTools(String serverId, String version) throws NacosException {
|
||||
McpServerIndexData indexData = mcpServerIndex.getMcpServerById(serverId);
|
||||
if (Objects.isNull(indexData)) {
|
||||
return null;
|
||||
}
|
||||
McpServerDetailInfo mcpServerDetail =
|
||||
mcpServerOperationService.getMcpServerDetail(indexData.getNamespaceId(),
|
||||
serverId, null, version);
|
||||
return mcpServerDetail.getToolSpec();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer frontend endpoints, fallback to backend.
|
||||
*/
|
||||
private List<McpEndpointInfo> pickEndpoints(List<McpEndpointInfo> frontend,
|
||||
List<McpEndpointInfo> backend) {
|
||||
if (CollectionUtils.isNotEmpty(frontend)) {
|
||||
return frontend;
|
||||
}
|
||||
return backend;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map endpoints to remotes with default headers.
|
||||
*/
|
||||
private List<Remote> toRemotes(List<McpEndpointInfo> endpoints, String type) {
|
||||
if (CollectionUtils.isEmpty(endpoints)) {
|
||||
return null;
|
||||
}
|
||||
return endpoints.stream().map((item) -> {
|
||||
Remote remote = new Remote();
|
||||
remote.setType(type);
|
||||
String url = buildUrl(item);
|
||||
remote.setUrl(url);
|
||||
remote.setHeaders(item.getHeaders());
|
||||
return remote;
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Build URL for endpoint, omitting default ports.
|
||||
* Default ports: 80 for http, 443 for https
|
||||
*/
|
||||
private String buildUrl(McpEndpointInfo endpoint) {
|
||||
String protocol = endpoint.getProtocol();
|
||||
int port = endpoint.getPort();
|
||||
boolean isDefaultHttpPort =
|
||||
Constants.PROTOCOL_TYPE_HTTP.equalsIgnoreCase(protocol) && port == DEFAULT_HTTP_PORT;
|
||||
boolean isDefaultHttpsPort =
|
||||
Constants.PROTOCOL_TYPE_HTTPS.equalsIgnoreCase(protocol) && port == DEFAULT_HTTPS_PORT;
|
||||
|
||||
if (isDefaultHttpPort || isDefaultHttpsPort) {
|
||||
return String.format("%s://%s%s", protocol, endpoint.getAddress(), endpoint.getPath());
|
||||
}
|
||||
return String.format("%s://%s:%d%s", protocol, endpoint.getAddress(), port,
|
||||
endpoint.getPath());
|
||||
}
|
||||
|
||||
private List<Remote> buildRemotes(McpServerDetailInfo mcpServerDetail) {
|
||||
List<McpEndpointInfo> endpoints = pickEndpoints(mcpServerDetail.getFrontendEndpoints(),
|
||||
mcpServerDetail.getBackendEndpoints());
|
||||
if (CollectionUtils.isEmpty(endpoints)) {
|
||||
return null;
|
||||
}
|
||||
if (mcpServerDetail.getFrontProtocol().equals(AiConstants.Mcp.MCP_PROTOCOL_STREAMABLE)) {
|
||||
return toRemotes(endpoints, AiConstants.Mcp.OFFICIAL_TRANSPORT_STREAMABLE);
|
||||
} else if (mcpServerDetail.getFrontProtocol().equals(AiConstants.Mcp.MCP_PROTOCOL_SSE)) {
|
||||
return toRemotes(endpoints, AiConstants.Mcp.OFFICIAL_TRANSPORT_SSE);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build registry detail from detailInfo and enrich including endpoints -> remotes.
|
||||
*/
|
||||
private ServerResponse buildServerResponse(McpServerDetailInfo mcpServerDetail) {
|
||||
if (mcpServerDetail == null) {
|
||||
return null;
|
||||
}
|
||||
ServerResponse result = new ServerResponse();
|
||||
result.setServer(buildMcpServer(mcpServerDetail));
|
||||
result.setMeta(buildMeta(mcpServerDetail));
|
||||
return result;
|
||||
}
|
||||
|
||||
private McpRegistryServerDetail buildMcpServer(McpServerDetailInfo mcpServerDetail) {
|
||||
McpRegistryServerDetail server = new McpRegistryServerDetail();
|
||||
server.setName(mcpServerDetail.getName());
|
||||
server.setDescription(mcpServerDetail.getDescription());
|
||||
server.setRepository(mcpServerDetail.getRepository());
|
||||
server.setPackages(mcpServerDetail.getPackages());
|
||||
server.setIcons(mcpServerDetail.getIcons());
|
||||
server.setWebsiteUrl(mcpServerDetail.getWebsiteUrl());
|
||||
if (mcpServerDetail.getVersionDetail() != null) {
|
||||
server.setVersion(mcpServerDetail.getVersionDetail().getVersion());
|
||||
}
|
||||
server.setRemotes(buildRemotes(mcpServerDetail));
|
||||
return server;
|
||||
}
|
||||
|
||||
private ServerResponse.Meta buildMeta(McpServerDetailInfo mcpServerDetail) {
|
||||
ServerResponse.Meta meta = new ServerResponse.Meta();
|
||||
OfficialMeta official = new OfficialMeta();
|
||||
ServerVersionDetail versionDetail = mcpServerDetail.getVersionDetail();
|
||||
List<ServerVersionDetail> allVersions = mcpServerDetail.getAllVersions();
|
||||
if (CollectionUtils.isNotEmpty(allVersions)) {
|
||||
ServerVersionDetail firstDetail = allVersions.get(0);
|
||||
official.setPublishedAt(firstDetail.getRelease_date());
|
||||
ServerVersionDetail latestDetail = allVersions.get(allVersions.size() - 1);
|
||||
official.setUpdatedAt(latestDetail.getRelease_date());
|
||||
official
|
||||
.setIsLatest(Objects.equals(versionDetail.getVersion(), latestDetail.getVersion()));
|
||||
}
|
||||
meta.setOfficial(official);
|
||||
return meta;
|
||||
}
|
||||
}
|
||||
+445
@@ -0,0 +1,445 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.service;
|
||||
|
||||
import com.alibaba.nacos.ai.constant.Constants;
|
||||
import com.alibaba.nacos.ai.model.skills.SkillIndexManifest;
|
||||
import com.alibaba.nacos.ai.service.skills.SkillIndexManifestService;
|
||||
import com.alibaba.nacos.ai.service.skills.SkillOperationService;
|
||||
import com.alibaba.nacos.api.ai.model.skills.Skill;
|
||||
import com.alibaba.nacos.api.ai.model.skills.SkillResource;
|
||||
import com.alibaba.nacos.api.ai.model.skills.SkillSummary;
|
||||
import com.alibaba.nacos.api.ai.model.skills.SkillUtils;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.api.model.Page;
|
||||
import com.alibaba.nacos.common.utils.CollectionUtils;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
import com.alibaba.nacos.airegistry.model.skills.SkillsSearchItem;
|
||||
import com.alibaba.nacos.airegistry.model.skills.SkillsSearchResponse;
|
||||
import com.alibaba.nacos.airegistry.model.skills.WellKnownSkillEntry;
|
||||
import com.alibaba.nacos.airegistry.model.skills.WellKnownSkillsIndex;
|
||||
import com.alibaba.nacos.plugin.visibility.constant.VisibilityConstants;
|
||||
import org.apache.commons.codec.digest.DigestUtils;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
/**
|
||||
* Service for exposing Nacos skills through the well-known protocol expected by the skills CLI.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
@Service
|
||||
@ConditionalOnProperty(name = "nacos.ai.skill.registry.enabled", havingValue = "true")
|
||||
public class NacosSkillsRegistryService {
|
||||
|
||||
private static final String SCHEMA_0_2 =
|
||||
"https://schemas.agentskills.io/discovery/0.2.0/schema.json";
|
||||
|
||||
private static final int LIST_PAGE_SIZE = 100;
|
||||
|
||||
private static final Set<String> BINARY_EXTENSIONS = new HashSet<>();
|
||||
|
||||
private static final String MARKDOWN_FILE = "SKILL.md";
|
||||
|
||||
private static final String ARCHIVE_TYPE = "archive";
|
||||
|
||||
private static final String SKILL_MD_TYPE = "skill-md";
|
||||
|
||||
private static final String ZIP_SUFFIX = ".zip";
|
||||
|
||||
private static final String DIGEST_SHA256_PREFIX = "sha256:";
|
||||
|
||||
private static final long STABLE_ZIP_ENTRY_TIME = 0L;
|
||||
|
||||
private static final String METADATA_ENCODING = "encoding";
|
||||
|
||||
private static final String METADATA_ENCODING_BASE64 = "base64";
|
||||
|
||||
static {
|
||||
Collections.addAll(BINARY_EXTENSIONS, "ttf", "otf", "woff", "woff2", "eot",
|
||||
"png", "jpg", "jpeg", "gif", "webp", "ico", "cur", "pdf", "bin");
|
||||
}
|
||||
|
||||
private final SkillOperationService skillOperationService;
|
||||
|
||||
private final SkillIndexManifestService skillIndexManifestService;
|
||||
|
||||
public NacosSkillsRegistryService(SkillOperationService skillOperationService,
|
||||
SkillIndexManifestService skillIndexManifestService) {
|
||||
this.skillOperationService = skillOperationService;
|
||||
this.skillIndexManifestService = skillIndexManifestService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the well-known skill index for a namespace.
|
||||
*
|
||||
* @param namespaceId namespace to query
|
||||
* @return well-known index response
|
||||
* @throws NacosException if query fails
|
||||
*/
|
||||
public WellKnownSkillsIndex buildAgentSkillsIndex(String namespaceId) throws NacosException {
|
||||
return buildIndex(namespaceId, WellKnownIndexVersion.V0_2_0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the legacy well-known skill index for a namespace.
|
||||
*
|
||||
* @param namespaceId namespace to query
|
||||
* @return legacy well-known index response
|
||||
* @throws NacosException if query fails
|
||||
*/
|
||||
public WellKnownSkillsIndex buildLegacySkillsIndex(String namespaceId) throws NacosException {
|
||||
return buildIndex(namespaceId, WellKnownIndexVersion.V0_1_0);
|
||||
}
|
||||
|
||||
private WellKnownSkillsIndex buildIndex(String namespaceId, WellKnownIndexVersion version)
|
||||
throws NacosException {
|
||||
List<ExportableSkill> skills =
|
||||
collectExportableSkills(namespaceId, null, Integer.MAX_VALUE);
|
||||
skills.sort(Comparator.comparing(each -> each.summary().getName()));
|
||||
List<WellKnownSkillEntry> entries = new ArrayList<>(skills.size());
|
||||
for (ExportableSkill each : skills) {
|
||||
entries.add(toWellKnownEntry(each, version));
|
||||
}
|
||||
WellKnownSkillsIndex result = new WellKnownSkillsIndex();
|
||||
if (version == WellKnownIndexVersion.V0_2_0) {
|
||||
result.setSchema(SCHEMA_0_2);
|
||||
}
|
||||
result.setSkills(entries);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search exportable skills for the CLI search endpoint.
|
||||
*
|
||||
* @param namespaceId namespace to query
|
||||
* @param query search keyword
|
||||
* @param limit max result count
|
||||
* @param sourceBaseUrl source URL reported to the CLI
|
||||
* @return CLI-compatible search response
|
||||
* @throws NacosException if query fails
|
||||
*/
|
||||
public SkillsSearchResponse search(String namespaceId, String query, int limit,
|
||||
String sourceBaseUrl)
|
||||
throws NacosException {
|
||||
List<ExportableSkill> skills = collectExportableSkills(namespaceId, query, limit);
|
||||
skills.sort(Comparator
|
||||
.comparingLong((ExportableSkill each) -> safeDownloadCount(each.summary())).reversed()
|
||||
.thenComparing(each -> each.summary().getName()));
|
||||
List<SkillsSearchItem> items = new ArrayList<>(Math.min(limit, skills.size()));
|
||||
for (int i = 0; i < skills.size() && i < limit; i++) {
|
||||
SkillSummary summary = skills.get(i).summary();
|
||||
SkillsSearchItem item = new SkillsSearchItem();
|
||||
item.setId(summary.getName());
|
||||
item.setName(summary.getName());
|
||||
item.setInstalls(safeDownloadCount(summary));
|
||||
item.setSource(sourceBaseUrl);
|
||||
items.add(item);
|
||||
}
|
||||
SkillsSearchResponse result = new SkillsSearchResponse();
|
||||
result.setSkills(items);
|
||||
return result;
|
||||
}
|
||||
|
||||
public String getSkillFileContent(String namespaceId, String skillName, String relativePath)
|
||||
throws NacosException {
|
||||
ExportableSkill skill = loadExportableSkill(namespaceId, skillName, false);
|
||||
if (skill == null) {
|
||||
return null;
|
||||
}
|
||||
if (MARKDOWN_FILE.equals(relativePath)) {
|
||||
return SkillUtils.toMarkdown(skill.skill());
|
||||
}
|
||||
if (skill.skill().getResource() == null) {
|
||||
return null;
|
||||
}
|
||||
for (SkillResource each : skill.skill().getResource().values()) {
|
||||
if (each == null) {
|
||||
continue;
|
||||
}
|
||||
if (relativePath.equals(buildRelativePath(each))) {
|
||||
return each.getContent();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public byte[] getSkillArchiveContent(String namespaceId, String skillName)
|
||||
throws NacosException {
|
||||
ExportableSkill skill = loadExportableSkill(namespaceId, skillName, true);
|
||||
return skill == null ? null : toArchiveBytes(skill.skill());
|
||||
}
|
||||
|
||||
private List<ExportableSkill> collectExportableSkills(String namespaceId, String query,
|
||||
int limit)
|
||||
throws NacosException {
|
||||
List<ExportableSkill> result = new ArrayList<>();
|
||||
int pageNo = 1;
|
||||
int pagesAvailable = 1;
|
||||
while (pageNo <= pagesAvailable) {
|
||||
Page<SkillSummary> page =
|
||||
skillOperationService.listSkills(namespaceId, query, Constants.Skills.SEARCH_BLUR,
|
||||
"download_count", pageNo, LIST_PAGE_SIZE);
|
||||
if (page == null || CollectionUtils.isEmpty(page.getPageItems())) {
|
||||
break;
|
||||
}
|
||||
pagesAvailable = page.getPagesAvailable();
|
||||
for (SkillSummary each : page.getPageItems()) {
|
||||
if (!isEligibleSummary(each)) {
|
||||
continue;
|
||||
}
|
||||
ExportableSkill exportable =
|
||||
loadExportableSkill(namespaceId, each.getName(), each, false);
|
||||
if (exportable == null) {
|
||||
continue;
|
||||
}
|
||||
result.add(exportable);
|
||||
if (limit != Integer.MAX_VALUE && result.size() >= limit) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
pageNo++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private ExportableSkill loadExportableSkill(String namespaceId, String skillName,
|
||||
boolean download)
|
||||
throws NacosException {
|
||||
Page<SkillSummary> page = skillOperationService.listSkills(namespaceId, skillName,
|
||||
Constants.Skills.SEARCH_ACCURATE,
|
||||
"download_count", 1, 1);
|
||||
if (page == null || CollectionUtils.isEmpty(page.getPageItems())) {
|
||||
return null;
|
||||
}
|
||||
SkillSummary summary = page.getPageItems().get(0);
|
||||
if (!isEligibleSummary(summary)) {
|
||||
return null;
|
||||
}
|
||||
return loadExportableSkill(namespaceId, skillName, summary, download);
|
||||
}
|
||||
|
||||
private ExportableSkill loadExportableSkill(String namespaceId, String skillName,
|
||||
SkillSummary summary, boolean download)
|
||||
throws NacosException {
|
||||
SkillIndexManifest manifest = skillIndexManifestService.query(namespaceId, skillName);
|
||||
String resolvedVersion = SkillIndexManifestService.resolveVersion(manifest, null,
|
||||
SkillIndexManifest.LABEL_LATEST);
|
||||
if (StringUtils.isBlank(resolvedVersion)) {
|
||||
return null;
|
||||
}
|
||||
Skill skill;
|
||||
try {
|
||||
skill = download ? skillOperationService.downloadSkillVersion(namespaceId, skillName,
|
||||
resolvedVersion)
|
||||
: skillOperationService.getSkillVersionDetail(namespaceId, skillName,
|
||||
resolvedVersion);
|
||||
} catch (NacosException e) {
|
||||
if (e.getErrCode() == NacosException.NOT_FOUND
|
||||
|| e.getErrCode() == NacosException.NO_RIGHT) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (skill == null || StringUtils.isBlank(skill.getName())
|
||||
|| StringUtils.isBlank(skill.getDescription())) {
|
||||
return null;
|
||||
}
|
||||
List<String> files = buildFiles(skill);
|
||||
if (files == null) {
|
||||
return null;
|
||||
}
|
||||
return new ExportableSkill(summary, skill, resolvedVersion, files);
|
||||
}
|
||||
|
||||
private boolean isEligibleSummary(SkillSummary summary) {
|
||||
return summary != null
|
||||
&& summary.isEnable()
|
||||
&& VisibilityConstants.SCOPE_PUBLIC.equalsIgnoreCase(summary.getScope())
|
||||
&& summary.getOnlineCnt() != null
|
||||
&& summary.getOnlineCnt() > 0
|
||||
&& StringUtils.isNotBlank(summary.getName())
|
||||
&& StringUtils.isNotBlank(summary.getDescription());
|
||||
}
|
||||
|
||||
private List<String> buildFiles(Skill skill) {
|
||||
List<String> result = new ArrayList<>();
|
||||
result.add(MARKDOWN_FILE);
|
||||
if (skill.getResource() == null || skill.getResource().isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<String> resourcePaths = new ArrayList<>(skill.getResource().size());
|
||||
for (SkillResource each : skill.getResource().values()) {
|
||||
if (each == null || StringUtils.isBlank(each.getName())) {
|
||||
continue;
|
||||
}
|
||||
if (isBinaryResource(each)) {
|
||||
return null;
|
||||
}
|
||||
resourcePaths.add(buildRelativePath(each));
|
||||
}
|
||||
resourcePaths.sort(String::compareTo);
|
||||
result.addAll(resourcePaths);
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean isBinaryResource(SkillResource resource) {
|
||||
Map<String, Object> metadata = resource.getMetadata();
|
||||
if (metadata != null && METADATA_ENCODING_BASE64.equals(metadata.get(METADATA_ENCODING))) {
|
||||
return true;
|
||||
}
|
||||
String name = resource.getName();
|
||||
if (StringUtils.isBlank(name)) {
|
||||
return false;
|
||||
}
|
||||
int dot = name.lastIndexOf('.');
|
||||
if (dot < 0 || dot == name.length() - 1) {
|
||||
return false;
|
||||
}
|
||||
String ext = name.substring(dot + 1).toLowerCase(Locale.ENGLISH);
|
||||
return BINARY_EXTENSIONS.contains(ext);
|
||||
}
|
||||
|
||||
private String buildRelativePath(SkillResource resource) {
|
||||
if (StringUtils.isBlank(resource.getType())) {
|
||||
return resource.getName();
|
||||
}
|
||||
return resource.getType() + "/" + resource.getName();
|
||||
}
|
||||
|
||||
private WellKnownSkillEntry toWellKnownEntry(ExportableSkill each,
|
||||
WellKnownIndexVersion version)
|
||||
throws NacosException {
|
||||
WellKnownSkillEntry entry = new WellKnownSkillEntry();
|
||||
entry.setName(each.skill().getName());
|
||||
entry.setDescription(each.skill().getDescription());
|
||||
if (version == WellKnownIndexVersion.V0_1_0) {
|
||||
entry.setFiles(each.files());
|
||||
return entry;
|
||||
}
|
||||
entry.setVersion(each.version());
|
||||
if (isSingleMarkdownSkill(each)) {
|
||||
entry.setType(SKILL_MD_TYPE);
|
||||
entry.setUrl(fileUrl(each.skill().getName(), MARKDOWN_FILE));
|
||||
entry.setDigest(
|
||||
sha256Digest(SkillUtils.toMarkdown(each.skill()).getBytes(StandardCharsets.UTF_8)));
|
||||
return entry;
|
||||
}
|
||||
entry.setType(ARCHIVE_TYPE);
|
||||
entry.setUrl(archiveUrl(each.skill().getName()));
|
||||
entry.setDigest(sha256Digest(toArchiveBytes(each.skill())));
|
||||
return entry;
|
||||
}
|
||||
|
||||
private boolean isSingleMarkdownSkill(ExportableSkill skill) {
|
||||
return skill.files().size() == 1 && MARKDOWN_FILE.equals(skill.files().get(0));
|
||||
}
|
||||
|
||||
private byte[] toArchiveBytes(Skill skill) throws NacosException {
|
||||
try {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) {
|
||||
addZipEntry(zip, MARKDOWN_FILE,
|
||||
SkillUtils.toMarkdown(skill).getBytes(StandardCharsets.UTF_8));
|
||||
if (skill.getResource() != null && !skill.getResource().isEmpty()) {
|
||||
List<SkillResource> resources = new ArrayList<>();
|
||||
for (SkillResource each : skill.getResource().values()) {
|
||||
if (each != null && StringUtils.isNotBlank(each.getName())) {
|
||||
resources.add(each);
|
||||
}
|
||||
}
|
||||
resources.sort(Comparator.comparing(this::buildRelativePath));
|
||||
for (SkillResource each : resources) {
|
||||
addZipEntry(zip, buildRelativePath(each),
|
||||
each.getContent() == null ? new byte[0]
|
||||
: each.getContent().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
}
|
||||
return output.toByteArray();
|
||||
} catch (Exception e) {
|
||||
throw new NacosException(NacosException.SERVER_ERROR,
|
||||
"Failed to create skill well-known archive: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void addZipEntry(ZipOutputStream zip, String path, byte[] bytes) throws Exception {
|
||||
SkillUtils.validatePathSafety(path);
|
||||
ZipEntry entry = new ZipEntry(path);
|
||||
entry.setTime(STABLE_ZIP_ENTRY_TIME);
|
||||
zip.putNextEntry(entry);
|
||||
zip.write(bytes);
|
||||
zip.closeEntry();
|
||||
}
|
||||
|
||||
private String fileUrl(String skillName, String file) {
|
||||
return encodePathSegment(skillName) + "/" + encodePath(file);
|
||||
}
|
||||
|
||||
private String archiveUrl(String skillName) {
|
||||
return encodePathSegment(skillName) + ZIP_SUFFIX;
|
||||
}
|
||||
|
||||
private String encodePath(String path) {
|
||||
String[] segments = path.split("/");
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (String each : segments) {
|
||||
if (result.length() > 0) {
|
||||
result.append('/');
|
||||
}
|
||||
result.append(encodePathSegment(each));
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private String encodePathSegment(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
}
|
||||
|
||||
private String sha256Digest(byte[] bytes) throws NacosException {
|
||||
return DIGEST_SHA256_PREFIX + DigestUtils.sha256Hex(bytes);
|
||||
}
|
||||
|
||||
private long safeDownloadCount(SkillSummary summary) {
|
||||
return summary.getDownloadCount() == null ? 0L : summary.getDownloadCount();
|
||||
}
|
||||
|
||||
private enum WellKnownIndexVersion {
|
||||
|
||||
V0_1_0,
|
||||
|
||||
V0_2_0
|
||||
}
|
||||
|
||||
private record ExportableSkill(SkillSummary summary, Skill skill, String version,
|
||||
List<String> files) {
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
#
|
||||
# Copyright 1999-2023 Alibaba Group Holding Ltd.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
com.alibaba.nacos.airegistry.NacosAiRegistryStartUp
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
#
|
||||
# Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
com.alibaba.nacos.airegistry.config.AiRegistryPackageExcludeFilter
|
||||
@@ -0,0 +1,14 @@
|
||||
,--.
|
||||
,--.'|
|
||||
,--,: : | Nacos AI Registry ${application.version}
|
||||
,`--.'`| ' : ,---. Running in ${nacos.mode} mode, ${nacos.function.mode} function modules
|
||||
| : : | | ' ,'\ .--.--. Port: ${nacos.ai.registry.port:${nacos.ai.mcp.registry.port:9080}}
|
||||
: | \ | : ,--.--. ,---. / / | / / ' Pid: ${pid}
|
||||
| : ' '; | / \ / \. ; ,. :| : /`./
|
||||
' ' ;. ;.--. .-. | / / '' | |: :| : ;_
|
||||
| | | \ | \__\/: . .. ' / ' | .; : \ \ `. https://nacos.io
|
||||
' : | ; .' ," .--.; |' ; :__| : | `----. \
|
||||
| | '`--' / / ,. |' | '.'|\ \ / / /`--' /
|
||||
' : | ; : .' \ : : `----' '--'. /
|
||||
; |.' | , .-./\ \ / `--'---'
|
||||
'---' `--`---' `----'
|
||||
@@ -0,0 +1,18 @@
|
||||
#
|
||||
# Copyright 1999-2023 Alibaba Group Holding Ltd.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
### nacos ai registry port:
|
||||
server.port=${nacos.ai.registry.port:${nacos.ai.mcp.registry.port:9080}}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class NacosAiRegistryStartUpTest {
|
||||
|
||||
NacosAiRegistryStartUp nacosAiRegistryStartUp;
|
||||
|
||||
@Mock
|
||||
Logger logger;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
nacosAiRegistryStartUp = new NacosAiRegistryStartUp();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPhaseNameInStartingInfo() {
|
||||
assertEquals("Nacos AI Registry", nacosAiRegistryStartUp.getPhaseNameInStartingInfo());
|
||||
}
|
||||
|
||||
@Test
|
||||
void logStarted() {
|
||||
nacosAiRegistryStartUp.logStarted(logger);
|
||||
verify(logger).info("Nacos AI Registry Started.");
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link NacosAiRegistry}.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
class NacosAiRegistryTest {
|
||||
|
||||
@Test
|
||||
void testConstruct() {
|
||||
assertNotNull(new NacosAiRegistry());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMainDelegatesToSpringApplication() {
|
||||
String[] args = {"--server.port=0"};
|
||||
try (MockedStatic<SpringApplication> springApplication = Mockito
|
||||
.mockStatic(SpringApplication.class)) {
|
||||
NacosAiRegistry.main(args);
|
||||
springApplication.verify(() -> SpringApplication.run(NacosAiRegistry.class, args));
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.config;
|
||||
|
||||
import com.alibaba.nacos.airegistry.NacosAiRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class AiRegistryPackageExcludeFilterTest {
|
||||
|
||||
@Test
|
||||
void testGetResponsiblePackagePrefix() {
|
||||
AiRegistryPackageExcludeFilter filter = new AiRegistryPackageExcludeFilter();
|
||||
|
||||
assertEquals(NacosAiRegistry.class.getPackage().getName(),
|
||||
filter.getResponsiblePackagePrefix());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsExcludedAlwaysReturnsTrue() {
|
||||
AiRegistryPackageExcludeFilter filter = new AiRegistryPackageExcludeFilter();
|
||||
|
||||
assertTrue(filter.isExcluded("com.alibaba.nacos.airegistry.NacosAiRegistry",
|
||||
Collections.emptySet()));
|
||||
assertTrue(filter.isExcluded(null, null));
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.config;
|
||||
|
||||
import org.apache.catalina.connector.Connector;
|
||||
import org.apache.tomcat.util.buf.EncodedSolidusHandling;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.tomcat.TomcatConnectorCustomizer;
|
||||
import org.springframework.security.config.annotation.web.builders.WebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
|
||||
import org.springframework.security.web.firewall.StrictHttpFirewall;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
class HttpPathConfigurationTest {
|
||||
|
||||
@Test
|
||||
void testConnectorCustomizerAllowsEncodedSolidus() {
|
||||
HttpPathConfiguration configuration = new HttpPathConfiguration();
|
||||
TomcatConnectorCustomizer customizer = configuration.connectorCustomizer();
|
||||
Connector connector = new Connector();
|
||||
|
||||
customizer.customize(connector);
|
||||
|
||||
assertEquals(EncodedSolidusHandling.PASS_THROUGH.getValue(),
|
||||
connector.getEncodedSolidusHandling());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testWebSecurityCustomizerCreated() {
|
||||
HttpPathConfiguration configuration = new HttpPathConfiguration();
|
||||
WebSecurityCustomizer customizer = configuration.webSecurityCustomizer();
|
||||
|
||||
assertNotNull(customizer);
|
||||
WebSecurity webSecurity = mock(WebSecurity.class);
|
||||
customizer.customize(webSecurity);
|
||||
verify(webSecurity).httpFirewall(any(StrictHttpFirewall.class));
|
||||
}
|
||||
}
|
||||
+605
@@ -0,0 +1,605 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.controller;
|
||||
|
||||
import com.alibaba.nacos.api.ai.model.mcp.registry.McpErrorResponse;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.registry.McpRegistryServerList;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.registry.ServerResponse;
|
||||
import com.alibaba.nacos.common.utils.JacksonUtils;
|
||||
import com.alibaba.nacos.airegistry.service.NacosMcpRegistryService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.test.context.web.WebAppConfiguration;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* Unit tests for McpRegistryController.
|
||||
*
|
||||
* @author xinluo
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@ContextConfiguration(classes = org.springframework.mock.web.MockServletContext.class)
|
||||
@WebAppConfiguration
|
||||
class McpRegistryControllerTest {
|
||||
|
||||
@InjectMocks
|
||||
private McpRegistryController mcpRegistryController;
|
||||
|
||||
@Mock
|
||||
private NacosMcpRegistryService nacosMcpRegistryService;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(mcpRegistryController).build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
// Cleanup if needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /v0/servers - List MCP servers with default parameters.
|
||||
*/
|
||||
@Test
|
||||
void testListMcpServersDefaultParams() throws Exception {
|
||||
// Setup
|
||||
McpRegistryServerList mockServerList = createMockServerList(2);
|
||||
mockServerList.setMetadata(new McpRegistryServerList.Metadata("2", 2));
|
||||
when(nacosMcpRegistryService.listMcpServers(any())).thenReturn(mockServerList);
|
||||
|
||||
// Execute
|
||||
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get("/v0/servers");
|
||||
String responseContent = mockMvc.perform(builder)
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
// Verify
|
||||
McpRegistryServerList response =
|
||||
JacksonUtils.toObj(responseContent, McpRegistryServerList.class);
|
||||
assertNotNull(response);
|
||||
assertNotNull(response.getServers());
|
||||
assertEquals(2, response.getServers().size());
|
||||
assertNotNull(response.getMetadata());
|
||||
assertNotNull(response.getMetadata().getNextCursor());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /v0/servers - List MCP servers with cursor parameter.
|
||||
*/
|
||||
@Test
|
||||
void testListMcpServersWithCursor() throws Exception {
|
||||
// Setup
|
||||
McpRegistryServerList mockServerList = createMockServerList(3);
|
||||
mockServerList.setMetadata(new McpRegistryServerList.Metadata("13", 3));
|
||||
when(nacosMcpRegistryService.listMcpServers(any())).thenReturn(mockServerList);
|
||||
|
||||
// Execute
|
||||
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get("/v0/servers")
|
||||
.param("cursor", "10");
|
||||
String responseContent = mockMvc.perform(builder)
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
// Verify
|
||||
McpRegistryServerList response =
|
||||
JacksonUtils.toObj(responseContent, McpRegistryServerList.class);
|
||||
assertNotNull(response);
|
||||
assertNotNull(response.getServers());
|
||||
assertEquals(3, response.getServers().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /v0/servers - List MCP servers with limit parameter.
|
||||
*/
|
||||
@Test
|
||||
void testListMcpServersWithLimit() throws Exception {
|
||||
// Setup
|
||||
McpRegistryServerList mockServerList = createMockServerList(5);
|
||||
mockServerList.setMetadata(new McpRegistryServerList.Metadata("5", 5));
|
||||
when(nacosMcpRegistryService.listMcpServers(any())).thenReturn(mockServerList);
|
||||
|
||||
// Execute
|
||||
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get("/v0/servers")
|
||||
.param("limit", "5");
|
||||
String responseContent = mockMvc.perform(builder)
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
// Verify
|
||||
McpRegistryServerList response =
|
||||
JacksonUtils.toObj(responseContent, McpRegistryServerList.class);
|
||||
assertNotNull(response);
|
||||
assertEquals(5, response.getServers().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /v0/servers - List MCP servers with search parameter.
|
||||
*/
|
||||
@Test
|
||||
void testListMcpServersWithSearch() throws Exception {
|
||||
// Setup
|
||||
McpRegistryServerList mockServerList = createMockServerList(1);
|
||||
mockServerList.setMetadata(new McpRegistryServerList.Metadata("1", 1));
|
||||
when(nacosMcpRegistryService.listMcpServers(any())).thenReturn(mockServerList);
|
||||
|
||||
// Execute
|
||||
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get("/v0/servers")
|
||||
.param("search", "filesystem");
|
||||
String responseContent = mockMvc.perform(builder)
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
// Verify
|
||||
McpRegistryServerList response =
|
||||
JacksonUtils.toObj(responseContent, McpRegistryServerList.class);
|
||||
assertNotNull(response);
|
||||
assertEquals(1, response.getServers().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /v0/servers - List MCP servers with updated_since parameter.
|
||||
*/
|
||||
@Test
|
||||
void testListMcpServersWithUpdatedSince() throws Exception {
|
||||
// Setup
|
||||
McpRegistryServerList mockServerList = createMockServerList(2);
|
||||
mockServerList.setMetadata(new McpRegistryServerList.Metadata("2", 2));
|
||||
when(nacosMcpRegistryService.listMcpServers(any())).thenReturn(mockServerList);
|
||||
|
||||
// Execute
|
||||
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get("/v0/servers")
|
||||
.param("updated_since", "2025-08-07T13:15:04.280Z");
|
||||
String responseContent = mockMvc.perform(builder)
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
// Verify
|
||||
McpRegistryServerList response =
|
||||
JacksonUtils.toObj(responseContent, McpRegistryServerList.class);
|
||||
assertNotNull(response);
|
||||
assertEquals(2, response.getServers().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /v0/servers - List MCP servers with version parameter.
|
||||
*/
|
||||
@Test
|
||||
void testListMcpServersWithVersion() throws Exception {
|
||||
// Setup
|
||||
McpRegistryServerList mockServerList = createMockServerList(1);
|
||||
mockServerList.setMetadata(new McpRegistryServerList.Metadata("1", 1));
|
||||
when(nacosMcpRegistryService.listMcpServers(any())).thenReturn(mockServerList);
|
||||
|
||||
// Execute
|
||||
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get("/v0/servers")
|
||||
.param("version", "1.2.3");
|
||||
String responseContent = mockMvc.perform(builder)
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
// Verify
|
||||
McpRegistryServerList response =
|
||||
JacksonUtils.toObj(responseContent, McpRegistryServerList.class);
|
||||
assertNotNull(response);
|
||||
assertEquals(1, response.getServers().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /v0/servers - List MCP servers with all parameters.
|
||||
*/
|
||||
@Test
|
||||
void testListMcpServersWithAllParams() throws Exception {
|
||||
// Setup
|
||||
McpRegistryServerList mockServerList = createMockServerList(3);
|
||||
mockServerList.setMetadata(new McpRegistryServerList.Metadata("8", 3));
|
||||
when(nacosMcpRegistryService.listMcpServers(any())).thenReturn(mockServerList);
|
||||
|
||||
// Execute
|
||||
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get("/v0/servers")
|
||||
.param("cursor", "5")
|
||||
.param("limit", "10")
|
||||
.param("search", "test")
|
||||
.param("updated_since", "2025-08-07T13:15:04.280Z")
|
||||
.param("version", "1.0.0");
|
||||
String responseContent = mockMvc.perform(builder)
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
// Verify
|
||||
McpRegistryServerList response =
|
||||
JacksonUtils.toObj(responseContent, McpRegistryServerList.class);
|
||||
assertNotNull(response);
|
||||
assertEquals(3, response.getServers().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /v0/servers - Empty server list.
|
||||
*/
|
||||
@Test
|
||||
void testListMcpServersEmptyList() throws Exception {
|
||||
// Setup
|
||||
McpRegistryServerList mockServerList = new McpRegistryServerList();
|
||||
mockServerList.setServers(Collections.emptyList());
|
||||
when(nacosMcpRegistryService.listMcpServers(any())).thenReturn(mockServerList);
|
||||
|
||||
// Execute
|
||||
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get("/v0/servers");
|
||||
String responseContent = mockMvc.perform(builder)
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
// Verify
|
||||
McpRegistryServerList response =
|
||||
JacksonUtils.toObj(responseContent, McpRegistryServerList.class);
|
||||
assertNotNull(response);
|
||||
assertNotNull(response.getServers());
|
||||
assertEquals(0, response.getServers().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /v0/servers/{serverName}/versions - Get all versions of a server.
|
||||
*/
|
||||
@Test
|
||||
void testGetServerVersionsSuccess() throws Exception {
|
||||
// Setup
|
||||
String serverName = "com.example%2Fmy-server";
|
||||
McpRegistryServerList mockServerList = createMockServerList(2);
|
||||
mockServerList.setMetadata(new McpRegistryServerList.Metadata(null, 2));
|
||||
when(nacosMcpRegistryService.getServerVersions(any(), any())).thenReturn(mockServerList);
|
||||
|
||||
// Execute
|
||||
MockHttpServletRequestBuilder builder =
|
||||
MockMvcRequestBuilders.get("/v0/servers/{name}/versions", serverName);
|
||||
String responseContent = mockMvc.perform(builder)
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
// Verify
|
||||
McpRegistryServerList response =
|
||||
JacksonUtils.toObj(responseContent, McpRegistryServerList.class);
|
||||
assertNotNull(response);
|
||||
assertNotNull(response.getServers());
|
||||
assertEquals(2, response.getServers().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /v0/servers/{serverName}/versions - Server not found (404).
|
||||
*/
|
||||
@Test
|
||||
void testGetServerVersionsNotFound() throws Exception {
|
||||
// Setup
|
||||
String serverName = "com.example%2Fnonexistent";
|
||||
when(nacosMcpRegistryService.getServerVersions(any(), any())).thenReturn(null);
|
||||
|
||||
// Execute
|
||||
MockHttpServletRequestBuilder builder =
|
||||
MockMvcRequestBuilders.get("/v0/servers/{name}/versions", serverName);
|
||||
String responseContent = mockMvc.perform(builder)
|
||||
.andExpect(status().isNotFound())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
// Verify
|
||||
McpErrorResponse errorResponse =
|
||||
JacksonUtils.toObj(responseContent, McpErrorResponse.class);
|
||||
assertNotNull(errorResponse);
|
||||
assertEquals("Server not found", errorResponse.getError());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /v0/servers/{serverName}/versions/{version} - Get specific version.
|
||||
*/
|
||||
@Test
|
||||
void testGetVersionedServerSuccess() throws Exception {
|
||||
// Setup
|
||||
String serverName = "com.example%2Fmy-server";
|
||||
String version = "1.0.0";
|
||||
ServerResponse mockServerResponse = createMockServerResponse(serverName, version);
|
||||
when(nacosMcpRegistryService.getServer(eq(serverName), isNull(), eq(version)))
|
||||
.thenReturn(mockServerResponse);
|
||||
|
||||
// Execute
|
||||
MockHttpServletRequestBuilder builder =
|
||||
MockMvcRequestBuilders.get("/v0/servers/{serverName}/versions/{version}",
|
||||
serverName, version);
|
||||
String responseContent = mockMvc.perform(builder)
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
// Verify
|
||||
ServerResponse response = JacksonUtils.toObj(responseContent, ServerResponse.class);
|
||||
assertNotNull(response);
|
||||
assertNotNull(response.getServer());
|
||||
assertEquals(serverName, response.getServer().getName());
|
||||
assertEquals(version, response.getServer().getVersion());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /v0/servers/{serverName}/versions/{version} - Get latest version.
|
||||
*/
|
||||
@Test
|
||||
void testGetVersionedServerLatestVersion() throws Exception {
|
||||
// Setup
|
||||
String serverName = "com.example%2Fmy-server";
|
||||
String version = "latest";
|
||||
ServerResponse mockServerResponse = createMockServerResponse(serverName, "2.0.0");
|
||||
when(nacosMcpRegistryService.getServer(eq(serverName), isNull(), eq(version)))
|
||||
.thenReturn(mockServerResponse);
|
||||
|
||||
// Execute
|
||||
MockHttpServletRequestBuilder builder =
|
||||
MockMvcRequestBuilders.get("/v0/servers/{serverName}/versions/{version}",
|
||||
serverName, version);
|
||||
String responseContent = mockMvc.perform(builder)
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
// Verify
|
||||
ServerResponse response = JacksonUtils.toObj(responseContent, ServerResponse.class);
|
||||
assertNotNull(response);
|
||||
assertNotNull(response.getServer());
|
||||
assertEquals(serverName, response.getServer().getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /v0/servers/{serverName}/versions/{version} - Version with build metadata.
|
||||
*/
|
||||
@Test
|
||||
void testGetVersionedServerWithBuildMetadata() throws Exception {
|
||||
// Setup
|
||||
String serverName = "com.example%2Fmy-server";
|
||||
String version = "1.0.0%2B20130313144700";
|
||||
ServerResponse mockServerResponse =
|
||||
createMockServerResponse(serverName, "1.0.0+20130313144700");
|
||||
when(nacosMcpRegistryService.getServer(eq(serverName), isNull(), eq(version)))
|
||||
.thenReturn(mockServerResponse);
|
||||
|
||||
// Execute
|
||||
MockHttpServletRequestBuilder builder =
|
||||
MockMvcRequestBuilders.get("/v0/servers/{serverName}/versions/{version}",
|
||||
serverName, version);
|
||||
String responseContent = mockMvc.perform(builder)
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
// Verify
|
||||
ServerResponse response = JacksonUtils.toObj(responseContent, ServerResponse.class);
|
||||
assertNotNull(response);
|
||||
assertNotNull(response.getServer());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /v0/servers/{serverName}/versions/{version} - Server not found (404).
|
||||
*/
|
||||
@Test
|
||||
void testGetVersionedServerNotFound() throws Exception {
|
||||
// Setup
|
||||
String serverName = "com.example%2Fnonexistent";
|
||||
String version = "1.0.0";
|
||||
when(nacosMcpRegistryService.getServer(eq(serverName), isNull(), eq(version)))
|
||||
.thenReturn(null);
|
||||
|
||||
// Execute
|
||||
MockHttpServletRequestBuilder builder =
|
||||
MockMvcRequestBuilders.get("/v0/servers/{serverName}/versions/{version}",
|
||||
serverName, version);
|
||||
String responseContent = mockMvc.perform(builder)
|
||||
.andExpect(status().isNotFound())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
// Verify
|
||||
McpErrorResponse errorResponse =
|
||||
JacksonUtils.toObj(responseContent, McpErrorResponse.class);
|
||||
assertNotNull(errorResponse);
|
||||
assertEquals("Server not found", errorResponse.getError());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /v0/servers/{serverName}/versions/{version} - Version not found (404).
|
||||
*/
|
||||
@Test
|
||||
void testGetVersionedServerVersionNotFound() throws Exception {
|
||||
// Setup
|
||||
String serverName = "com.example%2Fmy-server";
|
||||
String version = "999.999.999";
|
||||
when(nacosMcpRegistryService.getServer(eq(serverName), isNull(), eq(version)))
|
||||
.thenReturn(null);
|
||||
|
||||
// Execute
|
||||
MockHttpServletRequestBuilder builder =
|
||||
MockMvcRequestBuilders.get("/v0/servers/{serverName}/versions/{version}",
|
||||
serverName, version);
|
||||
String responseContent = mockMvc.perform(builder)
|
||||
.andExpect(status().isNotFound())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
// Verify
|
||||
McpErrorResponse errorResponse =
|
||||
JacksonUtils.toObj(responseContent, McpErrorResponse.class);
|
||||
assertNotNull(errorResponse);
|
||||
assertEquals("Server not found", errorResponse.getError());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /v0/servers - Service throws NacosException.
|
||||
*/
|
||||
@Test
|
||||
void testListMcpServersServiceException() throws Exception {
|
||||
// Setup
|
||||
when(nacosMcpRegistryService.listMcpServers(any())).thenReturn(new McpRegistryServerList());
|
||||
|
||||
// Execute & Verify
|
||||
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get("/v0/servers");
|
||||
mockMvc.perform(builder)
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /v0/servers/{serverName}/versions - Service throws NacosException.
|
||||
*/
|
||||
@Test
|
||||
void testGetServerVersionsServiceException() throws Exception {
|
||||
// Setup
|
||||
String serverName = "com.example%2Fmy-server";
|
||||
when(nacosMcpRegistryService.getServerVersions(any(), any()))
|
||||
.thenReturn(new McpRegistryServerList());
|
||||
|
||||
// Execute & Verify
|
||||
MockHttpServletRequestBuilder builder =
|
||||
MockMvcRequestBuilders.get("/v0/servers/{name}/versions", serverName);
|
||||
mockMvc.perform(builder)
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /v0/servers/{serverName}/versions/{version} - Service throws NacosException.
|
||||
*/
|
||||
@Test
|
||||
void testGetVersionedServerServiceException() throws Exception {
|
||||
// Setup
|
||||
String serverName = "com.example%2Fmy-server";
|
||||
String version = "1.0.0";
|
||||
ServerResponse mockServerResponse = createMockServerResponse(serverName, version);
|
||||
when(nacosMcpRegistryService.getServer(eq(serverName), isNull(), eq(version)))
|
||||
.thenReturn(mockServerResponse);
|
||||
|
||||
// Execute & Verify
|
||||
MockHttpServletRequestBuilder builder =
|
||||
MockMvcRequestBuilders.get("/v0/servers/{serverName}/versions/{version}",
|
||||
serverName, version);
|
||||
mockMvc.perform(builder)
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test pagination cursor calculation.
|
||||
*/
|
||||
@Test
|
||||
void testListMcpServersPaginationCursor() throws Exception {
|
||||
// Setup
|
||||
McpRegistryServerList mockServerList = createMockServerList(5);
|
||||
when(nacosMcpRegistryService.listMcpServers(any())).thenReturn(mockServerList);
|
||||
|
||||
// Execute with offset 10
|
||||
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get("/v0/servers")
|
||||
.param("cursor", "10");
|
||||
String responseContent = mockMvc.perform(builder)
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
// Verify cursor calculation: offset (10) + returned (5) = 15
|
||||
McpRegistryServerList response =
|
||||
JacksonUtils.toObj(responseContent, McpRegistryServerList.class);
|
||||
assertNotNull(response.getMetadata());
|
||||
assertEquals("15", response.getMetadata().getNextCursor());
|
||||
assertEquals(5, response.getMetadata().getCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create a mock ServerResponse.
|
||||
*/
|
||||
private ServerResponse createMockServerResponse(String serverName, String version) {
|
||||
ServerResponse response = new ServerResponse();
|
||||
com.alibaba.nacos.api.ai.model.mcp.registry.McpRegistryServerDetail server =
|
||||
new com.alibaba.nacos.api.ai.model.mcp.registry.McpRegistryServerDetail();
|
||||
server.setName(serverName);
|
||||
server.setDescription("Test MCP server description");
|
||||
server.setVersion(version);
|
||||
response.setServer(server);
|
||||
|
||||
ServerResponse.Meta meta = new ServerResponse.Meta();
|
||||
com.alibaba.nacos.api.ai.model.mcp.registry.OfficialMeta officialMeta =
|
||||
new com.alibaba.nacos.api.ai.model.mcp.registry.OfficialMeta();
|
||||
officialMeta.setStatus("active");
|
||||
officialMeta.setIsLatest(true);
|
||||
meta.setOfficial(officialMeta);
|
||||
response.setMeta(meta);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create a mock McpRegistryServerList.
|
||||
*/
|
||||
private McpRegistryServerList createMockServerList(int count) {
|
||||
McpRegistryServerList serverList = new McpRegistryServerList();
|
||||
List<ServerResponse> servers = new ArrayList<>();
|
||||
for (int i = 0; i < count; i++) {
|
||||
ServerResponse serverResponse = createMockServerResponse(
|
||||
"com.example%2Fserver-" + i,
|
||||
"1.0." + i);
|
||||
servers.add(serverResponse);
|
||||
}
|
||||
serverList.setServers(servers);
|
||||
return serverList;
|
||||
}
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.controller;
|
||||
|
||||
import com.alibaba.nacos.common.utils.JacksonUtils;
|
||||
import com.alibaba.nacos.airegistry.model.skills.SkillsSearchItem;
|
||||
import com.alibaba.nacos.airegistry.model.skills.SkillsSearchResponse;
|
||||
import com.alibaba.nacos.airegistry.model.skills.WellKnownSkillEntry;
|
||||
import com.alibaba.nacos.airegistry.model.skills.WellKnownSkillsIndex;
|
||||
import com.alibaba.nacos.airegistry.service.NacosSkillsRegistryService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.test.context.web.WebAppConfiguration;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.servlet.HandlerMapping;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link SkillsRegistryController}.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@ContextConfiguration(classes = org.springframework.mock.web.MockServletContext.class)
|
||||
@WebAppConfiguration
|
||||
class SkillsRegistryControllerTest {
|
||||
|
||||
@InjectMocks
|
||||
private SkillsRegistryController skillsRegistryController;
|
||||
|
||||
@Mock
|
||||
private NacosSkillsRegistryService nacosSkillsRegistryService;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
private static final String SCHEMA_0_2 =
|
||||
"https://schemas.agentskills.io/discovery/0.2.0/schema.json";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(skillsRegistryController).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAgentSkillsIndex() throws Exception {
|
||||
WellKnownSkillEntry entry = new WellKnownSkillEntry();
|
||||
entry.setName("demo-skill");
|
||||
entry.setDescription("demo");
|
||||
entry.setType("archive");
|
||||
entry.setUrl("demo-skill.zip");
|
||||
entry.setDigest("sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
|
||||
WellKnownSkillsIndex index = new WellKnownSkillsIndex();
|
||||
index.setSchema(SCHEMA_0_2);
|
||||
index.setSkills(List.of(entry));
|
||||
when(nacosSkillsRegistryService.buildAgentSkillsIndex("public")).thenReturn(index);
|
||||
|
||||
String content = mockMvc.perform(
|
||||
MockMvcRequestBuilders.get("/registry/public/.well-known/agent-skills/index.json"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
WellKnownSkillsIndex response = JacksonUtils.toObj(content, WellKnownSkillsIndex.class);
|
||||
assertEquals(SCHEMA_0_2, response.getSchema());
|
||||
assertEquals(1, response.getSkills().size());
|
||||
assertEquals("demo-skill", response.getSkills().get(0).getName());
|
||||
assertEquals("archive", response.getSkills().get(0).getType());
|
||||
assertEquals("demo-skill.zip", response.getSkills().get(0).getUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetLegacySkillsIndex() throws Exception {
|
||||
WellKnownSkillEntry entry = new WellKnownSkillEntry();
|
||||
entry.setName("demo-skill");
|
||||
entry.setDescription("demo");
|
||||
entry.setFiles(List.of("SKILL.md", "docs/guide.md"));
|
||||
WellKnownSkillsIndex index = new WellKnownSkillsIndex();
|
||||
index.setSkills(List.of(entry));
|
||||
when(nacosSkillsRegistryService.buildLegacySkillsIndex("public")).thenReturn(index);
|
||||
|
||||
String content = mockMvc.perform(
|
||||
MockMvcRequestBuilders.get("/registry/public/.well-known/skills/index.json"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
WellKnownSkillsIndex response = JacksonUtils.toObj(content, WellKnownSkillsIndex.class);
|
||||
assertEquals(1, response.getSkills().size());
|
||||
assertEquals(List.of("SKILL.md", "docs/guide.md"),
|
||||
response.getSkills().get(0).getFiles());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSearch() throws Exception {
|
||||
SkillsSearchItem item = new SkillsSearchItem();
|
||||
item.setId("demo-skill");
|
||||
item.setName("demo-skill");
|
||||
item.setInstalls(12);
|
||||
item.setSource("http://localhost/registry/public");
|
||||
SkillsSearchResponse response = new SkillsSearchResponse();
|
||||
response.setSkills(List.of(item));
|
||||
when(nacosSkillsRegistryService.search(eq("public"), eq("demo"), eq(10),
|
||||
eq("http://localhost/registry/public"))).thenReturn(response);
|
||||
|
||||
String content = mockMvc.perform(MockMvcRequestBuilders.get("/registry/public/api/search")
|
||||
.param("q", "demo"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
SkillsSearchResponse actual = JacksonUtils.toObj(content, SkillsSearchResponse.class);
|
||||
assertEquals(1, actual.getSkills().size());
|
||||
assertEquals("demo-skill", actual.getSkills().get(0).getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillMarkdown() throws Exception {
|
||||
when(nacosSkillsRegistryService.getSkillFileContent("public", "demo-skill", "SKILL.md"))
|
||||
.thenReturn("---\nname: demo-skill\n---\n");
|
||||
|
||||
String content = mockMvc.perform(
|
||||
MockMvcRequestBuilders
|
||||
.get("/registry/public/.well-known/agent-skills/demo-skill/SKILL.md"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
assertTrue(content.contains("demo-skill"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillMarkdownNotFound() throws Exception {
|
||||
when(nacosSkillsRegistryService.getSkillFileContent("public", "missing-skill", "SKILL.md"))
|
||||
.thenReturn(null);
|
||||
|
||||
mockMvc.perform(
|
||||
MockMvcRequestBuilders
|
||||
.get("/registry/public/.well-known/agent-skills/missing-skill/SKILL.md"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillArchive() throws Exception {
|
||||
when(nacosSkillsRegistryService.getSkillArchiveContent("public", "demo-skill"))
|
||||
.thenReturn("zip".getBytes());
|
||||
|
||||
byte[] content = mockMvc.perform(
|
||||
MockMvcRequestBuilders
|
||||
.get("/registry/public/.well-known/agent-skills/demo-skill.zip"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsByteArray();
|
||||
|
||||
assertEquals("zip", new String(content, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillArchiveNotFound() throws Exception {
|
||||
when(nacosSkillsRegistryService.getSkillArchiveContent("public", "missing-skill"))
|
||||
.thenReturn(null);
|
||||
|
||||
mockMvc.perform(
|
||||
MockMvcRequestBuilders
|
||||
.get("/registry/public/.well-known/agent-skills/missing-skill.zip"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetNestedFile() throws Exception {
|
||||
when(
|
||||
nacosSkillsRegistryService.getSkillFileContent("public", "demo-skill", "docs/guide.md"))
|
||||
.thenReturn("guide");
|
||||
|
||||
String content = mockMvc.perform(
|
||||
MockMvcRequestBuilders
|
||||
.get("/registry/public/.well-known/skills/demo-skill/docs/guide.md"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
assertEquals("guide", content);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetFileNotFound() throws Exception {
|
||||
when(nacosSkillsRegistryService.getSkillFileContent("public", "demo-skill",
|
||||
"docs/missing.md"))
|
||||
.thenReturn(null);
|
||||
|
||||
mockMvc.perform(
|
||||
MockMvcRequestBuilders
|
||||
.get("/registry/public/.well-known/agent-skills/demo-skill/docs/missing.md"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractFilePathBoundaryCases() throws Exception {
|
||||
Method extractFilePath =
|
||||
SkillsRegistryController.class.getDeclaredMethod("extractFilePath",
|
||||
HttpServletRequest.class);
|
||||
extractFilePath.setAccessible(true);
|
||||
|
||||
HttpServletRequest blankRequest = mock(HttpServletRequest.class);
|
||||
when(blankRequest.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE))
|
||||
.thenReturn("/registry/public/.well-known/skills/demo-skill/");
|
||||
when(blankRequest.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE))
|
||||
.thenReturn("/registry/{namespaceId}/.well-known/skills/{skillName}/**");
|
||||
assertEquals("", extractFilePath.invoke(skillsRegistryController, blankRequest));
|
||||
|
||||
HttpServletRequest slashRequest = mock(HttpServletRequest.class);
|
||||
when(slashRequest.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE))
|
||||
.thenReturn("/docs/guide.md");
|
||||
when(slashRequest.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE))
|
||||
.thenReturn("/**");
|
||||
assertEquals("docs/guide.md", extractFilePath.invoke(skillsRegistryController,
|
||||
slashRequest));
|
||||
}
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.form;
|
||||
|
||||
import com.alibaba.nacos.ai.constant.Constants;
|
||||
import com.alibaba.nacos.api.exception.api.NacosApiException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class AiRegistryFormTest {
|
||||
|
||||
@Test
|
||||
void testListServerFormValidateAcceptsValidSearchMode() {
|
||||
ListServerForm form = new ListServerForm();
|
||||
form.setOffset(1);
|
||||
form.setLimit(Constants.MAX_LIST_SIZE);
|
||||
form.setNamespaceId("public");
|
||||
form.setServerName("server");
|
||||
form.setSearchMode(Constants.MCP_LIST_SEARCH_BLUR);
|
||||
|
||||
assertDoesNotThrow(form::validate);
|
||||
assertEquals(1, form.getOffset());
|
||||
assertEquals(Constants.MAX_LIST_SIZE, form.getLimit());
|
||||
assertEquals("public", form.getNamespaceId());
|
||||
assertEquals("server", form.getServerName());
|
||||
assertEquals(Constants.MCP_LIST_SEARCH_BLUR, form.getSearchMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListServerFormValidateRejectsNegativeOffset() {
|
||||
ListServerForm form = new ListServerForm();
|
||||
form.setOffset(-1);
|
||||
|
||||
assertThrows(NacosApiException.class, form::validate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListServerFormValidateRejectsLimitTooLarge() {
|
||||
ListServerForm form = new ListServerForm();
|
||||
form.setLimit(Constants.MAX_LIST_SIZE + 1);
|
||||
|
||||
assertThrows(NacosApiException.class, form::validate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListServerFormValidateRejectsUnknownSearchMode() {
|
||||
ListServerForm form = new ListServerForm();
|
||||
form.setSearchMode("unknown");
|
||||
|
||||
assertThrows(NacosApiException.class, form::validate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListServersOfficialFormResolveOffsetDefaultsToZero() throws Exception {
|
||||
ListServersOfficialForm form = new ListServersOfficialForm();
|
||||
|
||||
assertEquals(0, form.resolveOffset());
|
||||
form.validate();
|
||||
assertEquals(ListServersOfficialForm.DEFAULT_LIMIT, form.getLimit());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListServersOfficialFormValidateRejectsNegativeLimit() {
|
||||
ListServersOfficialForm form = new ListServersOfficialForm();
|
||||
form.setLimit(-1);
|
||||
|
||||
assertThrows(NacosApiException.class, form::validate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListServersOfficialFormValidateRejectsLimitTooLarge() {
|
||||
ListServersOfficialForm form = new ListServersOfficialForm();
|
||||
form.setLimit(ListServersOfficialForm.MAX_LIMIT + 1);
|
||||
|
||||
assertThrows(NacosApiException.class, form::validate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListServersOfficialFormResolveOffsetRejectsNegativeCursor() {
|
||||
ListServersOfficialForm form = new ListServersOfficialForm();
|
||||
form.setCursor("-1");
|
||||
|
||||
assertThrows(NacosApiException.class, form::resolveOffset);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListServersOfficialFormResolveOffsetRejectsInvalidCursor() {
|
||||
ListServersOfficialForm form = new ListServersOfficialForm();
|
||||
form.setCursor("abc");
|
||||
|
||||
assertThrows(NacosApiException.class, form::resolveOffset);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListServersOfficialFormGetterAndSetter() throws Exception {
|
||||
ListServersOfficialForm form = new ListServersOfficialForm();
|
||||
form.setCursor("7");
|
||||
form.setLimit(null);
|
||||
form.setSearch("redis");
|
||||
form.setUpdatedSince("2026-06-01T00:00:00Z");
|
||||
|
||||
form.validate();
|
||||
|
||||
assertEquals(7, form.resolveOffset());
|
||||
assertEquals(ListServersOfficialForm.DEFAULT_LIMIT, form.getLimit());
|
||||
assertEquals("redis", form.getSearch());
|
||||
assertEquals("2026-06-01T00:00:00Z", form.getUpdatedSince());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListServersNacosFormGetterAndSetter() {
|
||||
ListServersNacosForm form = new ListServersNacosForm();
|
||||
form.setNamespaceId("namespace");
|
||||
|
||||
assertEquals("namespace", form.getNamespaceId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetServerFormValidateAndNamespace() {
|
||||
GetServerForm form = new GetServerForm();
|
||||
form.setNamespaceId("namespace");
|
||||
|
||||
assertDoesNotThrow(form::validate);
|
||||
assertEquals("namespace", form.getNamespaceId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSkillsSearchFormValidateRequiresNamespaceId() {
|
||||
SkillsSearchForm form = new SkillsSearchForm();
|
||||
|
||||
assertThrows(NacosApiException.class, form::validate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSkillsSearchFormValidateDefaultsAndCapsLimit() throws Exception {
|
||||
SkillsSearchForm form = new SkillsSearchForm();
|
||||
form.setNamespaceId("namespace");
|
||||
form.setQ("deploy");
|
||||
form.setLimit(100);
|
||||
|
||||
form.validate();
|
||||
|
||||
assertEquals("namespace", form.getNamespaceId());
|
||||
assertEquals("deploy", form.getQ());
|
||||
assertEquals(10, form.getLimit());
|
||||
|
||||
form.setLimit(0);
|
||||
form.validate();
|
||||
assertEquals(10, form.getLimit());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSkillsFileQueryFormValidateRequiresFields() {
|
||||
SkillsFileQueryForm form = new SkillsFileQueryForm();
|
||||
|
||||
assertThrows(NacosApiException.class, form::validate);
|
||||
|
||||
form.setNamespaceId("namespace");
|
||||
assertThrows(NacosApiException.class, form::validate);
|
||||
|
||||
form.setSkillName("skill");
|
||||
assertThrows(NacosApiException.class, form::validate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSkillsFileQueryFormValidateRejectsUnsafeFilePath() {
|
||||
SkillsFileQueryForm form = new SkillsFileQueryForm();
|
||||
form.setNamespaceId("namespace");
|
||||
form.setSkillName("skill");
|
||||
form.setFilePath("../README.md");
|
||||
|
||||
assertThrows(NacosApiException.class, form::validate);
|
||||
|
||||
form.setFilePath("/README.md");
|
||||
assertThrows(NacosApiException.class, form::validate);
|
||||
|
||||
form.setFilePath("\\README.md");
|
||||
assertThrows(NacosApiException.class, form::validate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSkillsFileQueryFormValidateAcceptsRelativeFilePath() {
|
||||
SkillsFileQueryForm form = new SkillsFileQueryForm();
|
||||
form.setNamespaceId("namespace");
|
||||
form.setSkillName("skill");
|
||||
form.setFilePath("docs/README.md");
|
||||
|
||||
assertDoesNotThrow(form::validate);
|
||||
assertEquals("namespace", form.getNamespaceId());
|
||||
assertEquals("skill", form.getSkillName());
|
||||
assertEquals("docs/README.md", form.getFilePath());
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.model.skills;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class SkillsModelTest {
|
||||
|
||||
@Test
|
||||
void testSkillsSearchItemGetterAndSetter() {
|
||||
SkillsSearchItem item = new SkillsSearchItem();
|
||||
item.setId("skill-id");
|
||||
item.setName("deploy");
|
||||
item.setInstalls(10L);
|
||||
item.setSource("skills-sh");
|
||||
|
||||
assertEquals("skill-id", item.getId());
|
||||
assertEquals("deploy", item.getName());
|
||||
assertEquals(10L, item.getInstalls());
|
||||
assertEquals("skills-sh", item.getSource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSkillsSearchResponseGetterAndSetter() {
|
||||
SkillsSearchItem item = new SkillsSearchItem();
|
||||
SkillsSearchResponse response = new SkillsSearchResponse();
|
||||
response.setSkills(Collections.singletonList(item));
|
||||
|
||||
assertEquals(Collections.singletonList(item), response.getSkills());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testWellKnownSkillEntryGetterAndSetter() {
|
||||
WellKnownSkillEntry entry = new WellKnownSkillEntry();
|
||||
entry.setName("deploy");
|
||||
entry.setType("skill");
|
||||
entry.setDescription("Deploy app");
|
||||
entry.setUrl("https://example.com/skill");
|
||||
entry.setDigest("sha256:123");
|
||||
entry.setVersion("1.0.0");
|
||||
entry.setFiles(Collections.singletonList("SKILL.md"));
|
||||
|
||||
assertEquals("deploy", entry.getName());
|
||||
assertEquals("skill", entry.getType());
|
||||
assertEquals("Deploy app", entry.getDescription());
|
||||
assertEquals("https://example.com/skill", entry.getUrl());
|
||||
assertEquals("sha256:123", entry.getDigest());
|
||||
assertEquals("1.0.0", entry.getVersion());
|
||||
assertEquals(Collections.singletonList("SKILL.md"), entry.getFiles());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testWellKnownSkillsIndexGetterAndSetter() {
|
||||
WellKnownSkillEntry entry = new WellKnownSkillEntry();
|
||||
WellKnownSkillsIndex index = new WellKnownSkillsIndex();
|
||||
index.setSchema("https://schemas.example.com/skills.json");
|
||||
index.setSkills(Collections.singletonList(entry));
|
||||
|
||||
assertEquals("https://schemas.example.com/skills.json", index.getSchema());
|
||||
assertEquals(Collections.singletonList(entry), index.getSkills());
|
||||
}
|
||||
}
|
||||
+548
@@ -0,0 +1,548 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.service;
|
||||
|
||||
import com.alibaba.nacos.ai.service.McpServerOperationService;
|
||||
import com.alibaba.nacos.ai.index.McpServerIndex;
|
||||
import com.alibaba.nacos.ai.model.mcp.McpServerIndexData;
|
||||
import com.alibaba.nacos.api.ai.constant.AiConstants;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.McpEndpointInfo;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.McpServerBasicInfo;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.McpServerDetailInfo;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.McpToolSpecification;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.registry.McpRegistryServerList;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.registry.Remote;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.registry.ServerResponse;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.registry.ServerVersionDetail;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.api.model.Page;
|
||||
import com.alibaba.nacos.api.model.response.Namespace;
|
||||
import com.alibaba.nacos.core.service.NamespaceOperationService;
|
||||
import com.alibaba.nacos.airegistry.form.ListServerForm;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class NacosAiRegistryServiceTest {
|
||||
|
||||
private static final String RANDOM_NAMESPACE_ID = UUID.randomUUID().toString();
|
||||
|
||||
@Mock
|
||||
private McpServerOperationService mcpServerOperationService;
|
||||
|
||||
@Mock
|
||||
private NamespaceOperationService namespaceOperationService;
|
||||
|
||||
@Mock
|
||||
private McpServerIndex mcpServerIndex;
|
||||
|
||||
NacosMcpRegistryService mcpRegistryService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mcpRegistryService =
|
||||
new NacosMcpRegistryService(mcpServerOperationService, namespaceOperationService,
|
||||
mcpServerIndex);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMcpServersWithZeroOffset() {
|
||||
ListServerForm listServerForm = new ListServerForm();
|
||||
listServerForm.setOffset(0);
|
||||
listServerForm.setLimit(0);
|
||||
mockMultipleNamespace();
|
||||
mockListMcpServerWithPage(AiConstants.Mcp.MCP_DEFAULT_NAMESPACE, 2);
|
||||
mockListMcpServerWithPage(RANDOM_NAMESPACE_ID, 10);
|
||||
McpRegistryServerList actual = mcpRegistryService.listMcpServers(listServerForm);
|
||||
assertTrue(actual.getServers().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMcpServersWithOffsetLargeThenTotalCount() {
|
||||
ListServerForm listServerForm = new ListServerForm();
|
||||
listServerForm.setOffset(100);
|
||||
listServerForm.setLimit(10);
|
||||
mockMultipleNamespace();
|
||||
mockListMcpServerWithPage(AiConstants.Mcp.MCP_DEFAULT_NAMESPACE, 2);
|
||||
mockListMcpServerWithPage(RANDOM_NAMESPACE_ID, 10);
|
||||
McpRegistryServerList actual = mcpRegistryService.listMcpServers(listServerForm);
|
||||
assertTrue(actual.getServers().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMcpServersWithoutOffsetAndLargeOffset() {
|
||||
ListServerForm listServerForm = new ListServerForm();
|
||||
listServerForm.setOffset(0);
|
||||
listServerForm.setLimit(100);
|
||||
mockMultipleNamespace();
|
||||
mockListMcpServerWithPage(AiConstants.Mcp.MCP_DEFAULT_NAMESPACE, 2);
|
||||
mockListMcpServerWithPage(RANDOM_NAMESPACE_ID, 10);
|
||||
McpRegistryServerList actual = mcpRegistryService.listMcpServers(listServerForm);
|
||||
assertEquals(12, actual.getServers().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMcpServerWithoutOffsetAndSmallLimit() {
|
||||
ListServerForm listServerForm = new ListServerForm();
|
||||
listServerForm.setOffset(0);
|
||||
listServerForm.setLimit(5);
|
||||
mockMultipleNamespace();
|
||||
mockListMcpServerWithPage(AiConstants.Mcp.MCP_DEFAULT_NAMESPACE, 2);
|
||||
mockListMcpServerWithPage(RANDOM_NAMESPACE_ID, 10);
|
||||
McpRegistryServerList actual = mcpRegistryService.listMcpServers(listServerForm);
|
||||
assertEquals(5, actual.getServers().size());
|
||||
for (ServerResponse each : actual.getServers()) {
|
||||
assertTrue(each.getServer().getDescription().endsWith(RANDOM_NAMESPACE_ID));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMcpServerWithoutOffsetAndLimitOverNamespace() {
|
||||
ListServerForm listServerForm = new ListServerForm();
|
||||
listServerForm.setOffset(0);
|
||||
listServerForm.setLimit(11);
|
||||
mockMultipleNamespace();
|
||||
mockListMcpServerWithPage(AiConstants.Mcp.MCP_DEFAULT_NAMESPACE, 2);
|
||||
mockListMcpServerWithPage(RANDOM_NAMESPACE_ID, 10);
|
||||
McpRegistryServerList actual = mcpRegistryService.listMcpServers(listServerForm);
|
||||
assertEquals(11, actual.getServers().size());
|
||||
for (int i = 0; i < 10; i++) {
|
||||
assertTrue(actual.getServers().get(i).getServer().getDescription()
|
||||
.endsWith(RANDOM_NAMESPACE_ID));
|
||||
}
|
||||
assertTrue(actual.getServers().get(10).getServer().getDescription()
|
||||
.endsWith(AiConstants.Mcp.MCP_DEFAULT_NAMESPACE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMcpServerWithOffsetAndLargeLimit() {
|
||||
ListServerForm listServerForm = new ListServerForm();
|
||||
listServerForm.setOffset(5);
|
||||
listServerForm.setLimit(100);
|
||||
mockMultipleNamespace();
|
||||
// After sorting, RANDOM_NAMESPACE_ID comes first, then public
|
||||
mockListMcpServerWithPage(RANDOM_NAMESPACE_ID, 10);
|
||||
mockListMcpServerWithPage(AiConstants.Mcp.MCP_DEFAULT_NAMESPACE, 2);
|
||||
McpRegistryServerList actual = mcpRegistryService.listMcpServers(listServerForm);
|
||||
// offset=5, first ns has 10, so get 10-5=5, then second ns has 2, so get 2. Total 7.
|
||||
assertEquals(7, actual.getServers().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMcpServerWithOffsetAndSmallLimit() {
|
||||
ListServerForm listServerForm = new ListServerForm();
|
||||
listServerForm.setOffset(5);
|
||||
listServerForm.setLimit(4);
|
||||
mockMultipleNamespace();
|
||||
mockListMcpServerWithPage(AiConstants.Mcp.MCP_DEFAULT_NAMESPACE, 2);
|
||||
mockListMcpServerWithPage(RANDOM_NAMESPACE_ID, 10);
|
||||
McpRegistryServerList actual = mcpRegistryService.listMcpServers(listServerForm);
|
||||
assertEquals(4, actual.getServers().size());
|
||||
for (ServerResponse each : actual.getServers()) {
|
||||
assertTrue(each.getServer().getDescription().endsWith(RANDOM_NAMESPACE_ID));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMcpServerWithOffsetAndLimitOverNamespace() {
|
||||
ListServerForm listServerForm = new ListServerForm();
|
||||
listServerForm.setOffset(5);
|
||||
listServerForm.setLimit(6);
|
||||
mockMultipleNamespace();
|
||||
mockListMcpServerWithPage(AiConstants.Mcp.MCP_DEFAULT_NAMESPACE, 2);
|
||||
mockListMcpServerWithPage(RANDOM_NAMESPACE_ID, 10);
|
||||
McpRegistryServerList actual = mcpRegistryService.listMcpServers(listServerForm);
|
||||
assertEquals(6, actual.getServers().size());
|
||||
for (int i = 0; i < 5; i++) {
|
||||
assertTrue(actual.getServers().get(i).getServer().getDescription()
|
||||
.endsWith(RANDOM_NAMESPACE_ID));
|
||||
}
|
||||
assertTrue(actual.getServers().get(5).getServer().getDescription()
|
||||
.endsWith(AiConstants.Mcp.MCP_DEFAULT_NAMESPACE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMcpServerWithOffsetOverNamespace() {
|
||||
ListServerForm listServerForm = new ListServerForm();
|
||||
listServerForm.setOffset(10);
|
||||
listServerForm.setLimit(10);
|
||||
mockMultipleNamespace();
|
||||
mockListMcpServerWithPage(AiConstants.Mcp.MCP_DEFAULT_NAMESPACE, 2);
|
||||
mockListMcpServerWithPage(RANDOM_NAMESPACE_ID, 10);
|
||||
McpRegistryServerList actual = mcpRegistryService.listMcpServers(listServerForm);
|
||||
assertEquals(2, actual.getServers().size());
|
||||
for (ServerResponse each : actual.getServers()) {
|
||||
assertTrue(
|
||||
each.getServer().getDescription().endsWith(AiConstants.Mcp.MCP_DEFAULT_NAMESPACE));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMcpServerForTargetNamespace() {
|
||||
ListServerForm listServerForm = new ListServerForm();
|
||||
listServerForm.setOffset(0);
|
||||
listServerForm.setLimit(30);
|
||||
listServerForm.setNamespaceId(RANDOM_NAMESPACE_ID);
|
||||
listServerForm.setServerName(null);
|
||||
mockListMcpServerWithPage(RANDOM_NAMESPACE_ID, 10);
|
||||
McpRegistryServerList actual = mcpRegistryService.listMcpServers(listServerForm);
|
||||
assertEquals(10, actual.getServers().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMcpServersSkipsEmptyNamespaceAndFailedDetail() throws NacosException {
|
||||
ListServerForm listServerForm = new ListServerForm();
|
||||
listServerForm.setOffset(0);
|
||||
listServerForm.setLimit(2);
|
||||
listServerForm.setNamespaceId(RANDOM_NAMESPACE_ID);
|
||||
Page<McpServerBasicInfo> countPage = new Page<>();
|
||||
countPage.setTotalCount(0);
|
||||
countPage.setPageItems(new LinkedList<>());
|
||||
when(mcpServerOperationService.listMcpServerWithPage(eq(RANDOM_NAMESPACE_ID),
|
||||
Mockito.any(), Mockito.any(), eq(1), eq(1))).thenReturn(countPage);
|
||||
assertTrue(mcpRegistryService.listMcpServers(listServerForm).getServers().isEmpty());
|
||||
|
||||
McpServerBasicInfo basicInfo = mockMcpServerBasicInfo(0, RANDOM_NAMESPACE_ID);
|
||||
Page<McpServerBasicInfo> dataPage = new Page<>();
|
||||
dataPage.setTotalCount(1);
|
||||
dataPage.setPageItems(List.of(basicInfo));
|
||||
when(mcpServerOperationService.listMcpServerWithPage(eq(RANDOM_NAMESPACE_ID),
|
||||
Mockito.any(), Mockito.any(), Mockito.anyInt(), Mockito.anyInt()))
|
||||
.thenReturn(dataPage);
|
||||
when(mcpServerOperationService.getMcpServerDetail(RANDOM_NAMESPACE_ID, null,
|
||||
basicInfo.getName(), null))
|
||||
.thenThrow(new NacosException(NacosException.SERVER_ERROR, "boom"));
|
||||
|
||||
assertTrue(mcpRegistryService.listMcpServers(listServerForm).getServers().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getServerNotFound() throws NacosException {
|
||||
String serverName = "nonExistentServer";
|
||||
when(mcpServerOperationService.getMcpServerDetail(null, null, serverName, null))
|
||||
.thenThrow(new NacosException(NacosException.NOT_FOUND, "not found"));
|
||||
assertNull(mcpRegistryService.getServer(serverName, null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getServerRethrowsUnexpectedException() throws NacosException {
|
||||
when(mcpServerOperationService.getMcpServerDetail(null, null, "boom", null))
|
||||
.thenThrow(new NacosException(NacosException.SERVER_ERROR, "boom"));
|
||||
|
||||
assertThrows(NacosException.class, () -> mcpRegistryService.getServer("boom", null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getServerWithoutBackendEndpoints() throws NacosException {
|
||||
String id = UUID.randomUUID().toString();
|
||||
when(mcpServerOperationService.getMcpServerDetail(RANDOM_NAMESPACE_ID, null, id, null))
|
||||
.thenReturn(
|
||||
mockMcpServerDetailInfo(id, RANDOM_NAMESPACE_ID, false, false));
|
||||
ServerResponse result = mcpRegistryService.getServer(id, RANDOM_NAMESPACE_ID, null);
|
||||
assertNotNull(result);
|
||||
assertNotNull(result.getServer());
|
||||
assertEquals("mockMcpServer", result.getServer().getName());
|
||||
assertEquals("Description:" + RANDOM_NAMESPACE_ID, result.getServer().getDescription());
|
||||
assertNull(result.getServer().getRepository());
|
||||
assertEquals("1.0.0", result.getServer().getVersion());
|
||||
assertNotNull(result.getMeta());
|
||||
assertNotNull(result.getMeta().getOfficial());
|
||||
assertEquals("2025-06-10T02:29:17Z", result.getMeta().getOfficial().getPublishedAt());
|
||||
assertNull(result.getServer().getRemotes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getServerWithBackendEndpoints() throws NacosException {
|
||||
String id = UUID.randomUUID().toString();
|
||||
when(mcpServerOperationService.getMcpServerDetail(RANDOM_NAMESPACE_ID, null, id, null))
|
||||
.thenReturn(
|
||||
mockMcpServerDetailInfo(id, RANDOM_NAMESPACE_ID, true, false));
|
||||
ServerResponse result = mcpRegistryService.getServer(id, RANDOM_NAMESPACE_ID, null);
|
||||
assertNotNull(result);
|
||||
assertNotNull(result.getServer());
|
||||
assertEquals("mockMcpServer", result.getServer().getName());
|
||||
assertEquals("Description:" + RANDOM_NAMESPACE_ID, result.getServer().getDescription());
|
||||
assertNull(result.getServer().getRepository());
|
||||
assertEquals("1.0.0", result.getServer().getVersion());
|
||||
assertNotNull(result.getMeta());
|
||||
assertNotNull(result.getMeta().getOfficial());
|
||||
assertEquals("2025-06-10T02:29:17Z", result.getMeta().getOfficial().getPublishedAt());
|
||||
assertNotNull(result.getServer().getRemotes());
|
||||
assertEquals(1, result.getServer().getRemotes().size());
|
||||
assertEquals("sse", result.getServer().getRemotes().get(0).getType());
|
||||
assertEquals("http://127.0.0.1:8080/api/path",
|
||||
result.getServer().getRemotes().get(0).getUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getToolsNotFound() throws NacosException {
|
||||
String id = UUID.randomUUID().toString();
|
||||
when(mcpServerOperationService.getMcpServerDetail(null, null, id, null)).thenReturn(null);
|
||||
assertNull(mcpRegistryService.getTools(id, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTools() throws NacosException {
|
||||
String id = UUID.randomUUID().toString();
|
||||
when(mcpServerOperationService.getMcpServerDetail(null, id, null, null)).thenReturn(
|
||||
mockMcpServerDetailInfo(id, RANDOM_NAMESPACE_ID, false, true));
|
||||
when(mcpServerIndex.getMcpServerById(eq(id))).thenReturn(new McpServerIndexData());
|
||||
assertNotNull(mcpRegistryService.getTools(id, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getServerVersionsReturnsSortedVersionsAndMetadata() throws NacosException {
|
||||
McpServerDetailInfo latest =
|
||||
mockMcpServerDetailInfo("id", RANDOM_NAMESPACE_ID, true, false);
|
||||
ServerVersionDetail v2 = new ServerVersionDetail();
|
||||
v2.setVersion("2.0.0");
|
||||
v2.setRelease_date("2025-06-11T02:29:17Z");
|
||||
ServerVersionDetail v1 = new ServerVersionDetail();
|
||||
v1.setVersion("1.0.0");
|
||||
v1.setRelease_date("2025-06-10T02:29:17Z");
|
||||
latest.setAllVersions(new LinkedList<>(List.of(v2, v1)));
|
||||
when(mcpServerOperationService.getMcpServerDetail(RANDOM_NAMESPACE_ID, null,
|
||||
"mockMcpServer", null)).thenReturn(latest);
|
||||
when(mcpServerOperationService.getMcpServerDetail(RANDOM_NAMESPACE_ID, null,
|
||||
"mockMcpServer", "1.0.0"))
|
||||
.thenReturn(mockMcpServerDetailInfo("id", RANDOM_NAMESPACE_ID, true, false));
|
||||
McpServerDetailInfo version2 =
|
||||
mockMcpServerDetailInfo("id", RANDOM_NAMESPACE_ID, true, false);
|
||||
version2.getVersionDetail().setVersion("2.0.0");
|
||||
when(mcpServerOperationService.getMcpServerDetail(RANDOM_NAMESPACE_ID, null,
|
||||
"mockMcpServer", "2.0.0")).thenReturn(version2);
|
||||
|
||||
McpRegistryServerList result =
|
||||
mcpRegistryService.getServerVersions(RANDOM_NAMESPACE_ID, "mockMcpServer");
|
||||
|
||||
assertEquals(2, result.getServers().size());
|
||||
assertEquals(2, result.getMetadata().getCount());
|
||||
assertEquals("1.0.0", result.getServers().get(0).getServer().getVersion());
|
||||
assertEquals("2.0.0", result.getServers().get(1).getServer().getVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getServerVersionsHandlesErrors() throws NacosException {
|
||||
when(mcpServerOperationService.getMcpServerDetail(RANDOM_NAMESPACE_ID, null, "missing",
|
||||
null))
|
||||
.thenThrow(new NacosException(NacosException.NOT_FOUND, "not found"));
|
||||
assertNull(mcpRegistryService.getServerVersions(RANDOM_NAMESPACE_ID, "missing"));
|
||||
|
||||
when(mcpServerOperationService.getMcpServerDetail(RANDOM_NAMESPACE_ID, null, "boom", null))
|
||||
.thenThrow(new NacosException(NacosException.SERVER_ERROR, "boom"));
|
||||
assertThrows(NacosException.class,
|
||||
() -> mcpRegistryService.getServerVersions(RANDOM_NAMESPACE_ID, "boom"));
|
||||
|
||||
McpServerDetailInfo latest =
|
||||
mockMcpServerDetailInfo("id", RANDOM_NAMESPACE_ID, false, false);
|
||||
ServerVersionDetail v1 = new ServerVersionDetail();
|
||||
v1.setVersion("1.0.0");
|
||||
latest.setAllVersions(new LinkedList<>(List.of(v1)));
|
||||
when(mcpServerOperationService.getMcpServerDetail(RANDOM_NAMESPACE_ID, null,
|
||||
"inner-boom", null)).thenReturn(latest);
|
||||
when(mcpServerOperationService.getMcpServerDetail(RANDOM_NAMESPACE_ID, null,
|
||||
"inner-boom", "1.0.0"))
|
||||
.thenThrow(new NacosException(NacosException.SERVER_ERROR, "inner boom"));
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> mcpRegistryService.getServerVersions(RANDOM_NAMESPACE_ID, "inner-boom"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void privateEndpointHelpersCoverProtocolBranches() throws Exception {
|
||||
Method pickEndpoints = NacosMcpRegistryService.class.getDeclaredMethod("pickEndpoints",
|
||||
List.class, List.class);
|
||||
pickEndpoints.setAccessible(true);
|
||||
List<McpEndpointInfo> frontend =
|
||||
List.of(buildEndpoint("https", "example.com", 443, "/mcp"));
|
||||
List<McpEndpointInfo> backend = List.of(buildEndpoint("http", "127.0.0.1", 8080, "/api"));
|
||||
assertEquals(frontend, pickEndpoints.invoke(mcpRegistryService, frontend, backend));
|
||||
|
||||
Method toRemotes =
|
||||
NacosMcpRegistryService.class.getDeclaredMethod("toRemotes", List.class, String.class);
|
||||
toRemotes.setAccessible(true);
|
||||
assertNull(toRemotes.invoke(mcpRegistryService, List.of(), "sse"));
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Remote> remotes =
|
||||
(List<Remote>) toRemotes.invoke(mcpRegistryService, frontend, "streamable");
|
||||
assertEquals("https://example.com/mcp", remotes.get(0).getUrl());
|
||||
|
||||
Method buildUrl =
|
||||
NacosMcpRegistryService.class.getDeclaredMethod("buildUrl", McpEndpointInfo.class);
|
||||
buildUrl.setAccessible(true);
|
||||
assertEquals("http://example.com/mcp",
|
||||
buildUrl.invoke(mcpRegistryService, buildEndpoint("http", "example.com", 80, "/mcp")));
|
||||
|
||||
Method buildRemotes =
|
||||
NacosMcpRegistryService.class.getDeclaredMethod("buildRemotes",
|
||||
McpServerDetailInfo.class);
|
||||
buildRemotes.setAccessible(true);
|
||||
McpServerDetailInfo streamable = mockMcpServerDetailInfo("id", RANDOM_NAMESPACE_ID, false,
|
||||
false);
|
||||
streamable.setFrontProtocol(AiConstants.Mcp.MCP_PROTOCOL_STREAMABLE);
|
||||
streamable.setFrontendEndpoints(frontend);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Remote> streamableRemotes =
|
||||
(List<Remote>) buildRemotes.invoke(mcpRegistryService, streamable);
|
||||
assertEquals(AiConstants.Mcp.OFFICIAL_TRANSPORT_STREAMABLE,
|
||||
streamableRemotes.get(0).getType());
|
||||
|
||||
McpServerDetailInfo unknownProtocol =
|
||||
mockMcpServerDetailInfo("id", RANDOM_NAMESPACE_ID, false, false);
|
||||
unknownProtocol.setFrontProtocol("unknown");
|
||||
unknownProtocol.setFrontendEndpoints(frontend);
|
||||
assertNull(buildRemotes.invoke(mcpRegistryService, unknownProtocol));
|
||||
}
|
||||
|
||||
private void mockMultipleNamespace() {
|
||||
Namespace namespace1 = new Namespace(AiConstants.Mcp.MCP_DEFAULT_NAMESPACE,
|
||||
AiConstants.Mcp.MCP_DEFAULT_NAMESPACE);
|
||||
Namespace namespace2 = new Namespace(RANDOM_NAMESPACE_ID, "test");
|
||||
List<Namespace> namespaces = List.of(namespace1, namespace2);
|
||||
when(namespaceOperationService.getNamespaceList()).thenReturn(namespaces);
|
||||
}
|
||||
|
||||
private void mockListMcpServerWithPage(String namespaceId, int totalCount) {
|
||||
List<McpServerBasicInfo> allServers = new LinkedList<>();
|
||||
for (int i = 0; i < totalCount; i++) {
|
||||
McpServerBasicInfo basicInfo = mockMcpServerBasicInfo(i, namespaceId);
|
||||
allServers.add(basicInfo);
|
||||
// ensure getServer won't return null by mocking detail lookup for each generated name
|
||||
try {
|
||||
Mockito.lenient()
|
||||
.when(mcpServerOperationService.getMcpServerDetail(namespaceId, null,
|
||||
basicInfo.getName(), null))
|
||||
.thenReturn(
|
||||
mockMcpServerDetailInfo(basicInfo.getId(), namespaceId, false, false));
|
||||
} catch (NacosException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Mock listMcpServerWithPage for any pageNum, pageSize, and serverName combination
|
||||
Mockito.lenient()
|
||||
.when(mcpServerOperationService.listMcpServerWithPage(eq(namespaceId), Mockito.any(),
|
||||
Mockito.any(), Mockito.anyInt(), Mockito.anyInt()))
|
||||
.thenAnswer(invocation -> {
|
||||
int pageNum = invocation.getArgument(3);
|
||||
int pageSize = invocation.getArgument(4);
|
||||
Page<McpServerBasicInfo> dataPage = new Page<>();
|
||||
dataPage.setPageNumber(pageNum);
|
||||
dataPage.setTotalCount(totalCount);
|
||||
|
||||
// Get items for this page from the full list
|
||||
int pageStart = (pageNum - 1) * pageSize;
|
||||
int pageEnd = Math.min(pageStart + pageSize, totalCount);
|
||||
|
||||
if (pageStart < totalCount && pageEnd > pageStart) {
|
||||
List<McpServerBasicInfo> pageItems =
|
||||
new LinkedList<>(allServers.subList(pageStart, pageEnd));
|
||||
dataPage.setPageItems(pageItems);
|
||||
} else {
|
||||
dataPage.setPageItems(new LinkedList<>());
|
||||
}
|
||||
|
||||
return dataPage;
|
||||
});
|
||||
}
|
||||
|
||||
private McpServerBasicInfo mockMcpServerBasicInfo(int number, String namespaceId) {
|
||||
String id = UUID.randomUUID().toString();
|
||||
String actualServerName = "mockMcpServer:" + number;
|
||||
McpServerBasicInfo result = new McpServerBasicInfo();
|
||||
result.setId(id);
|
||||
result.setNamespaceId(namespaceId);
|
||||
result.setName(actualServerName);
|
||||
result.setDescription("Description:" + namespaceId);
|
||||
result.setVersionDetail(new ServerVersionDetail());
|
||||
result.getVersionDetail().setVersion("1.0.0");
|
||||
result.getVersionDetail().setIs_latest(true);
|
||||
result.getVersionDetail().setRelease_date("2025-06-10T02:29:17Z");
|
||||
return result;
|
||||
}
|
||||
|
||||
private McpServerDetailInfo mockMcpServerDetailInfo(String id, String namespaceId,
|
||||
boolean withBackendEndpoints,
|
||||
boolean withTools) {
|
||||
McpServerDetailInfo result = new McpServerDetailInfo();
|
||||
result.setId(id);
|
||||
result.setName("mockMcpServer");
|
||||
result.setDescription("Description:" + namespaceId);
|
||||
result.setVersionDetail(new ServerVersionDetail());
|
||||
result.getVersionDetail().setVersion("1.0.0");
|
||||
result.getVersionDetail().setIs_latest(true);
|
||||
result.getVersionDetail().setRelease_date("2025-06-10T02:29:17Z");
|
||||
|
||||
// Set allVersions for buildMeta to work correctly
|
||||
ServerVersionDetail versionDetail = new ServerVersionDetail();
|
||||
versionDetail.setVersion("1.0.0");
|
||||
versionDetail.setIs_latest(true);
|
||||
versionDetail.setRelease_date("2025-06-10T02:29:17Z");
|
||||
result.setAllVersions(new LinkedList<>(java.util.List.of(versionDetail)));
|
||||
|
||||
result.setFrontProtocol(
|
||||
withBackendEndpoints ? AiConstants.Mcp.MCP_PROTOCOL_SSE
|
||||
: AiConstants.Mcp.MCP_PROTOCOL_STDIO);
|
||||
if (withBackendEndpoints) {
|
||||
McpEndpointInfo mcpEndpointInfo = new McpEndpointInfo();
|
||||
mcpEndpointInfo.setProtocol("http");
|
||||
mcpEndpointInfo.setAddress("127.0.0.1");
|
||||
mcpEndpointInfo.setPort(8080);
|
||||
mcpEndpointInfo.setPath("/api/path");
|
||||
List<McpEndpointInfo> endpoints = new LinkedList<>();
|
||||
endpoints.add(mcpEndpointInfo);
|
||||
result.setBackendEndpoints(endpoints);
|
||||
}
|
||||
if (withTools) {
|
||||
result.setToolSpec(new McpToolSpecification());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private McpEndpointInfo buildEndpoint(String protocol, String address, int port, String path) {
|
||||
McpEndpointInfo result = new McpEndpointInfo();
|
||||
result.setProtocol(protocol);
|
||||
result.setAddress(address);
|
||||
result.setPort(port);
|
||||
result.setPath(path);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+607
@@ -0,0 +1,607 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.airegistry.service;
|
||||
|
||||
import com.alibaba.nacos.ai.model.skills.SkillIndexManifest;
|
||||
import com.alibaba.nacos.ai.service.skills.SkillIndexManifestService;
|
||||
import com.alibaba.nacos.ai.service.skills.SkillOperationService;
|
||||
import com.alibaba.nacos.api.ai.model.skills.Skill;
|
||||
import com.alibaba.nacos.api.ai.model.skills.SkillResource;
|
||||
import com.alibaba.nacos.api.ai.model.skills.SkillSummary;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.api.model.Page;
|
||||
import com.alibaba.nacos.airegistry.model.skills.SkillsSearchResponse;
|
||||
import com.alibaba.nacos.airegistry.model.skills.WellKnownSkillsIndex;
|
||||
import com.alibaba.nacos.plugin.visibility.constant.VisibilityConstants;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import static com.alibaba.nacos.ai.constant.Constants.Skills.SEARCH_ACCURATE;
|
||||
import static com.alibaba.nacos.ai.constant.Constants.Skills.SEARCH_BLUR;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link NacosSkillsRegistryService}.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class NacosSkillsRegistryServiceTest {
|
||||
|
||||
@Mock
|
||||
private SkillOperationService skillOperationService;
|
||||
|
||||
@Mock
|
||||
private SkillIndexManifestService skillIndexManifestService;
|
||||
|
||||
private static final String SCHEMA_0_2 =
|
||||
"https://schemas.agentskills.io/discovery/0.2.0/schema.json";
|
||||
|
||||
private NacosSkillsRegistryService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new NacosSkillsRegistryService(skillOperationService, skillIndexManifestService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuildLegacyIndexFiltersBinarySkill() throws Exception {
|
||||
Page<SkillSummary> page = new Page<>();
|
||||
page.setPagesAvailable(1);
|
||||
page.setPageItems(
|
||||
List.of(buildSummary("text-skill", 5L), buildSummary("binary-skill", 10L)));
|
||||
when(skillOperationService.listSkills(eq("public"), eq((String) null), eq(SEARCH_BLUR),
|
||||
eq("download_count"),
|
||||
eq(1), eq(100))).thenReturn(page);
|
||||
when(skillIndexManifestService.query("public", "text-skill"))
|
||||
.thenReturn(buildManifest("v1"));
|
||||
when(skillIndexManifestService.query("public", "binary-skill"))
|
||||
.thenReturn(buildManifest("v1"));
|
||||
when(skillOperationService.getSkillVersionDetail("public", "text-skill", "v1")).thenReturn(
|
||||
buildTextSkill("text-skill"));
|
||||
when(skillOperationService.getSkillVersionDetail("public", "binary-skill", "v1"))
|
||||
.thenReturn(
|
||||
buildBinarySkill("binary-skill"));
|
||||
|
||||
WellKnownSkillsIndex result = service.buildLegacySkillsIndex("public");
|
||||
|
||||
assertNotNull(result);
|
||||
assertNull(result.getSchema());
|
||||
assertEquals(1, result.getSkills().size());
|
||||
assertEquals("text-skill", result.getSkills().get(0).getName());
|
||||
assertEquals(List.of("SKILL.md", "docs/guide.md"), result.getSkills().get(0).getFiles());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuildAgentSkillsIndexUsesVersion020Shape() throws Exception {
|
||||
Page<SkillSummary> page = new Page<>();
|
||||
page.setPagesAvailable(1);
|
||||
page.setPageItems(List.of(buildSummary("a-simple-skill", 3L),
|
||||
buildSummary("z-archive-skill", 5L)));
|
||||
when(skillOperationService.listSkills(eq("public"), eq((String) null), eq(SEARCH_BLUR),
|
||||
eq("download_count"), eq(1), eq(100))).thenReturn(page);
|
||||
when(skillIndexManifestService.query("public", "a-simple-skill"))
|
||||
.thenReturn(buildManifest("1.0.0"));
|
||||
when(skillIndexManifestService.query("public", "z-archive-skill"))
|
||||
.thenReturn(buildManifest("1.2.0"));
|
||||
when(skillOperationService.getSkillVersionDetail("public", "a-simple-skill", "1.0.0"))
|
||||
.thenReturn(buildSimpleSkill("a-simple-skill"));
|
||||
when(skillOperationService.getSkillVersionDetail("public", "z-archive-skill", "1.2.0"))
|
||||
.thenReturn(buildTextSkill("z-archive-skill"));
|
||||
|
||||
WellKnownSkillsIndex result = service.buildAgentSkillsIndex("public");
|
||||
|
||||
assertEquals(SCHEMA_0_2, result.getSchema());
|
||||
assertEquals(2, result.getSkills().size());
|
||||
assertEquals("a-simple-skill", result.getSkills().get(0).getName());
|
||||
assertEquals("skill-md", result.getSkills().get(0).getType());
|
||||
assertEquals("a-simple-skill/SKILL.md", result.getSkills().get(0).getUrl());
|
||||
assertEquals("1.0.0", result.getSkills().get(0).getVersion());
|
||||
assertEquals(sha256("a-simple-skill"), result.getSkills().get(0).getDigest());
|
||||
assertNull(result.getSkills().get(0).getFiles());
|
||||
assertEquals("z-archive-skill", result.getSkills().get(1).getName());
|
||||
assertEquals("archive", result.getSkills().get(1).getType());
|
||||
assertEquals("z-archive-skill.zip", result.getSkills().get(1).getUrl());
|
||||
assertEquals("1.2.0", result.getSkills().get(1).getVersion());
|
||||
assertTrue(result.getSkills().get(1).getDigest().startsWith("sha256:"));
|
||||
assertNull(result.getSkills().get(1).getFiles());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSearchBuildsCliShape() throws Exception {
|
||||
Page<SkillSummary> page = new Page<>();
|
||||
page.setPagesAvailable(1);
|
||||
page.setPageItems(List.of(buildSummary("b-skill", 2L), buildSummary("a-skill", 9L)));
|
||||
when(skillOperationService.listSkills(eq("public"), eq("demo"), eq(SEARCH_BLUR),
|
||||
eq("download_count"), eq(1),
|
||||
eq(100))).thenReturn(page);
|
||||
when(skillIndexManifestService.query("public", "a-skill")).thenReturn(buildManifest("v1"));
|
||||
when(skillIndexManifestService.query("public", "b-skill")).thenReturn(buildManifest("v1"));
|
||||
when(skillOperationService.getSkillVersionDetail("public", "a-skill", "v1")).thenReturn(
|
||||
buildTextSkill("a-skill"));
|
||||
when(skillOperationService.getSkillVersionDetail("public", "b-skill", "v1")).thenReturn(
|
||||
buildTextSkill("b-skill"));
|
||||
|
||||
SkillsSearchResponse result =
|
||||
service.search("public", "demo", 10, "http://localhost/registry/public");
|
||||
|
||||
assertEquals(2, result.getSkills().size());
|
||||
assertEquals("a-skill", result.getSkills().get(0).getName());
|
||||
assertEquals(9L, result.getSkills().get(0).getInstalls());
|
||||
assertEquals("http://localhost/registry/public", result.getSkills().get(0).getSource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSearchSortsTiedDownloadCountByName() throws Exception {
|
||||
Page<SkillSummary> page = new Page<>();
|
||||
page.setPagesAvailable(1);
|
||||
page.setPageItems(List.of(buildSummary("z-skill", 5L), buildSummary("a-skill", 5L)));
|
||||
when(skillOperationService.listSkills(eq("public"), eq("tie"), eq(SEARCH_BLUR),
|
||||
eq("download_count"), eq(1), eq(100))).thenReturn(page);
|
||||
when(skillIndexManifestService.query("public", "a-skill")).thenReturn(buildManifest("v1"));
|
||||
when(skillIndexManifestService.query("public", "z-skill")).thenReturn(buildManifest("v1"));
|
||||
when(skillOperationService.getSkillVersionDetail("public", "a-skill", "v1"))
|
||||
.thenReturn(buildSimpleSkill("a-skill"));
|
||||
when(skillOperationService.getSkillVersionDetail("public", "z-skill", "v1"))
|
||||
.thenReturn(buildSimpleSkill("z-skill"));
|
||||
|
||||
SkillsSearchResponse result = service.search("public", "tie", 10, "source");
|
||||
|
||||
assertEquals("a-skill", result.getSkills().get(0).getName());
|
||||
assertEquals("z-skill", result.getSkills().get(1).getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillFileContent() throws Exception {
|
||||
Page<SkillSummary> page = new Page<>();
|
||||
page.setPagesAvailable(1);
|
||||
page.setPageItems(List.of(buildSummary("demo-skill", 1L)));
|
||||
when(skillOperationService.listSkills(eq("public"), eq("demo-skill"), eq(SEARCH_ACCURATE),
|
||||
eq("download_count"),
|
||||
eq(1), eq(1))).thenReturn(page);
|
||||
when(skillIndexManifestService.query("public", "demo-skill"))
|
||||
.thenReturn(buildManifest("v1"));
|
||||
when(skillOperationService.getSkillVersionDetail("public", "demo-skill", "v1")).thenReturn(
|
||||
buildTextSkill("demo-skill"));
|
||||
|
||||
String markdown = service.getSkillFileContent("public", "demo-skill", "SKILL.md");
|
||||
String file = service.getSkillFileContent("public", "demo-skill", "docs/guide.md");
|
||||
String missing = service.getSkillFileContent("public", "demo-skill", "docs/missing.md");
|
||||
|
||||
assertTrue(markdown.contains("name: demo-skill"));
|
||||
assertEquals("guide", file);
|
||||
assertNull(missing);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillArchiveContentUsesDownloadAndRootArchive() throws Exception {
|
||||
Page<SkillSummary> page = new Page<>();
|
||||
page.setPagesAvailable(1);
|
||||
page.setPageItems(List.of(buildSummary("demo-skill", 1L)));
|
||||
when(skillOperationService.listSkills(eq("public"), eq("demo-skill"), eq(SEARCH_ACCURATE),
|
||||
eq("download_count"), eq(1), eq(1))).thenReturn(page);
|
||||
when(skillIndexManifestService.query("public", "demo-skill"))
|
||||
.thenReturn(buildManifest("v1"));
|
||||
when(skillOperationService.downloadSkillVersion("public", "demo-skill", "v1"))
|
||||
.thenReturn(buildTextSkill("demo-skill"));
|
||||
|
||||
byte[] zipBytes = service.getSkillArchiveContent("public", "demo-skill");
|
||||
|
||||
assertZipEntryContains(zipBytes, "SKILL.md", "name: demo-skill");
|
||||
assertZipEntryContains(zipBytes, "docs/guide.md", "guide");
|
||||
verify(skillOperationService).downloadSkillVersion("public", "demo-skill", "v1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSearchStopsAtLimitAndDefaultsDownloadCount() throws Exception {
|
||||
Page<SkillSummary> page = new Page<>();
|
||||
page.setPagesAvailable(2);
|
||||
page.setPageItems(List.of(buildSummary("first", null), buildSummary("second", 2L)));
|
||||
when(skillOperationService.listSkills(eq("public"), eq("q"), eq(SEARCH_BLUR),
|
||||
eq("download_count"), eq(1), eq(100))).thenReturn(page);
|
||||
when(skillIndexManifestService.query("public", "first")).thenReturn(buildManifest("v1"));
|
||||
when(skillOperationService.getSkillVersionDetail("public", "first", "v1")).thenReturn(
|
||||
buildSimpleSkill("first"));
|
||||
|
||||
SkillsSearchResponse result = service.search("public", "q", 1, "source");
|
||||
|
||||
assertEquals(1, result.getSkills().size());
|
||||
assertEquals(0L, result.getSkills().get(0).getInstalls());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuildIndexStopsOnEmptyPageAndSkipsIneligibleSummaries() throws Exception {
|
||||
Page<SkillSummary> page = new Page<>();
|
||||
page.setPagesAvailable(1);
|
||||
SkillSummary disabled = buildSummary("disabled", 1L);
|
||||
disabled.setEnable(false);
|
||||
SkillSummary privateSkill = buildSummary("private", 1L);
|
||||
privateSkill.setScope("private");
|
||||
SkillSummary offline = buildSummary("offline", 1L);
|
||||
offline.setOnlineCnt(0);
|
||||
SkillSummary blank = buildSummary("", 1L);
|
||||
page.setPageItems(Arrays.asList(null, disabled, privateSkill, offline, blank));
|
||||
when(skillOperationService.listSkills(eq("public"), eq((String) null), eq(SEARCH_BLUR),
|
||||
eq("download_count"), eq(1), eq(100))).thenReturn(page);
|
||||
|
||||
assertTrue(service.buildAgentSkillsIndex("public").getSkills().isEmpty());
|
||||
|
||||
Page<SkillSummary> emptyPage = new Page<>();
|
||||
emptyPage.setPagesAvailable(1);
|
||||
emptyPage.setPageItems(List.of());
|
||||
when(skillOperationService.listSkills(eq("public"), eq("empty"), eq(SEARCH_BLUR),
|
||||
eq("download_count"), eq(1), eq(100))).thenReturn(emptyPage);
|
||||
assertTrue(service.search("public", "empty", 10, "source").getSkills().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoadSkillReturnsNullForMissingManifestVersionAndDeniedDetail() throws Exception {
|
||||
Page<SkillSummary> page = new Page<>();
|
||||
page.setPagesAvailable(1);
|
||||
page.setPageItems(List.of(buildSummary("missing-version", 1L),
|
||||
buildSummary("denied", 1L), buildSummary("empty-skill", 1L)));
|
||||
when(skillOperationService.listSkills(eq("public"), eq((String) null), eq(SEARCH_BLUR),
|
||||
eq("download_count"), eq(1), eq(100))).thenReturn(page);
|
||||
when(skillIndexManifestService.query("public", "missing-version"))
|
||||
.thenReturn(new SkillIndexManifest());
|
||||
when(skillIndexManifestService.query("public", "denied")).thenReturn(buildManifest("v1"));
|
||||
when(skillOperationService.getSkillVersionDetail("public", "denied", "v1"))
|
||||
.thenThrow(new NacosException(NacosException.NO_RIGHT, "denied"));
|
||||
when(skillIndexManifestService.query("public", "empty-skill"))
|
||||
.thenReturn(buildManifest("v1"));
|
||||
when(skillOperationService.getSkillVersionDetail("public", "empty-skill", "v1"))
|
||||
.thenReturn(new Skill());
|
||||
|
||||
assertTrue(service.buildAgentSkillsIndex("public").getSkills().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoadSkillRethrowsUnexpectedDetailException() throws Exception {
|
||||
Page<SkillSummary> page = new Page<>();
|
||||
page.setPagesAvailable(1);
|
||||
page.setPageItems(List.of(buildSummary("boom", 1L)));
|
||||
when(skillOperationService.listSkills(eq("public"), eq((String) null), eq(SEARCH_BLUR),
|
||||
eq("download_count"), eq(1), eq(100))).thenReturn(page);
|
||||
when(skillIndexManifestService.query("public", "boom")).thenReturn(buildManifest("v1"));
|
||||
when(skillOperationService.getSkillVersionDetail("public", "boom", "v1"))
|
||||
.thenThrow(new NacosException(NacosException.SERVER_ERROR, "boom"));
|
||||
|
||||
assertThrows(NacosException.class, () -> service.buildAgentSkillsIndex("public"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillFileContentNullCases() throws Exception {
|
||||
Page<SkillSummary> empty = new Page<>();
|
||||
empty.setPageItems(List.of());
|
||||
when(skillOperationService.listSkills(eq("public"), eq("missing"), eq(SEARCH_ACCURATE),
|
||||
eq("download_count"), eq(1), eq(1))).thenReturn(empty);
|
||||
assertNull(service.getSkillFileContent("public", "missing", "SKILL.md"));
|
||||
|
||||
Page<SkillSummary> page = new Page<>();
|
||||
page.setPageItems(List.of(buildSummary("no-resource", 1L)));
|
||||
when(skillOperationService.listSkills(eq("public"), eq("no-resource"), eq(SEARCH_ACCURATE),
|
||||
eq("download_count"), eq(1), eq(1))).thenReturn(page);
|
||||
when(skillIndexManifestService.query("public", "no-resource"))
|
||||
.thenReturn(buildManifest("v1"));
|
||||
when(skillOperationService.getSkillVersionDetail("public", "no-resource", "v1"))
|
||||
.thenReturn(buildSimpleSkill("no-resource"));
|
||||
assertNull(service.getSkillFileContent("public", "no-resource", "docs/guide.md"));
|
||||
|
||||
Page<SkillSummary> nullEntryPage = new Page<>();
|
||||
nullEntryPage.setPageItems(List.of(buildSummary("null-entry", 1L)));
|
||||
when(skillOperationService.listSkills(eq("public"), eq("null-entry"), eq(SEARCH_ACCURATE),
|
||||
eq("download_count"), eq(1), eq(1))).thenReturn(nullEntryPage);
|
||||
when(skillIndexManifestService.query("public", "null-entry"))
|
||||
.thenReturn(buildManifest("v1"));
|
||||
Skill nullEntry = buildSimpleSkill("null-entry");
|
||||
Map<String, SkillResource> resources = new HashMap<>();
|
||||
resources.put("null", null);
|
||||
nullEntry.setResource(resources);
|
||||
when(skillOperationService.getSkillVersionDetail("public", "null-entry", "v1"))
|
||||
.thenReturn(nullEntry);
|
||||
assertNull(service.getSkillFileContent("public", "null-entry", "docs/guide.md"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuildFilesSkipsNullAndBlankResourcesAndRejectsBinaryExtension() throws Exception {
|
||||
Page<SkillSummary> page = new Page<>();
|
||||
page.setPagesAvailable(1);
|
||||
page.setPageItems(List.of(buildSummary("mixed", 1L), buildSummary("font", 1L)));
|
||||
when(skillOperationService.listSkills(eq("public"), eq((String) null), eq(SEARCH_BLUR),
|
||||
eq("download_count"), eq(1), eq(100))).thenReturn(page);
|
||||
when(skillIndexManifestService.query("public", "mixed")).thenReturn(buildManifest("v1"));
|
||||
when(skillIndexManifestService.query("public", "font")).thenReturn(buildManifest("v1"));
|
||||
when(skillOperationService.getSkillVersionDetail("public", "mixed", "v1"))
|
||||
.thenReturn(buildMixedResourceSkill("mixed"));
|
||||
when(skillOperationService.getSkillVersionDetail("public", "font", "v1"))
|
||||
.thenReturn(buildExtensionBinarySkill("font"));
|
||||
|
||||
WellKnownSkillsIndex result = service.buildLegacySkillsIndex("public");
|
||||
|
||||
assertEquals(1, result.getSkills().size());
|
||||
assertEquals(List.of("SKILL.md", "guide.md"), result.getSkills().get(0).getFiles());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoadSkillNullAndIneligibleCases() throws Exception {
|
||||
Page<SkillSummary> nullPage = null;
|
||||
when(skillOperationService.listSkills(eq("public"), eq("null-page"), eq(SEARCH_ACCURATE),
|
||||
eq("download_count"), eq(1), eq(1))).thenReturn(nullPage);
|
||||
assertNull(service.getSkillFileContent("public", "null-page", "SKILL.md"));
|
||||
|
||||
Page<SkillSummary> disabledPage = new Page<>();
|
||||
SkillSummary disabled = buildSummary("disabled", 1L);
|
||||
disabled.setEnable(false);
|
||||
disabledPage.setPageItems(List.of(disabled));
|
||||
when(skillOperationService.listSkills(eq("public"), eq("disabled"), eq(SEARCH_ACCURATE),
|
||||
eq("download_count"), eq(1), eq(1))).thenReturn(disabledPage);
|
||||
assertNull(service.getSkillArchiveContent("public", "disabled"));
|
||||
|
||||
Page<SkillSummary> noDescriptionPage = new Page<>();
|
||||
noDescriptionPage.setPageItems(List.of(buildSummary("no-description", 1L)));
|
||||
when(skillOperationService.listSkills(eq("public"), eq("no-description"),
|
||||
eq(SEARCH_ACCURATE), eq("download_count"), eq(1), eq(1)))
|
||||
.thenReturn(noDescriptionPage);
|
||||
when(skillIndexManifestService.query("public", "no-description"))
|
||||
.thenReturn(buildManifest("v1"));
|
||||
Skill noDescription = buildSimpleSkill("no-description");
|
||||
noDescription.setDescription(" ");
|
||||
when(skillOperationService.downloadSkillVersion("public", "no-description", "v1"))
|
||||
.thenReturn(noDescription);
|
||||
assertNull(service.getSkillArchiveContent("public", "no-description"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuildIndexSkipsNullExportableAndMultiplePages() throws Exception {
|
||||
Page<SkillSummary> firstPage = new Page<>();
|
||||
firstPage.setPagesAvailable(2);
|
||||
firstPage.setPageItems(List.of(buildSummary("not-found", 1L)));
|
||||
Page<SkillSummary> secondPage = new Page<>();
|
||||
secondPage.setPagesAvailable(2);
|
||||
secondPage.setPageItems(List.of(buildSummary("second-page", 2L)));
|
||||
when(skillOperationService.listSkills(eq("public"), eq((String) null), eq(SEARCH_BLUR),
|
||||
eq("download_count"), eq(1), eq(100))).thenReturn(firstPage);
|
||||
when(skillOperationService.listSkills(eq("public"), eq((String) null), eq(SEARCH_BLUR),
|
||||
eq("download_count"), eq(2), eq(100))).thenReturn(secondPage);
|
||||
when(skillIndexManifestService.query("public", "not-found"))
|
||||
.thenReturn(buildManifest("v1"));
|
||||
when(skillOperationService.getSkillVersionDetail("public", "not-found", "v1"))
|
||||
.thenThrow(new NacosException(NacosException.NOT_FOUND, "not found"));
|
||||
when(skillIndexManifestService.query("public", "second-page"))
|
||||
.thenReturn(buildManifest("v1"));
|
||||
when(skillOperationService.getSkillVersionDetail("public", "second-page", "v1"))
|
||||
.thenReturn(buildSimpleSkill("second-page"));
|
||||
|
||||
WellKnownSkillsIndex result = service.buildAgentSkillsIndex("public");
|
||||
|
||||
assertEquals(1, result.getSkills().size());
|
||||
assertEquals("second-page", result.getSkills().get(0).getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testArchiveCreationFailureForUnsafeResourcePath() throws Exception {
|
||||
Page<SkillSummary> page = new Page<>();
|
||||
page.setPageItems(List.of(buildSummary("unsafe", 1L)));
|
||||
when(skillOperationService.listSkills(eq("public"), eq("unsafe"), eq(SEARCH_ACCURATE),
|
||||
eq("download_count"), eq(1), eq(1))).thenReturn(page);
|
||||
when(skillIndexManifestService.query("public", "unsafe"))
|
||||
.thenReturn(buildManifest("v1"));
|
||||
when(skillOperationService.downloadSkillVersion("public", "unsafe", "v1"))
|
||||
.thenReturn(buildUnsafeResourceSkill("unsafe"));
|
||||
|
||||
NacosException exception = assertThrows(NacosException.class,
|
||||
() -> service.getSkillArchiveContent("public", "unsafe"));
|
||||
|
||||
assertEquals(NacosException.SERVER_ERROR, exception.getErrCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testArchiveWritesEmptyResourceForNullContent() throws Exception {
|
||||
Page<SkillSummary> page = new Page<>();
|
||||
page.setPageItems(List.of(buildSummary("empty-file", 1L)));
|
||||
when(skillOperationService.listSkills(eq("public"), eq("empty-file"), eq(SEARCH_ACCURATE),
|
||||
eq("download_count"), eq(1), eq(1))).thenReturn(page);
|
||||
when(skillIndexManifestService.query("public", "empty-file"))
|
||||
.thenReturn(buildManifest("v1"));
|
||||
when(skillOperationService.downloadSkillVersion("public", "empty-file", "v1"))
|
||||
.thenReturn(buildMixedResourceSkill("empty-file"));
|
||||
|
||||
byte[] zipBytes = service.getSkillArchiveContent("public", "empty-file");
|
||||
|
||||
assertZipEntryContains(zipBytes, "guide.md", "");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPrivateResourceHelpers() throws Exception {
|
||||
Method isBinaryResource =
|
||||
NacosSkillsRegistryService.class.getDeclaredMethod("isBinaryResource",
|
||||
SkillResource.class);
|
||||
isBinaryResource.setAccessible(true);
|
||||
SkillResource noExtension = new SkillResource();
|
||||
noExtension.setName("README");
|
||||
assertEquals(false, isBinaryResource.invoke(service, noExtension));
|
||||
SkillResource blankName = new SkillResource();
|
||||
blankName.setName(" ");
|
||||
assertEquals(false, isBinaryResource.invoke(service, blankName));
|
||||
SkillResource trailingDot = new SkillResource();
|
||||
trailingDot.setName("README.");
|
||||
assertEquals(false, isBinaryResource.invoke(service, trailingDot));
|
||||
|
||||
Method buildRelativePath =
|
||||
NacosSkillsRegistryService.class.getDeclaredMethod("buildRelativePath",
|
||||
SkillResource.class);
|
||||
buildRelativePath.setAccessible(true);
|
||||
SkillResource rootFile = new SkillResource();
|
||||
rootFile.setName("guide.md");
|
||||
assertEquals("guide.md", buildRelativePath.invoke(service, rootFile));
|
||||
|
||||
Method encodePath =
|
||||
NacosSkillsRegistryService.class.getDeclaredMethod("encodePath", String.class);
|
||||
encodePath.setAccessible(true);
|
||||
assertEquals("docs/a%20b.md", encodePath.invoke(service, "docs/a b.md"));
|
||||
|
||||
Method buildFiles =
|
||||
NacosSkillsRegistryService.class.getDeclaredMethod("buildFiles", Skill.class);
|
||||
buildFiles.setAccessible(true);
|
||||
assertEquals(List.of("SKILL.md"), buildFiles.invoke(service, buildEmptyResourceSkill()));
|
||||
}
|
||||
|
||||
private SkillSummary buildSummary(String name, Long downloadCount) {
|
||||
SkillSummary result = new SkillSummary();
|
||||
result.setNamespaceId("public");
|
||||
result.setName(name);
|
||||
result.setDescription(name + " description");
|
||||
result.setEnable(true);
|
||||
result.setScope(VisibilityConstants.SCOPE_PUBLIC);
|
||||
result.setOnlineCnt(1);
|
||||
result.setDownloadCount(downloadCount);
|
||||
return result;
|
||||
}
|
||||
|
||||
private SkillIndexManifest buildManifest(String version) {
|
||||
SkillIndexManifest manifest = new SkillIndexManifest();
|
||||
manifest.setLabels(Map.of(SkillIndexManifest.LABEL_LATEST, version));
|
||||
manifest.setVersions(Map.of(version, List.of("ignored")));
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private Skill buildTextSkill(String name) {
|
||||
Skill result = new Skill();
|
||||
result.setNamespaceId("public");
|
||||
result.setName(name);
|
||||
result.setDescription(name + " description");
|
||||
result.setSkillMd(
|
||||
"---\nname: " + name + "\ndescription: " + name + " description\n---\n\n# " + name);
|
||||
SkillResource resource = new SkillResource();
|
||||
resource.setType("docs");
|
||||
resource.setName("guide.md");
|
||||
resource.setContent("guide");
|
||||
result.setResource(Map.of("docs::guide.md", resource));
|
||||
return result;
|
||||
}
|
||||
|
||||
private Skill buildSimpleSkill(String name) {
|
||||
Skill result = new Skill();
|
||||
result.setNamespaceId("public");
|
||||
result.setName(name);
|
||||
result.setDescription(name + " description");
|
||||
result.setSkillMd(
|
||||
"---\nname: " + name + "\ndescription: " + name + " description\n---\n\n# " + name);
|
||||
return result;
|
||||
}
|
||||
|
||||
private Skill buildBinarySkill(String name) {
|
||||
Skill result = buildTextSkill(name);
|
||||
SkillResource binary = new SkillResource();
|
||||
binary.setType("assets");
|
||||
binary.setName("logo.png");
|
||||
binary.setContent("AA==");
|
||||
Map<String, Object> metadata = new HashMap<>();
|
||||
metadata.put("encoding", "base64");
|
||||
binary.setMetadata(metadata);
|
||||
result.setResource(Map.of("assets::logo.png", binary));
|
||||
return result;
|
||||
}
|
||||
|
||||
private Skill buildMixedResourceSkill(String name) {
|
||||
Skill result = buildSimpleSkill(name);
|
||||
SkillResource blankName = new SkillResource();
|
||||
blankName.setName(" ");
|
||||
SkillResource guide = new SkillResource();
|
||||
guide.setName("guide.md");
|
||||
guide.setContent(null);
|
||||
result.setResource(new HashMap<>());
|
||||
result.getResource().put("null", null);
|
||||
result.getResource().put("blank", blankName);
|
||||
result.getResource().put("guide", guide);
|
||||
return result;
|
||||
}
|
||||
|
||||
private Skill buildExtensionBinarySkill(String name) {
|
||||
Skill result = buildSimpleSkill(name);
|
||||
SkillResource binary = new SkillResource();
|
||||
binary.setName("font.ttf");
|
||||
binary.setContent("font");
|
||||
result.setResource(Map.of("font", binary));
|
||||
return result;
|
||||
}
|
||||
|
||||
private Skill buildEmptyResourceSkill() {
|
||||
Skill result = buildSimpleSkill("empty-resource");
|
||||
result.setResource(Collections.emptyMap());
|
||||
return result;
|
||||
}
|
||||
|
||||
private Skill buildUnsafeResourceSkill(String name) {
|
||||
Skill result = buildSimpleSkill(name);
|
||||
SkillResource unsafe = new SkillResource();
|
||||
unsafe.setName("../evil.md");
|
||||
unsafe.setContent("evil");
|
||||
result.setResource(Map.of("unsafe", unsafe));
|
||||
return result;
|
||||
}
|
||||
|
||||
private String sha256(String skillName) throws Exception {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(buildSimpleSkill(skillName).getSkillMd()
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder result = new StringBuilder("sha256:");
|
||||
for (byte each : hash) {
|
||||
result.append(String.format("%02x", each & 0xff));
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private void assertZipEntryContains(byte[] zipBytes, String entryName, String expected)
|
||||
throws Exception {
|
||||
try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(zipBytes),
|
||||
StandardCharsets.UTF_8)) {
|
||||
java.util.zip.ZipEntry entry;
|
||||
while ((entry = zip.getNextEntry()) != null) {
|
||||
if (entryName.equals(entry.getName())) {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
zip.transferTo(output);
|
||||
assertTrue(output.toString(StandardCharsets.UTF_8).contains(expected));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new AssertionError("Zip entry not found: " + entryName);
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.alibaba.nacos</groupId>
|
||||
<artifactId>nacos-all</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>nacos-ai</artifactId>
|
||||
<name>nacos-ai ${project.version}</name>
|
||||
<url>https://nacos.io</url>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.nacos</groupId>
|
||||
<artifactId>nacos-common</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.alibaba.nacos</groupId>
|
||||
<artifactId>nacos-naming</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.nacos</groupId>
|
||||
<artifactId>nacos-config</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.nacos</groupId>
|
||||
<artifactId>nacos-ai-plugin</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.nacos</groupId>
|
||||
<artifactId>nacos-visibility-plugin</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-compress</artifactId>
|
||||
<version>1.26.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>commons-codec</groupId>
|
||||
<artifactId>commons-codec</artifactId>
|
||||
<version>1.16.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-test-autoconfigure</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<version>2.2.224</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-inline</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,462 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.config;
|
||||
|
||||
import com.alibaba.nacos.ai.model.AiResource;
|
||||
import com.alibaba.nacos.ai.service.agentspecs.AgentSpecOperationService;
|
||||
import com.alibaba.nacos.ai.service.repository.AiResourcePersistService;
|
||||
import com.alibaba.nacos.ai.utils.AgentSpecSeedArchiveReader;
|
||||
import com.alibaba.nacos.ai.utils.AgentSpecZipParser;
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpec;
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpecMeta;
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpecResource;
|
||||
import com.alibaba.nacos.api.common.Constants;
|
||||
import com.alibaba.nacos.api.model.Page;
|
||||
import com.alibaba.nacos.common.executor.ExecutorFactory;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
import com.alibaba.nacos.common.utils.ThreadFactoryBuilder;
|
||||
import com.alibaba.nacos.config.server.exception.ConfigAlreadyExistsException;
|
||||
import com.alibaba.nacos.config.server.model.ConfigRequestInfo;
|
||||
import com.alibaba.nacos.config.server.model.form.ConfigForm;
|
||||
import com.alibaba.nacos.config.server.service.ConfigOperationService;
|
||||
import com.alibaba.nacos.config.server.service.query.ConfigQueryChainService;
|
||||
import com.alibaba.nacos.config.server.service.query.model.ConfigQueryChainRequest;
|
||||
import com.alibaba.nacos.config.server.service.query.model.ConfigQueryChainResponse;
|
||||
import com.alibaba.nacos.sys.env.EnvUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Bootstrap built-in AgentSpecs into an empty AI namespace after cluster startup.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
@Component
|
||||
public class AgentSpecDataBootstrapInitializer
|
||||
implements ApplicationListener<ApplicationReadyEvent> {
|
||||
|
||||
private static final Logger LOGGER =
|
||||
LoggerFactory.getLogger(AgentSpecDataBootstrapInitializer.class);
|
||||
|
||||
private static final String BOOTSTRAP_MARKER_DATA_ID = "nacos.ai.agentspec.bootstrap";
|
||||
|
||||
private static final String BOOTSTRAP_MARKER_GROUP = "nacos_internal";
|
||||
|
||||
private static final long BOOTSTRAP_MARKER_STALE_MILLIS = 10 * 60 * 1000L;
|
||||
|
||||
private static final String RESOURCE_TYPE_AGENTSPEC = "agentspec";
|
||||
|
||||
private static final int MAX_BOOTSTRAP_IMPORT_CONCURRENCY = 4;
|
||||
|
||||
private static final int BOOTSTRAP_IMPORT_CONCURRENCY = Math.max(1,
|
||||
Math.min(EnvUtil.getAvailableProcessors(), MAX_BOOTSTRAP_IMPORT_CONCURRENCY));
|
||||
|
||||
private final AtomicBoolean initialized = new AtomicBoolean(false);
|
||||
|
||||
private final ExecutorService bootstrapExecutor =
|
||||
ExecutorFactory.Managed.newSingleExecutorService(
|
||||
AgentSpecDataBootstrapInitializer.class.getCanonicalName(),
|
||||
new ThreadFactoryBuilder().daemon(true).nameFormat("nacos-ai-agentspec-bootstrap-%d")
|
||||
.build());
|
||||
|
||||
private final ExecutorService bootstrapImportExecutor =
|
||||
ExecutorFactory.Managed.newFixedExecutorService(
|
||||
AgentSpecDataBootstrapInitializer.class.getCanonicalName(),
|
||||
BOOTSTRAP_IMPORT_CONCURRENCY,
|
||||
new ThreadFactoryBuilder().daemon(true)
|
||||
.nameFormat("nacos-ai-agentspec-bootstrap-import-%d").build());
|
||||
|
||||
private final AiResourcePersistService aiResourcePersistService;
|
||||
|
||||
private final AgentSpecOperationService agentSpecOperationService;
|
||||
|
||||
private final ConfigOperationService configOperationService;
|
||||
|
||||
private final ConfigQueryChainService configQueryChainService;
|
||||
|
||||
public AgentSpecDataBootstrapInitializer(AiResourcePersistService aiResourcePersistService,
|
||||
AgentSpecOperationService agentSpecOperationService,
|
||||
ConfigOperationService configOperationService,
|
||||
ConfigQueryChainService configQueryChainService) {
|
||||
this.aiResourcePersistService = aiResourcePersistService;
|
||||
this.agentSpecOperationService = agentSpecOperationService;
|
||||
this.configOperationService = configOperationService;
|
||||
this.configQueryChainService = configQueryChainService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onApplicationEvent(ApplicationReadyEvent event) {
|
||||
if (event.getApplicationContext().getParent() != null) {
|
||||
return;
|
||||
}
|
||||
if (!initialized.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
bootstrapExecutor.execute(this::bootstrapBuiltInAgentSpecs);
|
||||
}
|
||||
|
||||
private void bootstrapBuiltInAgentSpecs() {
|
||||
boolean markerCreated = false;
|
||||
try {
|
||||
Resource bundledAgentSpecArchive = resolveBundledAgentSpecArchive();
|
||||
if (!bundledAgentSpecArchive.exists()) {
|
||||
LOGGER.warn("Built-in agentspec archive `{}` not found, skip bootstrap",
|
||||
bundledAgentSpecArchive);
|
||||
return;
|
||||
}
|
||||
|
||||
List<AgentSpecSeedArchiveReader.AgentSpecPackage> packages = readAgentSpecPackages();
|
||||
if (packages.isEmpty()) {
|
||||
LOGGER.info("No built-in agentspecs found in archive `{}`",
|
||||
bundledAgentSpecArchive);
|
||||
return;
|
||||
}
|
||||
BootstrapPlan bootstrapPlan = buildBootstrapPlan(packages);
|
||||
if (!bootstrapPlan.shouldBootstrap()) {
|
||||
LOGGER.info(bootstrapPlan.getSkipReason());
|
||||
return;
|
||||
}
|
||||
|
||||
markerCreated = tryAcquireBootstrapMarker();
|
||||
if (!markerCreated) {
|
||||
LOGGER
|
||||
.info("Skip built-in agentspec bootstrap because another node is initializing");
|
||||
return;
|
||||
}
|
||||
|
||||
bootstrapPlan = buildBootstrapPlan(packages);
|
||||
if (!bootstrapPlan.shouldBootstrap()) {
|
||||
LOGGER.info(bootstrapPlan.getSkipReason());
|
||||
return;
|
||||
}
|
||||
|
||||
int imported = 0;
|
||||
int skipped = bootstrapPlan.getExistingBuiltInCount();
|
||||
List<String> failed = new ArrayList<>();
|
||||
LOGGER.info(
|
||||
"Start built-in agentspec bootstrap in namespace `{}`, total {}, missing {}, existing {}, concurrency {}",
|
||||
Constants.DEFAULT_NAMESPACE_ID, packages.size(),
|
||||
bootstrapPlan.getMissingPackages().size(),
|
||||
bootstrapPlan.getExistingBuiltInCount(), BOOTSTRAP_IMPORT_CONCURRENCY);
|
||||
List<Future<ImportTaskResult>> futures =
|
||||
new ArrayList<>(bootstrapPlan.getMissingPackages().size());
|
||||
for (AgentSpecSeedArchiveReader.AgentSpecPackage pkg : bootstrapPlan
|
||||
.getMissingPackages()) {
|
||||
futures.add(bootstrapImportExecutor.submit(() -> importBuiltInAgentSpec(pkg)));
|
||||
}
|
||||
for (Future<ImportTaskResult> future : futures) {
|
||||
ImportTaskResult taskResult = future.get();
|
||||
if (taskResult.isSuccess()) {
|
||||
imported++;
|
||||
} else {
|
||||
failed.add(taskResult.getAgentSpecName());
|
||||
}
|
||||
}
|
||||
LOGGER.info(
|
||||
"Built-in agentspec bootstrap completed, imported {}, skipped {}, failed {}",
|
||||
imported, skipped,
|
||||
failed.size());
|
||||
if (!failed.isEmpty()) {
|
||||
LOGGER.warn("Built-in agentspec bootstrap still missing {} agentspecs: {}",
|
||||
failed.size(), failed);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Failed to bootstrap built-in agentspecs", e);
|
||||
} finally {
|
||||
if (markerCreated) {
|
||||
releaseBootstrapMarker();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<AgentSpecSeedArchiveReader.AgentSpecPackage> readAgentSpecPackages()
|
||||
throws IOException {
|
||||
Resource bundledAgentSpecArchive = resolveBundledAgentSpecArchive();
|
||||
try (InputStream inputStream = bundledAgentSpecArchive.getInputStream()) {
|
||||
return AgentSpecSeedArchiveReader.read(inputStream);
|
||||
}
|
||||
}
|
||||
|
||||
private Resource resolveBundledAgentSpecArchive() {
|
||||
Path archivePath = Paths.get(EnvUtil.getNacosHome(), "data", "agentspec-data.zip");
|
||||
return new FileSystemResource(archivePath);
|
||||
}
|
||||
|
||||
private ImportTaskResult importBuiltInAgentSpec(
|
||||
AgentSpecSeedArchiveReader.AgentSpecPackage pkg) {
|
||||
try {
|
||||
LOGGER.info("Import built-in agentspec `{}` from `{}`", pkg.getAgentSpecName(),
|
||||
pkg.getSourcePath());
|
||||
agentSpecOperationService.bootstrapAgentSpecFromZip(Constants.DEFAULT_NAMESPACE_ID,
|
||||
pkg.getZipBytes(),
|
||||
pkg.getFrom());
|
||||
return ImportTaskResult.success(pkg.getAgentSpecName());
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Failed to bootstrap built-in agentspec `{}` from `{}`",
|
||||
pkg.getAgentSpecName(),
|
||||
pkg.getSourcePath(), e);
|
||||
return ImportTaskResult.failed(pkg.getAgentSpecName());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasExistingAiData() {
|
||||
Page<AiResource> page =
|
||||
aiResourcePersistService.list(Constants.DEFAULT_NAMESPACE_ID, null, null, null, 1, 1);
|
||||
return page != null && page.getTotalCount() > 0;
|
||||
}
|
||||
|
||||
private BootstrapPlan buildBootstrapPlan(
|
||||
List<AgentSpecSeedArchiveReader.AgentSpecPackage> packages) {
|
||||
boolean existingAiData = hasExistingAiData();
|
||||
int existingBuiltInCount = 0;
|
||||
List<AgentSpecSeedArchiveReader.AgentSpecPackage> missing =
|
||||
new ArrayList<>(packages.size());
|
||||
for (AgentSpecSeedArchiveReader.AgentSpecPackage pkg : packages) {
|
||||
if (aiResourcePersistService.find(Constants.DEFAULT_NAMESPACE_ID,
|
||||
pkg.getAgentSpecName(),
|
||||
RESOURCE_TYPE_AGENTSPEC) != null) {
|
||||
existingBuiltInCount++;
|
||||
if (needsBuiltInRepair(pkg)) {
|
||||
missing.add(pkg);
|
||||
}
|
||||
} else {
|
||||
missing.add(pkg);
|
||||
}
|
||||
}
|
||||
if (missing.isEmpty()) {
|
||||
return BootstrapPlan.skip(
|
||||
"Skip built-in agentspec bootstrap because all bundled agentspecs already exist",
|
||||
existingBuiltInCount);
|
||||
}
|
||||
if (!existingAiData) {
|
||||
return BootstrapPlan.bootstrap(missing, existingBuiltInCount);
|
||||
}
|
||||
if (existingBuiltInCount == 0) {
|
||||
return BootstrapPlan.skip(
|
||||
"Skip built-in agentspec bootstrap because AI data already exists and no built-in agentspecs were imported before",
|
||||
0);
|
||||
}
|
||||
return BootstrapPlan.bootstrap(missing, existingBuiltInCount);
|
||||
}
|
||||
|
||||
private boolean needsBuiltInRepair(AgentSpecSeedArchiveReader.AgentSpecPackage pkg) {
|
||||
try {
|
||||
AgentSpec bundled = AgentSpecZipParser.parseAgentSpecFromZip(pkg.getZipBytes(),
|
||||
Constants.DEFAULT_NAMESPACE_ID);
|
||||
if (bundled.getResource() == null || bundled.getResource().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
AgentSpecMeta detail =
|
||||
agentSpecOperationService.getAgentSpecDetail(Constants.DEFAULT_NAMESPACE_ID,
|
||||
pkg.getAgentSpecName());
|
||||
String latestVersion = detail == null || detail.getLabels() == null ? null
|
||||
: detail.getLabels().get("latest");
|
||||
if (StringUtils.isBlank(latestVersion)) {
|
||||
return true;
|
||||
}
|
||||
AgentSpec current =
|
||||
agentSpecOperationService.getAgentSpecVersionDetail(Constants.DEFAULT_NAMESPACE_ID,
|
||||
pkg.getAgentSpecName(), latestVersion);
|
||||
return isBundledAgentsContentMissing(current, bundled);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn(
|
||||
"Failed to inspect built-in agentspec `{}` for repair, will retry bootstrap import",
|
||||
pkg.getAgentSpecName(), e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isBundledAgentsContentMissing(AgentSpec current, AgentSpec bundled) {
|
||||
if (bundled == null) {
|
||||
return false;
|
||||
}
|
||||
if (current == null) {
|
||||
return true;
|
||||
}
|
||||
String bundledAgentsContent = extractAgentsContent(bundled.getResource());
|
||||
if (StringUtils.isBlank(bundledAgentsContent)) {
|
||||
return false;
|
||||
}
|
||||
String currentAgentsContent = extractAgentsContent(current.getResource());
|
||||
return StringUtils.isBlank(currentAgentsContent);
|
||||
}
|
||||
|
||||
private static String extractAgentsContent(Map<String, AgentSpecResource> resources) {
|
||||
if (resources == null || resources.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
for (AgentSpecResource resource : resources.values()) {
|
||||
if (resource == null || StringUtils.isBlank(resource.getName())) {
|
||||
continue;
|
||||
}
|
||||
String normalizedName = resource.getName().trim();
|
||||
int lastSlash = normalizedName.lastIndexOf('/');
|
||||
if (lastSlash >= 0) {
|
||||
normalizedName = normalizedName.substring(lastSlash + 1);
|
||||
}
|
||||
if ("AGENTS.md".equalsIgnoreCase(normalizedName)) {
|
||||
return resource.getContent();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean tryAcquireBootstrapMarker() {
|
||||
for (int i = 0; i < 2; i++) {
|
||||
try {
|
||||
ConfigForm form = new ConfigForm();
|
||||
form.setNamespaceId(Constants.DEFAULT_NAMESPACE_ID);
|
||||
form.setGroup(BOOTSTRAP_MARKER_GROUP);
|
||||
form.setDataId(BOOTSTRAP_MARKER_DATA_ID);
|
||||
form.setContent(String.valueOf(System.currentTimeMillis()));
|
||||
form.setSrcUser("nacos");
|
||||
ConfigRequestInfo requestInfo = new ConfigRequestInfo();
|
||||
requestInfo.setUpdateForExist(false);
|
||||
configOperationService.publishConfig(form, requestInfo, null);
|
||||
return true;
|
||||
} catch (ConfigAlreadyExistsException e) {
|
||||
if (!hasExistingAiData() && isBootstrapMarkerStale()) {
|
||||
LOGGER.warn(
|
||||
"Found stale built-in agentspec bootstrap marker, removing it and retrying");
|
||||
releaseBootstrapMarker();
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Failed to create built-in agentspec bootstrap marker", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isBootstrapMarkerStale() {
|
||||
try {
|
||||
ConfigQueryChainRequest request = ConfigQueryChainRequest.buildConfigQueryChainRequest(
|
||||
BOOTSTRAP_MARKER_DATA_ID, BOOTSTRAP_MARKER_GROUP, Constants.DEFAULT_NAMESPACE_ID);
|
||||
ConfigQueryChainResponse response = configQueryChainService.handle(request);
|
||||
if (response.getStatus() == ConfigQueryChainResponse.ConfigQueryStatus.CONFIG_NOT_FOUND
|
||||
|| StringUtils.isBlank(response.getContent())) {
|
||||
return false;
|
||||
}
|
||||
long markerTime = Long.parseLong(response.getContent().trim());
|
||||
return System.currentTimeMillis() - markerTime > BOOTSTRAP_MARKER_STALE_MILLIS;
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to inspect built-in agentspec bootstrap marker", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseBootstrapMarker() {
|
||||
try {
|
||||
configOperationService.deleteConfig(BOOTSTRAP_MARKER_DATA_ID, BOOTSTRAP_MARKER_GROUP,
|
||||
Constants.DEFAULT_NAMESPACE_ID, null, null, "nacos", null);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to delete built-in agentspec bootstrap marker", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class BootstrapPlan {
|
||||
|
||||
private final boolean shouldBootstrap;
|
||||
|
||||
private final String skipReason;
|
||||
|
||||
private final List<AgentSpecSeedArchiveReader.AgentSpecPackage> missingPackages;
|
||||
|
||||
private final int existingBuiltInCount;
|
||||
|
||||
private BootstrapPlan(boolean shouldBootstrap, String skipReason,
|
||||
List<AgentSpecSeedArchiveReader.AgentSpecPackage> missingPackages,
|
||||
int existingBuiltInCount) {
|
||||
this.shouldBootstrap = shouldBootstrap;
|
||||
this.skipReason = skipReason;
|
||||
this.missingPackages = missingPackages;
|
||||
this.existingBuiltInCount = existingBuiltInCount;
|
||||
}
|
||||
|
||||
private static BootstrapPlan bootstrap(
|
||||
List<AgentSpecSeedArchiveReader.AgentSpecPackage> missingPackages,
|
||||
int existingBuiltInCount) {
|
||||
return new BootstrapPlan(true, null, missingPackages, existingBuiltInCount);
|
||||
}
|
||||
|
||||
private static BootstrapPlan skip(String skipReason, int existingBuiltInCount) {
|
||||
return new BootstrapPlan(false, skipReason, new ArrayList<>(0), existingBuiltInCount);
|
||||
}
|
||||
|
||||
private boolean shouldBootstrap() {
|
||||
return shouldBootstrap;
|
||||
}
|
||||
|
||||
private String getSkipReason() {
|
||||
return skipReason;
|
||||
}
|
||||
|
||||
private List<AgentSpecSeedArchiveReader.AgentSpecPackage> getMissingPackages() {
|
||||
return missingPackages;
|
||||
}
|
||||
|
||||
private int getExistingBuiltInCount() {
|
||||
return existingBuiltInCount;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ImportTaskResult {
|
||||
|
||||
private final String agentSpecName;
|
||||
|
||||
private final boolean success;
|
||||
|
||||
private ImportTaskResult(String agentSpecName, boolean success) {
|
||||
this.agentSpecName = agentSpecName;
|
||||
this.success = success;
|
||||
}
|
||||
|
||||
private static ImportTaskResult success(String agentSpecName) {
|
||||
return new ImportTaskResult(agentSpecName, true);
|
||||
}
|
||||
|
||||
private static ImportTaskResult failed(String agentSpecName) {
|
||||
return new ImportTaskResult(agentSpecName, false);
|
||||
}
|
||||
|
||||
private String getAgentSpecName() {
|
||||
return agentSpecName;
|
||||
}
|
||||
|
||||
private boolean isSuccess() {
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.config;
|
||||
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
import com.alibaba.nacos.sys.env.EnvUtil;
|
||||
import com.alibaba.nacos.sys.filter.NacosPackageExcludeFilter;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Nacos AI Component enabled filter.
|
||||
*
|
||||
* @author xiweng.yy
|
||||
*/
|
||||
public class AiEnabledFilter implements NacosPackageExcludeFilter {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(AiEnabledFilter.class);
|
||||
|
||||
public static final String AI_ENABLED_KEY = "nacos.extension.ai.enabled";
|
||||
|
||||
@Override
|
||||
public String getResponsiblePackagePrefix() {
|
||||
return "com.alibaba.nacos.ai";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isExcluded(String className, Set<String> annotationNames) {
|
||||
String functionMode = EnvUtil.getFunctionMode();
|
||||
// When not specified ai mode, AI module should be disabled
|
||||
if (StringUtils.isNotEmpty(functionMode)
|
||||
&& !EnvUtil.FUNCTION_MODE_AI.equals(functionMode)) {
|
||||
LOGGER.warn(
|
||||
"AI module disabled because function mode is {}, and AI mode requires empty or 'ai' mode",
|
||||
functionMode);
|
||||
return true;
|
||||
}
|
||||
boolean aiDisabled = !EnvUtil.getProperty(AI_ENABLED_KEY, Boolean.class, true);
|
||||
if (aiDisabled) {
|
||||
LOGGER.warn("AI module disabled because set {} as false", AI_ENABLED_KEY);
|
||||
}
|
||||
return aiDisabled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.config;
|
||||
|
||||
import com.alibaba.nacos.common.spi.NacosServiceLoader;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
import com.alibaba.nacos.plugin.ai.storage.AiResourceStorageRouter;
|
||||
import com.alibaba.nacos.plugin.ai.storage.spi.AiResourceStorage;
|
||||
import com.alibaba.nacos.plugin.ai.storage.spi.AiResourceStorageBuilder;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.context.event.ContextRefreshedEvent;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Initializer for {@link AiResourceStorage} implementations.
|
||||
*
|
||||
* <p>This component bridges SPI-based storage builders with Spring lifecycle.
|
||||
* It loads all {@link AiResourceStorageBuilder} SPI implementations when the root
|
||||
* {@link org.springframework.context.ApplicationContext} finishes refresh (before
|
||||
* {@link org.springframework.boot.context.event.ApplicationReadyEvent}), then
|
||||
* registers the built storages via {@link AiResourceStorageRouter#join(AiResourceStorage)}.
|
||||
* This ordering ensures {@link com.alibaba.nacos.core.plugin.PluginManager} can discover
|
||||
* AI storage plugins on startup.</p>
|
||||
*
|
||||
* <p><b>Why {@link ContextRefreshedEvent}:</b> the same ordering could be done with
|
||||
* {@code ApplicationReadyEvent} plus {@code @Order}, but refresh completes after non-lazy
|
||||
* singletons exist, so {@code ApplicationUtils.getBean} in builders (e.g. config services)
|
||||
* is safe; it also runs strictly before {@code ApplicationReadyEvent}.</p>
|
||||
*
|
||||
* <p><b>Repeat refresh / hierarchy:</b> registration runs at most once per initializer bean
|
||||
* (typical single root context). Skips non-root contexts ({@code getParent() != null}) to
|
||||
* avoid duplicate work in parent/child setups. A second {@code refresh()} on the same root
|
||||
* context is ignored after the first successful pass.</p>
|
||||
*
|
||||
* @author nacos
|
||||
* @since 3.2.0
|
||||
*/
|
||||
@Component
|
||||
public class AiResourceStorageInitializer implements ApplicationListener<ContextRefreshedEvent> {
|
||||
|
||||
private static final Logger LOGGER =
|
||||
LoggerFactory.getLogger(AiResourceStorageInitializer.class);
|
||||
|
||||
private final AtomicBoolean registered = new AtomicBoolean(false);
|
||||
|
||||
@Override
|
||||
public void onApplicationEvent(ContextRefreshedEvent event) {
|
||||
if (event.getApplicationContext().getParent() != null) {
|
||||
return;
|
||||
}
|
||||
if (!registered.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Collection<AiResourceStorageBuilder> builders =
|
||||
NacosServiceLoader.load(AiResourceStorageBuilder.class);
|
||||
for (AiResourceStorageBuilder builder : builders) {
|
||||
if (builder == null || StringUtils.isBlank(builder.type())) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
AiResourceStorage storage = builder.build();
|
||||
if (storage != null) {
|
||||
AiResourceStorageRouter.join(storage);
|
||||
LOGGER.info("Registered AiResourceStorage: {}", storage.type());
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOGGER.warn("Failed to build AiResourceStorage for type: {}", builder.type(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
registered.set(false);
|
||||
LOGGER.error(
|
||||
"[AiResourceStorageInitializer] Unexpected failure while registering storages", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.config;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Unified legacy prompt data for migration. Holds prompt metadata and version list
|
||||
* (without version content, which is loaded on demand via
|
||||
* {@link PromptLegacyDataReader#readVersionContent}).
|
||||
*
|
||||
* @author nacos
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public class LegacyPromptData {
|
||||
|
||||
private String namespaceId;
|
||||
|
||||
private String promptKey;
|
||||
|
||||
private String description;
|
||||
|
||||
private List<String> bizTags;
|
||||
|
||||
private Map<String, String> labels;
|
||||
|
||||
private String latestVersion;
|
||||
|
||||
private List<String> versions;
|
||||
|
||||
public String getNamespaceId() {
|
||||
return namespaceId;
|
||||
}
|
||||
|
||||
public void setNamespaceId(String namespaceId) {
|
||||
this.namespaceId = namespaceId;
|
||||
}
|
||||
|
||||
public String getPromptKey() {
|
||||
return promptKey;
|
||||
}
|
||||
|
||||
public void setPromptKey(String promptKey) {
|
||||
this.promptKey = promptKey;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public List<String> getBizTags() {
|
||||
return bizTags;
|
||||
}
|
||||
|
||||
public void setBizTags(List<String> bizTags) {
|
||||
this.bizTags = bizTags;
|
||||
}
|
||||
|
||||
public Map<String, String> getLabels() {
|
||||
return labels;
|
||||
}
|
||||
|
||||
public void setLabels(Map<String, String> labels) {
|
||||
this.labels = labels;
|
||||
}
|
||||
|
||||
public String getLatestVersion() {
|
||||
return latestVersion;
|
||||
}
|
||||
|
||||
public void setLatestVersion(String latestVersion) {
|
||||
this.latestVersion = latestVersion;
|
||||
}
|
||||
|
||||
public List<String> getVersions() {
|
||||
return versions;
|
||||
}
|
||||
|
||||
public void setVersions(List<String> versions) {
|
||||
this.versions = versions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* MCP Cache Index configuration properties. Centralized configuration management for MCP cache index related settings.
|
||||
*
|
||||
* @author misselvexu
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "nacos.mcp.cache")
|
||||
public class McpCacheIndexProperties {
|
||||
|
||||
/**
|
||||
* Whether MCP cache is enabled.
|
||||
*/
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* Maximum size of the cache.
|
||||
*/
|
||||
private int maxSize = 10000;
|
||||
|
||||
/**
|
||||
* Cache entry expiration time in seconds.
|
||||
*/
|
||||
private long expireTimeSeconds = 3600;
|
||||
|
||||
/**
|
||||
* Cache cleanup interval in seconds.
|
||||
*/
|
||||
private long cleanupIntervalSeconds = 300;
|
||||
|
||||
/**
|
||||
* Cache synchronization interval in seconds.
|
||||
*/
|
||||
private long syncIntervalSeconds = 300;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public int getMaxSize() {
|
||||
return maxSize;
|
||||
}
|
||||
|
||||
public void setMaxSize(int maxSize) {
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
public long getExpireTimeSeconds() {
|
||||
return expireTimeSeconds;
|
||||
}
|
||||
|
||||
public void setExpireTimeSeconds(long expireTimeSeconds) {
|
||||
this.expireTimeSeconds = expireTimeSeconds;
|
||||
}
|
||||
|
||||
public long getCleanupIntervalSeconds() {
|
||||
return cleanupIntervalSeconds;
|
||||
}
|
||||
|
||||
public void setCleanupIntervalSeconds(long cleanupIntervalSeconds) {
|
||||
this.cleanupIntervalSeconds = cleanupIntervalSeconds;
|
||||
}
|
||||
|
||||
public long getSyncIntervalSeconds() {
|
||||
return syncIntervalSeconds;
|
||||
}
|
||||
|
||||
public void setSyncIntervalSeconds(long syncIntervalSeconds) {
|
||||
this.syncIntervalSeconds = syncIntervalSeconds;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "McpCacheIndexProperties{" + "enabled=" + enabled + ", maxSize=" + maxSize
|
||||
+ ", expireTimeSeconds="
|
||||
+ expireTimeSeconds + ", cleanupIntervalSeconds=" + cleanupIntervalSeconds
|
||||
+ ", syncIntervalSeconds="
|
||||
+ syncIntervalSeconds + '}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.config;
|
||||
|
||||
import com.alibaba.nacos.core.code.ControllerMethodsCache;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* AI MCP spring configuration.
|
||||
*
|
||||
* @author xiweng.yy
|
||||
*/
|
||||
@Configuration
|
||||
public class McpConfiguration {
|
||||
|
||||
private final ControllerMethodsCache methodsCache;
|
||||
|
||||
public McpConfiguration(ControllerMethodsCache methodsCache) {
|
||||
this.methodsCache = methodsCache;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
methodsCache.initClassMethod("com.alibaba.nacos.ai.controller");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.config;
|
||||
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledThreadPoolExecutor;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
|
||||
import com.alibaba.nacos.ai.index.CachedMcpServerIndex;
|
||||
import com.alibaba.nacos.ai.index.McpCacheIndex;
|
||||
import com.alibaba.nacos.ai.index.McpServerIndex;
|
||||
import com.alibaba.nacos.ai.index.MemoryMcpCacheIndex;
|
||||
import com.alibaba.nacos.ai.index.PlainMcpServerIndex;
|
||||
import com.alibaba.nacos.config.server.service.ConfigDetailService;
|
||||
import com.alibaba.nacos.config.server.service.query.ConfigQueryChainService;
|
||||
import com.alibaba.nacos.core.service.NamespaceOperationService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
/**
|
||||
* MCP server index configuration class.
|
||||
*
|
||||
* @author misselvexu
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(McpCacheIndexProperties.class)
|
||||
public class McpServerIndexConfiguration {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(McpServerIndexConfiguration.class);
|
||||
|
||||
private final McpCacheIndexProperties cacheProperties;
|
||||
|
||||
public McpServerIndexConfiguration(McpCacheIndexProperties cacheProperties) {
|
||||
this.cacheProperties = cacheProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create memory cache index Bean.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "nacos.mcp.cache.enabled", havingValue = "true",
|
||||
matchIfMissing = true)
|
||||
public McpCacheIndex mcpCacheIndex() {
|
||||
LOGGER.info(
|
||||
"Creating McpCacheIndex bean with maxSize={}, expireTime={}s, cleanupInterval={}s",
|
||||
cacheProperties.getMaxSize(), cacheProperties.getExpireTimeSeconds(),
|
||||
cacheProperties.getCleanupIntervalSeconds());
|
||||
return new MemoryMcpCacheIndex(cacheProperties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create scheduled task executor Bean.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "nacos.mcp.cache.enabled", havingValue = "true",
|
||||
matchIfMissing = true)
|
||||
public ScheduledExecutorService mcpCacheScheduledExecutor() {
|
||||
LOGGER.info("Creating ScheduledExecutorService for MCP cache with syncInterval={}s",
|
||||
cacheProperties.getSyncIntervalSeconds());
|
||||
// Manually create thread pool, following Alibaba coding standards
|
||||
return new ScheduledThreadPoolExecutor(1, r -> {
|
||||
Thread t = new Thread(r, "mcp-cache-sync");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
}, new ThreadPoolExecutor.CallerRunsPolicy());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the primary MCP server index Bean when cache is enabled.
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
@ConditionalOnProperty(name = "nacos.mcp.cache.enabled", havingValue = "true",
|
||||
matchIfMissing = true)
|
||||
public McpServerIndex cachedMcpServerIndex(ConfigDetailService configDetailService,
|
||||
NamespaceOperationService namespaceOperationService,
|
||||
ConfigQueryChainService configQueryChainService,
|
||||
McpCacheIndex mcpCacheIndex, ScheduledExecutorService mcpCacheScheduledExecutor) {
|
||||
LOGGER.info("Creating CachedMcpServerIndex bean with cache enabled");
|
||||
return new CachedMcpServerIndex(configDetailService, namespaceOperationService,
|
||||
configQueryChainService,
|
||||
mcpCacheIndex, mcpCacheScheduledExecutor, cacheProperties.isEnabled(),
|
||||
cacheProperties.getSyncIntervalSeconds());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the primary MCP server index Bean when cache is disabled.
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
@ConditionalOnProperty(name = "nacos.mcp.cache.enabled", havingValue = "false")
|
||||
public McpServerIndex plainMcpServerIndex(ConfigDetailService configDetailService,
|
||||
NamespaceOperationService namespaceOperationService,
|
||||
ConfigQueryChainService configQueryChainService) {
|
||||
LOGGER.info("Creating PlainMcpServerIndex bean as cache is disabled");
|
||||
return new PlainMcpServerIndex(namespaceOperationService, configDetailService,
|
||||
configQueryChainService);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.config;
|
||||
|
||||
import com.alibaba.nacos.ai.constant.Constants;
|
||||
import com.alibaba.nacos.ai.utils.PromptDataIdUtils;
|
||||
import com.alibaba.nacos.api.ai.model.prompt.PromptVersionInfo;
|
||||
import com.alibaba.nacos.api.model.Page;
|
||||
import com.alibaba.nacos.api.model.response.Namespace;
|
||||
import com.alibaba.nacos.common.utils.JacksonUtils;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
import com.alibaba.nacos.config.server.model.ConfigAllInfo;
|
||||
import com.alibaba.nacos.config.server.model.ConfigInfo;
|
||||
import com.alibaba.nacos.config.server.service.query.ConfigQueryChainService;
|
||||
import com.alibaba.nacos.config.server.service.query.model.ConfigQueryChainRequest;
|
||||
import com.alibaba.nacos.config.server.service.query.model.ConfigQueryChainResponse;
|
||||
import com.alibaba.nacos.config.server.service.ConfigOperationService;
|
||||
import com.alibaba.nacos.config.server.service.repository.ConfigInfoPersistService;
|
||||
import com.alibaba.nacos.core.service.NamespaceOperationService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Default {@link PromptLegacyDataReader} for open-source Nacos.
|
||||
* Reads legacy prompt data from Nacos Config ({@code nacos-ai-prompt} group).
|
||||
*
|
||||
* @author nacos
|
||||
* @since 3.2.0
|
||||
*/
|
||||
@Component
|
||||
public class NacosPromptLegacyDataReader implements PromptLegacyDataReader {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(NacosPromptLegacyDataReader.class);
|
||||
|
||||
public static final String TYPE = "nacos";
|
||||
|
||||
private static final int SCAN_PAGE_SIZE = 100;
|
||||
|
||||
private static final String PROMPT_GROUP = Constants.Prompt.PROMPT_GROUP;
|
||||
|
||||
private final ConfigInfoPersistService configInfoPersistService;
|
||||
|
||||
private final ConfigQueryChainService configQueryChainService;
|
||||
|
||||
private final ConfigOperationService configOperationService;
|
||||
|
||||
private final NamespaceOperationService namespaceOperationService;
|
||||
|
||||
public NacosPromptLegacyDataReader(ConfigInfoPersistService configInfoPersistService,
|
||||
ConfigQueryChainService configQueryChainService,
|
||||
ConfigOperationService configOperationService,
|
||||
NamespaceOperationService namespaceOperationService) {
|
||||
this.configInfoPersistService = configInfoPersistService;
|
||||
this.configQueryChainService = configQueryChainService;
|
||||
this.configOperationService = configOperationService;
|
||||
this.namespaceOperationService = namespaceOperationService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String type() {
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<LegacyPromptData> scanLegacyPrompts() {
|
||||
List<String> namespaceIds = getAllNamespaceIds();
|
||||
List<LegacyPromptData> result = new ArrayList<>();
|
||||
for (String namespaceId : namespaceIds) {
|
||||
List<String> promptKeys = scanPromptKeys(namespaceId);
|
||||
for (String promptKey : promptKeys) {
|
||||
LegacyPromptData data = buildLegacyPromptData(namespaceId, promptKey);
|
||||
if (data != null) {
|
||||
result.add(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PromptVersionInfo readVersionContent(String namespaceId, String promptKey,
|
||||
String version) {
|
||||
String versionDataId = PromptDataIdUtils.buildVersionDataId(promptKey, version);
|
||||
ConfigAllInfo configAllInfo =
|
||||
configInfoPersistService.findConfigAllInfo(versionDataId, PROMPT_GROUP,
|
||||
namespaceId);
|
||||
if (configAllInfo == null || StringUtils.isBlank(configAllInfo.getContent())) {
|
||||
return null;
|
||||
}
|
||||
PromptVersionInfo info;
|
||||
try {
|
||||
info = JacksonUtils.toObj(configAllInfo.getContent(), PromptVersionInfo.class);
|
||||
} catch (Exception e) {
|
||||
info = new PromptVersionInfo();
|
||||
info.setTemplate(configAllInfo.getContent());
|
||||
}
|
||||
info.setPromptKey(promptKey);
|
||||
info.setVersion(version);
|
||||
// Use Config table md5 if not already in content JSON
|
||||
if (info.getMd5() == null && configAllInfo.getMd5() != null) {
|
||||
info.setMd5(configAllInfo.getMd5());
|
||||
}
|
||||
// srcUser from createUser (Config advance info)
|
||||
if (info.getSrcUser() == null && configAllInfo.getCreateUser() != null) {
|
||||
info.setSrcUser(configAllInfo.getCreateUser());
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
private List<String> getAllNamespaceIds() {
|
||||
List<String> ids = new ArrayList<>();
|
||||
try {
|
||||
List<Namespace> namespaces = namespaceOperationService.getNamespaceList();
|
||||
for (Namespace ns : namespaces) {
|
||||
ids.add(ns.getNamespace());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to list namespaces, fallback to default only: {}", e.getMessage());
|
||||
ids.add(com.alibaba.nacos.api.common.Constants.DEFAULT_NAMESPACE_ID);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private List<String> scanPromptKeys(String namespaceId) {
|
||||
List<String> promptKeys = new ArrayList<>();
|
||||
int pageNo = 1;
|
||||
while (true) {
|
||||
Page<ConfigInfo> page =
|
||||
configInfoPersistService.findConfigInfo4Page(pageNo, SCAN_PAGE_SIZE, null,
|
||||
PROMPT_GROUP, namespaceId, null);
|
||||
if (page == null || page.getPageItems() == null || page.getPageItems().isEmpty()) {
|
||||
break;
|
||||
}
|
||||
for (ConfigInfo info : page.getPageItems()) {
|
||||
if (PromptDataIdUtils.isDescriptorDataId(info.getDataId())) {
|
||||
String key =
|
||||
PromptDataIdUtils.extractPromptKeyFromDescriptorDataId(info.getDataId());
|
||||
if (StringUtils.isNotBlank(key)) {
|
||||
promptKeys.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (page.getPageItems().size() < SCAN_PAGE_SIZE) {
|
||||
break;
|
||||
}
|
||||
pageNo++;
|
||||
}
|
||||
return promptKeys;
|
||||
}
|
||||
|
||||
private LegacyPromptData buildLegacyPromptData(String namespaceId, String promptKey) {
|
||||
LegacyDescriptor descriptor = readConfigJson(namespaceId,
|
||||
PromptDataIdUtils.buildDescriptorDataId(promptKey), LegacyDescriptor.class);
|
||||
LegacyLabelVersionMapping mapping = readConfigJson(namespaceId,
|
||||
PromptDataIdUtils.buildLabelVersionMappingDataId(promptKey),
|
||||
LegacyLabelVersionMapping.class);
|
||||
|
||||
if (mapping == null || mapping.versions == null || mapping.versions.isEmpty()) {
|
||||
LOGGER.warn("Prompt '{}' in namespace '{}' has no versions in mapping, skip", promptKey,
|
||||
namespaceId);
|
||||
return null;
|
||||
}
|
||||
|
||||
LegacyPromptData data = new LegacyPromptData();
|
||||
data.setNamespaceId(namespaceId);
|
||||
data.setPromptKey(promptKey);
|
||||
data.setDescription(descriptor != null ? descriptor.description : null);
|
||||
data.setBizTags(descriptor != null ? descriptor.bizTags : null);
|
||||
data.setLabels(mapping.labels != null ? mapping.labels : new HashMap<>());
|
||||
data.setLatestVersion(mapping.latestVersion);
|
||||
data.setVersions(mapping.versions);
|
||||
return data;
|
||||
}
|
||||
|
||||
private <T> T readConfigJson(String namespaceId, String dataId, Class<T> clazz) {
|
||||
String content = readConfigContent(namespaceId, dataId);
|
||||
if (StringUtils.isBlank(content)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JacksonUtils.toObj(content, clazz);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to parse config '{}' as {}: {}", dataId, clazz.getSimpleName(),
|
||||
e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String readConfigContent(String namespaceId, String dataId) {
|
||||
try {
|
||||
ConfigQueryChainRequest request =
|
||||
ConfigQueryChainRequest.buildConfigQueryChainRequest(dataId,
|
||||
PROMPT_GROUP, namespaceId);
|
||||
ConfigQueryChainResponse response = configQueryChainService.handle(request);
|
||||
if (response
|
||||
.getStatus() == ConfigQueryChainResponse.ConfigQueryStatus.CONFIG_NOT_FOUND) {
|
||||
return null;
|
||||
}
|
||||
return response.getContent();
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to read config '{}': {}", dataId, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanupLegacyData(String namespaceId, String promptKey, List<String> versions) {
|
||||
deleteConfigSilently(namespaceId, PromptDataIdUtils.buildDescriptorDataId(promptKey));
|
||||
deleteConfigSilently(namespaceId,
|
||||
PromptDataIdUtils.buildLabelVersionMappingDataId(promptKey));
|
||||
if (versions != null) {
|
||||
for (String version : versions) {
|
||||
deleteConfigSilently(namespaceId,
|
||||
PromptDataIdUtils.buildVersionDataId(promptKey, version));
|
||||
}
|
||||
}
|
||||
LOGGER.info("Cleaned up legacy config for prompt '{}' in namespace '{}'", promptKey,
|
||||
namespaceId);
|
||||
}
|
||||
|
||||
private void deleteConfigSilently(String namespaceId, String dataId) {
|
||||
try {
|
||||
configOperationService.deleteConfig(dataId, PROMPT_GROUP, namespaceId, null, null,
|
||||
"nacos", null);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to cleanup legacy config '{}': {}", dataId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Legacy Config JSON structures (for deserialization only) ==========
|
||||
|
||||
/**
|
||||
* Legacy prompt descriptor stored in Config as {promptKey}.descriptor.json.
|
||||
*/
|
||||
static class LegacyDescriptor {
|
||||
|
||||
public int schemaVersion = 1;
|
||||
|
||||
public String promptKey;
|
||||
|
||||
public String description;
|
||||
|
||||
public List<String> bizTags = new ArrayList<>();
|
||||
|
||||
public Long gmtModified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy prompt label/version mapping stored in Config as {promptKey}.label-version-mapping.json.
|
||||
*/
|
||||
static class LegacyLabelVersionMapping {
|
||||
|
||||
public int schemaVersion = 1;
|
||||
|
||||
public String promptKey;
|
||||
|
||||
public List<String> versions = new ArrayList<>();
|
||||
|
||||
public Map<String, String> labels = new HashMap<>();
|
||||
|
||||
public String latestVersion;
|
||||
|
||||
public Long gmtModified;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.config;
|
||||
|
||||
import com.alibaba.nacos.ai.pipeline.PublishPipelineExecutor;
|
||||
import com.alibaba.nacos.ai.pipeline.PublishPipelineManager;
|
||||
import com.alibaba.nacos.ai.pipeline.config.FilePipelineConfigProvider;
|
||||
import com.alibaba.nacos.ai.pipeline.config.PipelineConfigProvider;
|
||||
import com.alibaba.nacos.ai.pipeline.repository.PipelineExecutionRepository;
|
||||
import com.alibaba.nacos.ai.pipeline.repository.PipelineExecutionRepositoryImpl;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* Spring configuration for the publish pipeline components.
|
||||
*
|
||||
* @author kiro
|
||||
*/
|
||||
@Configuration
|
||||
public class PipelineConfiguration {
|
||||
|
||||
@Bean
|
||||
public PipelineConfigProvider pipelineConfigProvider() {
|
||||
return FilePipelineConfigProvider.getInstance();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PublishPipelineManager publishPipelineManager(PipelineConfigProvider configProvider) {
|
||||
PublishPipelineManager manager = new PublishPipelineManager();
|
||||
manager.init(configProvider.getConfig());
|
||||
return manager;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PipelineExecutionRepository pipelineExecutionRepository() {
|
||||
return new PipelineExecutionRepositoryImpl();
|
||||
}
|
||||
|
||||
@Bean("pipelineExecutor")
|
||||
public ExecutorService pipelineExecutor() {
|
||||
return Executors.newFixedThreadPool(4, new ThreadFactory() {
|
||||
|
||||
private final AtomicInteger counter = new AtomicInteger(0);
|
||||
|
||||
@Override
|
||||
public Thread newThread(Runnable r) {
|
||||
Thread thread = new Thread(r, "pipeline-executor-" + counter.getAndIncrement());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PublishPipelineExecutor publishPipelineExecutor(PublishPipelineManager pipelineManager,
|
||||
PipelineConfigProvider configProvider, PipelineExecutionRepository executionRepository,
|
||||
ExecutorService pipelineExecutor) {
|
||||
return new PublishPipelineExecutor(pipelineManager, configProvider, executionRepository,
|
||||
pipelineExecutor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.config;
|
||||
|
||||
import com.alibaba.nacos.ai.model.AiResource;
|
||||
import com.alibaba.nacos.ai.model.AiResourceVersion;
|
||||
import com.alibaba.nacos.ai.service.prompt.PromptOperationService;
|
||||
import com.alibaba.nacos.ai.service.repository.AiResourcePersistService;
|
||||
import com.alibaba.nacos.ai.service.repository.AiResourceVersionPersistService;
|
||||
import com.alibaba.nacos.ai.storage.NacosConfigAiResourceStorage;
|
||||
import com.alibaba.nacos.api.ai.model.prompt.PromptUtils;
|
||||
import com.alibaba.nacos.api.ai.model.prompt.PromptVersionInfo;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.common.executor.ExecutorFactory;
|
||||
import com.alibaba.nacos.common.utils.JacksonUtils;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
import com.alibaba.nacos.common.utils.ThreadFactoryBuilder;
|
||||
import com.alibaba.nacos.config.server.exception.ConfigAlreadyExistsException;
|
||||
import com.alibaba.nacos.config.server.model.ConfigRequestInfo;
|
||||
import com.alibaba.nacos.config.server.model.form.ConfigForm;
|
||||
import com.alibaba.nacos.config.server.service.ConfigOperationService;
|
||||
import com.alibaba.nacos.config.server.service.query.ConfigQueryChainService;
|
||||
import com.alibaba.nacos.config.server.service.query.model.ConfigQueryChainRequest;
|
||||
import com.alibaba.nacos.config.server.service.query.model.ConfigQueryChainResponse;
|
||||
import com.alibaba.nacos.plugin.ai.storage.AiResourceStorageRouter;
|
||||
import com.alibaba.nacos.plugin.ai.storage.model.StorageKey;
|
||||
import com.alibaba.nacos.sys.env.EnvUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Migrates prompt data from legacy storage to the new DB + SPI typed storage architecture.
|
||||
*
|
||||
* <p>Uses {@link PromptLegacyDataReader} to read legacy data. The active reader is selected
|
||||
* by {@code nacos.ai.prompt.migration.provider} (default: {@code nacos}).</p>
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
@Component
|
||||
public class PromptDataMigrationTask implements ApplicationListener<ApplicationReadyEvent> {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(PromptDataMigrationTask.class);
|
||||
|
||||
private static final String MIGRATION_MARKER_DATA_ID = "nacos.ai.prompt.migration";
|
||||
|
||||
private static final String MIGRATION_MARKER_GROUP = "nacos_internal";
|
||||
|
||||
private static final long MIGRATION_MARKER_STALE_MILLIS = 10 * 60 * 1000L;
|
||||
|
||||
private static final String RESOURCE_TYPE_PROMPT = "prompt";
|
||||
|
||||
private static final String VERSION_STATUS_ONLINE = "online";
|
||||
|
||||
private static final String META_STATUS_ENABLE = "enable";
|
||||
|
||||
private static final String STORAGE_PROVIDER_NACOS_CONFIG = "nacos_config";
|
||||
|
||||
private static final String PROMPT_STORAGE_PROVIDER_CONFIG_KEY =
|
||||
"nacos.ai.prompt.storage.provider";
|
||||
|
||||
private static final String MIGRATION_ENABLED_KEY = "nacos.ai.prompt.migration.enabled";
|
||||
|
||||
private static final String MIGRATION_PROVIDER_KEY = "nacos.ai.prompt.migration.provider";
|
||||
|
||||
private final AtomicBoolean initialized = new AtomicBoolean(false);
|
||||
|
||||
private final ExecutorService migrationExecutor =
|
||||
ExecutorFactory.Managed.newSingleExecutorService(
|
||||
PromptDataMigrationTask.class.getCanonicalName(),
|
||||
new ThreadFactoryBuilder().daemon(true).nameFormat("nacos-ai-prompt-migration-%d")
|
||||
.build());
|
||||
|
||||
private final AiResourcePersistService aiResourcePersistService;
|
||||
|
||||
private final AiResourceVersionPersistService aiResourceVersionPersistService;
|
||||
|
||||
private final PromptOperationService promptOperationService;
|
||||
|
||||
private final ConfigQueryChainService configQueryChainService;
|
||||
|
||||
private final ConfigOperationService configOperationService;
|
||||
|
||||
private final List<PromptLegacyDataReader> legacyDataReaders;
|
||||
|
||||
public PromptDataMigrationTask(AiResourcePersistService aiResourcePersistService,
|
||||
AiResourceVersionPersistService aiResourceVersionPersistService,
|
||||
@Lazy PromptOperationService promptOperationService,
|
||||
ConfigQueryChainService configQueryChainService,
|
||||
ConfigOperationService configOperationService,
|
||||
List<PromptLegacyDataReader> legacyDataReaders) {
|
||||
this.aiResourcePersistService = aiResourcePersistService;
|
||||
this.aiResourceVersionPersistService = aiResourceVersionPersistService;
|
||||
this.promptOperationService = promptOperationService;
|
||||
this.configQueryChainService = configQueryChainService;
|
||||
this.configOperationService = configOperationService;
|
||||
this.legacyDataReaders = legacyDataReaders != null ? legacyDataReaders : new ArrayList<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onApplicationEvent(ApplicationReadyEvent event) {
|
||||
if (event.getApplicationContext().getParent() != null) {
|
||||
return;
|
||||
}
|
||||
if (!initialized.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
boolean enabled = Boolean.parseBoolean(EnvUtil.getProperty(MIGRATION_ENABLED_KEY, "true"));
|
||||
if (!enabled) {
|
||||
LOGGER.info("Prompt data migration is disabled via {}", MIGRATION_ENABLED_KEY);
|
||||
return;
|
||||
}
|
||||
migrationExecutor.execute(this::executeMigration);
|
||||
}
|
||||
|
||||
private PromptLegacyDataReader resolveLegacyDataReader() {
|
||||
String providerType =
|
||||
EnvUtil.getProperty(MIGRATION_PROVIDER_KEY, NacosPromptLegacyDataReader.TYPE);
|
||||
for (PromptLegacyDataReader reader : legacyDataReaders) {
|
||||
if (providerType.equals(reader.type())) {
|
||||
LOGGER.info("Using PromptLegacyDataReader: {}", reader.type());
|
||||
return reader;
|
||||
}
|
||||
}
|
||||
LOGGER.warn("No PromptLegacyDataReader found for type '{}', skip migration", providerType);
|
||||
return null;
|
||||
}
|
||||
|
||||
private void executeMigration() {
|
||||
boolean markerCreated = false;
|
||||
try {
|
||||
PromptLegacyDataReader reader = resolveLegacyDataReader();
|
||||
if (reader == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<LegacyPromptData> allPrompts = reader.scanLegacyPrompts();
|
||||
if (allPrompts.isEmpty()) {
|
||||
LOGGER.info("No legacy prompt data found by reader '{}', skip migration",
|
||||
reader.type());
|
||||
return;
|
||||
}
|
||||
|
||||
List<LegacyPromptData> needsMigration = filterNeedsMigration(allPrompts);
|
||||
if (needsMigration.isEmpty()) {
|
||||
LOGGER.info("All {} legacy prompts already migrated, skip", allPrompts.size());
|
||||
return;
|
||||
}
|
||||
|
||||
markerCreated = tryAcquireMigrationMarker();
|
||||
if (!markerCreated) {
|
||||
LOGGER.info("Skip prompt migration because another node is migrating");
|
||||
return;
|
||||
}
|
||||
|
||||
needsMigration = filterNeedsMigration(allPrompts);
|
||||
if (needsMigration.isEmpty()) {
|
||||
LOGGER.info("All legacy prompts already migrated after acquiring marker");
|
||||
return;
|
||||
}
|
||||
|
||||
LOGGER.info("Start prompt data migration: {} prompts to migrate out of {} total",
|
||||
needsMigration.size(), allPrompts.size());
|
||||
|
||||
int migrated = 0;
|
||||
int failed = 0;
|
||||
for (LegacyPromptData prompt : needsMigration) {
|
||||
try {
|
||||
migrateOnePrompt(prompt, reader);
|
||||
migrated++;
|
||||
} catch (Exception e) {
|
||||
failed++;
|
||||
LOGGER.error("Failed to migrate prompt '{}' in namespace '{}': {}",
|
||||
prompt.getPromptKey(),
|
||||
prompt.getNamespaceId(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
LOGGER.info("Prompt data migration completed: migrated={}, failed={}, skipped={}",
|
||||
migrated, failed, allPrompts.size() - needsMigration.size());
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Prompt data migration failed unexpectedly", e);
|
||||
} finally {
|
||||
if (markerCreated) {
|
||||
releaseMigrationMarker();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<LegacyPromptData> filterNeedsMigration(List<LegacyPromptData> allPrompts) {
|
||||
List<LegacyPromptData> result = new ArrayList<>();
|
||||
for (LegacyPromptData prompt : allPrompts) {
|
||||
String namespace = prompt.getNamespaceId();
|
||||
AiResource existing = aiResourcePersistService.find(namespace, prompt.getPromptKey(),
|
||||
RESOURCE_TYPE_PROMPT);
|
||||
if (existing == null) {
|
||||
result.add(prompt);
|
||||
continue;
|
||||
}
|
||||
if (prompt.getVersions() != null && !prompt.getVersions().isEmpty()) {
|
||||
if (hasUnmigratedVersions(namespace, prompt.getPromptKey(), prompt.getVersions())) {
|
||||
result.add(prompt);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean hasUnmigratedVersions(String namespace, String promptKey,
|
||||
List<String> legacyVersions) {
|
||||
Set<String> migratedVersions = new HashSet<>();
|
||||
int pageNo = 1;
|
||||
int pageSize = legacyVersions.size() + 10;
|
||||
com.alibaba.nacos.api.model.Page<AiResourceVersion> page =
|
||||
aiResourceVersionPersistService.list(
|
||||
namespace, promptKey, RESOURCE_TYPE_PROMPT, null, pageNo, pageSize);
|
||||
if (page != null && page.getPageItems() != null) {
|
||||
for (AiResourceVersion v : page.getPageItems()) {
|
||||
migratedVersions.add(v.getVersion());
|
||||
}
|
||||
}
|
||||
for (String version : legacyVersions) {
|
||||
if (!migratedVersions.contains(version)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void migrateOnePrompt(LegacyPromptData prompt, PromptLegacyDataReader reader)
|
||||
throws Exception {
|
||||
String namespace = prompt.getNamespaceId();
|
||||
String promptKey = prompt.getPromptKey();
|
||||
|
||||
if (prompt.getVersions() == null || prompt.getVersions().isEmpty()) {
|
||||
LOGGER.warn("Prompt '{}' in namespace '{}' has no versions, skip migration", promptKey,
|
||||
namespace);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, String> labels =
|
||||
prompt.getLabels() != null ? new HashMap<>(prompt.getLabels()) : new HashMap<>();
|
||||
if (StringUtils.isNotBlank(prompt.getLatestVersion()) && !labels.containsKey("latest")) {
|
||||
labels.put("latest", prompt.getLatestVersion());
|
||||
}
|
||||
|
||||
Map<String, Object> versionInfoMap = new HashMap<>(8);
|
||||
versionInfoMap.put("labels", labels);
|
||||
versionInfoMap.put("editingVersion", null);
|
||||
versionInfoMap.put("reviewingVersion", null);
|
||||
versionInfoMap.put("onlineCnt", prompt.getVersions().size());
|
||||
|
||||
AiResource resource = new AiResource();
|
||||
resource.setNamespaceId(namespace);
|
||||
resource.setName(promptKey);
|
||||
resource.setType(RESOURCE_TYPE_PROMPT);
|
||||
resource.setDesc(prompt.getDescription());
|
||||
resource.setStatus(META_STATUS_ENABLE);
|
||||
resource.setMetaVersion(1L);
|
||||
resource.setVersionInfo(JacksonUtils.toJson(versionInfoMap));
|
||||
resource.setBizTags(
|
||||
prompt.getBizTags() != null ? JacksonUtils.toJson(prompt.getBizTags()) : "[]");
|
||||
|
||||
try {
|
||||
aiResourcePersistService.insert(resource);
|
||||
LOGGER.info("Migrated prompt '{}' in namespace '{}' resource record to DB", promptKey,
|
||||
namespace);
|
||||
} catch (Exception e) {
|
||||
AiResource existing =
|
||||
aiResourcePersistService.find(namespace, promptKey, RESOURCE_TYPE_PROMPT);
|
||||
if (existing != null) {
|
||||
LOGGER.info(
|
||||
"Prompt '{}' in namespace '{}' resource record already exists, continue with version migration",
|
||||
promptKey, namespace);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
int versionsMigrated = 0;
|
||||
for (String version : prompt.getVersions()) {
|
||||
try {
|
||||
migrateOneVersion(namespace, promptKey, version, reader);
|
||||
versionsMigrated++;
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Failed to migrate prompt '{}' version '{}': {}", promptKey, version,
|
||||
e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
promptOperationService.refreshLatestMirror(namespace, promptKey);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to refresh legacy mirror for prompt '{}': {}", promptKey,
|
||||
e.getMessage());
|
||||
}
|
||||
|
||||
LOGGER.info("Migrated prompt '{}' in namespace '{}': {}/{} versions", promptKey, namespace,
|
||||
versionsMigrated, prompt.getVersions().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up legacy storage entries for a prompt. Should be called when a prompt is deleted
|
||||
* in the new system to prevent the migration task from re-importing it on next restart.
|
||||
*
|
||||
* <p>Delegates to the active {@link PromptLegacyDataReader} implementation, which knows
|
||||
* the specific legacy storage format and location.</p>
|
||||
*
|
||||
* @param namespaceId namespace ID
|
||||
* @param promptKey prompt key
|
||||
* @param versions version strings to clean up
|
||||
*/
|
||||
public void cleanupLegacyConfig(String namespaceId, String promptKey, List<String> versions) {
|
||||
PromptLegacyDataReader reader = resolveLegacyDataReader();
|
||||
if (reader == null) {
|
||||
return;
|
||||
}
|
||||
reader.cleanupLegacyData(namespaceId, promptKey, versions);
|
||||
}
|
||||
|
||||
private void migrateOneVersion(String namespace, String promptKey, String version,
|
||||
PromptLegacyDataReader reader) throws Exception {
|
||||
PromptVersionInfo versionInfo = reader.readVersionContent(namespace, promptKey, version);
|
||||
if (versionInfo == null) {
|
||||
LOGGER.warn("No content found for prompt '{}' version '{}', skip", promptKey, version);
|
||||
return;
|
||||
}
|
||||
|
||||
versionInfo.setPromptKey(promptKey);
|
||||
versionInfo.setVersion(version);
|
||||
|
||||
// Pre-compute md5 from content without md5 field
|
||||
versionInfo.setMd5(null);
|
||||
String contentJson = JacksonUtils.toJson(versionInfo);
|
||||
String md5 = com.alibaba.nacos.common.utils.MD5Utils.md5Hex(contentJson,
|
||||
java.nio.charset.StandardCharsets.UTF_8.name());
|
||||
versionInfo.setMd5(md5);
|
||||
|
||||
// Write to typed storage FIRST (idempotent overwrite)
|
||||
writeToTypedStorage(namespace, promptKey, version, versionInfo);
|
||||
|
||||
// Then create DB record (idempotent: skip if exists)
|
||||
AiResourceVersion existing = aiResourceVersionPersistService.find(namespace, promptKey,
|
||||
RESOURCE_TYPE_PROMPT, version);
|
||||
if (existing != null) {
|
||||
LOGGER.debug("Prompt '{}' version '{}' already in DB, skip insert", promptKey, version);
|
||||
return;
|
||||
}
|
||||
|
||||
AiResourceVersion versionRecord = new AiResourceVersion();
|
||||
versionRecord.setNamespaceId(namespace);
|
||||
versionRecord.setName(promptKey);
|
||||
versionRecord.setType(RESOURCE_TYPE_PROMPT);
|
||||
versionRecord.setVersion(version);
|
||||
versionRecord.setStatus(VERSION_STATUS_ONLINE);
|
||||
versionRecord.setAuthor(versionInfo.getSrcUser() != null ? versionInfo.getSrcUser() : "-");
|
||||
versionRecord.setDesc(versionInfo.getCommitMsg() != null ? versionInfo.getCommitMsg()
|
||||
: "migrated from legacy config");
|
||||
versionRecord.setStorage(buildStorageJson(namespace, promptKey, version));
|
||||
|
||||
try {
|
||||
aiResourceVersionPersistService.insert(versionRecord);
|
||||
} catch (Exception e) {
|
||||
AiResourceVersion check = aiResourceVersionPersistService.find(namespace, promptKey,
|
||||
RESOURCE_TYPE_PROMPT, version);
|
||||
if (check != null) {
|
||||
LOGGER.debug("Prompt '{}' version '{}' inserted by another node, skip", promptKey,
|
||||
version);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
LOGGER.debug("Migrated prompt '{}' version '{}'", promptKey, version);
|
||||
}
|
||||
|
||||
private boolean tryAcquireMigrationMarker() {
|
||||
for (int i = 0; i < 2; i++) {
|
||||
try {
|
||||
ConfigForm form = new ConfigForm();
|
||||
form.setNamespaceId(com.alibaba.nacos.api.common.Constants.DEFAULT_NAMESPACE_ID);
|
||||
form.setGroup(MIGRATION_MARKER_GROUP);
|
||||
form.setDataId(MIGRATION_MARKER_DATA_ID);
|
||||
form.setContent(String.valueOf(System.currentTimeMillis()));
|
||||
form.setSrcUser("nacos");
|
||||
ConfigRequestInfo requestInfo = new ConfigRequestInfo();
|
||||
requestInfo.setUpdateForExist(false);
|
||||
configOperationService.publishConfig(form, requestInfo, null);
|
||||
return true;
|
||||
} catch (ConfigAlreadyExistsException e) {
|
||||
if (isMigrationMarkerStale()) {
|
||||
LOGGER.warn("Found stale prompt migration marker, removing and retrying");
|
||||
releaseMigrationMarker();
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Failed to create prompt migration marker", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isMigrationMarkerStale() {
|
||||
try {
|
||||
ConfigQueryChainRequest request = ConfigQueryChainRequest.buildConfigQueryChainRequest(
|
||||
MIGRATION_MARKER_DATA_ID, MIGRATION_MARKER_GROUP,
|
||||
com.alibaba.nacos.api.common.Constants.DEFAULT_NAMESPACE_ID);
|
||||
ConfigQueryChainResponse response = configQueryChainService.handle(request);
|
||||
if (response.getStatus() == ConfigQueryChainResponse.ConfigQueryStatus.CONFIG_NOT_FOUND
|
||||
|| StringUtils.isBlank(response.getContent())) {
|
||||
return false;
|
||||
}
|
||||
long markerTime = Long.parseLong(response.getContent().trim());
|
||||
return System.currentTimeMillis() - markerTime > MIGRATION_MARKER_STALE_MILLIS;
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to inspect prompt migration marker", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseMigrationMarker() {
|
||||
try {
|
||||
configOperationService.deleteConfig(MIGRATION_MARKER_DATA_ID, MIGRATION_MARKER_GROUP,
|
||||
com.alibaba.nacos.api.common.Constants.DEFAULT_NAMESPACE_ID, null, null, "nacos",
|
||||
null);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to delete prompt migration marker", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeToTypedStorage(String namespace, String promptKey, String version,
|
||||
PromptVersionInfo versionInfo) throws NacosException {
|
||||
String provider = resolveStorageProvider();
|
||||
byte[] contentBytes = JacksonUtils.toJson(versionInfo).getBytes(StandardCharsets.UTF_8);
|
||||
StorageKey storageKey = NacosConfigAiResourceStorage.buildStorageKey(provider, namespace,
|
||||
NacosConfigAiResourceStorage.RESOURCE_TYPE_PROMPT, promptKey, version,
|
||||
PromptUtils.PROMPT_MAIN_DATA_ID);
|
||||
AiResourceStorageRouter.getInstance().route(storageKey).save(storageKey, contentBytes);
|
||||
}
|
||||
|
||||
private static String buildStorageJson(String namespace, String promptKey, String version) {
|
||||
Map<String, Object> json = new HashMap<>(4);
|
||||
json.put("provider", resolveStorageProvider());
|
||||
json.put("scope", namespace + ":" + promptKey + ":" + version);
|
||||
json.put("files", Collections.singletonList(PromptUtils.PROMPT_MAIN_DATA_ID));
|
||||
return JacksonUtils.toJson(json);
|
||||
}
|
||||
|
||||
private static String resolveStorageProvider() {
|
||||
String provider =
|
||||
EnvUtil.getProperty(PROMPT_STORAGE_PROVIDER_CONFIG_KEY, STORAGE_PROVIDER_NACOS_CONFIG);
|
||||
return StringUtils.isBlank(provider) ? STORAGE_PROVIDER_NACOS_CONFIG : provider.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.config;
|
||||
|
||||
import com.alibaba.nacos.api.ai.model.prompt.PromptVersionInfo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* SPI interface for reading legacy prompt data during migration.
|
||||
*
|
||||
* <p>Different environments (open-source Nacos vs commercial) may store prompt data
|
||||
* in different legacy formats. Implementations provide the ability to scan and read
|
||||
* legacy prompt data for migration to the new DB + typed storage architecture.</p>
|
||||
*
|
||||
* <p>The default implementation ({@code nacos}) reads from Nacos Config
|
||||
* ({@code nacos-ai-prompt} group). Commercial implementations can provide their own
|
||||
* {@code @Component} bean to override.</p>
|
||||
*
|
||||
* @author nacos
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public interface PromptLegacyDataReader {
|
||||
|
||||
/**
|
||||
* Provider type identifier. Used to select the active reader via configuration
|
||||
* property {@code nacos.ai.prompt.migration.provider}.
|
||||
*
|
||||
* @return type string, e.g. "nacos"
|
||||
*/
|
||||
String type();
|
||||
|
||||
/**
|
||||
* Scan legacy storage and return all prompts with their metadata and version lists.
|
||||
* Version content is NOT included; use {@link #readVersionContent} to load on demand.
|
||||
*
|
||||
* @return list of legacy prompt data
|
||||
*/
|
||||
List<LegacyPromptData> scanLegacyPrompts();
|
||||
|
||||
/**
|
||||
* Read the content of a specific prompt version from legacy storage.
|
||||
*
|
||||
* @param namespaceId namespace ID
|
||||
* @param promptKey prompt key
|
||||
* @param version version string
|
||||
* @return version info with template/variables/srcUser/commitMsg, or null if not found
|
||||
*/
|
||||
PromptVersionInfo readVersionContent(String namespaceId, String promptKey, String version);
|
||||
|
||||
/**
|
||||
* Clean up legacy storage entries for a prompt after it has been deleted in the new system.
|
||||
* This prevents the migration task from re-importing deleted prompts on next restart.
|
||||
*
|
||||
* <p>Default implementation is no-op for backward compatibility with existing implementations.</p>
|
||||
*
|
||||
* @param namespaceId namespace ID
|
||||
* @param promptKey prompt key
|
||||
* @param versions version strings to clean up
|
||||
*/
|
||||
default void cleanupLegacyData(String namespaceId, String promptKey, List<String> versions) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.config;
|
||||
|
||||
import com.alibaba.nacos.ai.model.AiResource;
|
||||
import com.alibaba.nacos.ai.service.repository.AiResourcePersistService;
|
||||
import com.alibaba.nacos.ai.service.skills.SkillOperationService;
|
||||
import com.alibaba.nacos.ai.utils.SkillSeedArchiveReader;
|
||||
import com.alibaba.nacos.api.common.Constants;
|
||||
import com.alibaba.nacos.api.model.Page;
|
||||
import com.alibaba.nacos.common.executor.ExecutorFactory;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
import com.alibaba.nacos.common.utils.ThreadFactoryBuilder;
|
||||
import com.alibaba.nacos.config.server.exception.ConfigAlreadyExistsException;
|
||||
import com.alibaba.nacos.config.server.model.ConfigRequestInfo;
|
||||
import com.alibaba.nacos.config.server.model.form.ConfigForm;
|
||||
import com.alibaba.nacos.config.server.service.ConfigOperationService;
|
||||
import com.alibaba.nacos.config.server.service.query.ConfigQueryChainService;
|
||||
import com.alibaba.nacos.config.server.service.query.model.ConfigQueryChainRequest;
|
||||
import com.alibaba.nacos.config.server.service.query.model.ConfigQueryChainResponse;
|
||||
import com.alibaba.nacos.sys.env.EnvUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Bootstrap built-in skills into an empty AI namespace after cluster startup.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
@Component
|
||||
public class SkillDataBootstrapInitializer implements ApplicationListener<ApplicationReadyEvent> {
|
||||
|
||||
private static final Logger LOGGER =
|
||||
LoggerFactory.getLogger(SkillDataBootstrapInitializer.class);
|
||||
|
||||
private static final String BOOTSTRAP_MARKER_DATA_ID = "nacos.ai.skills.bootstrap";
|
||||
|
||||
private static final String BOOTSTRAP_MARKER_GROUP = "nacos_internal";
|
||||
|
||||
private static final long BOOTSTRAP_MARKER_STALE_MILLIS = 10 * 60 * 1000L;
|
||||
|
||||
private static final String RESOURCE_TYPE_SKILL = "skill";
|
||||
|
||||
private static final int MAX_BOOTSTRAP_IMPORT_CONCURRENCY = 4;
|
||||
|
||||
private static final int BOOTSTRAP_IMPORT_CONCURRENCY = Math.max(1,
|
||||
Math.min(EnvUtil.getAvailableProcessors(), MAX_BOOTSTRAP_IMPORT_CONCURRENCY));
|
||||
|
||||
private final AtomicBoolean initialized = new AtomicBoolean(false);
|
||||
|
||||
private final ExecutorService bootstrapExecutor =
|
||||
ExecutorFactory.Managed.newSingleExecutorService(
|
||||
SkillDataBootstrapInitializer.class.getCanonicalName(),
|
||||
new ThreadFactoryBuilder().daemon(true).nameFormat("nacos-ai-skill-bootstrap-%d")
|
||||
.build());
|
||||
|
||||
private final ExecutorService bootstrapImportExecutor =
|
||||
ExecutorFactory.Managed.newFixedExecutorService(
|
||||
SkillDataBootstrapInitializer.class.getCanonicalName(), BOOTSTRAP_IMPORT_CONCURRENCY,
|
||||
new ThreadFactoryBuilder().daemon(true).nameFormat("nacos-ai-skill-bootstrap-import-%d")
|
||||
.build());
|
||||
|
||||
private final AiResourcePersistService aiResourcePersistService;
|
||||
|
||||
private final SkillOperationService skillOperationService;
|
||||
|
||||
private final ConfigOperationService configOperationService;
|
||||
|
||||
private final ConfigQueryChainService configQueryChainService;
|
||||
|
||||
public SkillDataBootstrapInitializer(AiResourcePersistService aiResourcePersistService,
|
||||
SkillOperationService skillOperationService,
|
||||
ConfigOperationService configOperationService,
|
||||
ConfigQueryChainService configQueryChainService) {
|
||||
this.aiResourcePersistService = aiResourcePersistService;
|
||||
this.skillOperationService = skillOperationService;
|
||||
this.configOperationService = configOperationService;
|
||||
this.configQueryChainService = configQueryChainService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onApplicationEvent(ApplicationReadyEvent event) {
|
||||
if (event.getApplicationContext().getParent() != null) {
|
||||
return;
|
||||
}
|
||||
if (!initialized.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
bootstrapExecutor.execute(this::bootstrapBuiltInSkills);
|
||||
}
|
||||
|
||||
private void bootstrapBuiltInSkills() {
|
||||
boolean markerCreated = false;
|
||||
try {
|
||||
Resource bundledSkillsArchive = resolveBundledSkillsArchive();
|
||||
if (!bundledSkillsArchive.exists()) {
|
||||
LOGGER.warn("Built-in skill archive `{}` not found, skip bootstrap",
|
||||
bundledSkillsArchive);
|
||||
return;
|
||||
}
|
||||
|
||||
List<SkillSeedArchiveReader.SkillPackage> skillPackages = readSkillPackages();
|
||||
if (skillPackages.isEmpty()) {
|
||||
LOGGER.info("No built-in skills found in archive `{}`", bundledSkillsArchive);
|
||||
return;
|
||||
}
|
||||
BootstrapPlan bootstrapPlan = buildBootstrapPlan(skillPackages);
|
||||
if (!bootstrapPlan.shouldBootstrap()) {
|
||||
LOGGER.info(bootstrapPlan.getSkipReason());
|
||||
return;
|
||||
}
|
||||
|
||||
markerCreated = tryAcquireBootstrapMarker();
|
||||
if (!markerCreated) {
|
||||
LOGGER.info("Skip built-in skill bootstrap because another node is initializing");
|
||||
return;
|
||||
}
|
||||
|
||||
bootstrapPlan = buildBootstrapPlan(skillPackages);
|
||||
if (!bootstrapPlan.shouldBootstrap()) {
|
||||
LOGGER.info(bootstrapPlan.getSkipReason());
|
||||
return;
|
||||
}
|
||||
|
||||
int imported = 0;
|
||||
int skipped = bootstrapPlan.getExistingBuiltInCount();
|
||||
List<String> failedSkills = new ArrayList<>();
|
||||
LOGGER.info(
|
||||
"Start built-in skill bootstrap in namespace `{}`, total {}, missing {}, existing {}, concurrency {}",
|
||||
Constants.DEFAULT_NAMESPACE_ID, skillPackages.size(),
|
||||
bootstrapPlan.getMissingSkillPackages().size(),
|
||||
bootstrapPlan.getExistingBuiltInCount(), BOOTSTRAP_IMPORT_CONCURRENCY);
|
||||
List<Future<ImportTaskResult>> futures =
|
||||
new ArrayList<>(bootstrapPlan.getMissingSkillPackages().size());
|
||||
for (SkillSeedArchiveReader.SkillPackage skillPackage : bootstrapPlan
|
||||
.getMissingSkillPackages()) {
|
||||
futures.add(bootstrapImportExecutor.submit(() -> importBuiltInSkill(skillPackage)));
|
||||
}
|
||||
for (Future<ImportTaskResult> future : futures) {
|
||||
ImportTaskResult taskResult = future.get();
|
||||
if (taskResult.isSuccess()) {
|
||||
imported++;
|
||||
} else {
|
||||
failedSkills.add(taskResult.getSkillName());
|
||||
}
|
||||
}
|
||||
LOGGER.info("Built-in skill bootstrap completed, imported {}, skipped {}, failed {}",
|
||||
imported, skipped,
|
||||
failedSkills.size());
|
||||
if (!failedSkills.isEmpty()) {
|
||||
LOGGER.warn("Built-in skill bootstrap still missing {} skills: {}",
|
||||
failedSkills.size(), failedSkills);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Failed to bootstrap built-in skills", e);
|
||||
} finally {
|
||||
if (markerCreated) {
|
||||
releaseBootstrapMarker();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<SkillSeedArchiveReader.SkillPackage> readSkillPackages() throws IOException {
|
||||
Resource bundledSkillsArchive = resolveBundledSkillsArchive();
|
||||
try (InputStream inputStream = bundledSkillsArchive.getInputStream()) {
|
||||
return SkillSeedArchiveReader.read(inputStream);
|
||||
}
|
||||
}
|
||||
|
||||
private Resource resolveBundledSkillsArchive() {
|
||||
Path archivePath = Paths.get(EnvUtil.getNacosHome(), "data", "skills-data.zip");
|
||||
return new FileSystemResource(archivePath);
|
||||
}
|
||||
|
||||
private ImportTaskResult importBuiltInSkill(SkillSeedArchiveReader.SkillPackage skillPackage) {
|
||||
try {
|
||||
LOGGER.info("Import built-in skill `{}` from `{}`", skillPackage.getSkillName(),
|
||||
skillPackage.getSourcePath());
|
||||
skillOperationService.bootstrapSkillFromZip(Constants.DEFAULT_NAMESPACE_ID,
|
||||
skillPackage.getZipBytes(),
|
||||
skillPackage.getFrom());
|
||||
return ImportTaskResult.success(skillPackage.getSkillName());
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Failed to bootstrap built-in skill `{}` from `{}`",
|
||||
skillPackage.getSkillName(),
|
||||
skillPackage.getSourcePath(), e);
|
||||
return ImportTaskResult.failed(skillPackage.getSkillName());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasExistingAiData() {
|
||||
Page<AiResource> page =
|
||||
aiResourcePersistService.list(Constants.DEFAULT_NAMESPACE_ID, null, null, null, 1, 1);
|
||||
return page != null && page.getTotalCount() > 0;
|
||||
}
|
||||
|
||||
private BootstrapPlan buildBootstrapPlan(
|
||||
List<SkillSeedArchiveReader.SkillPackage> skillPackages) {
|
||||
boolean existingAiData = hasExistingAiData();
|
||||
int existingBuiltInCount = 0;
|
||||
List<SkillSeedArchiveReader.SkillPackage> missingSkillPackages =
|
||||
new ArrayList<>(skillPackages.size());
|
||||
for (SkillSeedArchiveReader.SkillPackage skillPackage : skillPackages) {
|
||||
if (aiResourcePersistService.find(Constants.DEFAULT_NAMESPACE_ID,
|
||||
skillPackage.getSkillName(),
|
||||
RESOURCE_TYPE_SKILL) != null) {
|
||||
existingBuiltInCount++;
|
||||
} else {
|
||||
missingSkillPackages.add(skillPackage);
|
||||
}
|
||||
}
|
||||
if (missingSkillPackages.isEmpty()) {
|
||||
return BootstrapPlan.skip(
|
||||
"Skip built-in skill bootstrap because all bundled skills already exist",
|
||||
existingBuiltInCount);
|
||||
}
|
||||
if (!existingAiData) {
|
||||
return BootstrapPlan.bootstrap(missingSkillPackages, existingBuiltInCount);
|
||||
}
|
||||
if (existingBuiltInCount == 0) {
|
||||
return BootstrapPlan.skip(
|
||||
"Skip built-in skill bootstrap because AI data already exists and no built-in skills were imported before",
|
||||
0);
|
||||
}
|
||||
return BootstrapPlan.bootstrap(missingSkillPackages, existingBuiltInCount);
|
||||
}
|
||||
|
||||
private boolean tryAcquireBootstrapMarker() {
|
||||
for (int i = 0; i < 2; i++) {
|
||||
try {
|
||||
ConfigForm form = new ConfigForm();
|
||||
form.setNamespaceId(Constants.DEFAULT_NAMESPACE_ID);
|
||||
form.setGroup(BOOTSTRAP_MARKER_GROUP);
|
||||
form.setDataId(BOOTSTRAP_MARKER_DATA_ID);
|
||||
form.setContent(String.valueOf(System.currentTimeMillis()));
|
||||
form.setSrcUser("nacos");
|
||||
ConfigRequestInfo requestInfo = new ConfigRequestInfo();
|
||||
requestInfo.setUpdateForExist(false);
|
||||
configOperationService.publishConfig(form, requestInfo, null);
|
||||
return true;
|
||||
} catch (ConfigAlreadyExistsException e) {
|
||||
if (!hasExistingAiData() && isBootstrapMarkerStale()) {
|
||||
LOGGER.warn(
|
||||
"Found stale built-in skill bootstrap marker, removing it and retrying");
|
||||
releaseBootstrapMarker();
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Failed to create built-in skill bootstrap marker", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isBootstrapMarkerStale() {
|
||||
try {
|
||||
ConfigQueryChainRequest request = ConfigQueryChainRequest.buildConfigQueryChainRequest(
|
||||
BOOTSTRAP_MARKER_DATA_ID, BOOTSTRAP_MARKER_GROUP, Constants.DEFAULT_NAMESPACE_ID);
|
||||
ConfigQueryChainResponse response = configQueryChainService.handle(request);
|
||||
if (response.getStatus() == ConfigQueryChainResponse.ConfigQueryStatus.CONFIG_NOT_FOUND
|
||||
|| StringUtils.isBlank(response.getContent())) {
|
||||
return false;
|
||||
}
|
||||
long markerTime = Long.parseLong(response.getContent().trim());
|
||||
return System.currentTimeMillis() - markerTime > BOOTSTRAP_MARKER_STALE_MILLIS;
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to inspect built-in skill bootstrap marker", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseBootstrapMarker() {
|
||||
try {
|
||||
configOperationService.deleteConfig(BOOTSTRAP_MARKER_DATA_ID, BOOTSTRAP_MARKER_GROUP,
|
||||
Constants.DEFAULT_NAMESPACE_ID, null, null, "nacos", null);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to delete built-in skill bootstrap marker", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class BootstrapPlan {
|
||||
|
||||
private final boolean shouldBootstrap;
|
||||
|
||||
private final String skipReason;
|
||||
|
||||
private final List<SkillSeedArchiveReader.SkillPackage> missingSkillPackages;
|
||||
|
||||
private final int existingBuiltInCount;
|
||||
|
||||
private BootstrapPlan(boolean shouldBootstrap, String skipReason,
|
||||
List<SkillSeedArchiveReader.SkillPackage> missingSkillPackages,
|
||||
int existingBuiltInCount) {
|
||||
this.shouldBootstrap = shouldBootstrap;
|
||||
this.skipReason = skipReason;
|
||||
this.missingSkillPackages = missingSkillPackages;
|
||||
this.existingBuiltInCount = existingBuiltInCount;
|
||||
}
|
||||
|
||||
private static BootstrapPlan bootstrap(
|
||||
List<SkillSeedArchiveReader.SkillPackage> missingSkillPackages,
|
||||
int existingBuiltInCount) {
|
||||
return new BootstrapPlan(true, null, missingSkillPackages, existingBuiltInCount);
|
||||
}
|
||||
|
||||
private static BootstrapPlan skip(String skipReason, int existingBuiltInCount) {
|
||||
return new BootstrapPlan(false, skipReason, new ArrayList<>(0), existingBuiltInCount);
|
||||
}
|
||||
|
||||
private boolean shouldBootstrap() {
|
||||
return shouldBootstrap;
|
||||
}
|
||||
|
||||
private String getSkipReason() {
|
||||
return skipReason;
|
||||
}
|
||||
|
||||
private List<SkillSeedArchiveReader.SkillPackage> getMissingSkillPackages() {
|
||||
return missingSkillPackages;
|
||||
}
|
||||
|
||||
private int getExistingBuiltInCount() {
|
||||
return existingBuiltInCount;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ImportTaskResult {
|
||||
|
||||
private final String skillName;
|
||||
|
||||
private final boolean success;
|
||||
|
||||
private ImportTaskResult(String skillName, boolean success) {
|
||||
this.skillName = skillName;
|
||||
this.success = success;
|
||||
}
|
||||
|
||||
private static ImportTaskResult success(String skillName) {
|
||||
return new ImportTaskResult(skillName, true);
|
||||
}
|
||||
|
||||
private static ImportTaskResult failed(String skillName) {
|
||||
return new ImportTaskResult(skillName, false);
|
||||
}
|
||||
|
||||
private String getSkillName() {
|
||||
return skillName;
|
||||
}
|
||||
|
||||
private boolean isSuccess() {
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.constant;
|
||||
|
||||
/**
|
||||
* Shared constants for AI resource operations (Skill, AgentSpec, etc.).
|
||||
*
|
||||
* <p>Extracts duplicated constants that were previously defined independently
|
||||
* in {@code SkillOperationServiceImpl} and {@code AgentSpecOperationServiceImpl}.</p>
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
public final class AiResourceConstants {
|
||||
|
||||
private AiResourceConstants() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta status: resource is enabled.
|
||||
*/
|
||||
public static final String META_STATUS_ENABLE = "enable";
|
||||
|
||||
/**
|
||||
* Meta status: resource is disabled.
|
||||
*/
|
||||
public static final String META_STATUS_DISABLE = "disable";
|
||||
|
||||
/**
|
||||
* Version status: version is online (published and active).
|
||||
*/
|
||||
public static final String VERSION_STATUS_ONLINE = "online";
|
||||
|
||||
/**
|
||||
* Version status: version is in draft (not yet submitted).
|
||||
*/
|
||||
public static final String VERSION_STATUS_DRAFT = "draft";
|
||||
|
||||
/**
|
||||
* Version status: version is under review (pipeline running).
|
||||
*/
|
||||
public static final String VERSION_STATUS_REVIEWING = "reviewing";
|
||||
|
||||
/**
|
||||
* Version status: version has been reviewed (pipeline approved, awaiting publish).
|
||||
*/
|
||||
public static final String VERSION_STATUS_REVIEWED = "reviewed";
|
||||
|
||||
/**
|
||||
* Version status: version has been taken offline.
|
||||
*/
|
||||
public static final String VERSION_STATUS_OFFLINE = "offline";
|
||||
|
||||
/**
|
||||
* Maximum retry count for CAS-based meta update operations.
|
||||
*/
|
||||
public static final int MAX_WORKING_VERSION_RETRY = 3;
|
||||
|
||||
/**
|
||||
* Label key that points to the latest published version.
|
||||
*/
|
||||
public static final String LABEL_LATEST = "latest";
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.constant;
|
||||
|
||||
/**
|
||||
* Nacos AI Server Constants.
|
||||
*
|
||||
* @author xiweng.yy
|
||||
*/
|
||||
public class Constants {
|
||||
|
||||
public static final String MCP_PATH = "/ai/mcp";
|
||||
|
||||
public static final String MCP_ADMIN_PATH = "/v3/admin" + MCP_PATH;
|
||||
|
||||
public static final String MCP_CONSOLE_PATH = "/v3/console" + MCP_PATH;
|
||||
|
||||
public static final String AI_RESOURCE_IMPORT_ADMIN_PATH = "/v3/admin/ai/import";
|
||||
|
||||
public static final String AI_RESOURCE_IMPORT_CONSOLE_PATH = "/v3/console/ai/import";
|
||||
|
||||
public static final String MCP_LIST_SEARCH_ACCURATE = "accurate";
|
||||
|
||||
public static final String MCP_LIST_SEARCH_BLUR = "blur";
|
||||
|
||||
public static final String ALL_PATTERN = com.alibaba.nacos.api.common.Constants.ALL_PATTERN;
|
||||
|
||||
public static final String MCP_SERVER_VERSIONS_GROUP = "mcp-server-versions";
|
||||
|
||||
public static final String MCP_SERVER_GROUP = "mcp-server";
|
||||
|
||||
public static final String MCP_SERVER_TOOL_GROUP = "mcp-tools";
|
||||
|
||||
public static final String MCP_SERVER_RESOURCE_GROUP = "mcp-resources";
|
||||
|
||||
public static final String MCP_SERVER_SPEC_DATA_ID_SUFFIX = "-mcp-server.json";
|
||||
|
||||
public static final String MCP_SERVER_VERSION_DATA_ID_SUFFIX = "-mcp-versions.json";
|
||||
|
||||
public static final String MCP_SERVER_TOOL_DATA_ID_SUFFIX = "-mcp-tools.json";
|
||||
|
||||
public static final String MCP_SERVER_RESOURCE_DATA_ID_SUFFIX = "-mcp-resources.json";
|
||||
|
||||
public static final String MCP_SERVER_ENDPOINT_GROUP = "mcp-endpoints";
|
||||
|
||||
public static final String MCP_SERVER_ENDPOINT_CLUSTER =
|
||||
com.alibaba.nacos.api.common.Constants.DEFAULT_CLUSTER_NAME;
|
||||
|
||||
public static final String MCP_BACKEND_INSTANCE_PROTOCOL_KEY = "transportProtocol";
|
||||
|
||||
public static final String MCP_SERVER_ENDPOINT_ADDRESS = "address";
|
||||
|
||||
public static final String MCP_SERVER_ENDPOINT_PORT = "port";
|
||||
|
||||
public static final String MCP_SERVER_ENDPOINT_METADATA_MARK = "__nacos.ai.mcp.service__";
|
||||
|
||||
public static final String MCP_SERVER_CONFIG_MARK = "nacos.internal.config=mcp";
|
||||
|
||||
public static final String PROTOCOL_TYPE_HTTP = "http";
|
||||
|
||||
public static final String PROTOCOL_TYPE_HTTPS = "https";
|
||||
|
||||
public static final String SERVER_EXPORT_PATH_KEY = "exportPath";
|
||||
|
||||
public static final String MCP_SERVER_NAME_TAG_KEY_PREFIX = "mcpServerName=";
|
||||
|
||||
public static final int MAX_LIST_SIZE = 100;
|
||||
|
||||
public static final String RELEASE_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'Z'";
|
||||
|
||||
public static final String CONFIG_TAGS_NAME = "config_tags";
|
||||
|
||||
public static final String META_PATH = "path";
|
||||
|
||||
public static final String SERVER_VERSION_CONFIG_DATA_ID_TEMPLATE =
|
||||
"%s" + MCP_SERVER_VERSION_DATA_ID_SUFFIX;
|
||||
|
||||
public static final String SERVER_SPECIFICATION_CONFIG_DATA_ID_TEMPLATE =
|
||||
"%s-%s" + MCP_SERVER_SPEC_DATA_ID_SUFFIX;
|
||||
|
||||
public static final String SERVER_TOOLS_SPEC_CONFIG_DATA_ID_TEMPLATE =
|
||||
"%s-%s" + MCP_SERVER_TOOL_DATA_ID_SUFFIX;
|
||||
|
||||
public static final String SERVER_RESOURCE_SPEC_CONFIG_DATA_ID_TEMPLATE =
|
||||
"%s-%s" + MCP_SERVER_RESOURCE_DATA_ID_SUFFIX;
|
||||
|
||||
public static class A2A {
|
||||
|
||||
public static final String CONSOLE_PATH = "/v3/console/ai/a2a";
|
||||
|
||||
public static final String ADMIN_PATH = "/v3/admin/ai/a2a";
|
||||
|
||||
public static final String AGENT_GROUP = "agent";
|
||||
|
||||
public static final String AGENT_VERSION_GROUP = "agent-version";
|
||||
|
||||
public static final String SEARCH_BLUR = "blur";
|
||||
|
||||
public static final String SEARCH_ACCURATE = "accurate";
|
||||
|
||||
public static final String AGENT_ENDPOINT_GROUP = "agent-endpoints";
|
||||
|
||||
public static final String AGENT_ENDPOINT_PATH_KEY = "__nacos.agent.endpoint.path__";
|
||||
|
||||
public static final String AGENT_ENDPOINT_TRANSPORT_KEY =
|
||||
"__nacos.agent.endpoint.transport__";
|
||||
|
||||
public static final String NACOS_AGENT_ENDPOINT_SUPPORT_TLS =
|
||||
"__nacos.agent.endpoint.supportTls__";
|
||||
|
||||
public static final String NACOS_AGENT_ENDPOINT_PROTOCOL_KEY =
|
||||
"__nacos.agent.endpoint.protocol__";
|
||||
|
||||
public static final String NACOS_AGENT_ENDPOINT_QUERY_KEY =
|
||||
"__nacos.agent.endpoint.query__";
|
||||
|
||||
public static final String NACOS_AGENT_ENDPOINT_PROTOCOL_VERSION_KEY =
|
||||
"__nacos.agent.endpoint.protocolVersion__";
|
||||
|
||||
public static final String NACOS_AGENT_ENDPOINT_TENANT_KEY =
|
||||
"__nacos.agent.endpoint.tenant__";
|
||||
}
|
||||
|
||||
public static class Skills {
|
||||
|
||||
public static final String CONSOLE_PATH = "/v3/console/ai/skills";
|
||||
|
||||
public static final String ADMIN_PATH = "/v3/admin/ai/skills";
|
||||
|
||||
public static final String CLIENT_PATH = "/v3/client/ai/skills";
|
||||
|
||||
public static final String SKILL_GROUP = "skill";
|
||||
|
||||
public static final String SKILL_VERSION_GROUP = "skill-version";
|
||||
|
||||
public static final String SEARCH_BLUR = "blur";
|
||||
|
||||
public static final String SEARCH_ACCURATE = "accurate";
|
||||
|
||||
public static final String SKILL_DEFAULT_NAMESPACE = "public";
|
||||
|
||||
/**
|
||||
* Resource type constant used in {@code ai_resource_version.type} for skill rows.
|
||||
*/
|
||||
public static final String RESOURCE_TYPE_SKILL = "skill";
|
||||
|
||||
/**
|
||||
* Key inside {@code ai_resource_version.storage} JSON for the published content MD5.
|
||||
*/
|
||||
public static final String STORAGE_KEY_CONTENT_MD5 = "contentMd5";
|
||||
|
||||
/**
|
||||
* Response header carrying the published skill content MD5 for client listener cache.
|
||||
*/
|
||||
public static final String HEADER_SKILL_MD5 = "X-Nacos-Skill-Md5";
|
||||
|
||||
/**
|
||||
* Response header carrying the resolved version when the client queries by label.
|
||||
*/
|
||||
public static final String HEADER_SKILL_RESOLVED_VERSION = "X-Nacos-Skill-Resolved-Version";
|
||||
|
||||
/**
|
||||
* Default max allowed size for skill zip upload (10MB).
|
||||
*
|
||||
* <p>Runtime callers should use
|
||||
* {@code com.alibaba.nacos.ai.utils.SkillZipParser#resolveMaxUploadBytes()} instead, which
|
||||
* honors the {@code nacos.ai.skill.zip.max-upload-size-mb} property when an operator
|
||||
* needs to raise this cap. This constant is preserved as the historical default and for
|
||||
* backward compatibility with callers outside the skill upload path.
|
||||
*/
|
||||
public static final long MAX_UPLOAD_ZIP_BYTES = 10L * 1024 * 1024;
|
||||
}
|
||||
|
||||
public static class AgentSpecs {
|
||||
|
||||
public static final String ADMIN_PATH = "/v3/admin/ai/agentspecs";
|
||||
|
||||
public static final String CLIENT_PATH = "/v3/client/ai/agentspecs";
|
||||
|
||||
public static final String CONSOLE_PATH = "/v3/console/ai/agentspecs";
|
||||
|
||||
public static final String AGENTSPEC_GROUP_PREFIX = "agentspec_";
|
||||
|
||||
public static final String RESOURCE_TYPE_AGENTSPEC = "agentspec";
|
||||
|
||||
public static final String AGENTSPEC_MAIN_DATA_ID = "manifest.json";
|
||||
|
||||
/**
|
||||
* Default max allowed size for agentspec zip upload (50MB).
|
||||
*
|
||||
* <p>Runtime callers should use
|
||||
* {@code com.alibaba.nacos.ai.utils.AgentSpecZipParser#resolveMaxUploadBytes()} instead,
|
||||
* which honors the {@code nacos.ai.agentspec.zip.max-upload-size-mb} property when an
|
||||
* operator needs to raise this cap. This constant is preserved as the historical default
|
||||
* and for backward compatibility with callers outside the AgentSpec upload path.
|
||||
*/
|
||||
public static final long MAX_UPLOAD_ZIP_BYTES = 50L * 1024 * 1024;
|
||||
|
||||
public static final String AGENTSPEC_STORAGE_PROVIDER_CONFIG_KEY =
|
||||
"nacos.ai.agentspec.storage.provider";
|
||||
|
||||
public static final String SEARCH_BLUR = "blur";
|
||||
|
||||
public static final String SEARCH_ACCURATE = "accurate";
|
||||
|
||||
public static final String AGENTSPEC_DEFAULT_NAMESPACE = "public";
|
||||
|
||||
public static final String HEADER_AGENTSPEC_MD5 = "X-Nacos-AgentSpec-Md5";
|
||||
|
||||
public static final String HEADER_AGENTSPEC_RESOLVED_VERSION =
|
||||
"X-Nacos-AgentSpec-Resolved-Version";
|
||||
}
|
||||
|
||||
public static class Pipeline {
|
||||
|
||||
public static final String ADMIN_PATH = "/v3/admin/ai/pipelines";
|
||||
|
||||
public static final String CONSOLE_PATH = "/v3/console/ai/pipelines";
|
||||
|
||||
/**
|
||||
* List pipeline executions (aligned with Skill/Prompt {@code /list} style).
|
||||
*/
|
||||
public static final String LIST_SUBPATH = "/list";
|
||||
|
||||
/**
|
||||
* Get single pipeline execution by id (query parameter {@code pipelineId}).
|
||||
*/
|
||||
public static final String DETAIL_SUBPATH = "/detail";
|
||||
}
|
||||
|
||||
public static class Prompt {
|
||||
|
||||
public static final String CONSOLE_PATH = "/v3/console/ai/prompt";
|
||||
|
||||
public static final String ADMIN_PATH = "/v3/admin/ai/prompt";
|
||||
|
||||
public static final String CLIENT_PATH = "/v3/client/ai/prompt";
|
||||
|
||||
/**
|
||||
* Fixed group for all prompt configurations.
|
||||
*/
|
||||
public static final String PROMPT_GROUP = "nacos-ai-prompt";
|
||||
|
||||
/**
|
||||
* DataId suffix for prompt configurations.
|
||||
*/
|
||||
public static final String PROMPT_DATA_ID_SUFFIX = ".json";
|
||||
|
||||
/**
|
||||
* DataId suffix for descriptor side prompt metadata.
|
||||
*/
|
||||
public static final String DESCRIPTOR_DATA_ID_SUFFIX =
|
||||
".descriptor" + PROMPT_DATA_ID_SUFFIX;
|
||||
|
||||
/**
|
||||
* DataId suffix for runtime label/version mapping.
|
||||
*/
|
||||
public static final String LABEL_VERSION_MAPPING_DATA_ID_SUFFIX =
|
||||
".label-version-mapping" + PROMPT_DATA_ID_SUFFIX;
|
||||
|
||||
/**
|
||||
* Key for prompt version in extInfo.
|
||||
*/
|
||||
public static final String EXT_PROMPT_VERSION = "prompt_version";
|
||||
|
||||
/**
|
||||
* Key for prompt commit message in extInfo.
|
||||
*/
|
||||
public static final String EXT_PROMPT_COMMIT_MSG = "prompt_commit_msg";
|
||||
|
||||
/**
|
||||
* Search mode: blur search.
|
||||
*/
|
||||
public static final String SEARCH_BLUR = "blur";
|
||||
|
||||
/**
|
||||
* Search mode: accurate search.
|
||||
*/
|
||||
public static final String SEARCH_ACCURATE = "accurate";
|
||||
|
||||
/**
|
||||
* Default namespace for prompt.
|
||||
*/
|
||||
public static final String PROMPT_DEFAULT_NAMESPACE = "public";
|
||||
|
||||
/**
|
||||
* Config type for prompt.
|
||||
*/
|
||||
public static final String PROMPT_CONFIG_TYPE = "json";
|
||||
|
||||
/**
|
||||
* JSON field: promptKey.
|
||||
*/
|
||||
public static final String FIELD_PROMPT_KEY = "promptKey";
|
||||
|
||||
/**
|
||||
* JSON field: version.
|
||||
*/
|
||||
public static final String FIELD_VERSION = "version";
|
||||
|
||||
/**
|
||||
* JSON field: template.
|
||||
*/
|
||||
public static final String FIELD_TEMPLATE = "template";
|
||||
|
||||
/**
|
||||
* JSON field: commitMsg.
|
||||
*/
|
||||
public static final String FIELD_COMMIT_MSG = "commitMsg";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.constant;
|
||||
|
||||
/**
|
||||
* Constants for MCP server validation.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
public final class McpServerValidationConstants {
|
||||
|
||||
/**
|
||||
* Validation status: valid.
|
||||
*/
|
||||
public static final String STATUS_VALID = "valid";
|
||||
|
||||
/**
|
||||
* Validation status: invalid.
|
||||
*/
|
||||
public static final String STATUS_INVALID = "invalid";
|
||||
|
||||
/**
|
||||
* Validation status: duplicate.
|
||||
*/
|
||||
public static final String STATUS_DUPLICATE = "duplicate";
|
||||
|
||||
private McpServerValidationConstants() {
|
||||
// Private constructor to prevent instantiation
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.controller;
|
||||
|
||||
import com.alibaba.nacos.api.annotation.Since;
|
||||
import com.alibaba.nacos.ai.constant.Constants;
|
||||
import com.alibaba.nacos.ai.form.a2a.admin.AgentCardForm;
|
||||
import com.alibaba.nacos.ai.form.a2a.admin.AgentCardUpdateForm;
|
||||
import com.alibaba.nacos.ai.form.a2a.admin.AgentForm;
|
||||
import com.alibaba.nacos.ai.form.a2a.admin.AgentListForm;
|
||||
import com.alibaba.nacos.ai.param.AgentHttpParamExtractor;
|
||||
import com.alibaba.nacos.ai.service.a2a.A2aServerOperationService;
|
||||
import com.alibaba.nacos.ai.utils.AgentRequestUtil;
|
||||
import com.alibaba.nacos.api.ai.model.a2a.AgentCard;
|
||||
import com.alibaba.nacos.api.ai.model.a2a.AgentCardDetailInfo;
|
||||
import com.alibaba.nacos.api.ai.model.a2a.AgentCardVersionInfo;
|
||||
import com.alibaba.nacos.api.ai.model.a2a.AgentVersionDetail;
|
||||
import com.alibaba.nacos.api.annotation.NacosApi;
|
||||
import com.alibaba.nacos.api.common.ApiType;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.api.exception.api.NacosApiException;
|
||||
import com.alibaba.nacos.api.model.Page;
|
||||
import com.alibaba.nacos.api.model.v2.Result;
|
||||
import com.alibaba.nacos.auth.annotation.Secured;
|
||||
import com.alibaba.nacos.core.model.form.PageForm;
|
||||
import com.alibaba.nacos.core.paramcheck.ExtractorManager;
|
||||
import com.alibaba.nacos.plugin.auth.constant.ActionTypes;
|
||||
import com.alibaba.nacos.plugin.auth.constant.SignType;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Nacos A2A Admin controller.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
@NacosApi
|
||||
@RestController
|
||||
@RequestMapping(Constants.A2A.ADMIN_PATH)
|
||||
@ExtractorManager.Extractor(httpExtractor = AgentHttpParamExtractor.class)
|
||||
public class A2aAdminController {
|
||||
|
||||
private final A2aServerOperationService a2aServerOperationService;
|
||||
|
||||
public A2aAdminController(A2aServerOperationService a2aServerOperationService) {
|
||||
this.a2aServerOperationService = a2aServerOperationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register agent.
|
||||
*
|
||||
* @param form the agent detail form to register
|
||||
* @return result of the registration operation
|
||||
* @throws NacosException if the agent registration fails due to invalid input or internal error
|
||||
*/
|
||||
@Since("3.1.0")
|
||||
@PostMapping
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<String> registerAgent(AgentCardForm form) throws NacosException {
|
||||
form.validate();
|
||||
AgentCard agentCard = AgentRequestUtil.parseAgentCard(form);
|
||||
a2aServerOperationService.registerAgent(agentCard, form.getNamespaceId(),
|
||||
form.getRegistrationType());
|
||||
return Result.success("ok");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent card.
|
||||
*
|
||||
* @param form the agent form to get
|
||||
* @return result of the get operation
|
||||
* @throws NacosApiException if the agent get fails due to invalid input or internal error
|
||||
*/
|
||||
@Since("3.1.0")
|
||||
@GetMapping
|
||||
@Secured(action = ActionTypes.READ, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<AgentCardDetailInfo> getAgentCard(AgentForm form) throws NacosApiException {
|
||||
form.validate();
|
||||
return Result.success(
|
||||
a2aServerOperationService.getAgentCard(form.getNamespaceId(), form.getAgentName(),
|
||||
form.getVersion(),
|
||||
form.getRegistrationType()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update agent.
|
||||
*
|
||||
* @param form the agent update form to update
|
||||
* @return result of the update operation
|
||||
* @throws NacosException if the agent update fails due to invalid input or internal error
|
||||
*/
|
||||
@Since("3.1.0")
|
||||
@PutMapping
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<String> updateAgentCard(AgentCardUpdateForm form) throws NacosException {
|
||||
form.validate();
|
||||
AgentCard agentCard = AgentRequestUtil.parseAgentCard(form);
|
||||
a2aServerOperationService.updateAgentCard(agentCard, form.getNamespaceId(),
|
||||
form.getRegistrationType(),
|
||||
form.getSetAsLatest());
|
||||
return Result.success("ok");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete agent.
|
||||
*
|
||||
* @param form the agent form to delete
|
||||
* @return result of the deletion operation
|
||||
* @throws NacosException if the agent deletion fails due to invalid input or internal error
|
||||
*/
|
||||
@Since("3.1.0")
|
||||
@DeleteMapping
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<String> deleteAgent(AgentForm form) throws NacosException {
|
||||
form.validate();
|
||||
a2aServerOperationService.deleteAgent(form.getNamespaceId(), form.getAgentName(),
|
||||
form.getVersion());
|
||||
return Result.success("ok");
|
||||
}
|
||||
|
||||
/**
|
||||
* List agents.
|
||||
*
|
||||
* @param agentListForm the agent list form to list
|
||||
* @param pageForm the page form to list
|
||||
* @return result of the list operation
|
||||
* @throws NacosException if the agent list fails due to invalid input or internal error
|
||||
*/
|
||||
@Since("3.1.0")
|
||||
@GetMapping("/list")
|
||||
@Secured(action = ActionTypes.READ, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<Page<AgentCardVersionInfo>> listAgents(AgentListForm agentListForm,
|
||||
PageForm pageForm)
|
||||
throws NacosException {
|
||||
agentListForm.validate();
|
||||
pageForm.validate();
|
||||
return Result.success(
|
||||
a2aServerOperationService.listAgents(agentListForm.getNamespaceId(),
|
||||
agentListForm.getAgentName(),
|
||||
agentListForm.getSearch(), pageForm.getPageNo(), pageForm.getPageSize()));
|
||||
}
|
||||
|
||||
/**
|
||||
* List all versions for target Agent.
|
||||
*
|
||||
* @param agentForm agent form
|
||||
* @return all version for target agent.
|
||||
* @throws NacosException nacos exception
|
||||
*/
|
||||
@Since("3.1.0")
|
||||
@GetMapping("/version/list")
|
||||
@Secured(action = ActionTypes.READ, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<List<AgentVersionDetail>> listAgentVersions(AgentForm agentForm)
|
||||
throws NacosException {
|
||||
agentForm.validate();
|
||||
return Result.success(
|
||||
a2aServerOperationService.listAgentVersions(agentForm.getNamespaceId(),
|
||||
agentForm.getAgentName()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.controller;
|
||||
|
||||
import com.alibaba.nacos.api.annotation.Since;
|
||||
import com.alibaba.nacos.ai.constant.Constants;
|
||||
import com.alibaba.nacos.ai.form.AiResourceFilterableForm;
|
||||
import com.alibaba.nacos.ai.form.agentspecs.admin.AgentSpecBizTagsUpdateForm;
|
||||
import com.alibaba.nacos.ai.form.agentspecs.admin.AgentSpecDraftCreateForm;
|
||||
import com.alibaba.nacos.ai.form.agentspecs.admin.AgentSpecForm;
|
||||
import com.alibaba.nacos.ai.form.agentspecs.admin.AgentSpecLabelsUpdateForm;
|
||||
import com.alibaba.nacos.ai.form.agentspecs.admin.AgentSpecListForm;
|
||||
import com.alibaba.nacos.ai.form.agentspecs.admin.AgentSpecOnlineForm;
|
||||
import com.alibaba.nacos.ai.form.agentspecs.admin.AgentSpecPublishForm;
|
||||
import com.alibaba.nacos.ai.form.agentspecs.admin.AgentSpecScopeForm;
|
||||
import com.alibaba.nacos.ai.form.agentspecs.admin.AgentSpecSubmitForm;
|
||||
import com.alibaba.nacos.ai.form.agentspecs.admin.AgentSpecUpdateForm;
|
||||
import com.alibaba.nacos.ai.param.AgentSpecHttpParamExtractor;
|
||||
import com.alibaba.nacos.ai.service.agentspecs.AgentSpecOperationService;
|
||||
import com.alibaba.nacos.ai.utils.AgentSpecRequestUtil;
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpec;
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpecMeta;
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpecSummary;
|
||||
import com.alibaba.nacos.api.annotation.NacosApi;
|
||||
import com.alibaba.nacos.api.common.ApiType;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.api.model.Page;
|
||||
import com.alibaba.nacos.api.model.v2.Result;
|
||||
import com.alibaba.nacos.auth.annotation.Secured;
|
||||
import com.alibaba.nacos.common.utils.JacksonUtils;
|
||||
import com.alibaba.nacos.common.utils.NamespaceUtil;
|
||||
import com.alibaba.nacos.core.model.form.PageForm;
|
||||
import com.alibaba.nacos.core.paramcheck.ExtractorManager;
|
||||
import com.alibaba.nacos.plugin.auth.constant.ActionTypes;
|
||||
import com.alibaba.nacos.plugin.auth.constant.SignType;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static com.alibaba.nacos.plugin.auth.constant.Constants.Tag.ALLOW_ANONYMOUS;
|
||||
|
||||
/**
|
||||
* AgentSpec admin controller.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
@NacosApi
|
||||
@RestController
|
||||
@RequestMapping(Constants.AgentSpecs.ADMIN_PATH)
|
||||
@ExtractorManager.Extractor(httpExtractor = AgentSpecHttpParamExtractor.class)
|
||||
public class AgentSpecAdminController {
|
||||
|
||||
private final AgentSpecOperationService agentSpecOperationService;
|
||||
|
||||
public AgentSpecAdminController(AgentSpecOperationService agentSpecOperationService) {
|
||||
this.agentSpecOperationService = agentSpecOperationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agentspec detail for admin (includes version governance info and all version summaries).
|
||||
*
|
||||
* @param form the agentspec form to get
|
||||
* @return result of the get operation
|
||||
* @throws NacosException if the agentspec get fails
|
||||
*/
|
||||
@Since("3.2.0")
|
||||
@GetMapping
|
||||
@Secured(action = ActionTypes.READ, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<AgentSpecMeta> getAgentSpec(AgentSpecForm form) throws NacosException {
|
||||
form.validate();
|
||||
return Result.success(
|
||||
agentSpecOperationService.getAgentSpecDetail(form.getNamespaceId(),
|
||||
form.getAgentSpecName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific version detail of an agentspec for viewing or editing.
|
||||
*
|
||||
* @param form the agentspec form containing agentSpecName and version
|
||||
* @return full agentspec content for the specified version
|
||||
* @throws NacosException if the agentspec or version not found
|
||||
*/
|
||||
@Since("3.2.0")
|
||||
@GetMapping("/version")
|
||||
@Secured(action = ActionTypes.READ, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<AgentSpec> getAgentSpecVersion(AgentSpecForm form) throws NacosException {
|
||||
form.validate();
|
||||
return Result.success(
|
||||
agentSpecOperationService.getAgentSpecVersionDetail(form.getNamespaceId(),
|
||||
form.getAgentSpecName(),
|
||||
form.getVersion()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific version metadata of an agentspec without resource content. Returns the agentspec main content and
|
||||
* resource list (name + type only), skipping resource file IO.
|
||||
*
|
||||
* @param form the agentspec form containing agentSpecName and version
|
||||
* @return agentspec with resource list containing only name and type
|
||||
* @throws NacosException if the agentspec or version not found
|
||||
*/
|
||||
@Since("3.2.1")
|
||||
@GetMapping("/version/meta")
|
||||
@Secured(action = ActionTypes.READ, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<AgentSpec> getAgentSpecVersionMeta(AgentSpecForm form) throws NacosException {
|
||||
form.validate();
|
||||
return Result.success(
|
||||
agentSpecOperationService.getAgentSpecVersionMeta(form.getNamespaceId(),
|
||||
form.getAgentSpecName(),
|
||||
form.getVersion()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete agentspec.
|
||||
*
|
||||
* @param form the agentspec form to delete
|
||||
* @return result of the deletion operation
|
||||
* @throws NacosException if the agentspec deletion fails
|
||||
*/
|
||||
@Since("3.2.0")
|
||||
@DeleteMapping
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<String> deleteAgentSpec(AgentSpecForm form) throws NacosException {
|
||||
form.validate();
|
||||
agentSpecOperationService.deleteAgentSpec(form.getNamespaceId(), form.getAgentSpecName());
|
||||
return Result.success("ok");
|
||||
}
|
||||
|
||||
/**
|
||||
* List agentspecs for admin (includes governance metadata: status, tags, labels, etc.).
|
||||
*
|
||||
* @param agentSpecListForm the agentspec list form to list
|
||||
* @param pageForm the page form to list
|
||||
* @return result of the list operation
|
||||
* @throws NacosException if the agentspec list fails
|
||||
*/
|
||||
@Since("3.2.1")
|
||||
@GetMapping("/list")
|
||||
@Secured(action = ActionTypes.READ, signType = SignType.AI, apiType = ApiType.ADMIN_API,
|
||||
tags = {ALLOW_ANONYMOUS})
|
||||
public Result<Page<AgentSpecSummary>> listAgentSpecs(AgentSpecListForm agentSpecListForm,
|
||||
AiResourceFilterableForm filterableForm, PageForm pageForm)
|
||||
throws NacosException {
|
||||
agentSpecListForm.validate();
|
||||
filterableForm.validate();
|
||||
pageForm.validate();
|
||||
return Result.success(agentSpecOperationService.listAgentSpecs(
|
||||
agentSpecListForm.getNamespaceId(),
|
||||
agentSpecListForm.getAgentSpecName(), agentSpecListForm.getSearch(),
|
||||
agentSpecListForm.getOrderBy(), filterableForm.getOwner(), filterableForm.getScope(),
|
||||
pageForm.getPageNo(), pageForm.getPageSize()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload agentspec from zip file.
|
||||
*
|
||||
* @param request HTTP servlet request
|
||||
* @param namespaceId namespace ID
|
||||
* @param file zip file containing agentspec
|
||||
* @return result of the upload operation
|
||||
* @throws NacosException if the upload fails
|
||||
*/
|
||||
@Since("3.2.0")
|
||||
@PostMapping(value = "/upload", consumes = "multipart/form-data")
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
@ExtractorManager.Extractor(httpExtractor = ExtractorManager.DefaultHttpExtractor.class)
|
||||
public Result<String> uploadAgentSpec(HttpServletRequest request,
|
||||
@RequestParam(value = "namespaceId", required = false) String namespaceId,
|
||||
@RequestParam(value = "overwrite", required = false,
|
||||
defaultValue = "false") boolean overwrite,
|
||||
@RequestParam("file") MultipartFile file) throws NacosException {
|
||||
namespaceId = NamespaceUtil.processNamespaceParameter(namespaceId);
|
||||
byte[] zipBytes = AgentSpecRequestUtil.validateAndExtractZipBytes(file);
|
||||
String agentSpecName =
|
||||
agentSpecOperationService.uploadAgentSpecFromZip(namespaceId, zipBytes, overwrite);
|
||||
return Result.success(agentSpecName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create draft version.
|
||||
*/
|
||||
@Since("3.2.0")
|
||||
@PostMapping("/draft")
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<String> createDraft(AgentSpecDraftCreateForm form) throws NacosException {
|
||||
form.validate();
|
||||
String v =
|
||||
agentSpecOperationService.createDraft(form.getNamespaceId(), form.getAgentSpecName(),
|
||||
form.getBasedOnVersion(), form.getTargetVersion());
|
||||
return Result.success(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current draft content.
|
||||
*/
|
||||
@Since("3.2.0")
|
||||
@PutMapping("/draft")
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<String> updateDraft(AgentSpecUpdateForm form) throws NacosException {
|
||||
form.validate();
|
||||
AgentSpec agentSpec = AgentSpecRequestUtil.parseAgentSpec(form);
|
||||
agentSpecOperationService.updateDraft(form.getNamespaceId(), agentSpec);
|
||||
return Result.success("ok");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete current draft version.
|
||||
*/
|
||||
@Since("3.2.0")
|
||||
@DeleteMapping("/draft")
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<String> deleteDraft(AgentSpecForm form) throws NacosException {
|
||||
form.validate();
|
||||
agentSpecOperationService.deleteDraft(form.getNamespaceId(), form.getAgentSpecName());
|
||||
return Result.success("ok");
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a version for pipeline review.
|
||||
*/
|
||||
@Since("3.2.0")
|
||||
@PostMapping("/submit")
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<String> submit(AgentSpecSubmitForm form) throws NacosException {
|
||||
form.validate();
|
||||
String result =
|
||||
agentSpecOperationService.submit(form.getNamespaceId(), form.getAgentSpecName(),
|
||||
form.getVersion());
|
||||
return Result.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an approved reviewing version.
|
||||
*/
|
||||
@Since("3.2.0")
|
||||
@PostMapping("/publish")
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<String> publish(AgentSpecPublishForm form) throws NacosException {
|
||||
form.validate();
|
||||
agentSpecOperationService.publish(form.getNamespaceId(), form.getAgentSpecName(),
|
||||
form.getVersion(),
|
||||
true);
|
||||
return Result.success("ok");
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-publish an agentspec version, bypassing pipeline validation. Accepts draft, reviewing, and reviewed
|
||||
* versions. Only admin users can call this endpoint.
|
||||
*/
|
||||
@Since("3.2.1")
|
||||
@PostMapping("/force-publish")
|
||||
@Secured(resource = Constants.AgentSpecs.ADMIN_PATH
|
||||
+ "/force-publish", action = ActionTypes.WRITE, signType = SignType.CONSOLE,
|
||||
apiType = ApiType.ADMIN_API)
|
||||
public Result<String> forcePublish(AgentSpecPublishForm form) throws NacosException {
|
||||
form.validate();
|
||||
agentSpecOperationService.forcePublish(form.getNamespaceId(), form.getAgentSpecName(),
|
||||
form.getVersion(),
|
||||
true);
|
||||
return Result.success("ok");
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-edit a reviewed agentspec version, transitioning it back to draft for modification.
|
||||
*/
|
||||
@Since("3.2.2")
|
||||
@PostMapping("/redraft")
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<String> redraft(AgentSpecPublishForm form) throws NacosException {
|
||||
form.validate();
|
||||
agentSpecOperationService.redraft(form.getNamespaceId(), form.getAgentSpecName(),
|
||||
form.getVersion());
|
||||
return Result.success("ok");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update runtime route labels without changing version status.
|
||||
*/
|
||||
@Since("3.2.0")
|
||||
@PutMapping("/labels")
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<String> updateLabels(AgentSpecLabelsUpdateForm form) throws NacosException {
|
||||
form.validate();
|
||||
Map<String, String> labels = JacksonUtils.toObj(form.getLabels(), Map.class);
|
||||
agentSpecOperationService.updateLabels(form.getNamespaceId(), form.getAgentSpecName(),
|
||||
labels);
|
||||
return Result.success("ok");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update agentspec biz tags without changing version status.
|
||||
*/
|
||||
@Since("3.2.0")
|
||||
@PutMapping("/biz-tags")
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<String> updateBizTags(AgentSpecBizTagsUpdateForm form) throws NacosException {
|
||||
form.validate();
|
||||
agentSpecOperationService.updateBizTags(form.getNamespaceId(), form.getAgentSpecName(),
|
||||
form.getBizTags());
|
||||
return Result.success("ok");
|
||||
}
|
||||
|
||||
/**
|
||||
* Online operation (version-level or agentspec-level by scope).
|
||||
*/
|
||||
@Since("3.2.0")
|
||||
@PostMapping("/online")
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<String> online(AgentSpecOnlineForm form) throws NacosException {
|
||||
form.validate();
|
||||
agentSpecOperationService.changeOnlineStatus(form.getNamespaceId(), form.getAgentSpecName(),
|
||||
form.getScope(),
|
||||
form.getVersion(), true);
|
||||
return Result.success("ok");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update agentspec visibility scope (PUBLIC or PRIVATE).
|
||||
*
|
||||
* @param form the scope update form
|
||||
* @return result of the update operation
|
||||
* @throws NacosException if the agentspec not found or no permission
|
||||
*/
|
||||
@Since("3.2.0")
|
||||
@PutMapping("/scope")
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<String> updateScope(AgentSpecScopeForm form) throws NacosException {
|
||||
form.validate();
|
||||
agentSpecOperationService.updateScope(form.getNamespaceId(), form.getAgentSpecName(),
|
||||
form.getScope());
|
||||
return Result.success("ok");
|
||||
}
|
||||
|
||||
/**
|
||||
* Offline operation (version-level or agentspec-level by scope).
|
||||
*/
|
||||
@Since("3.2.0")
|
||||
@PostMapping("/offline")
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<String> offline(AgentSpecOnlineForm form) throws NacosException {
|
||||
form.validate();
|
||||
agentSpecOperationService.changeOnlineStatus(form.getNamespaceId(), form.getAgentSpecName(),
|
||||
form.getScope(),
|
||||
form.getVersion(), false);
|
||||
return Result.success("ok");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.controller;
|
||||
|
||||
import com.alibaba.nacos.api.annotation.Since;
|
||||
import com.alibaba.nacos.ai.constant.Constants;
|
||||
import com.alibaba.nacos.ai.form.agentspecs.client.AgentSpecQueryForm;
|
||||
import com.alibaba.nacos.ai.form.agentspecs.client.AgentSpecSearchForm;
|
||||
import com.alibaba.nacos.ai.service.agentspecs.AgentSpecOperationService;
|
||||
import com.alibaba.nacos.ai.service.agentspecs.AgentSpecQueryResult;
|
||||
import com.alibaba.nacos.ai.utils.AgentSpecRequestUtil;
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpec;
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpecBasicInfo;
|
||||
import com.alibaba.nacos.api.annotation.NacosApi;
|
||||
import com.alibaba.nacos.api.common.ApiType;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.api.model.Page;
|
||||
import com.alibaba.nacos.api.model.v2.Result;
|
||||
import com.alibaba.nacos.auth.annotation.Secured;
|
||||
import com.alibaba.nacos.core.model.form.PageForm;
|
||||
import com.alibaba.nacos.core.paramcheck.ExtractorManager;
|
||||
import com.alibaba.nacos.plugin.auth.constant.ActionTypes;
|
||||
import com.alibaba.nacos.plugin.auth.constant.SignType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import static com.alibaba.nacos.plugin.auth.constant.Constants.Tag.ALLOW_ANONYMOUS;
|
||||
|
||||
/**
|
||||
* AgentSpec client controller for runtime read query.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
@NacosApi
|
||||
@RestController
|
||||
@RequestMapping(Constants.AgentSpecs.CLIENT_PATH)
|
||||
@ExtractorManager.Extractor(httpExtractor = ExtractorManager.DefaultHttpExtractor.class)
|
||||
public class AgentSpecClientController {
|
||||
|
||||
private final AgentSpecOperationService agentSpecOperationService;
|
||||
|
||||
public AgentSpecClientController(AgentSpecOperationService agentSpecOperationService) {
|
||||
this.agentSpecOperationService = agentSpecOperationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search enabled agentspecs for runtime usage.
|
||||
*/
|
||||
@Since("3.2.0")
|
||||
@GetMapping("/search")
|
||||
@Secured(action = ActionTypes.READ, signType = SignType.AI, apiType = ApiType.OPEN_API)
|
||||
public Result<Page<AgentSpecBasicInfo>> search(AgentSpecSearchForm form, PageForm pageForm)
|
||||
throws NacosException {
|
||||
form.validate();
|
||||
pageForm.validate();
|
||||
return Result.success(
|
||||
agentSpecOperationService.searchAgentSpecs(form.getNamespaceId(), form.getKeyword(),
|
||||
pageForm.getPageNo(), pageForm.getPageSize()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an online agentspec version by label/version/latest.
|
||||
* Supports MD5-based conditional query: returns 304 when the client cache is fresh.
|
||||
*/
|
||||
@Since("3.2.0")
|
||||
@GetMapping
|
||||
@Secured(action = ActionTypes.READ, signType = SignType.AI, apiType = ApiType.OPEN_API,
|
||||
tags = {ALLOW_ANONYMOUS})
|
||||
public ResponseEntity<Result<AgentSpec>> get(AgentSpecQueryForm form)
|
||||
throws NacosException {
|
||||
form.validate();
|
||||
AgentSpecQueryResult result =
|
||||
agentSpecOperationService.queryAgentSpecForClient(
|
||||
form.getNamespaceId(), form.getName(), form.getVersion(),
|
||||
form.getLabel(), form.getMd5());
|
||||
if (result.isNotModified()) {
|
||||
return AgentSpecRequestUtil.buildAgentSpecNotModifiedResponse(result.getMd5());
|
||||
}
|
||||
return AgentSpecRequestUtil.buildAgentSpecResponse(result.getAgentSpec(),
|
||||
result.getMd5(), result.getResolvedVersion());
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.controller;
|
||||
|
||||
import com.alibaba.nacos.api.annotation.Since;
|
||||
import com.alibaba.nacos.ai.constant.Constants;
|
||||
import com.alibaba.nacos.ai.form.importer.AiResourceImportExecuteForm;
|
||||
import com.alibaba.nacos.ai.form.importer.AiResourceImportSearchForm;
|
||||
import com.alibaba.nacos.ai.form.importer.AiResourceImportSourceListForm;
|
||||
import com.alibaba.nacos.ai.form.importer.AiResourceImportValidateForm;
|
||||
import com.alibaba.nacos.ai.importer.manager.AiResourceImportManager;
|
||||
import com.alibaba.nacos.api.ai.model.importer.AiResourceImportExecuteResponse;
|
||||
import com.alibaba.nacos.api.ai.model.importer.AiResourceImportSearchResponse;
|
||||
import com.alibaba.nacos.api.ai.model.importer.AiResourceImportSourceInfo;
|
||||
import com.alibaba.nacos.api.ai.model.importer.AiResourceImportValidateResponse;
|
||||
import com.alibaba.nacos.api.annotation.NacosApi;
|
||||
import com.alibaba.nacos.api.common.ApiType;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.api.model.v2.Result;
|
||||
import com.alibaba.nacos.auth.annotation.Secured;
|
||||
import com.alibaba.nacos.plugin.auth.constant.ActionTypes;
|
||||
import com.alibaba.nacos.plugin.auth.constant.SignType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Admin API controller for AI resource import.
|
||||
*
|
||||
* @author xiweng.yy
|
||||
* @since 3.2.1
|
||||
*/
|
||||
@NacosApi
|
||||
@RestController
|
||||
@RequestMapping(Constants.AI_RESOURCE_IMPORT_ADMIN_PATH)
|
||||
public class AiResourceImportAdminController {
|
||||
|
||||
private final AiResourceImportManager importManager;
|
||||
|
||||
public AiResourceImportAdminController(AiResourceImportManager importManager) {
|
||||
this.importManager = importManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* List configured import sources.
|
||||
*
|
||||
* @param form source list form
|
||||
* @return source list
|
||||
* @throws NacosException if source configuration is invalid
|
||||
*/
|
||||
@Since("3.2.2")
|
||||
@GetMapping("/sources")
|
||||
@Secured(action = ActionTypes.READ, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<List<AiResourceImportSourceInfo>> listSources(
|
||||
AiResourceImportSourceListForm form) throws NacosException {
|
||||
form.validate();
|
||||
return Result.success(importManager.listSources(form.getResourceType()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Search external import candidates.
|
||||
*
|
||||
* @param form search form
|
||||
* @return candidate page
|
||||
* @throws NacosException if the source cannot be searched
|
||||
*/
|
||||
@Since("3.2.2")
|
||||
@PostMapping("/search")
|
||||
@Secured(action = ActionTypes.READ, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<AiResourceImportSearchResponse> search(AiResourceImportSearchForm form)
|
||||
throws NacosException {
|
||||
form.validate();
|
||||
return Result.success(importManager.search(form.toRequest()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate selected import candidates.
|
||||
*
|
||||
* @param form validate form
|
||||
* @return validation result
|
||||
* @throws NacosException if validation cannot start
|
||||
*/
|
||||
@Since("3.2.2")
|
||||
@PostMapping("/validate")
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<AiResourceImportValidateResponse> validate(AiResourceImportValidateForm form)
|
||||
throws NacosException {
|
||||
form.validate();
|
||||
return Result.success(importManager.validate(form.toRequest()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute import for selected candidates.
|
||||
*
|
||||
* @param form execute form
|
||||
* @return import result
|
||||
* @throws NacosException if import cannot start
|
||||
*/
|
||||
@Since("3.2.2")
|
||||
@PostMapping("/execute")
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<AiResourceImportExecuteResponse> execute(AiResourceImportExecuteForm form)
|
||||
throws NacosException {
|
||||
form.validate();
|
||||
return Result.success(importManager.execute(form.toRequest()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.controller;
|
||||
|
||||
import com.alibaba.nacos.api.annotation.Since;
|
||||
import com.alibaba.nacos.ai.constant.Constants;
|
||||
import com.alibaba.nacos.ai.form.mcp.admin.McpDetailForm;
|
||||
import com.alibaba.nacos.ai.form.mcp.admin.McpForm;
|
||||
import com.alibaba.nacos.ai.form.mcp.admin.McpListForm;
|
||||
import com.alibaba.nacos.ai.form.mcp.admin.McpUpdateForm;
|
||||
import com.alibaba.nacos.ai.param.McpHttpParamExtractor;
|
||||
import com.alibaba.nacos.ai.service.McpServerOperationService;
|
||||
import com.alibaba.nacos.ai.utils.McpRequestUtil;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.McpEndpointSpec;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.McpResourceSpecification;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.McpServerBasicInfo;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.McpServerDetailInfo;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.McpToolSpecification;
|
||||
import com.alibaba.nacos.api.annotation.NacosApi;
|
||||
import com.alibaba.nacos.api.common.ApiType;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.api.exception.api.NacosApiException;
|
||||
import com.alibaba.nacos.api.model.Page;
|
||||
import com.alibaba.nacos.api.model.v2.Result;
|
||||
import com.alibaba.nacos.auth.annotation.Secured;
|
||||
import com.alibaba.nacos.core.model.form.PageForm;
|
||||
import com.alibaba.nacos.core.paramcheck.ExtractorManager;
|
||||
import com.alibaba.nacos.plugin.auth.constant.ActionTypes;
|
||||
import com.alibaba.nacos.plugin.auth.constant.SignType;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Nacos AI MCP controller.
|
||||
*
|
||||
* @author xiweng.yy
|
||||
*/
|
||||
@NacosApi
|
||||
@RestController
|
||||
@RequestMapping(Constants.MCP_ADMIN_PATH)
|
||||
@ExtractorManager.Extractor(httpExtractor = McpHttpParamExtractor.class)
|
||||
public class McpAdminController {
|
||||
|
||||
private final McpServerOperationService mcpServerOperationService;
|
||||
|
||||
public McpAdminController(McpServerOperationService mcpServerOperationService) {
|
||||
this.mcpServerOperationService = mcpServerOperationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* List mcp server.
|
||||
*
|
||||
* @param mcpListForm list mcp servers request form.
|
||||
* @param pageForm page info about the request.
|
||||
* @return mcp server list wrapper with {@link Result}
|
||||
* @throws NacosApiException if request parameter is invalid or handle error
|
||||
*/
|
||||
@Since("3.0.1")
|
||||
@GetMapping(value = "/list")
|
||||
@Secured(action = ActionTypes.READ, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<Page<McpServerBasicInfo>> listMcpServers(McpListForm mcpListForm,
|
||||
PageForm pageForm)
|
||||
throws NacosException {
|
||||
mcpListForm.validate();
|
||||
pageForm.validate();
|
||||
return Result.success(
|
||||
mcpServerOperationService.listMcpServerWithPage(mcpListForm.getNamespaceId(),
|
||||
mcpListForm.getMcpName(),
|
||||
mcpListForm.getSearch(), pageForm.getPageNo(), pageForm.getPageSize()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specified mcp server detail info.
|
||||
*
|
||||
* @param mcpForm get mcp server request form
|
||||
* @return detail info with {@link McpServerDetailInfo}
|
||||
* @throws NacosException any exception during handling
|
||||
*/
|
||||
@Since("3.0.1")
|
||||
@GetMapping
|
||||
@Secured(action = ActionTypes.READ, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<McpServerDetailInfo> getMcpServer(McpForm mcpForm) throws NacosException {
|
||||
mcpForm.validate();
|
||||
return Result.success(mcpServerOperationService.getMcpServerDetail(mcpForm.getNamespaceId(),
|
||||
mcpForm.getMcpId(),
|
||||
mcpForm.getMcpName(), mcpForm.getVersion()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new mcp server.
|
||||
*
|
||||
* @param mcpForm create mcp server request form
|
||||
* @throws NacosException any exception during handling
|
||||
*/
|
||||
@Since("3.0.1")
|
||||
@PostMapping
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<String> createMcpServer(McpDetailForm mcpForm) throws NacosException {
|
||||
mcpForm.validate();
|
||||
McpServerBasicInfo basicInfo = McpRequestUtil.parseMcpServerBasicInfo(mcpForm);
|
||||
McpToolSpecification mcpTools = McpRequestUtil.parseMcpTools(mcpForm);
|
||||
McpResourceSpecification mcpResources = McpRequestUtil.parseMcpResources(mcpForm);
|
||||
McpEndpointSpec endpointSpec = McpRequestUtil.parseMcpEndpointSpec(basicInfo, mcpForm);
|
||||
String mcpId =
|
||||
mcpServerOperationService.createMcpServer(mcpForm.getNamespaceId(), basicInfo, mcpTools,
|
||||
mcpResources, endpointSpec);
|
||||
return Result.success(mcpId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existed mcp server.
|
||||
*
|
||||
* <p>
|
||||
* `namespaceId` and `mcpName` can't be changed.
|
||||
* </p>
|
||||
*
|
||||
* @param mcpForm update mcp servers request form
|
||||
* @throws NacosException any exception during handling
|
||||
*/
|
||||
@Since("3.0.1")
|
||||
@PutMapping
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<String> updateMcpServer(McpUpdateForm mcpForm) throws NacosException {
|
||||
mcpForm.validate();
|
||||
McpServerBasicInfo basicInfo = McpRequestUtil.parseMcpServerBasicInfo(mcpForm);
|
||||
McpToolSpecification mcpTools = McpRequestUtil.parseMcpTools(mcpForm);
|
||||
McpResourceSpecification mcpResources = McpRequestUtil.parseMcpResources(mcpForm);
|
||||
McpEndpointSpec endpointSpec = McpRequestUtil.parseMcpEndpointSpec(basicInfo, mcpForm);
|
||||
mcpServerOperationService.updateMcpServer(mcpForm.getNamespaceId(), mcpForm.getLatest(),
|
||||
basicInfo, mcpTools,
|
||||
mcpResources, endpointSpec, mcpForm.isOverrideExisting());
|
||||
return Result.success("ok");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete existed mcp server.
|
||||
*
|
||||
* @param mcpForm delete mcp server request form
|
||||
* @throws NacosException any exception during handling
|
||||
*/
|
||||
@Since("3.0.1")
|
||||
@DeleteMapping
|
||||
@Secured(action = ActionTypes.WRITE, signType = SignType.AI, apiType = ApiType.ADMIN_API)
|
||||
public Result<String> deleteMcpServer(McpForm mcpForm) throws NacosException {
|
||||
mcpForm.validate();
|
||||
mcpServerOperationService.deleteMcpServer(mcpForm.getNamespaceId(), mcpForm.getMcpName(),
|
||||
mcpForm.getMcpId(),
|
||||
mcpForm.getVersion());
|
||||
return Result.success("ok");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user