Merge pull request 'feat: production blue-green deployment for all business services' (#14) from test into main
Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
@@ -6,13 +6,11 @@ import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
|
||||
*/
|
||||
@MapperScan(basePackages = "com.baiyue.audit", annotationClass = Mapper.class)
|
||||
@EnableScheduling
|
||||
@Import(MybatisPlusConfiguration.class)
|
||||
@SpringBootApplication
|
||||
public class BaiyueAuditApplication {
|
||||
|
||||
-2
@@ -7,14 +7,12 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 审计 Outbox 的 RocketMQ 发布配置。
|
||||
* 该配置与基础审计配置隔离:未引入 RocketMQ 的服务仍可使用 HTTP 审计投递。
|
||||
*/
|
||||
@AutoConfiguration(after = BaiyueAuditAutoConfiguration.class)
|
||||
@EnableScheduling
|
||||
@ConditionalOnClass(name = "org.apache.rocketmq.spring.core.RocketMQTemplate")
|
||||
@ConditionalOnBean(type = {
|
||||
"com.baiyue.common.audit.AuditOutboxRepository",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/** Enables scheduled workers by default while allowing blue-green web slots to disable them. */
|
||||
@AutoConfiguration
|
||||
@EnableScheduling
|
||||
@ConditionalOnProperty(prefix = "baiyue.runtime", name = "scheduling-enabled",
|
||||
havingValue = "true", matchIfMissing = true)
|
||||
public class BaiyueSchedulingAutoConfiguration {
|
||||
}
|
||||
+1
@@ -2,3 +2,4 @@ com.baiyue.common.web.LongIdJacksonConfiguration
|
||||
com.baiyue.common.web.CommonWebAutoConfiguration
|
||||
com.baiyue.common.web.FeignTracePropagationAutoConfiguration
|
||||
com.baiyue.common.web.RestClientTracePropagationAutoConfiguration
|
||||
com.baiyue.common.web.BaiyueSchedulingAutoConfiguration
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.scheduling.config.TaskManagementConfigUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class BaiyueSchedulingAutoConfigurationTest {
|
||||
private final ApplicationContextRunner runner = new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(BaiyueSchedulingAutoConfiguration.class));
|
||||
|
||||
@Test
|
||||
void schedulingIsEnabledByDefault() {
|
||||
runner.run(context -> assertThat(context)
|
||||
.hasBean(TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME));
|
||||
}
|
||||
|
||||
@Test
|
||||
void blueGreenWebSlotCanDisableScheduling() {
|
||||
runner.withPropertyValues("baiyue.runtime.scheduling-enabled=false")
|
||||
.run(context -> assertThat(context)
|
||||
.doesNotHaveBean(TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME));
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,16 @@ name: firstleg-${DEPLOY_ENV:-test}-overseas
|
||||
|
||||
x-overseas-common: &overseas-common
|
||||
restart: "on-failure:3"
|
||||
stop_grace_period: ${STOP_GRACE_PERIOD:-45s}
|
||||
networks: [firstleg-overseas-network, infra-network]
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment: &overseas-environment
|
||||
BAIYUE_DEPLOY_SLOT: ${DEPLOY_SLOT:-rolling}
|
||||
BAIYUE_RELEASE_ID: ${RELEASE_ID:-local}
|
||||
BAIYUE_RUNTIME_SCHEDULING_ENABLED: ${BAIYUE_RUNTIME_SCHEDULING_ENABLED:-true}
|
||||
SERVER_SHUTDOWN: graceful
|
||||
SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE: ${SHUTDOWN_PHASE_TIMEOUT:-30s}
|
||||
SPRING_PROFILES_ACTIVE: overseas
|
||||
NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR}
|
||||
NACOS_USERNAME: ${OVERSEAS_NACOS_USERNAME}
|
||||
@@ -37,8 +43,8 @@ services:
|
||||
gateway-overseas:
|
||||
<<: *overseas-common
|
||||
image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PORT:-9300}:9300"]
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas${DEPLOY_SLOT_SUFFIX:-}
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PUBLISHED_PORT:-${GATEWAY_OVERSEAS_PORT:-9300}}:9300"]
|
||||
environment:
|
||||
<<: *overseas-environment
|
||||
SERVER_PORT: 9300
|
||||
@@ -53,8 +59,8 @@ services:
|
||||
lastleg-service:
|
||||
<<: *overseas-common
|
||||
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PORT:-9303}:9303"]
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg${DEPLOY_SLOT_SUFFIX:-}
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PUBLISHED_PORT:-${LASTLEG_PORT:-9303}}:9303"]
|
||||
environment:
|
||||
<<: *overseas-environment
|
||||
SERVER_PORT: 9303
|
||||
@@ -72,8 +78,8 @@ services:
|
||||
warehouse-overseas-service:
|
||||
<<: *overseas-common
|
||||
image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"]
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas${DEPLOY_SLOT_SUFFIX:-}
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PUBLISHED_PORT:-${WAREHOUSE_OVERSEAS_PORT:-9305}}:9305"]
|
||||
environment:
|
||||
<<: *overseas-environment
|
||||
SERVER_PORT: 9305
|
||||
@@ -87,8 +93,8 @@ services:
|
||||
event-bridge-service:
|
||||
<<: *overseas-common
|
||||
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"]
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas${DEPLOY_SLOT_SUFFIX:-}
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PUBLISHED_PORT:-${EVENT_BRIDGE_OVERSEAS_PORT:-9313}}:9312"]
|
||||
environment:
|
||||
<<: *overseas-environment
|
||||
SERVER_PORT: 9312
|
||||
|
||||
+29
-23
@@ -2,12 +2,18 @@ name: firstleg-${DEPLOY_ENV:-test}
|
||||
|
||||
x-common: &common
|
||||
restart: "on-failure:3"
|
||||
stop_grace_period: ${STOP_GRACE_PERIOD:-45s}
|
||||
networks: [firstleg-network, infra-network]
|
||||
# 测试机复用宿主机的 MySQL、Redis、Nacos 时,容器通过此别名访问宿主机。
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment: &common-environment
|
||||
DEPLOY_ENV: ${DEPLOY_ENV:-test}
|
||||
BAIYUE_DEPLOY_SLOT: ${DEPLOY_SLOT:-rolling}
|
||||
BAIYUE_RELEASE_ID: ${RELEASE_ID:-local}
|
||||
BAIYUE_RUNTIME_SCHEDULING_ENABLED: ${BAIYUE_RUNTIME_SCHEDULING_ENABLED:-true}
|
||||
SERVER_SHUTDOWN: graceful
|
||||
SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE: ${SHUTDOWN_PHASE_TIMEOUT:-30s}
|
||||
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-cn}
|
||||
NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR}
|
||||
NACOS_USERNAME: ${NACOS_USERNAME}
|
||||
@@ -39,8 +45,8 @@ services:
|
||||
gateway:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-gateway
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PORT:-9000}:9000"]
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-gateway${DEPLOY_SLOT_SUFFIX:-}
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PUBLISHED_PORT:-${GATEWAY_PORT:-9000}}:9000"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
SERVER_PORT: 9000
|
||||
@@ -51,8 +57,8 @@ services:
|
||||
auth:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-auth
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PORT:-9100}:9100"]
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-auth${DEPLOY_SLOT_SUFFIX:-}
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PUBLISHED_PORT:-${AUTH_PORT:-9100}}:9100"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
SERVER_PORT: 9100
|
||||
@@ -69,8 +75,8 @@ services:
|
||||
iam:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-iam
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PORT:-9201}:9201"]
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-iam${DEPLOY_SLOT_SUFFIX:-}
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PUBLISHED_PORT:-${IAM_PORT:-9201}}:9201"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
SERVER_PORT: 9201
|
||||
@@ -82,8 +88,8 @@ services:
|
||||
firstleg:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-business
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PORT:-9202}:9202"]
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-business${DEPLOY_SLOT_SUFFIX:-}
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PUBLISHED_PORT:-${FIRSTLEG_PORT:-9202}}:9202"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
SERVER_PORT: 9202
|
||||
@@ -115,8 +121,8 @@ services:
|
||||
finance:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-finance
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PORT:-9203}:9203"]
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-finance${DEPLOY_SLOT_SUFFIX:-}
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PUBLISHED_PORT:-${FINANCE_PORT:-9203}}:9203"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
SERVER_PORT: 9203
|
||||
@@ -128,8 +134,8 @@ services:
|
||||
order:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-order
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PORT:-9204}:9204"]
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-order${DEPLOY_SLOT_SUFFIX:-}
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PUBLISHED_PORT:-${ORDER_PORT:-9204}}:9204"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
SERVER_PORT: 9204
|
||||
@@ -140,8 +146,8 @@ services:
|
||||
warehouse:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PORT:-9205}:9205"]
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse${DEPLOY_SLOT_SUFFIX:-}
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PUBLISHED_PORT:-${WAREHOUSE_PORT:-9205}}:9205"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
SERVER_PORT: 9205
|
||||
@@ -152,8 +158,8 @@ services:
|
||||
file:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/file:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-file
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PORT:-9206}:9206"]
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-file${DEPLOY_SLOT_SUFFIX:-}
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PUBLISHED_PORT:-${FILE_PORT:-9206}}:9206"]
|
||||
volumes:
|
||||
- "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files"
|
||||
environment:
|
||||
@@ -176,8 +182,8 @@ services:
|
||||
print:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-print
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PORT:-9207}:9207"]
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-print${DEPLOY_SLOT_SUFFIX:-}
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PUBLISHED_PORT:-${PRINT_PORT:-9207}}:9207"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
SERVER_PORT: 9207
|
||||
@@ -186,8 +192,8 @@ services:
|
||||
integration-service:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-integration
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PORT:-9210}:9210"]
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-integration${DEPLOY_SLOT_SUFFIX:-}
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PUBLISHED_PORT:-${INTEGRATION_PORT:-9210}}:9210"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
SERVER_PORT: 9210
|
||||
@@ -235,8 +241,8 @@ services:
|
||||
event-bridge-service:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PORT:-9312}:9312"]
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge${DEPLOY_SLOT_SUFFIX:-}
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PUBLISHED_PORT:-${EVENT_BRIDGE_PORT:-9312}}:9312"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
SERVER_PORT: 9312
|
||||
@@ -256,7 +262,7 @@ services:
|
||||
audit-service:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/audit-service:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-audit
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-audit${DEPLOY_SLOT_SUFFIX:-}
|
||||
expose: ["9700"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -ne 7 ]; then
|
||||
echo "用法: $0 <环境> <镜像仓库> <镜像标签> <服务> <环境文件> <Compose文件> <项目名>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
DEPLOY_ENV="$1"
|
||||
IMAGE_REPOSITORY="$2"
|
||||
IMAGE_TAG="$3"
|
||||
SERVICE="$4"
|
||||
ENV_FILE="$5"
|
||||
COMPOSE_FILE="$6"
|
||||
BASE_PROJECT="$7"
|
||||
|
||||
case "$DEPLOY_ENV" in
|
||||
test|production) ;;
|
||||
*) echo "蓝绿执行器仅用于 test/production,当前: $DEPLOY_ENV" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
for file in "$ENV_FILE" "$COMPOSE_FILE"; do
|
||||
[ -f "$file" ] || { echo "必需文件不存在: $file" >&2; exit 2; }
|
||||
done
|
||||
|
||||
case "$SERVICE" in
|
||||
*[!A-Za-z0-9_-]*|'') echo "非法服务名: $SERVICE" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
env_value() {
|
||||
sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'
|
||||
}
|
||||
|
||||
service_contract() {
|
||||
case "$SERVICE:$COMPOSE_FILE" in
|
||||
gateway:*) echo "gateway|9000|9000|gateway|gateway-active" ;;
|
||||
auth:*) echo "auth|9100|9100|auth-service|auth" ;;
|
||||
iam:*) echo "iam|9201|9201|iam-service|iam" ;;
|
||||
firstleg:*) echo "business|9202|9202|firstleg-service|firstleg" ;;
|
||||
finance:*) echo "finance|9203|9203|finance-service|finance" ;;
|
||||
order:*) echo "order|9204|9204|order-service|order" ;;
|
||||
warehouse:*) echo "warehouse|9205|9205|warehouse-service|warehouse" ;;
|
||||
file:*) echo "file|9206|9206|file-service|file" ;;
|
||||
print:*) echo "print|9207|9207|print-service|print" ;;
|
||||
integration-service:*) echo "integration|9210|9210|integration-service|integration-service" ;;
|
||||
audit-service:*) echo "audit|9700|9700|audit-service|audit-service" ;;
|
||||
gateway-overseas:*) echo "gateway-overseas|9300|9300|gateway-overseas|gateway-overseas-active" ;;
|
||||
lastleg-service:*) echo "lastleg|9303|9303|lastleg-service|lastleg-service" ;;
|
||||
warehouse-overseas-service:*) echo "warehouse-overseas|9305|9305|warehouse-overseas-service|warehouse-overseas-service" ;;
|
||||
event-bridge-service:*overseas*) echo "event-bridge-overseas|9312|9313|event-bridge-overseas|event-bridge-overseas" ;;
|
||||
event-bridge-service:*) echo "event-bridge|9312|9312|event-bridge-cn|event-bridge-cn" ;;
|
||||
*) echo "不支持蓝绿部署的服务: $SERVICE" >&2; exit 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
IFS='|' read -r CONTAINER_STEM INTERNAL_PORT BASE_PORT NACOS_SERVICE ROUTING_ALIAS <<EOF
|
||||
$(service_contract)
|
||||
EOF
|
||||
|
||||
STATE_ROOT="${BLUE_GREEN_STATE_ROOT:-/data/firstleg/deploy/blue-green-state}"
|
||||
STATE_KEY="${BASE_PROJECT}-${SERVICE}"
|
||||
STATE_FILE="$STATE_ROOT/$STATE_KEY.active"
|
||||
ROUTING_NETWORK="${BLUE_GREEN_ROUTING_NETWORK:-firstleg-${DEPLOY_ENV}-routing}"
|
||||
HEALTH_TIMEOUT="${DEPLOY_HEALTH_TIMEOUT_SECONDS:-240}"
|
||||
HEALTH_POLL="${DEPLOY_HEALTH_POLL_SECONDS:-5}"
|
||||
STABILIZATION_SECONDS="${BLUE_GREEN_STABILIZATION_SECONDS:-10}"
|
||||
STOP_TIMEOUT="${BLUE_GREEN_STOP_TIMEOUT_SECONDS:-45}"
|
||||
IMAGE="$IMAGE_REPOSITORY/$SERVICE:$IMAGE_TAG"
|
||||
|
||||
for value in "$HEALTH_TIMEOUT" "$HEALTH_POLL" "$STABILIZATION_SECONDS" "$STOP_TIMEOUT"; do
|
||||
case "$value" in ''|*[!0-9]*) echo "蓝绿时间参数必须为非负整数: $value" >&2; exit 2 ;; esac
|
||||
done
|
||||
|
||||
mkdir -p "$STATE_ROOT"
|
||||
chmod 750 "$STATE_ROOT"
|
||||
docker network inspect "$ROUTING_NETWORK" >/dev/null 2>&1 || docker network create "$ROUTING_NETWORK" >/dev/null
|
||||
|
||||
legacy_container="firstleg-${DEPLOY_ENV}-${CONTAINER_STEM}"
|
||||
active_slot=""
|
||||
active_container=""
|
||||
active_port=""
|
||||
active_worker=""
|
||||
if [ -s "$STATE_FILE" ]; then
|
||||
IFS='|' read -r active_slot active_container active_port active_image active_worker < "$STATE_FILE"
|
||||
elif docker inspect "$legacy_container" >/dev/null 2>&1; then
|
||||
active_slot="legacy"
|
||||
active_container="$legacy_container"
|
||||
active_port="$BASE_PORT"
|
||||
fi
|
||||
|
||||
if [ "$active_slot" = "blue" ]; then
|
||||
target_slot="green"
|
||||
else
|
||||
target_slot="blue"
|
||||
[ "$active_slot" = "legacy" ] && target_slot="green"
|
||||
fi
|
||||
|
||||
if [ "$target_slot" = "blue" ]; then
|
||||
target_port="$BASE_PORT"
|
||||
else
|
||||
target_port=$((BASE_PORT + 10000))
|
||||
fi
|
||||
candidate="firstleg-${DEPLOY_ENV}-${CONTAINER_STEM}-${target_slot}"
|
||||
worker_candidate="${candidate}-worker"
|
||||
|
||||
if docker inspect "$candidate" >/dev/null 2>&1; then
|
||||
if [ "$candidate" = "$active_container" ]; then
|
||||
echo "活动槽状态异常,候选容器与活动容器相同: $candidate" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker rm -f "$candidate" >/dev/null
|
||||
fi
|
||||
|
||||
compose() {
|
||||
docker compose --project-name "$BASE_PROJECT" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
|
||||
}
|
||||
|
||||
container_health() {
|
||||
container="$1"
|
||||
docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \
|
||||
"$container" 2>/dev/null || echo missing
|
||||
}
|
||||
|
||||
cleanup_candidate() {
|
||||
docker rm -f "$candidate" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
wait_for_container() {
|
||||
container="$1"
|
||||
label="$2"
|
||||
elapsed=0
|
||||
while [ "$elapsed" -lt "$HEALTH_TIMEOUT" ]; do
|
||||
state="$(container_health "$container")"
|
||||
echo "$label 健康状态: $state(已等待 ${elapsed}s)"
|
||||
case "$state" in
|
||||
healthy) return 0 ;;
|
||||
unhealthy|exited|dead|missing) return 1 ;;
|
||||
esac
|
||||
sleep "$HEALTH_POLL"
|
||||
elapsed=$((elapsed + HEALTH_POLL))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
needs_worker() {
|
||||
case "$SERVICE" in
|
||||
iam|firstleg|finance|order|event-bridge-service|lastleg-service) return 0 ;;
|
||||
file)
|
||||
[ "$(env_value FILE_EXPORT_CONSUMER_ENABLED)" = true ] \
|
||||
|| [ "$(env_value FILE_EXPORT_PUBLISHER_ENABLED)" = true ] \
|
||||
|| [ "$(env_value FILE_EXPORT_RECONCILER_ENABLED)" = true ] \
|
||||
|| [ "$(env_value AUDIT_OUTBOX_PUBLISHER_ENABLED)" = true ]
|
||||
;;
|
||||
audit-service)
|
||||
[ "$(env_value AUDIT_MQ_CONSUMER_ENABLED)" = true ] \
|
||||
|| [ "$(env_value AUDIT_DLQ_CONSUMER_ENABLED)" = true ] \
|
||||
|| [ "$(env_value AUDIT_DLQ_COMPENSATION_ENABLED)" = true ]
|
||||
;;
|
||||
auth|warehouse|warehouse-overseas-service)
|
||||
[ "$(env_value AUDIT_OUTBOX_PUBLISHER_ENABLED)" = true ]
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
start_worker_candidate() {
|
||||
docker rm -f "$worker_candidate" >/dev/null 2>&1 || true
|
||||
echo "启动单例 Worker: $worker_candidate"
|
||||
if [ "$SERVICE" = file ]; then
|
||||
worker_java_options="-Xms512m -Xmx2g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
|
||||
else
|
||||
worker_java_options="-Xms256m -Xmx1g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
|
||||
fi
|
||||
if ! compose run -d --no-deps --name "$worker_candidate" \
|
||||
-e "NACOS_REGISTER_ENABLED=false" \
|
||||
-e "BAIYUE_RUNTIME_SCHEDULING_ENABLED=true" \
|
||||
-e "BAIYUE_DEPLOY_SLOT=${target_slot}-worker" \
|
||||
-e "JAVA_TOOL_OPTIONS=$worker_java_options" \
|
||||
-e "BAIYUE_RELEASE_ID=$IMAGE_TAG" "$SERVICE" >/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
docker network connect "$ROUTING_NETWORK" "$worker_candidate"
|
||||
if ! wait_for_container "$worker_candidate" "候选 Worker"; then
|
||||
docker logs --tail 200 "$worker_candidate" 2>&1 || true
|
||||
docker rm -f "$worker_candidate" >/dev/null 2>&1 || true
|
||||
return 1
|
||||
fi
|
||||
worker_image="$(docker inspect --format '{{.Config.Image}}' "$worker_candidate")"
|
||||
[ "$worker_image" = "$IMAGE" ] || return 1
|
||||
}
|
||||
|
||||
connect_routing_aliases() {
|
||||
container="$1"
|
||||
shift
|
||||
docker network inspect "$ROUTING_NETWORK" --format '{{json .Containers}}' | grep -q "\"$container\"" \
|
||||
&& return 0
|
||||
set -- docker network connect $(for alias_name in "$@"; do printf '%s\n' --alias "$alias_name"; done)
|
||||
"$@" "$ROUTING_NETWORK" "$container"
|
||||
}
|
||||
|
||||
bootstrap_legacy_routes() {
|
||||
for pair in \
|
||||
gateway-active,gateway:gateway auth,auth-service:auth iam,iam-service:iam \
|
||||
firstleg,firstleg-service:business finance,finance-service:finance order,order-service:order \
|
||||
warehouse,warehouse-service:warehouse file,file-service:file print,print-service:print \
|
||||
integration-service:integration audit-service:audit \
|
||||
gateway-overseas-active,gateway-overseas:gateway-overseas \
|
||||
lastleg-service:lastleg warehouse-overseas-service:warehouse-overseas \
|
||||
event-bridge-cn:event-bridge event-bridge-overseas:event-bridge-overseas
|
||||
do
|
||||
aliases="${pair%%:*}"
|
||||
stem="${pair#*:}"
|
||||
container="firstleg-${DEPLOY_ENV}-${stem}"
|
||||
if docker inspect "$container" >/dev/null 2>&1; then
|
||||
connect_routing_aliases "$container" $(printf '%s' "$aliases" | tr ',' ' ') || true
|
||||
fi
|
||||
done
|
||||
for container in firstleg-prod-admin-web firstleg-prod-client-web; do
|
||||
if docker inspect "$container" >/dev/null 2>&1; then
|
||||
connect_routing_aliases "$container" "${container##*-}" || true
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
switch_gateway_proxy() {
|
||||
[ "$SERVICE" = "gateway" ] || [ "$SERVICE" = "gateway-overseas" ] || return 0
|
||||
if [ "$DEPLOY_ENV" = test ]; then
|
||||
echo "测试 Gateway 仍由固定入口承载,不在后端蓝绿执行器中切换" >&2
|
||||
return 1
|
||||
fi
|
||||
if [ "$SERVICE" = "gateway" ]; then required_alias=gateway-active; else required_alias=gateway-overseas-active; fi
|
||||
for frontend_service in admin-web client-web; do
|
||||
frontend_state="$STATE_ROOT/frontend-$frontend_service.active"
|
||||
if [ ! -s "$frontend_state" ]; then
|
||||
echo "$frontend_service 尚未完成首次蓝绿迁移,拒绝切换 Gateway" >&2
|
||||
return 1
|
||||
fi
|
||||
IFS='|' read -r frontend_slot frontend frontend_port frontend_image < "$frontend_state"
|
||||
if ! docker inspect "$frontend" >/dev/null 2>&1 \
|
||||
|| ! docker exec "$frontend" nginx -T 2>&1 | grep -q "$required_alias"; then
|
||||
echo "$frontend 尚未使用稳定上游 $required_alias,拒绝切换 Gateway" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
if [ "$SERVICE" = "gateway" ]; then
|
||||
config="$(env_value GATEWAY_BLUE_GREEN_NGINX_CONFIG)"
|
||||
config="${config:-/www/server/panel/vhost/nginx/api-cn.buggoglobal.com.conf}"
|
||||
else
|
||||
config="$(env_value GATEWAY_OVERSEAS_BLUE_GREEN_NGINX_CONFIG)"
|
||||
config="${config:-/www/server/panel/vhost/nginx/api-global.buggoglobal.com.conf}"
|
||||
fi
|
||||
if [ -z "$config" ] || [ ! -f "$config" ]; then
|
||||
echo "$SERVICE 蓝绿切换要求环境文件配置有效的 Nginx 配置路径" >&2
|
||||
return 1
|
||||
fi
|
||||
backup="$config.blue-green-backup"
|
||||
cp -f "$config" "$backup"
|
||||
if ! sed -i "s#127\.0\.0\.1:${active_port:-$BASE_PORT}#127.0.0.1:$target_port#g" "$config" \
|
||||
|| cmp -s "$config" "$backup"; then
|
||||
cp -f "$backup" "$config"
|
||||
echo "未能在 Nginx 配置中切换 $SERVICE 上游端口" >&2
|
||||
return 1
|
||||
fi
|
||||
if ! nginx -t || ! nginx -s reload; then
|
||||
cp -f "$backup" "$config"
|
||||
nginx -t && nginx -s reload || true
|
||||
return 1
|
||||
fi
|
||||
rm -f "$backup"
|
||||
}
|
||||
|
||||
reload_frontend_proxies() {
|
||||
[ "$SERVICE" = "gateway" ] || [ "$SERVICE" = "gateway-overseas" ] || return 0
|
||||
[ "$DEPLOY_ENV" = production ] || return 0
|
||||
for frontend_service in admin-web client-web; do
|
||||
frontend_state="$STATE_ROOT/frontend-$frontend_service.active"
|
||||
[ -s "$frontend_state" ] || return 1
|
||||
IFS='|' read -r frontend_slot frontend frontend_port frontend_image < "$frontend_state"
|
||||
docker inspect "$frontend" >/dev/null 2>&1 || return 1
|
||||
docker exec "$frontend" nginx -t
|
||||
docker exec "$frontend" nginx -s reload
|
||||
done
|
||||
}
|
||||
|
||||
rollback_gateway_proxy() {
|
||||
[ "$SERVICE" = "gateway" ] || [ "$SERVICE" = "gateway-overseas" ] || return 0
|
||||
[ "$DEPLOY_ENV" = production ] || return 0
|
||||
if [ "$SERVICE" = "gateway" ]; then
|
||||
config="$(env_value GATEWAY_BLUE_GREEN_NGINX_CONFIG)"
|
||||
config="${config:-/www/server/panel/vhost/nginx/api-cn.buggoglobal.com.conf}"
|
||||
else
|
||||
config="$(env_value GATEWAY_OVERSEAS_BLUE_GREEN_NGINX_CONFIG)"
|
||||
config="${config:-/www/server/panel/vhost/nginx/api-global.buggoglobal.com.conf}"
|
||||
fi
|
||||
old_port="${active_port:-$BASE_PORT}"
|
||||
sed -i "s#127\.0\.0\.1:$target_port#127.0.0.1:$old_port#g" "$config"
|
||||
nginx -t && nginx -s reload
|
||||
}
|
||||
|
||||
bootstrap_legacy_routes
|
||||
|
||||
export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG
|
||||
echo "启动候选槽: service=$SERVICE slot=$target_slot port=$target_port image=$IMAGE"
|
||||
if ! compose run -d --no-deps --name "$candidate" \
|
||||
-e "BAIYUE_DEPLOY_SLOT=$target_slot" -e "BAIYUE_RELEASE_ID=$IMAGE_TAG" \
|
||||
-e "BAIYUE_RUNTIME_SCHEDULING_ENABLED=false" \
|
||||
-e "AUDIT_OUTBOX_PUBLISHER_ENABLED=false" \
|
||||
-e "NOTIFICATION_SCHEDULER_ENABLED=false" \
|
||||
-e "NOTIFICATION_MQ_CONSUMER_ENABLED=false" \
|
||||
-e "FILE_EXPORT_CONSUMER_ENABLED=false" \
|
||||
-e "FILE_EXPORT_PUBLISHER_ENABLED=false" \
|
||||
-e "FILE_EXPORT_RECONCILER_ENABLED=false" \
|
||||
-e "AUDIT_MQ_CONSUMER_ENABLED=false" \
|
||||
-e "AUDIT_DLQ_CONSUMER_ENABLED=false" \
|
||||
-e "AUDIT_DLQ_COMPENSATION_ENABLED=false" \
|
||||
-p "${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:$target_port:$INTERNAL_PORT" "$SERVICE" >/dev/null; then
|
||||
cleanup_candidate
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! wait_for_container "$candidate" "候选 Web 槽 $target_slot"; then
|
||||
echo "候选槽未通过健康检查,保留旧实例" >&2
|
||||
docker logs --tail 200 "$candidate" 2>&1 || true
|
||||
cleanup_candidate
|
||||
exit 1
|
||||
fi
|
||||
|
||||
running_image="$(docker inspect --format '{{.Config.Image}}' "$candidate")"
|
||||
if [ "$running_image" != "$IMAGE" ]; then
|
||||
echo "候选镜像不一致: expected=$IMAGE actual=$running_image" >&2
|
||||
cleanup_candidate
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$NACOS_SERVICE" = "$ROUTING_ALIAS" ]; then
|
||||
connect_routing_aliases "$candidate" "$ROUTING_ALIAS"
|
||||
else
|
||||
connect_routing_aliases "$candidate" "$ROUTING_ALIAS" "$NACOS_SERVICE"
|
||||
fi
|
||||
|
||||
if [ "$STABILIZATION_SECONDS" -gt 0 ]; then
|
||||
echo "候选槽稳定观察 ${STABILIZATION_SECONDS}s"
|
||||
sleep "$STABILIZATION_SECONDS"
|
||||
[ "$(container_health "$candidate")" = "healthy" ] || { cleanup_candidate; exit 1; }
|
||||
fi
|
||||
|
||||
if ! switch_gateway_proxy; then
|
||||
cleanup_candidate
|
||||
exit 1
|
||||
fi
|
||||
|
||||
worker_required=false
|
||||
if needs_worker; then worker_required=true; fi
|
||||
|
||||
if [ "$worker_required" = true ] && [ "$active_slot" != legacy ]; then
|
||||
if [ -n "$active_worker" ] && docker inspect "$active_worker" >/dev/null 2>&1; then
|
||||
echo "暂停旧 Worker,避免新旧任务并行: $active_worker"
|
||||
docker stop --time "$STOP_TIMEOUT" "$active_worker" >/dev/null
|
||||
fi
|
||||
if ! start_worker_candidate; then
|
||||
[ -z "$active_worker" ] || docker start "$active_worker" >/dev/null 2>&1 || true
|
||||
rollback_gateway_proxy || true
|
||||
cleanup_candidate
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
old_container_stopped=false
|
||||
if [ -n "$active_container" ] && docker inspect "$active_container" >/dev/null 2>&1; then
|
||||
echo "优雅停止旧 Web 实例: $active_container"
|
||||
docker stop --time "$STOP_TIMEOUT" "$active_container" >/dev/null
|
||||
old_container_stopped=true
|
||||
fi
|
||||
|
||||
if [ "$worker_required" = true ] && [ "$active_slot" = legacy ]; then
|
||||
if ! start_worker_candidate; then
|
||||
[ "$old_container_stopped" = false ] || docker start "$active_container" >/dev/null 2>&1 || true
|
||||
rollback_gateway_proxy || true
|
||||
cleanup_candidate
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! reload_frontend_proxies; then
|
||||
[ "$old_container_stopped" = false ] || docker start "$active_container" >/dev/null 2>&1 || true
|
||||
if [ "$worker_required" = true ]; then
|
||||
docker rm -f "$worker_candidate" >/dev/null 2>&1 || true
|
||||
[ -z "$active_worker" ] || docker start "$active_worker" >/dev/null 2>&1 || true
|
||||
fi
|
||||
rollback_gateway_proxy || true
|
||||
cleanup_candidate
|
||||
reload_frontend_proxies || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[ -z "$active_worker" ] || [ "$active_worker" = "$worker_candidate" ] \
|
||||
|| docker rm "$active_worker" >/dev/null 2>&1 || true
|
||||
[ "$old_container_stopped" = false ] || docker rm "$active_container" >/dev/null
|
||||
|
||||
if [ "$worker_required" = true ]; then
|
||||
next_worker="$worker_candidate"
|
||||
else
|
||||
next_worker=""
|
||||
fi
|
||||
printf '%s|%s|%s|%s|%s\n' "$target_slot" "$candidate" "$target_port" "$IMAGE" "$next_worker" > "$STATE_FILE.tmp"
|
||||
chmod 640 "$STATE_FILE.tmp"
|
||||
mv -f "$STATE_FILE.tmp" "$STATE_FILE"
|
||||
echo "蓝绿切换完成: service=$SERVICE active=$target_slot container=$candidate port=$target_port"
|
||||
@@ -348,6 +348,17 @@ deploy_local() {
|
||||
|
||||
if [ "${TARGET_IS_LOCAL:-false}" = "true" ]; then
|
||||
echo "镜像已在目标服务器本机,直接部署: $IMAGE"
|
||||
if [ "$DEPLOY_ENV" = "production" ] \
|
||||
|| { [ "$DEPLOY_ENV" = "test" ] && [ "$SERVICE" != "gateway" ] && [ "$SERVICE" != "gateway-overseas" ]; }; then
|
||||
printf '%s' "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
|
||||
trap local_registry_logout EXIT HUP INT TERM
|
||||
docker pull "$IMAGE"
|
||||
sh deploy/scripts/deploy-blue-green-service.sh \
|
||||
"$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" "$ENV_FILE" "$COMPOSE_FILE" "$PROJECT_NAME"
|
||||
local_registry_logout
|
||||
trap - EXIT HUP INT TERM
|
||||
exit 0
|
||||
fi
|
||||
deploy_local
|
||||
exit 0
|
||||
fi
|
||||
@@ -389,20 +400,28 @@ run_ssh install -d -m 750 "$REMOTE_DIR"
|
||||
SUFFIX="${IMAGE_TAG}.$$.${SERVICE}"
|
||||
REMOTE_ENV_NAME=".env.$PROJECT_NAME"
|
||||
REMOTE_COMPOSE_NAME="docker-compose.$PROJECT_NAME.yml"
|
||||
REMOTE_BLUE_GREEN_NAME="deploy-blue-green-service.sh"
|
||||
REMOTE_ENV_TMP="$REMOTE_DIR/$REMOTE_ENV_NAME.$SUFFIX.tmp"
|
||||
REMOTE_COMPOSE_TMP="$REMOTE_DIR/$REMOTE_COMPOSE_NAME.$SUFFIX.tmp"
|
||||
REMOTE_BLUE_GREEN_TMP="$REMOTE_DIR/$REMOTE_BLUE_GREEN_NAME.$SUFFIX.tmp"
|
||||
run_scp "$ENV_FILE" "$SSH_TARGET:$REMOTE_ENV_TMP"
|
||||
run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE_TMP"
|
||||
run_ssh sh -s -- "$REMOTE_ENV_TMP" "$REMOTE_COMPOSE_TMP" "$REMOTE_DIR/$REMOTE_ENV_NAME" "$REMOTE_DIR/$REMOTE_COMPOSE_NAME" <<'REMOTE_PREPARE'
|
||||
run_scp "deploy/scripts/deploy-blue-green-service.sh" "$SSH_TARGET:$REMOTE_BLUE_GREEN_TMP"
|
||||
run_ssh sh -s -- "$REMOTE_ENV_TMP" "$REMOTE_COMPOSE_TMP" "$REMOTE_BLUE_GREEN_TMP" \
|
||||
"$REMOTE_DIR/$REMOTE_ENV_NAME" "$REMOTE_DIR/$REMOTE_COMPOSE_NAME" "$REMOTE_DIR/$REMOTE_BLUE_GREEN_NAME" <<'REMOTE_PREPARE'
|
||||
set -eu
|
||||
env_tmp="$1"
|
||||
compose_tmp="$2"
|
||||
env_target="$3"
|
||||
compose_target="$4"
|
||||
blue_green_tmp="$3"
|
||||
env_target="$4"
|
||||
compose_target="$5"
|
||||
blue_green_target="$6"
|
||||
chmod 600 "$env_tmp"
|
||||
chmod 644 "$compose_tmp"
|
||||
chmod 750 "$blue_green_tmp"
|
||||
mv -f "$env_tmp" "$env_target"
|
||||
mv -f "$compose_tmp" "$compose_target"
|
||||
mv -f "$blue_green_tmp" "$blue_green_target"
|
||||
REMOTE_PREPARE
|
||||
|
||||
remote_registry_logout() {
|
||||
@@ -414,6 +433,17 @@ printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --pa
|
||||
trap remote_registry_logout EXIT HUP INT TERM
|
||||
run_ssh docker pull "$IMAGE"
|
||||
|
||||
if [ "$DEPLOY_ENV" = "production" ] \
|
||||
|| { [ "$DEPLOY_ENV" = "test" ] && [ "$SERVICE" != "gateway" ] && [ "$SERVICE" != "gateway-overseas" ]; }; then
|
||||
echo "$DEPLOY_ENV 服务使用蓝绿执行器,旧实例在候选槽健康前保持运行"
|
||||
run_ssh sh "$REMOTE_DIR/$REMOTE_BLUE_GREEN_NAME" \
|
||||
"$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" \
|
||||
"$REMOTE_DIR/$REMOTE_ENV_NAME" "$REMOTE_DIR/$REMOTE_COMPOSE_NAME" "$PROJECT_NAME"
|
||||
remote_registry_logout
|
||||
trap - EXIT HUP INT TERM
|
||||
exit 0
|
||||
fi
|
||||
|
||||
run_ssh sh -s -- "$REMOTE_DIR" "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" "$PROJECT_NAME" "$REMOTE_ENV_NAME" "$REMOTE_COMPOSE_NAME" "$HEALTH_TIMEOUT_SECONDS" "$HEALTH_POLL_SECONDS" <<'REMOTE_DEPLOY'
|
||||
set -eu
|
||||
remote_dir="$1"
|
||||
|
||||
@@ -188,6 +188,24 @@ if [ "$DEPLOY_ENV" = "development" ]; then
|
||||
"$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$ENV_FILE"
|
||||
fi
|
||||
|
||||
if [ "$DEPLOY_ENV" = "test" ] || [ "$DEPLOY_ENV" = "production" ]; then
|
||||
docker compose \
|
||||
--project-name "$PROJECT_NAME" \
|
||||
--env-file "$ENV_FILE" \
|
||||
-f "$COMPOSE_FILE" \
|
||||
pull $SERVICES
|
||||
for service in $SERVICES; do
|
||||
if [ "$DEPLOY_ENV" = test ] && { [ "$service" = gateway ] || [ "$service" = gateway-overseas ]; }; then
|
||||
docker compose --project-name "$PROJECT_NAME" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" \
|
||||
up -d --no-deps --pull never "$service"
|
||||
continue
|
||||
fi
|
||||
sh deploy/scripts/deploy-blue-green-service.sh \
|
||||
"$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$service" "$ENV_FILE" "$COMPOSE_FILE" "$PROJECT_NAME"
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
|
||||
docker compose \
|
||||
--project-name "$PROJECT_NAME" \
|
||||
--env-file "$ENV_FILE" \
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Region: CN
|
||||
-- Schema: baiyue_iam
|
||||
-- Purpose: 补齐 PDA 版本页面和发布接口使用的权限资源。
|
||||
-- Preconditions: 连接必须指向 baiyue_iam;不修改角色、角色授权和数据范围。
|
||||
-- Postconditions: 两条 PDA 权限资源存在,权限定义与当前初始化基线一致。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
INSERT INTO iam_permission (
|
||||
id, tenant_id, app_code, permission_type, parent_id, permission_code,
|
||||
permission_name, route_path, http_method, sort_no, status, built_in,
|
||||
created_at, updated_at, deleted
|
||||
)
|
||||
SELECT 5101060, 0, 'PLATFORM', 'MENU', 5101000, 'platform:pda-release:list',
|
||||
'PDA版本', '/system/pda-releases', NULL, 60, 'ACTIVE', 1,
|
||||
CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3), 0
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM iam_permission WHERE tenant_id = 0 AND permission_code = 'platform:pda-release:list'
|
||||
);
|
||||
|
||||
INSERT INTO iam_permission (
|
||||
id, tenant_id, app_code, permission_type, parent_id, permission_code,
|
||||
permission_name, route_path, http_method, sort_no, status, built_in,
|
||||
created_at, updated_at, deleted
|
||||
)
|
||||
SELECT 5101061, 0, 'PLATFORM', 'BUTTON', 5101060, 'platform:pda-release:manage',
|
||||
'发布PDA版本', NULL, NULL, 10, 'ACTIVE', 1,
|
||||
CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3), 0
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM iam_permission WHERE tenant_id = 0 AND permission_code = 'platform:pda-release:manage'
|
||||
);
|
||||
@@ -7735,4 +7735,4 @@ USE baiyue_finance;
|
||||
-- 系统基线 fin_subject:1 行。
|
||||
INSERT INTO fin_subject (id, subject_code, subject_name, status, version, created_by, updated_by, created_at, updated_at) VALUES (1,'PLATFORM_DEFAULT','平台','ACTIVE',0,0,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3));
|
||||
|
||||
SELECT 'PASS' AS initialization_status, 'CN' AS region, 208 AS initialized_tables;
|
||||
SELECT 'PASS' AS initialization_status, 'CN' AS region, 210 AS initialized_tables;
|
||||
|
||||
@@ -6,14 +6,12 @@ import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 跨区域事件桥启动入口。
|
||||
*/
|
||||
@MapperScan(basePackages = "com.baiyue.eventbridge", annotationClass = Mapper.class)
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
@EnableConfigurationProperties(EventDispatcherProperties.class)
|
||||
public class EventBridgeApplication {
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -23,6 +23,8 @@ spring:
|
||||
username: ${NACOS_USERNAME:nacos}
|
||||
password: ${NACOS_PASSWORD:nacos}
|
||||
discovery:
|
||||
enabled: ${NACOS_DISCOVERY_ENABLED:true}
|
||||
register-enabled: ${NACOS_REGISTER_ENABLED:true}
|
||||
group: ${NACOS_GROUP:BAIYUE}
|
||||
namespace: ${NACOS_NAMESPACE:}
|
||||
config:
|
||||
|
||||
@@ -5,7 +5,6 @@ import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 文件中心服务启动入口。
|
||||
@@ -13,7 +12,6 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
@MapperScan(basePackages = "com.baiyue.file", annotationClass = Mapper.class)
|
||||
@EnableFeignClients(basePackages = "com.baiyue.file.client")
|
||||
@EnableScheduling
|
||||
public class FileServiceApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(FileServiceApplication.class, args);
|
||||
|
||||
@@ -5,13 +5,11 @@ import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/** Finance 独立服务启动入口。首期只承载人工账单、收款和成本台账。 */
|
||||
@SpringBootApplication
|
||||
@MapperScan(basePackages = "com.baiyue.finance", annotationClass = Mapper.class)
|
||||
@EnableFeignClients(basePackages = "com.baiyue.finance.client")
|
||||
@EnableScheduling
|
||||
public class BaiyueFinanceApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -12,7 +12,9 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
class FinanceContractTest {
|
||||
@Test
|
||||
void applicationEnablesReliableOutboxScheduling() {
|
||||
assertNotNull(BaiyueFinanceApplication.class.getAnnotation(org.springframework.scheduling.annotation.EnableScheduling.class));
|
||||
assertNull(BaiyueFinanceApplication.class.getAnnotation(org.springframework.scheduling.annotation.EnableScheduling.class));
|
||||
assertNotNull(com.baiyue.common.web.BaiyueSchedulingAutoConfiguration.class
|
||||
.getAnnotation(org.springframework.scheduling.annotation.EnableScheduling.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -5,14 +5,12 @@ import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。
|
||||
*/
|
||||
@MapperScan(basePackages = "com.baiyue.firstleg", annotationClass = Mapper.class)
|
||||
@EnableFeignClients(basePackages = "com.baiyue.firstleg")
|
||||
@EnableScheduling
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
public class BaiyueFirstlegApplication {
|
||||
public static void main(String[] args) { SpringApplication.run(BaiyueFirstlegApplication.class, args); }
|
||||
|
||||
@@ -5,14 +5,12 @@ import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 身份与权限服务启动入口,负责装配租户、成员、双端 RBAC 和授权快照相关组件。
|
||||
*/
|
||||
@MapperScan(basePackages = "com.baiyue.iam", annotationClass = Mapper.class)
|
||||
@EnableFeignClients(basePackages = "com.baiyue.iam.client")
|
||||
@EnableScheduling
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
public class BaiyueIamApplication {
|
||||
public static void main(String[] args) { SpringApplication.run(BaiyueIamApplication.class, args); }
|
||||
|
||||
@@ -5,14 +5,12 @@ import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 尾程微服务预留模块启动入口,初始化 Spring Boot 应用并扫描尾程 Mapper。
|
||||
*/
|
||||
@MapperScan(basePackages = "com.baiyue.lastleg", annotationClass = Mapper.class)
|
||||
@EnableFeignClients(basePackages = "com.baiyue.lastleg")
|
||||
@EnableScheduling
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
public class BaiyueLastlegApplication {
|
||||
public static void main(String[] args) { SpringApplication.run(BaiyueLastlegApplication.class, args); }
|
||||
|
||||
@@ -5,14 +5,12 @@ import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 订单微服务启动入口,负责装配订单领域组件并扫描 MyBatis-Plus Mapper。
|
||||
*/
|
||||
@MapperScan(basePackages = "com.baiyue.order", annotationClass = Mapper.class)
|
||||
@EnableFeignClients(basePackages = "com.baiyue.order.client")
|
||||
@EnableScheduling
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
public class BaiyueOrderApplication {
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -40,7 +40,8 @@ test('booking supplier fixtures keep ACTIVE and BOOKING but never invent a geogr
|
||||
financeFixture.indexOf('async function createOrder'))
|
||||
assert.match(supplierBody, /category,/)
|
||||
assert.match(supplierBody, /status: 'ACTIVE'/)
|
||||
assert.doesNotMatch(supplierBody, /scope|DOMESTIC|INTERNATIONAL/)
|
||||
assert.match(supplierBody, /truckingScope: category === 'TRUCKING' \? 'DOMESTIC' : null/)
|
||||
assert.doesNotMatch(supplierBody, /bookingScope|category === 'BOOKING' \? 'DOMESTIC'/)
|
||||
})
|
||||
|
||||
test('Warehouse supplier contract filters by active category without scope in query, save or snapshot', () => {
|
||||
@@ -54,7 +55,9 @@ test('Firstleg booking supplier snapshot requires ACTIVE BOOKING and has no dome
|
||||
assert.match(firstlegSnapshot, /return resolve\(supplierId, suppliedName, "BOOKING"\)/)
|
||||
assert.match(firstlegSnapshot, /!"ACTIVE"\.equals\(response\.data\(\)\.status\(\)\)/)
|
||||
assert.match(firstlegSnapshot, /!category\.equals\(response\.data\(\)\.category\(\)\)/)
|
||||
assert.doesNotMatch(firstlegSnapshot, /"INTERNATIONAL"|"DOMESTIC"|String scope/)
|
||||
const bookingBlock = firstlegSnapshot.slice(firstlegSnapshot.indexOf('public WarehouseSupplierClient.Snapshot booking('),
|
||||
firstlegSnapshot.indexOf('public WarehouseSupplierClient.Snapshot domesticTrucking('))
|
||||
assert.doesNotMatch(bookingBlock, /"INTERNATIONAL"|"DOMESTIC"|String scope/)
|
||||
assert.doesNotMatch(firstlegClient, /String scope|String category,\s*String scope/s)
|
||||
})
|
||||
|
||||
@@ -88,10 +91,10 @@ test('supplier DTO, service and HTTP acceptance freeze normalized multi-carrier
|
||||
|
||||
test('normalized relation table and lookup index exist in module baseline and unified SQL', () => {
|
||||
for (const sql of [supplierSchema, unifiedSql]) {
|
||||
assert.match(sql, /CREATE TABLE IF NOT EXISTS wh_supplier_carrier/)
|
||||
assert.match(sql, /CREATE TABLE(?: IF NOT EXISTS)? wh_supplier_carrier/)
|
||||
assert.match(sql, /PRIMARY KEY\s*\(?\s*supplier_id\s*,\s*carrier_code\s*\)?/i)
|
||||
assert.match(sql, /idx_wh_supplier_carrier_code\s*\(?\s*tenant_id\s*,\s*carrier_code\s*,\s*supplier_id\s*\)?/i)
|
||||
assert.match(sql, /carrier_code IN\s*\(?\s*'MATSON'\s*,\s*'SML'\s*,\s*'ZIM'\s*,\s*'COSCO'\s*,\s*'EMC'\s*,\s*'HEDE'\s*,\s*'OTHER'\s*\)?/i)
|
||||
assert.match(sql, /carrier_code IN\s*\(?\s*(?:_utf8mb4)?'MATSON'\s*,\s*(?:_utf8mb4)?'SML'\s*,\s*(?:_utf8mb4)?'ZIM'\s*,\s*(?:_utf8mb4)?'COSCO'\s*,\s*(?:_utf8mb4)?'EMC'\s*,\s*(?:_utf8mb4)?'HEDE'\s*,\s*(?:_utf8mb4)?'OTHER'\s*\)?/i)
|
||||
}
|
||||
assert.match(supplierMapper, /FROM wh_supplier_carrier[\s\S]*tenant_id=#\{tenantId\} AND carrier_code=#\{carrierCode\}/)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { replayCompletedCommand } from './phase3-loading-finalize.mjs'
|
||||
|
||||
test('completed-command replay retries one connection failure and keeps its diagnostic', async () => {
|
||||
const failure = { http: 0, raw: 'fetch failed' }
|
||||
const success = { http: 200, body: { code: '200' } }
|
||||
const responses = [failure, success]
|
||||
const diagnostics = []
|
||||
let calls = 0
|
||||
assert.equal(await replayCompletedCommand(async () => { calls++; return responses.shift() }, r => diagnostics.push(r)), success)
|
||||
assert.equal(calls, 2)
|
||||
assert.deepEqual(diagnostics, [failure])
|
||||
})
|
||||
test('business failures are never retried; a second connection failure remains failure', async () => {
|
||||
for (const response of [{ http: 200, body: { code: 'A0409' } }, { http: 503 }, { http: 0 }]) {
|
||||
let calls = 0
|
||||
const result = await replayCompletedCommand(async () => { calls++; return response }, () => {})
|
||||
assert.equal(result, response)
|
||||
assert.equal(calls, response.http === 0 ? 2 : 1)
|
||||
}
|
||||
})
|
||||
@@ -9,7 +9,7 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa
|
||||
const overseasSchemas = ['baiyue_event_bridge_overseas', 'baiyue_lastleg', 'baiyue_lastlag_warehouse']
|
||||
|
||||
test('两个入口分别只操作国内九库与海外三库,包括限定名引用', () => {
|
||||
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 208], [overseas, overseasSchemas, domesticSchemas, 138]]) {
|
||||
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 210], [overseas, overseasSchemas, domesticSchemas, 138]]) {
|
||||
assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed)
|
||||
assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed)
|
||||
const body = sql.replace(/^--.*$/gm, '')
|
||||
@@ -28,7 +28,7 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前
|
||||
assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi)
|
||||
assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i)
|
||||
assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1)
|
||||
assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (208|138) AS initialized_tables;$/)
|
||||
assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (210|138) AS initialized_tables;$/)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -47,7 +47,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客
|
||||
})
|
||||
|
||||
test('必要系统种子完整保留,但不写入示例资源与业务事实', () => {
|
||||
for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 364, iam_role_permission: 539,
|
||||
for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 367, iam_role_permission: 543,
|
||||
iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4,
|
||||
fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4,
|
||||
file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name)
|
||||
|
||||
+3
-3
@@ -58,12 +58,12 @@ test('completion operation has durable PROCESSING and terminal response state',
|
||||
assert.match(completionCommand, /nextRetryAt/)
|
||||
assert.match(completionCommand, /completedAt/)
|
||||
assert.match(completionCommand, /failureStage/)
|
||||
const commandTable = unifiedSql.slice(unifiedSql.indexOf('CREATE TABLE IF NOT EXISTS fl_loading_completion_command'),
|
||||
unifiedSql.indexOf(';', unifiedSql.indexOf('CREATE TABLE IF NOT EXISTS fl_loading_completion_command')) + 1)
|
||||
const commandStart = unifiedSql.search(/CREATE TABLE(?: IF NOT EXISTS)? fl_loading_completion_command/)
|
||||
const commandTable = unifiedSql.slice(commandStart, unifiedSql.indexOf(';', commandStart) + 1)
|
||||
assert.match(commandTable, /command_status/i)
|
||||
assert.match(commandTable, /next_retry_at/i)
|
||||
assert.match(commandTable, /completed_at/i)
|
||||
assert.match(unifiedSql, /CREATE TABLE IF NOT EXISTS fl_loading_completion_checkpoint/)
|
||||
assert.match(unifiedSql, /CREATE TABLE(?: IF NOT EXISTS)? fl_loading_completion_checkpoint/)
|
||||
})
|
||||
|
||||
test('durable worker processes inbound settlements incrementally with retry backoff', () => {
|
||||
|
||||
@@ -3,6 +3,15 @@ const OPTIONS = { scenario: 'phase3-loading-hard-gate-finalize', deploymentBlock
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
|
||||
export async function replayCompletedCommand(send, recordTransportFailure) {
|
||||
const first = await send()
|
||||
if (first.http !== 0) return first
|
||||
// Used only after authoritative COMPLETED + operation history have been read.
|
||||
// The closure retains the exact original body and idempotency key.
|
||||
recordTransportFailure(first)
|
||||
return send()
|
||||
}
|
||||
function evidenceSignature(data) {
|
||||
return JSON.stringify((data?.evidences || []).map((item) => ({
|
||||
id: id(item?.id), type: item?.evidenceType || null, fileId: id(item?.fileId),
|
||||
@@ -268,8 +277,12 @@ export async function run(context) {
|
||||
&& id(concurrentCompleteOperations[0]?.operatorId) && Boolean(concurrentCompleteOperations[0]?.createdAt)
|
||||
&& completion.task?.taskStatus === 'COMPLETED' && completion.task?.workflowStatus === 'COMPLETED'
|
||||
const completeResponse = completion.task?.taskStatus === 'COMPLETED'
|
||||
? await request(context, 'POST', `${taskPath}/complete`, completeBody,
|
||||
{ 'Idempotency-Key': completeKey })
|
||||
? await replayCompletedCommand(() => request(context, 'POST', `${taskPath}/complete`, completeBody,
|
||||
{ 'Idempotency-Key': completeKey }), failed => {
|
||||
context.createdResources.loadingCompleteReplayTransportFailures ??= []
|
||||
context.createdResources.loadingCompleteReplayTransportFailures.push({ taskId,
|
||||
operationId: completeBody.operationId, http: failed.http, error: failed.raw || 'transport failure' })
|
||||
})
|
||||
: acceptedCompleteResponse
|
||||
context.assert(MODULE, '两个不同幂等键并发 complete 仅一成功且只写一条完成审计',
|
||||
completeResponse, concurrentCompleteVerified,
|
||||
|
||||
@@ -10,19 +10,23 @@ const unifiedSql = readFileSync(fileURLToPath(new URL('../../deploy/sql/头程
|
||||
test('unified migration selects firstleg before creating the loading model bridge', () => {
|
||||
assert.match(
|
||||
unifiedSql,
|
||||
/USE baiyue_firstleg;\s*\/\* Phase 3: link inbound preload plans to one or more container loading plans\. \*\/\s*CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link/,
|
||||
/USE baiyue_firstleg;[\s\S]*?CREATE TABLE(?: IF NOT EXISTS)? fl_preload_loading_plan_link/,
|
||||
)
|
||||
})
|
||||
|
||||
test('unified migration converges declaration ledger table comments before final schema validation', () => {
|
||||
assert.match(unifiedSql, /queue_table_comment\('baiyue_firstleg', 'fl_order_declaration_event', '[^']*[一-龥][^']*'\)/)
|
||||
assert.match(unifiedSql, /queue_table_comment\('baiyue_firstleg', 'fl_order_declaration_snapshot', '[^']*[一-龥][^']*'\)/)
|
||||
test('unified baseline defines declaration ledger comments before final schema validation', () => {
|
||||
const eventStart = unifiedSql.indexOf('CREATE TABLE fl_order_declaration_event')
|
||||
const snapshotStart = unifiedSql.indexOf('CREATE TABLE fl_order_declaration_snapshot')
|
||||
const eventTable = unifiedSql.slice(eventStart, snapshotStart)
|
||||
const snapshotTable = unifiedSql.slice(snapshotStart, unifiedSql.indexOf('CREATE TABLE', snapshotStart + 20))
|
||||
assert.match(eventTable, /COMMENT='Order客户申报事件永久幂等账本'/)
|
||||
assert.match(snapshotTable, /COMMENT='Order客户申报版本快照/)
|
||||
for (const column of ['tenant_id', 'event_id', 'order_id', 'order_version', 'request_hash', 'consumed_at']) {
|
||||
assert.match(unifiedSql, new RegExp(`queue_column_comment\\('baiyue_firstleg','fl_order_declaration_event','${column}','[^']*[一-龥][^']*'\\)`))
|
||||
assert.match(eventTable, new RegExp(`\\b${column}\\b[^\\n]*COMMENT '[^']*[一-龥][^']*'`))
|
||||
}
|
||||
for (const column of ['tenant_id', 'order_id', 'event_id', 'order_version', 'request_hash', 'customer_remark',
|
||||
'expected_arrival_at', 'channel_code', 'channel_name', 'cargoes_json', 'changes_json', 'occurred_at', 'created_at', 'updated_at']) {
|
||||
assert.match(unifiedSql, new RegExp(`queue_column_comment\\('baiyue_firstleg','fl_order_declaration_snapshot','${column}','[^']*[一-龥][^']*'\\)`))
|
||||
assert.match(snapshotTable, new RegExp(`\\b${column}\\b[^\\n]*COMMENT '[^']*[一-龥][^']*'`))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ test('pending backend contract derives workflow from task items instead of assig
|
||||
assert.match(openApi,
|
||||
/PendingTask:[\s\S]*workflowStatus: \{type: string, enum: \[UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, PROCESSING, COMPLETION_FAILED\][^\r\n]*}/)
|
||||
assert.match(mapper,
|
||||
/WHEN SUM\(CASE WHEN pick_item\.item_status IN \('PENDING', 'PICKING'\)[\s\S]*= 0[\s\S]*THEN 'AWAITING_REVIEW'/)
|
||||
/WHEN COUNT\(DISTINCT CASE WHEN pick_item\.item_status IN \('PENDING', 'PICKING'\)[\s\S]*= 0[\s\S]*THEN 'AWAITING_REVIEW'/)
|
||||
assert.doesNotMatch(mapper,
|
||||
/CASE WHEN assignment\.pick_task_id IS NULL THEN 'UNASSIGNED' ELSE 'IN_PROGRESS' END AS workflowStatus/)
|
||||
})
|
||||
|
||||
@@ -29,9 +29,9 @@ test('loading pick operation schemas accept whole-pallet dropship audits', () =>
|
||||
const moduleSql = read('../../firstleg/src/main/resources/db/phase3-pda-convergence-schema.sql')
|
||||
const unifiedSql = read('../../deploy/sql/头程数据库统一增量-MySQL8.sql')
|
||||
for (const sql of [moduleSql, unifiedSql]) {
|
||||
assert.match(sql, /chk_loading_pick_operation_action[\s\S]*?'PICK_PALLET'/)
|
||||
assert.match(sql, /chk_loading_pick_operation_action[\s\S]*?(?:_utf8mb4)?'PICK_PALLET'/)
|
||||
}
|
||||
assert.equal((unifiedSql.match(/CHECK \(action_type IN \([^)]*'PICK_PALLET'[^)]*\)\)/g) || []).length, 2)
|
||||
assert.equal((unifiedSql.match(/CHECK \(\(?action_type in \([^)]*(?:_utf8mb4)?'PICK_PALLET'[^)]*\)\)?\)/gi) || []).length, 1)
|
||||
})
|
||||
|
||||
test('producer collector requires dropship FBA, standard FBA, OVERSEAS and a fourth distinct container', () => {
|
||||
|
||||
@@ -66,10 +66,9 @@ test('domestic and overseas PDA operators use isolated authorities', () => {
|
||||
assert.match(iamSql, /permission\.permission_code='lastleg:pda:operate'/)
|
||||
})
|
||||
|
||||
test('unified migration creates the phase-four role matrix in the IAM schema used by its assertion routine', () => {
|
||||
assert.match(
|
||||
unifiedSql,
|
||||
/USE baiyue_iam;[\s\S]*?DROP TEMPORARY TABLE IF EXISTS tmp_phase4_warehouse_role_permission;\s*CREATE TEMPORARY TABLE tmp_phase4_warehouse_role_permission/,
|
||||
)
|
||||
assert.match(unifiedSql, /CREATE PROCEDURE assert_phase4_warehouse_role_minimum\(\)/)
|
||||
test('unified baseline creates the phase-four role matrix in the IAM schema', () => {
|
||||
assert.match(unifiedSql, /USE baiyue_iam;/)
|
||||
assert.match(unifiedSql, /'OVERSEAS_WAREHOUSE_PDA_OPERATOR'/)
|
||||
assert.match(unifiedSql, /'lastleg:pda:operate'/)
|
||||
assert.doesNotMatch(unifiedSql, /tmp_phase4_warehouse_role_permission|assert_phase4_warehouse_role_minimum/)
|
||||
})
|
||||
|
||||
@@ -106,6 +106,7 @@ test('P5 follows warehouse review, customer confirmation and fulfillment complet
|
||||
assert.match(source, /manualCompletion: true/)
|
||||
assert.match(source, /P5返工重新确认箱标阶段/)
|
||||
assert.match(source, /P5返工重新完成SKU阶段/)
|
||||
assert.match(source, /newLabel: redoItem\.fbaCartonMark \|\| redoItem\.newSkuCode/)
|
||||
assert.ok(source.indexOf('P5换标箱${index + 1}拆托') < source.indexOf('P5返工组托后提交完工等待客户确认'))
|
||||
assert.ok(source.indexOf('P5返工组托后提交完工等待客户确认') < source.indexOf('const confirmBody'))
|
||||
assert.match(source, /applicationStatus === 'CUSTOMER_CONFIRM_PENDING'/)
|
||||
|
||||
@@ -557,7 +557,8 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
detail = carton.data
|
||||
}
|
||||
const redo = await request(context, token, 'POST', 'P5返工重新完成SKU阶段', unitPath, {
|
||||
itemId: id(redoItem.itemId), oldLabel: redoItem.cartonMark, newLabel: redoItem.newSkuCode,
|
||||
itemId: id(redoItem.itemId), oldLabel: redoItem.cartonMark,
|
||||
newLabel: redoItem.fbaCartonMark || redoItem.newSkuCode,
|
||||
skuVerifications: (redoItem.skuItems || []).map(sku => ({
|
||||
skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity),
|
||||
})),
|
||||
|
||||
@@ -254,6 +254,20 @@ function previewGroupsSorted(groups) {
|
||||
|
||||
async function convertContainerPlans(context, producer, details) {
|
||||
const bookingId = id(producer.containerBookingId)
|
||||
const arrival = await api(context, 'POST', '按柜转换前读取预计到仓时间',
|
||||
`${ORDERS}/container-arrival-times/query`, { sourceContainerBookingIds: [bookingId] })
|
||||
const currentArrival = Array.isArray(arrival.data)
|
||||
? arrival.data.find(row => id(row.sourceContainerBookingId) === bookingId) : null
|
||||
if (!success(arrival.response) || !currentArrival || !Number.isInteger(Number(currentArrival.version))) return null
|
||||
if (!nonBlank(currentArrival.estimatedWarehouseArrivalAt)) {
|
||||
const expected = await api(context, 'PUT', '按柜转换前补齐预计到仓时间',
|
||||
`${ORDERS}/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`, {
|
||||
estimatedWarehouseArrivalAt: new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 19),
|
||||
expectedVersion: Number(currentArrival.version), remark: `${context.marker}-container-plan-arrival`,
|
||||
}, { 'Idempotency-Key': `${context.marker}-container-plan-arrival`.slice(0, 128) })
|
||||
if (!success(expected.response) || id(expected.data?.sourceContainerBookingId) !== bookingId
|
||||
|| !nonBlank(expected.data?.estimatedWarehouseArrivalAt)) return null
|
||||
}
|
||||
const previewPath = query(`${PLANS}/container-preview`, { sourceContainerBookingId: bookingId })
|
||||
const preview = await api(context, 'GET', '预览整柜待生成入仓计划清单', previewPath)
|
||||
const groups = Array.isArray(preview.data?.groups) ? preview.data.groups : []
|
||||
|
||||
@@ -254,17 +254,19 @@ export async function run(context) {
|
||||
const merchantToken = await loginMerchant(context, customerId)
|
||||
if (!merchantToken) return
|
||||
|
||||
const beforeEdit = await platform(context, 'GET', '编辑车辆前读取服务端目的仓线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
if (!success(beforeEdit.response) || beforeEdit.data?.dispatchNo !== dispatchNo) return
|
||||
const updateBody = {
|
||||
vehiclePlate: `DEV-${context.marker.slice(-8)}`.slice(0, 64),
|
||||
driverName: `五期验收司机-${context.marker.slice(-6)}`,
|
||||
driverPhone: '19900000000', carrierName: '五期开发验收车队',
|
||||
route: `${context.marker}-PHASE5-ROUTE`, estimatedArrivalAt: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
estimatedArrivalAt: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
}
|
||||
const updated = await platform(context, 'PUT', '补齐本轮排车车辆司机与线路',
|
||||
`${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`, updateBody)
|
||||
context.assert(MODULE, '排车编辑字段完整回显', updated.response,
|
||||
updated.data?.dispatchNo === dispatchNo && updated.data?.vehiclePlate === updateBody.vehiclePlate
|
||||
&& updated.data?.driverName === updateBody.driverName && updated.data?.route === updateBody.route,
|
||||
&& updated.data?.driverName === updateBody.driverName && updated.data?.route === beforeEdit.data.route,
|
||||
trace(updated.response), OPTIONS)
|
||||
if (!success(updated.response)) return
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.url), 'utf8'))
|
||||
.replaceAll('\r\n', '\n')
|
||||
|
||||
test('all backend business services have blue-green runtime contracts', async () => {
|
||||
const script = await read('deploy/scripts/deploy-blue-green-service.sh')
|
||||
for (const service of [
|
||||
'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
||||
'integration-service', 'audit-service', 'gateway-overseas', 'lastleg-service',
|
||||
'warehouse-overseas-service', 'event-bridge-service'
|
||||
]) assert.match(script, new RegExp(`${service.replaceAll('-', '\\-')}:`))
|
||||
|
||||
assert.match(script, /target_slot="green"/)
|
||||
assert.match(script, /target_port=\$\(\(BASE_PORT \+ 10000\)\)/)
|
||||
assert.match(script, /compose run -d --no-deps --name "\$candidate"/)
|
||||
assert.match(script, /wait_for_container "\$candidate"/)
|
||||
assert.match(script, /BAIYUE_RUNTIME_SCHEDULING_ENABLED=false/)
|
||||
assert.match(script, /NACOS_REGISTER_ENABLED=false/)
|
||||
assert.match(script, /start_worker_candidate/)
|
||||
assert.match(script, /worker_java_options="-Xms512m -Xmx2g/)
|
||||
assert.match(script, /worker_java_options="-Xms256m -Xmx1g/)
|
||||
assert.match(script, /connect_routing_aliases "\$candidate" "\$ROUTING_ALIAS" "\$NACOS_SERVICE"/)
|
||||
assert.match(script, /file,file-service:file/)
|
||||
assert.match(script, /docker stop --time "\$STOP_TIMEOUT" "\$active_container"/)
|
||||
assert.match(script, /mv -f "\$STATE_FILE\.tmp" "\$STATE_FILE"/)
|
||||
})
|
||||
|
||||
test('candidate failure keeps the active container and successful cutover stops it last', async () => {
|
||||
const script = await read('deploy/scripts/deploy-blue-green-service.sh')
|
||||
const start = script.lastIndexOf('compose run -d')
|
||||
const healthy = script.indexOf('if ! wait_for_container "$candidate"')
|
||||
const route = script.indexOf('connect_routing_aliases "$candidate"')
|
||||
const gateway = script.indexOf('if ! switch_gateway_proxy')
|
||||
const stop = script.indexOf('docker stop --time "$STOP_TIMEOUT" "$active_container"')
|
||||
const state = script.indexOf('mv -f "$STATE_FILE.tmp" "$STATE_FILE"')
|
||||
assert.ok(start >= 0 && start < healthy)
|
||||
assert.ok(healthy < route && route < gateway && gateway < stop && stop < state)
|
||||
assert.match(script.slice(healthy, route), /cleanup_candidate/)
|
||||
})
|
||||
|
||||
test('gateway cutover is atomic and restores nginx config on validation failure', async () => {
|
||||
const script = await read('deploy/scripts/deploy-blue-green-service.sh')
|
||||
assert.match(script, /api-cn\.buggoglobal\.com\.conf/)
|
||||
assert.match(script, /api-global\.buggoglobal\.com\.conf/)
|
||||
assert.match(script, /尚未完成首次蓝绿迁移,拒绝切换 Gateway/)
|
||||
assert.match(script, /frontend-\$frontend_service\.active/)
|
||||
assert.match(script, /尚未使用稳定上游 \$required_alias,拒绝切换 Gateway/)
|
||||
assert.match(script, /reload_frontend_proxies/)
|
||||
assert.match(script, /cp -f "\$config" "\$backup"/)
|
||||
assert.match(script, /nginx -t \|\| ! nginx -s reload/)
|
||||
assert.match(script, /cp -f "\$backup" "\$config"/)
|
||||
})
|
||||
|
||||
test('compose templates preserve rolling defaults and expose slot metadata', async () => {
|
||||
const domestic = await read('deploy/docker-compose.yml')
|
||||
const overseas = await read('deploy/docker-compose.overseas.yml')
|
||||
for (const compose of [domestic, overseas]) {
|
||||
assert.match(compose, /BAIYUE_DEPLOY_SLOT: \$\{DEPLOY_SLOT:-rolling\}/)
|
||||
assert.match(compose, /BAIYUE_RELEASE_ID: \$\{RELEASE_ID:-local\}/)
|
||||
assert.match(compose, /SERVER_SHUTDOWN: graceful/)
|
||||
assert.match(compose, /stop_grace_period: \$\{STOP_GRACE_PERIOD:-45s\}/)
|
||||
assert.match(compose, /\$\{DEPLOY_SLOT_SUFFIX:-\}/)
|
||||
}
|
||||
})
|
||||
|
||||
test('every scheduled worker service can disable Nacos registration', async () => {
|
||||
for (const application of [
|
||||
'iam/src/main/resources/application.yml',
|
||||
'firstleg/src/main/resources/application.yml',
|
||||
'finance/src/main/resources/application.yml',
|
||||
'order/src/main/resources/application.yml',
|
||||
'file/src/main/resources/application.yml',
|
||||
'event-bridge-service/src/main/resources/application.yml',
|
||||
'audit-service/src/main/resources/application.yml',
|
||||
'lastleg-service/src/main/resources/application.yml'
|
||||
]) {
|
||||
assert.match(await read(application), /register-enabled: \$\{NACOS_REGISTER_ENABLED:true\}/, application)
|
||||
}
|
||||
})
|
||||
|
||||
test('production single-service pipeline dispatches the blue-green executor', async () => {
|
||||
const deploy = await read('deploy/scripts/deploy-built-service.sh')
|
||||
const batch = await read('deploy/scripts/deploy-services.sh')
|
||||
assert.match(deploy, /run_scp "deploy\/scripts\/deploy-blue-green-service\.sh"/)
|
||||
assert.match(deploy, /if \[ "\$DEPLOY_ENV" = "production" \]/)
|
||||
assert.match(deploy, /run_ssh sh "\$REMOTE_DIR\/\$REMOTE_BLUE_GREEN_NAME"/)
|
||||
assert.match(deploy, /TARGET_IS_LOCAL[\s\S]*?deploy-blue-green-service\.sh/)
|
||||
assert.match(batch, /if \[ "\$DEPLOY_ENV" = "test" \] \|\| \[ "\$DEPLOY_ENV" = "production" \][\s\S]*?deploy-blue-green-service\.sh/)
|
||||
})
|
||||
|
||||
test('test business services use blue-green while test gateways retain their fixed entry ports', async () => {
|
||||
const deploy = await read('deploy/scripts/deploy-built-service.sh')
|
||||
const batch = await read('deploy/scripts/deploy-services.sh')
|
||||
assert.match(deploy, /\[ "\$DEPLOY_ENV" = "test" \][\s\S]*?\[ "\$SERVICE" != "gateway" \]/)
|
||||
assert.match(batch, /\[ "\$DEPLOY_ENV" = "test" \] \|\| \[ "\$DEPLOY_ENV" = "production" \]/)
|
||||
assert.match(batch, /\[ "\$service" = gateway \] \|\| \[ "\$service" = gateway-overseas \]/)
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { delimiter, join, resolve } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import test from 'node:test'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const script = resolve(root, 'deploy/scripts/deploy-blue-green-service.sh')
|
||||
const shell = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : '/bin/sh'
|
||||
|
||||
const dockerMock = `#!/usr/bin/env sh
|
||||
set -eu
|
||||
printf '%s\\n' "$*" >> "$MOCK_LOG"
|
||||
command="$1"
|
||||
shift
|
||||
case "$command" in
|
||||
network)
|
||||
[ "$1" = inspect ] && { echo '{}'; exit 0; }
|
||||
exit 0
|
||||
;;
|
||||
compose)
|
||||
container_name=''
|
||||
previous=''
|
||||
for argument in "$@"; do
|
||||
if [ "$previous" = --name ]; then container_name="$argument"; fi
|
||||
previous="$argument"
|
||||
if [ "$argument" = run ]; then
|
||||
action=run
|
||||
fi
|
||||
done
|
||||
if [ "\${action:-}" = run ]; then
|
||||
case "$container_name" in
|
||||
*-worker) : > "$MOCK_WORKER_EXISTS" ;;
|
||||
*) : > "$MOCK_CANDIDATE_EXISTS" ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
inspect)
|
||||
target=''
|
||||
for argument in "$@"; do target="$argument"; done
|
||||
case "$target" in
|
||||
firstleg-production-file)
|
||||
[ ! -f "$MOCK_LEGACY_REMOVED" ]
|
||||
;;
|
||||
firstleg-production-file-green)
|
||||
[ -f "$MOCK_CANDIDATE_EXISTS" ] || exit 1
|
||||
case "$*" in
|
||||
*State.Health*) printf '%s\\n' "\${MOCK_CANDIDATE_HEALTH:-healthy}" ;;
|
||||
*Config.Image*) printf '%s\\n' 'registry.local/group/file:newtag' ;;
|
||||
esac
|
||||
;;
|
||||
firstleg-production-file-green-worker)
|
||||
[ -f "$MOCK_WORKER_EXISTS" ] || exit 1
|
||||
case "$*" in
|
||||
*State.Health*) printf '%s\\n' healthy ;;
|
||||
*Config.Image*) printf '%s\\n' 'registry.local/group/file:newtag' ;;
|
||||
esac
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
stop)
|
||||
target=''
|
||||
for argument in "$@"; do target="$argument"; done
|
||||
[ "$target" = firstleg-production-file ] && : > "$MOCK_LEGACY_STOPPED"
|
||||
;;
|
||||
rm)
|
||||
target=''
|
||||
for argument in "$@"; do target="$argument"; done
|
||||
case "$target" in
|
||||
firstleg-production-file) : > "$MOCK_LEGACY_REMOVED" ;;
|
||||
firstleg-production-file-green) rm -f "$MOCK_CANDIDATE_EXISTS" ;;
|
||||
firstleg-production-file-green-worker) rm -f "$MOCK_WORKER_EXISTS" ;;
|
||||
esac
|
||||
;;
|
||||
logs) echo 'candidate logs' ;;
|
||||
*) echo "unexpected docker command: $command $*" >&2; exit 2 ;;
|
||||
esac
|
||||
`
|
||||
|
||||
async function runBlueGreen(health) {
|
||||
const temp = await mkdtemp(join(tmpdir(), 'blue-green-runtime-'))
|
||||
const bin = join(temp, 'bin')
|
||||
const docker = join(bin, 'docker')
|
||||
const sleep = join(bin, 'sleep')
|
||||
const envFile = join(temp, 'deployment.env')
|
||||
const compose = join(temp, 'compose.yml')
|
||||
const stateRoot = join(temp, 'state')
|
||||
const log = join(temp, 'docker.log')
|
||||
await mkdir(bin)
|
||||
await mkdir(stateRoot)
|
||||
await writeFile(docker, dockerMock, 'utf8')
|
||||
await writeFile(sleep, '#!/usr/bin/env sh\nexit 0\n', 'utf8')
|
||||
await chmod(docker, 0o755)
|
||||
await chmod(sleep, 0o755)
|
||||
await writeFile(envFile, 'FILE_EXPORT_CONSUMER_ENABLED=true\n', 'utf8')
|
||||
await writeFile(compose, 'services: {}\n', 'utf8')
|
||||
await writeFile(log, '', 'utf8')
|
||||
|
||||
const paths = {
|
||||
candidate: join(temp, 'candidate'), stopped: join(temp, 'legacy-stopped'),
|
||||
removed: join(temp, 'legacy-removed'), worker: join(temp, 'worker')
|
||||
}
|
||||
const result = spawnSync(shell, [
|
||||
script.replaceAll('\\', '/'), 'production', 'registry.local/group', 'newtag', 'file',
|
||||
envFile.replaceAll('\\', '/'), compose.replaceAll('\\', '/'), 'firstleg-production'
|
||||
], {
|
||||
cwd: root, encoding: 'utf8', env: {
|
||||
...process.env,
|
||||
PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`,
|
||||
BLUE_GREEN_STATE_ROOT: stateRoot,
|
||||
BLUE_GREEN_STABILIZATION_SECONDS: '0',
|
||||
DEPLOY_HEALTH_TIMEOUT_SECONDS: '2',
|
||||
DEPLOY_HEALTH_POLL_SECONDS: '1',
|
||||
MOCK_LOG: log,
|
||||
MOCK_CANDIDATE_EXISTS: paths.candidate,
|
||||
MOCK_WORKER_EXISTS: paths.worker,
|
||||
MOCK_LEGACY_STOPPED: paths.stopped,
|
||||
MOCK_LEGACY_REMOVED: paths.removed,
|
||||
MOCK_CANDIDATE_HEALTH: health
|
||||
}
|
||||
})
|
||||
const output = `${result.stdout}\n${result.stderr}`
|
||||
const dockerLog = await readFile(log, 'utf8')
|
||||
const stateFile = join(stateRoot, 'firstleg-production-file.active')
|
||||
const state = existsSync(stateFile) ? await readFile(stateFile, 'utf8') : ''
|
||||
const evidence = {
|
||||
...result, output, dockerLog, state,
|
||||
stopped: existsSync(paths.stopped), removed: existsSync(paths.removed),
|
||||
worker: existsSync(paths.worker)
|
||||
}
|
||||
await rm(temp, { recursive: true, force: true })
|
||||
return evidence
|
||||
}
|
||||
|
||||
test('healthy candidate becomes active only after health and old instance drains', async (context) => {
|
||||
if (!existsSync(shell)) context.skip(`shell unavailable: ${shell}`)
|
||||
const result = await runBlueGreen('healthy')
|
||||
assert.equal(result.status, 0, result.output)
|
||||
assert.equal(result.stopped, true)
|
||||
assert.equal(result.removed, true)
|
||||
assert.equal(result.worker, true)
|
||||
assert.match(result.state, /^green\|firstleg-production-file-green\|19206\|registry\.local\/group\/file:newtag\|firstleg-production-file-green-worker/)
|
||||
const run = result.dockerLog.indexOf('compose --project-name firstleg-production')
|
||||
const stop = result.dockerLog.indexOf('stop --time 45 firstleg-production-file')
|
||||
assert.ok(run >= 0 && run < stop)
|
||||
assert.match(result.dockerLog,
|
||||
/network connect --alias file --alias file-service firstleg-production-routing firstleg-production-file-green/)
|
||||
})
|
||||
|
||||
test('unhealthy candidate is removed while old instance and state remain untouched', async (context) => {
|
||||
if (!existsSync(shell)) context.skip(`shell unavailable: ${shell}`)
|
||||
const result = await runBlueGreen('unhealthy')
|
||||
assert.equal(result.status, 1, result.output)
|
||||
assert.equal(result.stopped, false)
|
||||
assert.equal(result.removed, false)
|
||||
assert.equal(result.worker, false)
|
||||
assert.equal(result.state, '')
|
||||
assert.doesNotMatch(result.dockerLog, /stop --time .*firstleg-production-file/)
|
||||
})
|
||||
@@ -76,7 +76,7 @@ test('overseas compose is isolated from CN defaults and disables implicit schema
|
||||
assert.match(compose, /^ lastleg-service:\s*$/m)
|
||||
assert.match(compose, /^ warehouse-overseas-service:\s*$/m)
|
||||
assert.match(compose, /^ event-bridge-service:\s*$/m)
|
||||
assert.match(compose, /EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312/)
|
||||
assert.match(compose, /EVENT_BRIDGE_OVERSEAS_PUBLISHED_PORT:-\$\{EVENT_BRIDGE_OVERSEAS_PORT:-9313\}}:9312/)
|
||||
assert.match(compose, /INTERNAL_SERVICE_TOKEN: \$\{INTERNAL_SERVICE_TOKEN\}/)
|
||||
assert.match(compose, /LASTLEG_DB_URL: \$\{LASTLEG_DB_URL\}/)
|
||||
assert.match(compose, /EVENT_BRIDGE_DB_URL: \$\{EVENT_BRIDGE_OVERSEAS_DB_URL\}/)
|
||||
|
||||
@@ -128,8 +128,8 @@ test('development and test deployment locks are acquired before target executors
|
||||
})
|
||||
|
||||
test('development publishes every service on loopback without changing test defaults', () => {
|
||||
assert.match(serviceCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_PORT:-9000\}:9000/)
|
||||
assert.match(overseasCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_OVERSEAS_PORT:-9300\}:9300/)
|
||||
assert.match(serviceCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_PUBLISHED_PORT:-\$\{GATEWAY_PORT:-9000\}\}:9000/)
|
||||
assert.match(overseasCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_OVERSEAS_PUBLISHED_PORT:-\$\{GATEWAY_OVERSEAS_PORT:-9300\}\}:9300/)
|
||||
assert.match(directDeployScript, /if \[ "\$DEPLOY_ENV" = "development" \]; then\s+PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/)
|
||||
assert.match(directDeployScript, /if \[ "\$deploy_env" = "development" \]; then\s+export PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/)
|
||||
assert.doesNotMatch(directDeployScript, /gateway\|gateway-overseas\) (?:export )?PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/)
|
||||
@@ -180,10 +180,12 @@ test('parallel remote deployments keep env and compose assets isolated by projec
|
||||
assert.match(directDeployScript, /REMOTE_COMPOSE_NAME="docker-compose\.\$PROJECT_NAME\.yml"/)
|
||||
assert.match(directDeployScript, /REMOTE_ENV_TMP="\$REMOTE_DIR\/\$REMOTE_ENV_NAME\.\$SUFFIX\.tmp"/)
|
||||
assert.match(directDeployScript, /REMOTE_COMPOSE_TMP="\$REMOTE_DIR\/\$REMOTE_COMPOSE_NAME\.\$SUFFIX\.tmp"/)
|
||||
assert.match(directDeployScript, /env_target="\$3"/)
|
||||
assert.match(directDeployScript, /compose_target="\$4"/)
|
||||
assert.match(directDeployScript, /env_target="\$4"/)
|
||||
assert.match(directDeployScript, /compose_target="\$5"/)
|
||||
assert.match(directDeployScript, /blue_green_target="\$6"/)
|
||||
assert.match(directDeployScript, /mv -f "\$env_tmp" "\$env_target"/)
|
||||
assert.match(directDeployScript, /mv -f "\$compose_tmp" "\$compose_target"/)
|
||||
assert.match(directDeployScript, /mv -f "\$blue_green_tmp" "\$blue_green_target"/)
|
||||
assert.match(directDeployScript, /env_file="\$7"/)
|
||||
assert.match(directDeployScript, /compose_file="\$8"/)
|
||||
assert.match(directDeployScript, /--env-file "\$env_file"/)
|
||||
|
||||
Reference in New Issue
Block a user