feat: 新增镜像管理 + 控制台实例管理功能
- 新增 Harbor 镜像仓库查询、最新 tag 查询、skopeo 推送接口 - 新增 Choerodon DevOps 实例列表、Pod 状态、YAML 配置查询与更新接口 - 修复全局异常过滤器 404 日志刷屏问题 - 前端新增「镜像管理」和「控制台实例」Tab 页 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
# xj-oss-syn 项目说明
|
||||
|
||||
## 项目目标
|
||||
1. **OSS 同步**:将业务系统中存储在本地的文件批量上传到新疆油田 IOSP OSS 平台,并将业务表中的文件路径更新为 OSS 路径。
|
||||
2. **镜像管理**:通过 Harbor API + skopeo CLI 查询镜像仓库列表、最新 tag,并支持将 tar 包推送到 Harbor。
|
||||
3. **控制台实例管理**:通过 Choerodon DevOps API 查询实例列表、Pod 状态、YAML 配置,并支持更新配置。
|
||||
|
||||
## 技术栈
|
||||
- 框架:Solon 3.10.0
|
||||
- ORM:MyBatis-Plus(solon 插件)
|
||||
- 数据库:MySQL 8.x(HikariCP 连接池)
|
||||
- HTTP 客户端:Java 内置 `java.net.http.HttpClient`(trust-all SSLContext,支持 NEVER/NORMAL 重定向策略)
|
||||
- JSON:Fastjson2
|
||||
- 认证:Sa-Token(单账号 HTTP Basic,凭证来自 `sa-token.http-basic` 配置)
|
||||
- 模板引擎:FreeMarker(`src/main/resources/templates/`)
|
||||
- 构建:Maven + solon-maven-plugin
|
||||
|
||||
## 包结构
|
||||
```
|
||||
com.yxtech.ossyn
|
||||
├── App.java # 启动入口
|
||||
├── config/
|
||||
│ ├── BizDataSourceConfig.java # 业务库连接公共参数
|
||||
│ ├── BizFileMappingConfig.java # 业务表映射配置(来自 yml)
|
||||
│ ├── ConsoleConfig.java # Choerodon 控制台参数
|
||||
│ ├── HarborConfig.java # Harbor 镜像仓库参数
|
||||
│ ├── LocalFileConfig.java # 本地文件根路径
|
||||
│ ├── MybatisPlusConfig.java # MyBatis-Plus 分页插件等
|
||||
│ ├── OssConfig.java # OSS 连接参数
|
||||
│ ├── SaTokenConfig.java # Sa-Token 路由白名单
|
||||
│ └── SkopeoConfig.java # skopeo 可执行文件及镜像目录
|
||||
├── controller/
|
||||
│ ├── AuthController.java # 登录/登出页面(/auth/*)
|
||||
│ ├── BizFileMappingController.java # 映射配置 CRUD 接口
|
||||
│ ├── DevopsController.java # 镜像管理 + 控制台实例接口(/devops/*)
|
||||
│ └── OssSyncController.java # OSS 同步触发接口(/oss-sync/*)
|
||||
├── entity/
|
||||
│ ├── BizFileMappingEntity.java # 业务表映射配置实体
|
||||
│ ├── QhOssFileLog.java # 文件转换日志实体
|
||||
│ └── SyncTask.java # 同步任务实体
|
||||
├── filter/
|
||||
│ └── GlobalExceptionFilter.java # 全局异常处理(404 DEBUG / 其他 ERROR)
|
||||
├── mapper/
|
||||
│ ├── BizFileMappingMapper.java
|
||||
│ ├── QhOssFileLogMapper.java
|
||||
│ └── SyncTaskMapper.java
|
||||
├── service/
|
||||
│ ├── ConsoleService.java # Choerodon 控制台 API(token 缓存 + 401 重试)
|
||||
│ ├── HarborService.java # Harbor API + skopeo push(trust-all SSL)
|
||||
│ └── OssSyncService.java # OSS 同步核心逻辑
|
||||
└── util/
|
||||
├── DynamicDataSourceUtil.java # 动态数据源(按 dbName 创建连接池)
|
||||
└── OssUtil.java # OSS 上传工具(内存缓存 token)
|
||||
```
|
||||
|
||||
## 数据库
|
||||
- **数据源1(本项目)**:存储 `QH_OSS_FILE_LOG` 日志表,建表脚本见 `sql/init.sql`
|
||||
- **数据源2(业务库)**:动态创建,根据 `biz-file-mappings` 配置的 dbName 连接
|
||||
|
||||
## 配置说明(app.yml)
|
||||
| 配置项 | 说明 |
|
||||
|--------|------|
|
||||
| `solon.dataSources.db1!` | 本项目数据库连接 |
|
||||
| `server.request-maxBodySize` | 请求体大小上限(当前 1GB,支持大镜像上传) |
|
||||
| `biz-datasource` | 业务库公共连接参数(host/port/user/pass) |
|
||||
| `biz-file-mappings` | 业务表映射列表(dbName/tableName/idCol/fileCol) |
|
||||
| `local-file.root-path` | 本地文件根路径 |
|
||||
| `xj-oss.*` | OSS 网关、鉴权、目录等参数 |
|
||||
| `harbor.*` | Harbor 地址、项目名、账号密码 |
|
||||
| `skopeo.exe-path` | skopeo.exe 可执行文件路径 |
|
||||
| `skopeo.images-path` | 镜像 tar 包存放目录 |
|
||||
| `console.api-base` | Choerodon DevOps API 网关地址 |
|
||||
| `console.project-id` | 控制台项目 ID |
|
||||
| `console.env-id` | 控制台环境 ID |
|
||||
| `console.login-username/password` | 控制台登录账号 |
|
||||
| `console.public-key` | 控制台 RSA 公钥(用于密码加密) |
|
||||
| `sa-token.http-basic` | 本系统登录凭证(格式:用户名:密码) |
|
||||
|
||||
> 敏感配置(密码、密钥等)可通过 `app-env.yml` 覆盖,该文件不纳入版本管理。
|
||||
|
||||
## HTTP 接口
|
||||
### 认证(AuthController)
|
||||
| 接口 | 说明 |
|
||||
|------|------|
|
||||
| GET /auth/login | 显示登录页 |
|
||||
| POST /auth/doLogin | 处理登录(凭证来自 sa-token.http-basic) |
|
||||
| GET /auth/logout | 退出登录 |
|
||||
|
||||
### OSS 同步(OssSyncController)
|
||||
| 接口 | 说明 |
|
||||
|------|------|
|
||||
| POST /oss-sync/all | 一键全量同步(扫描+上传+回写) |
|
||||
| POST /oss-sync/scan | 仅扫描业务表,写入日志 |
|
||||
| POST /oss-sync/upload | 仅上传待处理文件 |
|
||||
| POST /oss-sync/write-back | 仅回写业务表 |
|
||||
|
||||
### DevOps(DevopsController)
|
||||
| 接口 | 说明 |
|
||||
|------|------|
|
||||
| GET /devops/harbor/repositories | 查询 Harbor 镜像仓库列表 |
|
||||
| GET /devops/harbor/latest-tag?repo= | 查询指定仓库最新 tag |
|
||||
| POST /devops/harbor/push | 上传 tar 包并推送到 Harbor(multipart,自动计算下一个 tag) |
|
||||
| POST /devops/console/instances | 查询所有实例(全量 size=500) |
|
||||
| POST /devops/console/pods | 查询实例 Pod 列表 |
|
||||
| GET /devops/console/yaml | 查询实例 YAML 配置 |
|
||||
| PUT /devops/console/yaml | 更新实例 YAML 配置 |
|
||||
|
||||
## OSS 处理状态说明
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| 0 | 待处理 |
|
||||
| 1 | 上传成功 |
|
||||
| 2 | 上传失败 |
|
||||
| 3 | 文件不存在 |
|
||||
| 4 | 已回写业务表 |
|
||||
|
||||
## 关键设计说明
|
||||
|
||||
### Choerodon 控制台登录流程
|
||||
登录需要两步(均需要 `NEVER` 重定向策略才能拿到 302 响应头):
|
||||
1. `POST /oauth/choerodon/login` → 收集响应中所有 `Set-Cookie`(`allValues`),拼成完整 Cookie 字符串
|
||||
2. `GET /oauth/oauth/authorize` 携带 Cookie → 从 302 响应的 `Location` URL fragment(`#` 后)提取 `access_token`
|
||||
|
||||
- `authClient`:`NEVER` 重定向(登录/授权专用)
|
||||
- `apiClient`:`NORMAL` 重定向(业务请求专用)
|
||||
- token 内存缓存,有效期 ~24h;收到 401 自动清除缓存并重试一次
|
||||
|
||||
### SSL 证书
|
||||
内部域名使用自签证书,`HarborService.buildTrustAllClient()` 构造 trust-all `SSLContext`,
|
||||
所有 `HttpClient` 实例均通过该方法创建,跳过证书验证。
|
||||
|
||||
### 镜像上传(skopeo)
|
||||
```
|
||||
skopeo copy --dest-creds user:pass --dest-tls-verify=false \
|
||||
docker-archive:{tarAbsPath} \
|
||||
docker://{harborHost}/{project}/{repo}:{tag}
|
||||
```
|
||||
- tar 包按日期子目录存放(`imagesPath/yyyyMMdd/filename.tar`)
|
||||
- tag 自动在最新 tag 基础上末位 +1(如 `1.0.10` → `1.0.11`)
|
||||
|
||||
### 注意事项
|
||||
- 扫描阶段幂等,同一业务表+ID+列已存在日志则跳过
|
||||
- OSS token 使用内存缓存,无需 Redis
|
||||
- 动态数据源按 dbName 缓存连接池,避免重复创建
|
||||
- 业务库列名/表名来自内部配置,不存在 SQL 注入风险
|
||||
- 文件上传暂不支持 JSON 类型(TXX-19,待排查)
|
||||
|
||||
## Linear 任务追踪
|
||||
所有功能任务和 Bug 统一记录到 Linear `tar-update` 项目(团队 `txxz`)。
|
||||
@@ -3,6 +3,16 @@ package com.yxtech.ossyn;
|
||||
import org.noear.solon.Solon;
|
||||
import org.noear.solon.annotation.SolonMain;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* 应用程序入口
|
||||
*/
|
||||
@@ -11,4 +21,149 @@ public class App {
|
||||
public static void main(String[] args) {
|
||||
Solon.start(App.class, args);
|
||||
}
|
||||
|
||||
|
||||
// private static final String BASE_URL = "http://api.dev.xjyt.petrochina/oauth/choerodon/login"; // 替换为实际域名
|
||||
// private static final String PUBLIC_KEY = "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAJL0JkqsUoK6kt3JyogsgqNp9VDGDp+t3ZAGMbVoMPdHNT2nfiIVh9ZMNHF7g2XiAa8O8AQWyh2PjMR0NiUSVQMCAwEAAQ==";
|
||||
//
|
||||
// /**
|
||||
// * RSA 加密密码
|
||||
// */
|
||||
// public static String encryptPassword(String password) throws Exception {
|
||||
// byte[] keyBytes = Base64.getDecoder().decode(PUBLIC_KEY);
|
||||
// X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
|
||||
// PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic(keySpec);
|
||||
//
|
||||
// Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
|
||||
// cipher.init(Cipher.ENCRYPT_MODE, publicKey);
|
||||
// byte[] encrypted = cipher.doFinal(password.getBytes("UTF-8"));
|
||||
// return Base64.getEncoder().encodeToString(encrypted);
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 登录,返回 Cookie(后续请求需携带)
|
||||
// */
|
||||
// public static String login(String username, String password) throws Exception {
|
||||
// String encryptedPassword = encryptPassword(password);
|
||||
//
|
||||
// // 构建 form 表单体
|
||||
// String formBody = "username=" + java.net.URLEncoder.encode(username, "UTF-8")
|
||||
// + "&password=" + java.net.URLEncoder.encode(encryptedPassword, "UTF-8");
|
||||
//
|
||||
// // 打印等效的 curl 命令(Windows 格式)
|
||||
// System.out.println("curl \"" + BASE_URL + "\" ^");
|
||||
// System.out.println(" -H \"Origin: http://api.dev.xjyt.petrochina\" ^");
|
||||
// System.out.println(" -H \"Content-Type: application/x-www-form-urlencoded\" ^");
|
||||
// System.out.println(" -H \"User-Agent: Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36\" ^");
|
||||
// System.out.println(" -H \"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9\" ^");
|
||||
// System.out.println(" -H \"Referer: http://api.dev.xjyt.petrochina/oauth/choerodon/login\" ^");
|
||||
// System.out.println(" -H \"Accept-Language: zh-CN,zh;q=0.9\" ^");
|
||||
// System.out.println(" -H \"Cookie: SESSION=M2MwYTZjM2ItNzk4Ni00NTM0LWJlYzYtZDAwMDA5Zjc2ZTE1; route=289a995e776d44c58b544d574adb34a0; _hatt=26a898962c71fb9c70f01348ee9e32bb\" ^");
|
||||
// System.out.println(" --data-raw \"" + formBody + "\"");
|
||||
//
|
||||
// HttpClient client = HttpClient.newBuilder()
|
||||
// .followRedirects(HttpClient.Redirect.NEVER)
|
||||
// .build();
|
||||
//
|
||||
// HttpRequest request = HttpRequest.newBuilder()
|
||||
// .uri(URI.create(BASE_URL))
|
||||
// .header("Origin", "http://api.dev.xjyt.petrochina")
|
||||
// .header("Content-Type", "application/x-www-form-urlencoded")
|
||||
// .header("User-Agent", "Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36")
|
||||
// .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9")
|
||||
// .header("Referer", "http://api.dev.xjyt.petrochina/oauth/choerodon/login")
|
||||
// .header("Accept-Language", "zh-CN,zh;q=0.9")
|
||||
// .header("Cookie", "SESSION=M2MwYTZjM2ItNzk4Ni00NTM0LWJlYzYtZDAwMDA5Zjc2ZTE1; route=289a995e776d44c58b544d574adb34a0; _hatt=26a898962c71fb9c70f01348ee9e32bb")
|
||||
// .POST(HttpRequest.BodyPublishers.ofString(formBody))
|
||||
// .build();
|
||||
//
|
||||
// HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
//
|
||||
// System.out.println("状态码: " + response.statusCode());
|
||||
// System.out.println("响应体: " + response.body());
|
||||
//
|
||||
// // 登录成功后服务器返回 302,Set-Cookie 中携带新 Session
|
||||
// String cookie = response.headers().firstValue("Set-Cookie").orElse(null);
|
||||
// System.out.println("Cookie: " + cookie);
|
||||
//
|
||||
// return cookie;
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * OAuth 授权,返回重定向地址(含 access_token)
|
||||
// */
|
||||
// public static String authorize(String cookie) throws Exception {
|
||||
// String url = "http://api.dev.xjyt.petrochina/oauth/oauth/authorize" +
|
||||
// "?response_type=token&client_id=choerodon&state=&redirect_uri=" +
|
||||
// java.net.URLEncoder.encode("http://dev.xjyt.petrochina/#/devops/resource" +
|
||||
// "?activeKey=instance&category=AGILE&id=748209321615708160" +
|
||||
// "&name=%E5%85%A5%E5%BA%B7%E7%AE%A1%E7%90%86%E5%B9%B3%E5%8F%B0" +
|
||||
// "&organizationId=1&type=project&redirectFlag", "UTF-8");
|
||||
//
|
||||
// // 打印等效的 curl 命令(Windows 格式)
|
||||
// System.out.println("curl -v \"" + url + "\" ^");
|
||||
// System.out.println(" -H \"User-Agent: Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36\" ^");
|
||||
// System.out.println(" -H \"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8\" ^");
|
||||
// System.out.println(" -H \"Referer: http://dev.xjyt.petrochina/\" ^");
|
||||
// System.out.println(" -H \"Accept-Language: zh-CN,zh;q=0.9\" ^");
|
||||
// System.out.println(" -H \"Cookie: " + cookie + "\" ^");
|
||||
// System.out.println(" -L");
|
||||
//
|
||||
// HttpClient client = HttpClient.newBuilder()
|
||||
// .followRedirects(HttpClient.Redirect.NEVER)
|
||||
// .build();
|
||||
//
|
||||
// HttpRequest request = HttpRequest.newBuilder()
|
||||
// .uri(URI.create(url))
|
||||
// .header("User-Agent", "Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36")
|
||||
// .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9")
|
||||
// .header("Referer", "http://dev.xjyt.petrochina/")
|
||||
// .header("Accept-Language", "zh-CN,zh;q=0.9")
|
||||
// .header("Cookie", cookie)
|
||||
// .GET()
|
||||
// .build();
|
||||
//
|
||||
// HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
//
|
||||
// System.out.println("authorize 状态码: " + response.statusCode());
|
||||
// System.out.println("authorize 响应体: " + response.body());
|
||||
//
|
||||
// // 授权成功返回 302,Location 中含 access_token
|
||||
// String location = response.headers().firstValue("Location").orElse(null);
|
||||
// System.out.println("Location: " + location);
|
||||
//
|
||||
// return location;
|
||||
// }
|
||||
//
|
||||
// public static void main(String[] args) throws Exception {
|
||||
// String cookie = login("jy-lianlonggang", "UserPwdLlgA@123!");
|
||||
// System.out.println("登录成功,Cookie: " + cookie);
|
||||
//
|
||||
// if (cookie != null) {
|
||||
// String location = authorize(cookie);
|
||||
// System.out.println("授权跳转地址: " + location);
|
||||
//
|
||||
// String token = extractToken(location);
|
||||
// System.out.println("access_token: " + token);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 从 Location URL 的 fragment(# 后)中提取 access_token
|
||||
// */
|
||||
// public static String extractToken(String location) {
|
||||
// if (location == null) return null;
|
||||
// int hashIdx = location.indexOf('#');
|
||||
// if (hashIdx < 0) return null;
|
||||
// String fragment = location.substring(hashIdx + 1);
|
||||
// // fragment 格式:/path?key=val&access_token=xxx&...
|
||||
// int queryIdx = fragment.indexOf('?');
|
||||
// String query = queryIdx >= 0 ? fragment.substring(queryIdx + 1) : fragment;
|
||||
// for (String param : query.split("&")) {
|
||||
// if (param.startsWith("access_token=")) {
|
||||
// return param.substring("access_token=".length());
|
||||
// }
|
||||
// }
|
||||
// return null;
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.yxtech.ossyn.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.noear.solon.annotation.Configuration;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
/**
|
||||
* 控制台(Choerodon DevOps)配置
|
||||
*/
|
||||
@Data
|
||||
@Configuration
|
||||
public class ConsoleConfig {
|
||||
|
||||
@Inject("${console.api-base}")
|
||||
private String apiBase;
|
||||
|
||||
@Inject("${console.project-id}")
|
||||
private String projectId;
|
||||
|
||||
@Inject("${console.env-id}")
|
||||
private String envId;
|
||||
|
||||
@Inject("${console.login-username}")
|
||||
private String loginUsername;
|
||||
|
||||
@Inject("${console.login-password}")
|
||||
private String loginPassword;
|
||||
|
||||
/** RSA 公钥(用于密码加密) */
|
||||
@Inject("${console.public-key}")
|
||||
private String publicKey;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.yxtech.ossyn.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.noear.solon.annotation.Configuration;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
/**
|
||||
* Harbor 镜像仓库配置
|
||||
*/
|
||||
@Data
|
||||
@Configuration
|
||||
public class HarborConfig {
|
||||
|
||||
@Inject("${harbor.url}")
|
||||
private String url;
|
||||
|
||||
@Inject("${harbor.project}")
|
||||
private String project;
|
||||
|
||||
@Inject("${harbor.username}")
|
||||
private String username;
|
||||
|
||||
@Inject("${harbor.password}")
|
||||
private String password;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.yxtech.ossyn.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.noear.solon.annotation.Configuration;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
/**
|
||||
* Skopeo 工具配置
|
||||
*/
|
||||
@Data
|
||||
@Configuration
|
||||
public class SkopeoConfig {
|
||||
|
||||
/** skopeo.exe 可执行文件路径 */
|
||||
@Inject("${skopeo.exe-path}")
|
||||
private String exePath;
|
||||
|
||||
/** 镜像 tar 包存放目录 */
|
||||
@Inject("${skopeo.images-path}")
|
||||
private String imagesPath;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.yxtech.ossyn.controller;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.yxtech.ossyn.service.ConsoleService;
|
||||
import com.yxtech.ossyn.service.HarborService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.*;
|
||||
import org.noear.solon.core.handle.UploadedFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 镜像管理 + 控制台实例管理接口
|
||||
*/
|
||||
@Slf4j
|
||||
@Controller
|
||||
@Mapping("/devops")
|
||||
public class DevopsController {
|
||||
|
||||
@Inject
|
||||
private HarborService harborService;
|
||||
|
||||
@Inject
|
||||
private ConsoleService consoleService;
|
||||
|
||||
@Inject("${skopeo.images-path}")
|
||||
private String imagesPath;
|
||||
|
||||
// ===================== Harbor 镜像 =====================
|
||||
|
||||
/** 查询镜像仓库列表 */
|
||||
@Get
|
||||
@Mapping("/harbor/repositories")
|
||||
public Map<String, Object> listRepositories() {
|
||||
try {
|
||||
return ok(harborService.listRepositories());
|
||||
} catch (Exception e) {
|
||||
log.error("[Devops] listRepositories error", e);
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询指定镜像最新 tag */
|
||||
@Get
|
||||
@Mapping("/harbor/latest-tag")
|
||||
public Map<String, Object> latestTag(@Param String repo) {
|
||||
try {
|
||||
return ok(harborService.latestTag(repo));
|
||||
} catch (Exception e) {
|
||||
log.error("[Devops] latestTag error", e);
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传 tar 包并推送到 Harbor
|
||||
* 参数:file(multipart)、repo(目标仓库名)、tag(可选,不传则自动+1)
|
||||
*/
|
||||
@Post
|
||||
@Mapping("/harbor/push")
|
||||
public Map<String, Object> pushImage(
|
||||
@Param String repo,
|
||||
@Param(required = false) String tag,
|
||||
UploadedFile file) {
|
||||
try {
|
||||
// 保存 tar 到 images 目录
|
||||
String subDir = new java.text.SimpleDateFormat("yyyyMMdd")
|
||||
.format(new java.util.Date());
|
||||
File dir = Paths.get(imagesPath, subDir).toFile();
|
||||
if (!dir.exists()) dir.mkdirs();
|
||||
File tarFile = new File(dir, file.getName());
|
||||
try (FileOutputStream fos = new FileOutputStream(tarFile)) {
|
||||
file.getContent().transferTo(fos);
|
||||
}
|
||||
|
||||
// 自动计算 tag
|
||||
if (tag == null || tag.isBlank()) {
|
||||
JSONObject latest = harborService.latestTag(repo);
|
||||
tag = nextTag(latest);
|
||||
}
|
||||
|
||||
String result = harborService.pushImage(subDir + "/" + tarFile.getName(), repo, tag);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("tag", tag);
|
||||
data.put("output", result);
|
||||
return ok(data);
|
||||
} catch (Exception e) {
|
||||
log.error("[Devops] pushImage error", e);
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 在最新 tag 基础上 +1(支持 1.0.10 → 1.0.11 格式) */
|
||||
private String nextTag(JSONObject latestArtifact) {
|
||||
if (latestArtifact == null) return "1.0.1";
|
||||
var tags = latestArtifact.getJSONArray("tags");
|
||||
if (tags == null || tags.isEmpty()) return "1.0.1";
|
||||
String name = tags.getJSONObject(0).getString("name");
|
||||
if (name == null) return "1.0.1";
|
||||
String[] parts = name.split("\\.");
|
||||
try {
|
||||
int last = Integer.parseInt(parts[parts.length - 1]) + 1;
|
||||
parts[parts.length - 1] = String.valueOf(last);
|
||||
return String.join(".", parts);
|
||||
} catch (NumberFormatException e) {
|
||||
return name + "-1";
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 控制台实例 =====================
|
||||
|
||||
/** 查询所有实例 */
|
||||
@Post
|
||||
@Mapping("/console/instances")
|
||||
public Map<String, Object> listInstances() {
|
||||
try {
|
||||
return ok(consoleService.listInstances());
|
||||
} catch (Exception e) {
|
||||
log.error("[Devops] listInstances error", e);
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询实例的 Pod 列表 */
|
||||
@Post
|
||||
@Mapping("/console/pods")
|
||||
public Map<String, Object> listPods(@Param String instanceId) {
|
||||
try {
|
||||
return ok(consoleService.listPods(instanceId));
|
||||
} catch (Exception e) {
|
||||
log.error("[Devops] listPods error", e);
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询实例 YAML 配置 */
|
||||
@Get
|
||||
@Mapping("/console/yaml")
|
||||
public Map<String, Object> getYaml(@Param String instanceId, @Param String versionId) {
|
||||
try {
|
||||
return ok(consoleService.getInstanceYaml(instanceId, versionId));
|
||||
} catch (Exception e) {
|
||||
log.error("[Devops] getYaml error", e);
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新实例 YAML 配置 */
|
||||
@Put
|
||||
@Mapping("/console/yaml")
|
||||
public Map<String, Object> updateYaml(@Body JSONObject payload) {
|
||||
try {
|
||||
return ok(consoleService.updateInstanceYaml(payload));
|
||||
} catch (Exception e) {
|
||||
log.error("[Devops] updateYaml error", e);
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 工具方法 =====================
|
||||
|
||||
private Map<String, Object> ok(Object data) {
|
||||
Map<String, Object> r = new HashMap<>(3);
|
||||
r.put("code", 200);
|
||||
r.put("msg", "ok");
|
||||
r.put("data", data);
|
||||
return r;
|
||||
}
|
||||
|
||||
private Map<String, Object> fail(String msg) {
|
||||
Map<String, Object> r = new HashMap<>(2);
|
||||
r.put("code", 500);
|
||||
r.put("msg", msg);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.core.handle.Context;
|
||||
import org.noear.solon.core.handle.Filter;
|
||||
import org.noear.solon.core.handle.FilterChain;
|
||||
import org.noear.solon.core.exception.StatusException;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
@@ -22,11 +23,21 @@ public class GlobalExceptionFilter implements Filter {
|
||||
try {
|
||||
chain.doFilter(ctx);
|
||||
} catch (Throwable e) {
|
||||
log.error("[GlobalFilter] 请求异常 {} {} params={}",
|
||||
ctx.method(), ctx.path(), ctx.paramMap(), e);
|
||||
ctx.status(500);
|
||||
int status = 500;
|
||||
// StatusException 直接用其状态码,避免 404 被当成 500 处理
|
||||
if (e instanceof StatusException se) {
|
||||
status = se.getCode();
|
||||
}
|
||||
// 静态资源 404 不打 ERROR,避免刷屏
|
||||
if (status == 404) {
|
||||
log.debug("[GlobalFilter] 404 {} {}", ctx.method(), ctx.path());
|
||||
} else {
|
||||
log.error("[GlobalFilter] 请求异常 {} {} params={}",
|
||||
ctx.method(), ctx.path(), ctx.paramMap(), e);
|
||||
}
|
||||
ctx.status(status);
|
||||
Map<String, Object> result = new HashMap<>(2);
|
||||
result.put("code", 500);
|
||||
result.put("code", status);
|
||||
result.put("msg", e.getMessage());
|
||||
ctx.render(result);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package com.yxtech.ossyn.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.yxtech.ossyn.config.ConsoleConfig;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 控制台(Choerodon DevOps)服务
|
||||
* token 内存缓存,401 时自动重新登录
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ConsoleService {
|
||||
|
||||
@Inject
|
||||
private ConsoleConfig cfg;
|
||||
|
||||
/** 缓存的 access_token */
|
||||
private volatile String cachedToken;
|
||||
/** token 过期时间戳(毫秒) */
|
||||
private final AtomicLong tokenExpireAt = new AtomicLong(0);
|
||||
|
||||
/** 登录/授权专用:不跟随重定向,才能拿到 302 的 Set-Cookie 和 Location */
|
||||
private final HttpClient authClient = HarborService.buildTrustAllClientNoRedirect();
|
||||
|
||||
/** 业务请求专用:跟随重定向 */
|
||||
private final HttpClient apiClient = HarborService.buildTrustAllClient();
|
||||
|
||||
// ===================== token 获取 =====================
|
||||
|
||||
/** 强制清除缓存 token,下次调用 getToken() 时重新登录 */
|
||||
public void invalidateToken() {
|
||||
cachedToken = null;
|
||||
tokenExpireAt.set(0);
|
||||
log.info("[Console] token 缓存已清除,下次请求将重新登录");
|
||||
}
|
||||
|
||||
/** 获取有效 token(自动刷新) */
|
||||
public String getToken() throws Exception {
|
||||
if (cachedToken != null && System.currentTimeMillis() < tokenExpireAt.get() - 60_000) {
|
||||
return cachedToken;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (cachedToken != null && System.currentTimeMillis() < tokenExpireAt.get() - 60_000) {
|
||||
return cachedToken;
|
||||
}
|
||||
String cookie = login();
|
||||
String location = authorize(cookie);
|
||||
String token = extractToken(location);
|
||||
if (token == null) throw new RuntimeException("授权成功但未能从 Location 中提取 access_token: " + location);
|
||||
cachedToken = token;
|
||||
tokenExpireAt.set(System.currentTimeMillis() + 86399_000L);
|
||||
log.info("[Console] token 已刷新");
|
||||
}
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
private String login() throws Exception {
|
||||
String encPwd = encryptPassword(cfg.getLoginPassword(), cfg.getPublicKey());
|
||||
String formBody = "username=" + java.net.URLEncoder.encode(cfg.getLoginUsername(), "UTF-8")
|
||||
+ "&password=" + java.net.URLEncoder.encode(encPwd, "UTF-8");
|
||||
String loginUrl = cfg.getApiBase() + "/oauth/choerodon/login";
|
||||
|
||||
log.info("[Console][login] curl \"{}\" ^\n -H \"Origin: {}\" ^\n -H \"Content-Type: application/x-www-form-urlencoded\" ^\n --data-raw \"{}\"",
|
||||
loginUrl, cfg.getApiBase(), formBody);
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(loginUrl))
|
||||
.header("Origin", cfg.getApiBase())
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.header("Referer", loginUrl)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(formBody))
|
||||
.build();
|
||||
|
||||
// 使用 authClient(NEVER 重定向),确保能拿到 302 响应中的 Set-Cookie
|
||||
HttpResponse<String> resp = authClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
log.info("[Console][login] status={}", resp.statusCode());
|
||||
log.info("[Console][login] headers={}", resp.headers().map());
|
||||
log.info("[Console][login] body={}", resp.body());
|
||||
// 收集所有 Set-Cookie,拼成 Cookie 字符串传给 authorize
|
||||
java.util.List<String> cookies = resp.headers().allValues("Set-Cookie");
|
||||
if (cookies.isEmpty()) {
|
||||
throw new RuntimeException("登录失败,未获取到 Set-Cookie,状态码: " + resp.statusCode());
|
||||
}
|
||||
String cookie = cookies.stream()
|
||||
.map(c -> c.split(";")[0]) // 每条只取 name=value 部分
|
||||
.collect(java.util.stream.Collectors.joining("; "));
|
||||
log.info("[Console][login] 拼装 Cookie: {}", cookie);
|
||||
return cookie;
|
||||
}
|
||||
|
||||
private String authorize(String cookie) throws Exception {
|
||||
String redirectUri = java.net.URLEncoder.encode(
|
||||
"http://dev.xjyt.petrochina/#/devops/resource?activeKey=instance&category=AGILE"
|
||||
+ "&id=" + cfg.getProjectId()
|
||||
+ "&organizationId=1&type=project&redirectFlag", "UTF-8");
|
||||
String url = cfg.getApiBase() + "/oauth/oauth/authorize"
|
||||
+ "?response_type=token&client_id=choerodon&state=&redirect_uri=" + redirectUri;
|
||||
|
||||
log.info("[Console][authorize] curl -v \"{}\" ^\n -H \"Cookie: {}\"", url, cookie);
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.header("Referer", "http://dev.xjyt.petrochina/")
|
||||
.header("Cookie", cookie)
|
||||
.GET().build();
|
||||
|
||||
// 使用 authClient(NEVER 重定向),确保能拿到 302 的 Location 头
|
||||
HttpResponse<String> resp = authClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
log.info("[Console][authorize] status={}", resp.statusCode());
|
||||
log.info("[Console][authorize] headers={}", resp.headers().map());
|
||||
log.info("[Console][authorize] body={}", resp.body());
|
||||
|
||||
String location = resp.headers().firstValue("Location").orElse(null);
|
||||
log.info("[Console][authorize] Location={}", location);
|
||||
|
||||
if (location == null) {
|
||||
throw new RuntimeException("授权失败,未获取到 Location,状态码: " + resp.statusCode());
|
||||
}
|
||||
return location;
|
||||
}
|
||||
|
||||
/** 从 Location URL 的 fragment(# 后)中提取 access_token */
|
||||
private String extractToken(String location) {
|
||||
int hashIdx = location.indexOf('#');
|
||||
if (hashIdx < 0) return null;
|
||||
String fragment = location.substring(hashIdx + 1);
|
||||
int queryIdx = fragment.indexOf('?');
|
||||
String query = queryIdx >= 0 ? fragment.substring(queryIdx + 1) : fragment;
|
||||
for (String param : query.split("&")) {
|
||||
if (param.startsWith("access_token=")) return param.substring("access_token=".length());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String encryptPassword(String password, String publicKeyBase64) throws Exception {
|
||||
byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64);
|
||||
PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(keyBytes));
|
||||
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
|
||||
return Base64.getEncoder().encodeToString(cipher.doFinal(password.getBytes("UTF-8")));
|
||||
}
|
||||
|
||||
// ===================== 通用请求工具 =====================
|
||||
|
||||
private HttpRequest.Builder baseRequest(String url) throws Exception {
|
||||
return HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Authorization", "bearer " + getToken())
|
||||
.header("Accept", "application/json")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("H-Tenant-Id", "1")
|
||||
.header("H-Menu-Id", "1968")
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.header("Origin", "http://dev.xjyt.petrochina")
|
||||
.header("Referer", "http://dev.xjyt.petrochina/");
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送请求,401 时清除 token 缓存并重试一次
|
||||
*/
|
||||
private HttpResponse<String> sendWithRetry(HttpRequest.Builder reqBuilder, String body, String method) throws Exception {
|
||||
HttpRequest req = buildReq(reqBuilder, body, method);
|
||||
HttpResponse<String> resp = apiClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() == 401) {
|
||||
log.warn("[Console] 收到 401,清除 token 缓存后重试");
|
||||
invalidateToken();
|
||||
// 重新构建请求(带新 token)
|
||||
String url = req.uri().toString();
|
||||
HttpRequest.Builder newBuilder = baseRequest(url);
|
||||
req = buildReq(newBuilder, body, method);
|
||||
resp = apiClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
private HttpRequest buildReq(HttpRequest.Builder builder, String body, String method) {
|
||||
return switch (method) {
|
||||
case "POST" -> builder.POST(body != null
|
||||
? HttpRequest.BodyPublishers.ofString(body)
|
||||
: HttpRequest.BodyPublishers.noBody()).build();
|
||||
case "PUT" -> builder.PUT(body != null
|
||||
? HttpRequest.BodyPublishers.ofString(body)
|
||||
: HttpRequest.BodyPublishers.noBody()).build();
|
||||
default -> builder.GET().build();
|
||||
};
|
||||
}
|
||||
|
||||
// ===================== 实例列表 =====================
|
||||
|
||||
/** 查询所有实例(全量,size=500) */
|
||||
public JSONObject listInstances() throws Exception {
|
||||
String url = cfg.getApiBase() + "/devops/v1/projects/" + cfg.getProjectId()
|
||||
+ "/app_service_instances/info/page_by_options?page=0&size=500&env_id=" + cfg.getEnvId();
|
||||
String body = "{\"params\":[],\"searchParam\":{}}";
|
||||
HttpResponse<String> resp = sendWithRetry(baseRequest(url), body, "POST");
|
||||
log.info("[Console][listInstances] status={}", resp.statusCode());
|
||||
return JSON.parseObject(resp.body());
|
||||
}
|
||||
|
||||
// ===================== Pod 列表 =====================
|
||||
|
||||
/** 查询指定实例的 Pod 列表 */
|
||||
public JSONObject listPods(String instanceId) throws Exception {
|
||||
String url = cfg.getApiBase() + "/devops/v1/projects/" + cfg.getProjectId()
|
||||
+ "/pods/page_by_options?page=0&size=50&env_id=" + cfg.getEnvId()
|
||||
+ "&instance_id=" + instanceId;
|
||||
String body = "{\"params\":[],\"searchParam\":{}}";
|
||||
HttpResponse<String> resp = sendWithRetry(baseRequest(url), body, "POST");
|
||||
return JSON.parseObject(resp.body());
|
||||
}
|
||||
|
||||
// ===================== YAML 配置 =====================
|
||||
|
||||
/** 查询实例的 YAML 配置 */
|
||||
public JSONObject getInstanceYaml(String instanceId, String versionId) throws Exception {
|
||||
String url = cfg.getApiBase() + "/devops/v1/projects/" + cfg.getProjectId()
|
||||
+ "/app_service_instances/" + instanceId
|
||||
+ "/appServiceVersion/" + versionId + "/upgrade_value";
|
||||
HttpResponse<String> resp = sendWithRetry(baseRequest(url), null, "GET");
|
||||
return JSON.parseObject(resp.body());
|
||||
}
|
||||
|
||||
/** 更新实例的 YAML 配置 */
|
||||
public JSONObject updateInstanceYaml(JSONObject payload) throws Exception {
|
||||
String url = cfg.getApiBase() + "/devops/v1/projects/" + cfg.getProjectId()
|
||||
+ "/app_service_instances";
|
||||
HttpResponse<String> resp = sendWithRetry(baseRequest(url), payload.toJSONString(), "PUT");
|
||||
return JSON.parseObject(resp.body());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.yxtech.ossyn.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.yxtech.ossyn.config.HarborConfig;
|
||||
import com.yxtech.ossyn.config.SkopeoConfig;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Base64;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
/**
|
||||
* Harbor 镜像仓库服务
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class HarborService {
|
||||
|
||||
@Inject
|
||||
private HarborConfig harborConfig;
|
||||
|
||||
@Inject
|
||||
private SkopeoConfig skopeoConfig;
|
||||
|
||||
private final HttpClient httpClient = buildTrustAllClient();
|
||||
|
||||
/** 构建忽略 SSL 证书验证的 HttpClient(内部域名自签证书) */
|
||||
public static HttpClient buildTrustAllClient() {
|
||||
return buildTrustAllClient(HttpClient.Redirect.NORMAL);
|
||||
}
|
||||
|
||||
/** 构建忽略 SSL 证书验证、不跟随重定向的 HttpClient(用于登录/授权,需拿 302 头信息) */
|
||||
public static HttpClient buildTrustAllClientNoRedirect() {
|
||||
return buildTrustAllClient(HttpClient.Redirect.NEVER);
|
||||
}
|
||||
|
||||
private static HttpClient buildTrustAllClient(HttpClient.Redirect redirect) {
|
||||
try {
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, new TrustManager[]{new X509TrustManager() {
|
||||
public void checkClientTrusted(X509Certificate[] c, String a) {}
|
||||
public void checkServerTrusted(X509Certificate[] c, String a) {}
|
||||
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
|
||||
}}, null);
|
||||
return HttpClient.newBuilder()
|
||||
.followRedirects(redirect)
|
||||
.sslContext(sslContext)
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("初始化 TrustAll SSLContext 失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 构建 Basic Auth 头 */
|
||||
private String basicAuth() {
|
||||
String cred = harborConfig.getUsername() + ":" + harborConfig.getPassword();
|
||||
return "Basic " + Base64.getEncoder().encodeToString(cred.getBytes());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询项目下所有镜像仓库列表
|
||||
*/
|
||||
public JSONArray listRepositories() throws Exception {
|
||||
String url = harborConfig.getUrl() + "/api/v2.0/projects/"
|
||||
+ harborConfig.getProject() + "/repositories?page=1&page_size=100";
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Authorization", basicAuth())
|
||||
.header("Accept", "application/json")
|
||||
.GET().build();
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
log.info("[Harbor] listRepositories status={}", resp.statusCode());
|
||||
return JSON.parseArray(resp.body());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定镜像仓库的最新 tag
|
||||
*
|
||||
* @param repoName 仓库名(不含项目前缀,如 health-bank-admin)
|
||||
*/
|
||||
public JSONObject latestTag(String repoName) throws Exception {
|
||||
String url = harborConfig.getUrl() + "/api/v2.0/projects/"
|
||||
+ harborConfig.getProject() + "/repositories/"
|
||||
+ repoName + "/artifacts?page_size=1&sort=-push_time&with_tag=true";
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Authorization", basicAuth())
|
||||
.header("Accept", "application/json")
|
||||
.GET().build();
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
JSONArray arr = JSON.parseArray(resp.body());
|
||||
if (arr == null || arr.isEmpty()) return null;
|
||||
return arr.getJSONObject(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 skopeo 将 tar 包推送到 Harbor
|
||||
*
|
||||
* @param tarRelPath tar 包相对于 images-path 的路径(如 20260417/xxx.tar)
|
||||
* @param repoName 目标仓库名(不含项目前缀)
|
||||
* @param tag 目标 tag
|
||||
*/
|
||||
public String pushImage(String tarRelPath, String repoName, String tag) throws Exception {
|
||||
String tarAbsPath = skopeoConfig.getImagesPath().replace("\\", "/") + "/" + tarRelPath;
|
||||
String dest = harborConfig.getUrl().replace("https://", "")
|
||||
.replace("http://", "");
|
||||
dest = harborConfig.getUrl().startsWith("https") ? dest : dest;
|
||||
String destImage = "docker://" + dest.replace("https://", "").replace("http://", "")
|
||||
+ "/" + harborConfig.getProject() + "/" + repoName + ":" + tag;
|
||||
// 修正:直接拼完整 harbor host
|
||||
String harborHost = harborConfig.getUrl().replaceFirst("https?://", "");
|
||||
destImage = "docker://" + harborHost + "/" + harborConfig.getProject() + "/" + repoName + ":" + tag;
|
||||
|
||||
String[] cmd = {
|
||||
skopeoConfig.getExePath(),
|
||||
"copy",
|
||||
"--dest-creds", harborConfig.getUsername() + ":" + harborConfig.getPassword(),
|
||||
"--dest-tls-verify=false",
|
||||
"docker-archive:" + tarAbsPath,
|
||||
destImage
|
||||
};
|
||||
log.info("[Harbor] skopeo push: {} -> {}", tarAbsPath, destImage);
|
||||
Process process = new ProcessBuilder(cmd)
|
||||
.redirectErrorStream(true)
|
||||
.start();
|
||||
String output = new String(process.getInputStream().readAllBytes());
|
||||
int exitCode = process.waitFor();
|
||||
log.info("[Harbor] skopeo exit={} output={}", exitCode, output);
|
||||
if (exitCode != 0) {
|
||||
throw new RuntimeException("skopeo 推送失败: " + output);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
server.port: 8080
|
||||
server.request-maxBodySize: 1073741824
|
||||
solon.config.load:
|
||||
- "app-env.yml" #可以是环境相关的
|
||||
|
||||
solon.app:
|
||||
name: 'xj-oss-syn'
|
||||
@@ -68,7 +71,31 @@ xj-oss:
|
||||
file-type-white-list: dat,svg,dwg,pr,pptm,ai,bak,pptx,lic,db,caj,wps,xmind,eps,exe,doc,docx,xls,xlsx,ppt,pps,pdf,xml,webp,MOV,MP4,HEVC,wmv,rmvb,rm,odt,3gp,swf,gz,tgz,log,bz,bz2,tbz,zip,rar,tar,7z,mp3,mid,ogg,mpga,mp4a,wav,wma,avi,dv,mp4,bmp,xlsm,dps,bak,cdr,et,kdh,nh,,nh,mpeg,mpg,mov,wm,flv,mkv,bmp,jpg,jpeg,gif,png,tif,tiff,tga,psd,txt,php,html,htm,js,css,rtf,rtfd,py,java,rb,sh,pl,sql,xml,gdb,gdbx
|
||||
|
||||
# =====================================
|
||||
# Sa-Token 鉴权配置
|
||||
# Harbor 镜像仓库配置
|
||||
# =====================================
|
||||
harbor:
|
||||
url: https://harbor.dev.xjyt.petrochina
|
||||
project: operation-jkglpt
|
||||
username: jkglpt
|
||||
password: Jkglpt@123
|
||||
|
||||
# =====================================
|
||||
# Skopeo 配置
|
||||
# =====================================
|
||||
skopeo:
|
||||
exe-path: "E:/skopeo/skopeo.exe"
|
||||
images-path: "E:/skopeo/images"
|
||||
|
||||
# =====================================
|
||||
# 控制台(Choerodon DevOps)配置
|
||||
# =====================================
|
||||
console:
|
||||
api-base: http://api.dev.xjyt.petrochina
|
||||
project-id: "748209321615708160"
|
||||
env-id: "766633041995898880"
|
||||
login-username: jy-lianlonggang
|
||||
login-password: "UserPwdLlgA@123!"
|
||||
public-key: "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAJL0JkqsUoK6kt3JyogsgqNp9VDGDp+t3ZAGMbVoMPdHNT2nfiIVh9ZMNHF7g2XiAa8O8AQWyh2PjMR0NiUSVQMCAwEAAQ=="
|
||||
# =====================================
|
||||
sa-token:
|
||||
# token 名称(同时也是 cookie 名称)
|
||||
|
||||
@@ -115,6 +115,8 @@
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" onclick="switchTab('sync')">同步管理</button>
|
||||
<button class="tab-btn" onclick="switchTab('mapping')">映射配置</button>
|
||||
<button class="tab-btn" onclick="switchTab('harbor')">镜像管理</button>
|
||||
<button class="tab-btn" onclick="switchTab('console')">控制台实例</button>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 同步管理 Tab ==================== -->
|
||||
@@ -241,6 +243,107 @@
|
||||
</div>
|
||||
</div><!-- end tab-mapping -->
|
||||
|
||||
<!-- ==================== 镜像管理 Tab ==================== -->
|
||||
<div id="tab-harbor" class="tab-panel">
|
||||
<div class="card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||||
<div class="card-title" style="margin-bottom:0">Harbor 镜像仓库</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-default" onclick="loadHarbor()">刷新</button>
|
||||
<button class="btn btn-primary" onclick="openPushModal()">上传镜像</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>仓库名</th><th>镜像数</th><th>最新 Tag</th><th>最后更新</th></tr>
|
||||
</thead>
|
||||
<tbody id="harborTableBody">
|
||||
<tr><td colspan="4" style="text-align:center;color:#aaa;padding:30px">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end tab-harbor -->
|
||||
|
||||
<!-- ==================== 控制台实例 Tab ==================== -->
|
||||
<div id="tab-console" class="tab-panel">
|
||||
<div class="card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||||
<div class="card-title" style="margin-bottom:0">控制台实例</div>
|
||||
<button class="btn btn-default" onclick="loadInstances()">刷新</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>服务名</th><th>Code</th><th>状态</th><th>版本</th><th>最后更新</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody id="instanceTableBody">
|
||||
<tr><td colspan="6" style="text-align:center;color:#aaa;padding:30px">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end tab-console -->
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 上传镜像弹窗 -->
|
||||
<div class="modal-mask" id="pushModal">
|
||||
<div class="modal" style="width:440px">
|
||||
<div class="modal-title">上传镜像到 Harbor</div>
|
||||
<div class="form-item">
|
||||
<label>目标仓库 <span style="color:red">*</span></label>
|
||||
<input id="push-repo" placeholder="如 health-bank-admin">
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>Tag(留空自动 +1)</label>
|
||||
<input id="push-tag" placeholder="如 1.0.11,不填则自动计算">
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>镜像 tar 包 <span style="color:red">*</span></label>
|
||||
<input type="file" id="push-file" accept=".tar">
|
||||
</div>
|
||||
<div id="push-result" style="font-size:12px;color:#555;margin-top:8px;white-space:pre-wrap;max-height:120px;overflow:auto"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-default" onclick="document.getElementById('pushModal').classList.remove('show')">取消</button>
|
||||
<button class="btn btn-primary" id="pushBtn" onclick="doPushImage()">开始上传</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- YAML 配置弹窗 -->
|
||||
<div class="modal-mask" id="yamlModal">
|
||||
<div class="modal" style="width:700px;max-width:96vw">
|
||||
<div class="modal-title">实例 YAML 配置</div>
|
||||
<input type="hidden" id="yaml-instanceId">
|
||||
<input type="hidden" id="yaml-versionId">
|
||||
<input type="hidden" id="yaml-envId">
|
||||
<input type="hidden" id="yaml-appServiceId">
|
||||
<input type="hidden" id="yaml-appServiceVersionId">
|
||||
<input type="hidden" id="yaml-objectVersionNumber">
|
||||
<textarea id="yaml-content" style="width:100%;height:360px;font-family:monospace;font-size:12px;padding:10px;border:1px solid #d9d9d9;border-radius:6px;resize:vertical;outline:none"></textarea>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-default" onclick="document.getElementById('yamlModal').classList.remove('show')">取消</button>
|
||||
<button class="btn btn-primary" onclick="doUpdateYaml()">保存更新</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pod 列表弹窗 -->
|
||||
<div class="modal-mask" id="podModal">
|
||||
<div class="modal" style="width:700px;max-width:96vw">
|
||||
<div class="modal-title" id="podModalTitle">Pod 列表</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Pod 名称</th><th>IP</th><th>状态</th><th>Ready</th><th>创建时间</th></tr></thead>
|
||||
<tbody id="podTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-default" onclick="document.getElementById('podModal').classList.remove('show')">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 清理日志弹窗 -->
|
||||
@@ -519,11 +622,13 @@
|
||||
// ===================== Tab 切换 =====================
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab-btn').forEach((b, i) => {
|
||||
b.classList.toggle('active', ['sync','mapping'][i] === name);
|
||||
b.classList.toggle('active', ['sync','mapping','harbor','console'][i] === name);
|
||||
});
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById('tab-' + name).classList.add('active');
|
||||
if (name === 'mapping') loadMappings();
|
||||
if (name === 'harbor') loadHarbor();
|
||||
if (name === 'console') loadInstances();
|
||||
}
|
||||
|
||||
// ===================== 映射配置 =====================
|
||||
@@ -617,6 +722,188 @@
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ===================== Harbor 镜像 =====================
|
||||
async function loadHarbor() {
|
||||
try {
|
||||
const res = await fetch('/devops/harbor/repositories');
|
||||
const json = await res.json();
|
||||
if (json.code !== 200) return;
|
||||
const tbody = document.getElementById('harborTableBody');
|
||||
const list = json.data || [];
|
||||
if (!list.length) { tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:#aaa;padding:30px">暂无数据</td></tr>'; return; }
|
||||
tbody.innerHTML = list.map(r => {
|
||||
const shortName = (r.name || '').replace(/^[^/]+\//, '');
|
||||
return `<tr>
|
||||
<td>${shortName}</td>
|
||||
<td>${r.artifact_count ?? '-'}</td>
|
||||
<td><span id="tag-${shortName}" style="color:#aaa">加载中...</span></td>
|
||||
<td style="color:#aaa;font-size:12px">${(r.update_time||'').replace('T',' ').substring(0,19)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
// 渲染完列表后异步逐条加载最新 tag
|
||||
list.forEach(r => {
|
||||
const shortName = (r.name || '').replace(/^[^/]+\//, '');
|
||||
loadLatestTag(shortName);
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function loadLatestTag(repo) {
|
||||
try {
|
||||
const res = await fetch('/devops/harbor/latest-tag?repo=' + encodeURIComponent(repo));
|
||||
const json = await res.json();
|
||||
const el = document.getElementById('tag-' + repo);
|
||||
if (!el) return;
|
||||
if (json.code === 200 && json.data) {
|
||||
const tags = json.data.tags;
|
||||
el.textContent = tags && tags.length ? tags[0].name : '-';
|
||||
el.style.color = '#1677ff';
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function openPushModal() {
|
||||
document.getElementById('push-repo').value = '';
|
||||
document.getElementById('push-tag').value = '';
|
||||
document.getElementById('push-file').value = '';
|
||||
document.getElementById('push-result').textContent = '';
|
||||
document.getElementById('pushModal').classList.add('show');
|
||||
}
|
||||
|
||||
async function doPushImage() {
|
||||
const repo = document.getElementById('push-repo').value.trim();
|
||||
const tag = document.getElementById('push-tag').value.trim();
|
||||
const fileInput = document.getElementById('push-file');
|
||||
if (!repo) { alert('请填写目标仓库名'); return; }
|
||||
if (!fileInput.files.length) { alert('请选择 tar 包文件'); return; }
|
||||
const btn = document.getElementById('pushBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '上传中...';
|
||||
document.getElementById('push-result').textContent = '正在上传,请稍候...';
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('repo', repo);
|
||||
if (tag) fd.append('tag', tag);
|
||||
fd.append('file', fileInput.files[0]);
|
||||
const res = await fetch('/devops/harbor/push', { method: 'POST', body: fd });
|
||||
const json = await res.json();
|
||||
if (json.code === 200) {
|
||||
document.getElementById('push-result').textContent = '✅ 推送成功!Tag: ' + json.data.tag + '\n' + (json.data.output || '');
|
||||
loadHarbor();
|
||||
} else {
|
||||
document.getElementById('push-result').textContent = '❌ 失败:' + json.msg;
|
||||
}
|
||||
} catch(e) {
|
||||
document.getElementById('push-result').textContent = '❌ 请求失败:' + e.message;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始上传';
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 控制台实例 =====================
|
||||
async function loadInstances() {
|
||||
try {
|
||||
const res = await fetch('/devops/console/instances', { method: 'POST' });
|
||||
const json = await res.json();
|
||||
if (json.code !== 200) return;
|
||||
const list = (json.data && json.data.content) || [];
|
||||
const tbody = document.getElementById('instanceTableBody');
|
||||
if (!list.length) { tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:#aaa;padding:30px">暂无数据</td></tr>'; return; }
|
||||
tbody.innerHTML = list.map(inst => {
|
||||
const statusColor = inst.status === 'running' ? '#52c41a' : inst.status === 'failed' ? '#f5222d' : '#fa8c16';
|
||||
return `<tr>
|
||||
<td>${inst.appServiceName || '-'}</td>
|
||||
<td style="font-size:12px;color:#888">${inst.code || '-'}</td>
|
||||
<td><span style="color:${statusColor};font-weight:500">${inst.status || '-'}</span></td>
|
||||
<td style="font-size:12px">${inst.commandVersion || inst.versionName || '-'}</td>
|
||||
<td style="color:#aaa;font-size:12px">${(inst.lastUpdateDate||'').substring(0,16)}</td>
|
||||
<td>
|
||||
<button class="btn btn-default" style="padding:3px 10px;font-size:12px" onclick="openYamlModal('${inst.id}','${inst.appServiceVersionId||inst.commandVersionId||''}','${inst.appServiceId||''}')">查看配置</button>
|
||||
<button class="btn btn-default" style="padding:3px 10px;font-size:12px;margin-left:4px" onclick="openPodModal('${inst.id}','${inst.appServiceName||inst.code}')">Pod</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function openYamlModal(instanceId, versionId, appServiceId) {
|
||||
document.getElementById('yaml-instanceId').value = instanceId;
|
||||
document.getElementById('yaml-versionId').value = versionId;
|
||||
document.getElementById('yaml-appServiceId').value = appServiceId;
|
||||
document.getElementById('yaml-content').value = '加载中...';
|
||||
document.getElementById('yamlModal').classList.add('show');
|
||||
try {
|
||||
const res = await fetch('/devops/console/yaml?instanceId=' + instanceId + '&versionId=' + versionId);
|
||||
const json = await res.json();
|
||||
if (json.code === 200 && json.data) {
|
||||
document.getElementById('yaml-content').value = json.data.yaml || '';
|
||||
document.getElementById('yaml-envId').value = json.data.envId || '';
|
||||
document.getElementById('yaml-appServiceVersionId').value = json.data.id || versionId;
|
||||
document.getElementById('yaml-objectVersionNumber').value = json.data.objectVersionNumber || 1;
|
||||
} else {
|
||||
document.getElementById('yaml-content').value = '加载失败:' + (json.msg || '');
|
||||
}
|
||||
} catch(e) { document.getElementById('yaml-content').value = '加载失败:' + e.message; }
|
||||
}
|
||||
|
||||
async function doUpdateYaml() {
|
||||
const payload = {
|
||||
values: document.getElementById('yaml-content').value,
|
||||
instanceId: document.getElementById('yaml-instanceId').value,
|
||||
type: 'update',
|
||||
environmentId: document.getElementById('yaml-envId').value,
|
||||
appServiceId: document.getElementById('yaml-appServiceId').value,
|
||||
appServiceVersionId: document.getElementById('yaml-appServiceVersionId').value,
|
||||
};
|
||||
try {
|
||||
const res = await fetch('/devops/console/yaml', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code === 200) {
|
||||
alert('更新成功!');
|
||||
document.getElementById('yamlModal').classList.remove('show');
|
||||
loadInstances();
|
||||
} else {
|
||||
alert('更新失败:' + json.msg);
|
||||
}
|
||||
} catch(e) { alert('请求失败:' + e.message); }
|
||||
}
|
||||
|
||||
async function openPodModal(instanceId, name) {
|
||||
document.getElementById('podModalTitle').textContent = 'Pod 列表 - ' + name;
|
||||
document.getElementById('podTableBody').innerHTML = '<tr><td colspan="5" style="text-align:center;color:#aaa;padding:20px">加载中...</td></tr>';
|
||||
document.getElementById('podModal').classList.add('show');
|
||||
try {
|
||||
const res = await fetch('/devops/console/pods', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'instanceId=' + encodeURIComponent(instanceId)
|
||||
});
|
||||
const json = await res.json();
|
||||
const list = (json.data && json.data.content) || [];
|
||||
if (!list.length) {
|
||||
document.getElementById('podTableBody').innerHTML = '<tr><td colspan="5" style="text-align:center;color:#aaa;padding:20px">暂无 Pod</td></tr>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('podTableBody').innerHTML = list.map(p => {
|
||||
const sc = p.status === 'Running' ? '#52c41a' : p.status === 'Pending' ? '#fa8c16' : '#f5222d';
|
||||
return `<tr>
|
||||
<td style="font-size:12px">${p.name||'-'}</td>
|
||||
<td>${p.ip||'-'}</td>
|
||||
<td><span style="color:${sc};font-weight:500">${p.status||'-'}</span></td>
|
||||
<td>${p.ready ? '✅' : '❌'}</td>
|
||||
<td style="color:#aaa;font-size:12px">${(p.creationDate||'').substring(0,16)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
} catch(e) {
|
||||
document.getElementById('podTableBody').innerHTML = '<tr><td colspan="5" style="color:#f5222d;padding:20px">加载失败:' + e.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 初始化 =====================
|
||||
loadLogs(1);
|
||||
loadTaskHistory();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package features;
|
||||
|
||||
import com.yxtech.watch.App;
|
||||
import com.yxtech.ossyn.App;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user