Files
ecs-controller/internal/worker/worker.go

827 lines
37 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package worker
import (
"context"
"encoding/json"
"fmt"
"log"
"strconv"
"strings"
"time"
"github.com/Kori1c/ecs-controller/internal/app"
"github.com/Kori1c/ecs-controller/internal/cloud"
"github.com/Kori1c/ecs-controller/internal/notify"
"github.com/Kori1c/ecs-controller/internal/store"
)
type Worker struct {
Store *store.Store
Cloud cloud.Client
CloudFactory func(app.AccountGroup) cloud.Client
Log *log.Logger
}
func (w *Worker) Monitor(ctx context.Context, interval time.Duration) {
if interval < 30*time.Second {
interval = 30 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w.Store.SetLastRun()
now := time.Now()
if err := w.Store.ResetMonthlyTraffic(); err != nil {
w.Store.AddLog("warning", "月度流量状态重置失败: "+err.Error())
}
maintenanceDay := now.Format("2006-01-02")
if w.Store.GetSetting("maintenance_day", "") != maintenanceDay {
if err := w.Store.PruneMaintenance(now); err != nil {
w.Store.AddLog("warning", "数据库历史清理失败: "+err.Error())
}
if now.Hour() == 4 {
if err := w.Store.Vacuum(); err != nil {
w.Store.AddLog("warning", "数据库 VACUUM 失败: "+err.Error())
}
}
_ = w.Store.SetSetting("maintenance_day", maintenanceDay)
}
accounts, err := w.Store.LoadAccounts(false)
if err != nil {
w.Store.AddLog("error", "读取监控账号失败: "+err.Error())
continue
}
w.Store.AddLog("heartbeat", fmt.Sprintf("监控心跳正常:已检查 %d 个监控账号。", len(accounts)))
if w.ddnsEnabled() {
lastDDNS, _ := strconv.ParseInt(w.Store.GetSetting("last_ddns_reconcile", "0"), 10, 64)
if now.Unix()-lastDDNS >= 6*60*60 {
w.syncAllDDNS(ctx)
_ = w.Store.SetSetting("last_ddns_reconcile", strconv.FormatInt(now.Unix(), 10))
}
}
threshold, _ := strconv.ParseFloat(w.Store.GetSetting("traffic_threshold", "95"), 64)
if threshold <= 0 || threshold > 100 {
threshold = 95
}
thresholdAction := w.Store.GetSetting("threshold_action", "stop_and_notify")
shutdownMode := w.Store.GetSetting("shutdown_mode", "KeepCharging")
keepAlive := w.Store.GetSetting("keep_alive", "0") == "1"
monthlyAutoStart := w.Store.GetSetting("monthly_auto_start", "0") == "1"
enableBilling := w.Store.GetSetting("enable_billing", "0") == "1"
apiInterval, _ := strconv.ParseInt(w.Store.GetSetting("api_interval", "600"), 10, 64)
if apiInterval < 30 {
apiInterval = 600
}
for _, account := range accounts {
if account.InstanceID == "" {
continue
}
client := w.Cloud
if w.CloudFactory != nil {
client = w.CloudFactory(app.AccountGroup{AccessKeyID: account.AccessKeyID, AccessKeySecret: account.AccessKeySecret, RegionID: account.RegionID, SiteType: account.SiteType})
}
if client == nil {
continue
}
transient := account.InstanceStatus == "Starting" || account.InstanceStatus == "Stopping" || account.InstanceStatus == "Pending" || account.InstanceStatus == "Unknown"
shouldRefresh := account.UpdatedAt <= 0 || now.Unix()-account.UpdatedAt >= apiInterval || transient || now.Minute() == 0
if !shouldRefresh {
w.runCachedAutomation(ctx, client, &account, now, threshold, thresholdAction, shutdownMode, keepAlive, monthlyAutoStart)
continue
}
oldPublicIP := account.PublicIP
oldStatus := account.InstanceStatus
instance, describeErr := client.DescribeInstance(ctx, account.RegionID, account.InstanceID)
if describeErr != nil {
metadata := map[string]any{"health_status": "error", "traffic_api_status": "unknown", "traffic_api_message": describeErr.Error()}
if cloud.IsCredentialError(describeErr) {
metadata["protection_suspended"] = true
metadata["protection_suspend_reason"] = "credential_invalid"
if account.ProtectionNotifiedAt == 0 {
w.dispatchEvent(ctx, notify.Event{Title: "阿里云凭据异常", Summary: "已暂停自动停机保护", AccountID: accountLabel(account), Text: fmt.Sprintf("【ECS Controller】阿里云凭据异常\n账号/实例: %s\n实例 ID: %s\n区域: %s\n错误: %s\n系统已暂停自动停机保护请更新 AK 后恢复。", accountLabel(account), account.InstanceID, account.RegionID, describeErr.Error()), Fields: map[string]string{"instance_id": account.InstanceID, "reason": "credential_invalid"}})
account.ProtectionNotifiedAt = now.Unix()
metadata["protection_suspend_notified_at"] = account.ProtectionNotifiedAt
}
}
_ = w.Store.UpdateAccountStatus(account.ID, account.TrafficUsed, account.InstanceStatus, now.Unix(), metadata)
continue
}
account.InstanceStatus, account.PublicIP, account.PrivateIP, account.InstanceType = instance.Status, instance.PublicIP, instance.PrivateIP, instance.InstanceType
if account.PublicIPMode == "eip" && account.EIPAddress != "" {
account.PublicIP = account.EIPAddress
}
account.HealthStatus, account.UpdatedAt = "ok", now.Unix()
if oldStatus != "" && oldStatus != account.InstanceStatus {
w.dispatchEvent(ctx, statusEvent(account, oldStatus, account.InstanceStatus, "系统监控检测到实例状态变化。"))
}
traffic, trafficStatus, trafficMessage, trafficErr := w.refreshTraffic(ctx, client, account, now)
if trafficErr != nil {
reason := "traffic_api_error"
if cloud.IsCredentialError(trafficErr) {
reason = "credential_invalid"
}
account.TrafficAPIStatus, account.TrafficAPIMessage = "error", trafficErr.Error()
account.ProtectionSuspended, account.ProtectionSuspendReason = true, reason
account.UpdatedAt, account.TrafficBillingMonth = now.Unix(), now.Format("2006-01")
// CMS may be delayed or temporarily unavailable. A failed CMS
// request must not skip the independent CDT safety check.
if available := w.applyTrafficProtection(ctx, client, &account, now, threshold, thresholdAction, shutdownMode, account.TrafficUsed, false, keepAlive, monthlyAutoStart); available {
account.ProtectionSuspended, account.ProtectionSuspendReason = false, ""
}
if reason == "credential_invalid" && account.ProtectionNotifiedAt == 0 {
w.dispatchEvent(ctx, notify.Event{Title: "阿里云凭据异常", Summary: "已暂停自动停机保护", AccountID: accountLabel(account), Text: fmt.Sprintf("【ECS Controller】阿里云凭据异常\n账号/实例: %s\n实例 ID: %s\n错误: %s\n系统已暂停自动停机保护请更新 AK 后恢复。", accountLabel(account), account.InstanceID, trafficErr.Error()), Fields: map[string]string{"instance_id": account.InstanceID, "reason": "credential_invalid"}})
account.ProtectionNotifiedAt = now.Unix()
}
if err := w.Store.UpsertAccount(account); err != nil {
w.Store.AddLog("error", "保存流量保护状态失败: "+err.Error())
}
continue
}
account.TrafficUsed = traffic
account.TrafficBillingMonth = now.Format("2006-01")
account.TrafficAPIStatus, account.TrafficAPIMessage, account.ProtectionSuspended, account.ProtectionSuspendReason = trafficStatus, trafficMessage, false, ""
if account.ProtectionNotifiedAt != 0 {
account.ProtectionNotifiedAt = 0
}
_ = w.Store.AddTrafficHistory(account.ID, traffic, now)
if enableBilling && account.InstanceID != "" {
cycle := now.Format("2006-01")
if billingClient, ok := client.(cloud.BillingClient); ok {
if _, cacheOK := w.Store.GetBillingCache(account.ID, "balance", "", 6*time.Hour); !cacheOK {
if balance, currency, balanceErr := billingClient.GetAccountBalance(ctx, account.SiteType); balanceErr == nil {
_ = w.Store.SetBillingCache(account.ID, "balance", "", map[string]any{"balance": balance, "currency": currency})
} else {
_ = w.Store.SetBillingCache(account.ID, "balance", "", map[string]any{"error": balanceErr.Error()})
}
}
if _, cacheOK := w.Store.GetBillingCache(account.ID, "bill_overview", cycle, 6*time.Hour); !cacheOK {
if total, currency, overviewErr := billingClient.GetBillOverview(ctx, account.SiteType, cycle); overviewErr == nil {
_ = w.Store.SetBillingCache(account.ID, "bill_overview", cycle, map[string]any{"monthly_cost": total, "currency": currency})
} else {
_ = w.Store.SetBillingCache(account.ID, "bill_overview", cycle, map[string]any{"error": overviewErr.Error()})
}
}
}
if _, ok := w.Store.GetBillingCache(account.ID, "instance_bill", cycle, 6*time.Hour); !ok {
balance, monthlyCost, currency, billingErr := client.GetBilling(ctx, account.SiteType, account.InstanceID, cycle)
if billingErr != nil {
_ = w.Store.SetBillingCache(account.ID, "instance_bill", cycle, map[string]any{"error": billingErr.Error()})
} else {
_ = w.Store.SetBillingCache(account.ID, "instance_bill", cycle, map[string]any{"monthly_cost": monthlyCost, "balance": balance, "currency": currency})
}
}
}
w.applyTrafficProtection(ctx, client, &account, now, threshold, thresholdAction, shutdownMode, traffic, true, keepAlive, monthlyAutoStart)
if err := w.Store.UpsertAccount(account); err != nil {
w.Store.AddLog("error", "保存监控状态失败: "+err.Error())
}
if account.PublicIP != "" && account.PublicIP != oldPublicIP {
w.syncDDNSAccount(ctx, account)
w.dispatchEvent(ctx, notify.Event{Title: "公网 IP 已变化", Summary: fmt.Sprintf("%s 公网 IP 已从 %s 变为 %s", accountLabel(account), oldPublicIP, account.PublicIP), AccountID: accountLabel(account), Text: fmt.Sprintf("【ECS Controller】公网 IP 已变化\n实例: %s\n实例 ID: %s\n旧 IP: %s\n新 IP: %s\n时间: %s", accountLabel(account), account.InstanceID, oldPublicIP, account.PublicIP, time.Now().Format("2006-01-02 15:04:05")), Fields: map[string]string{"old_ip": oldPublicIP, "new_ip": account.PublicIP, "instance_id": account.InstanceID}})
}
}
w.runDailyTrafficSummary(ctx, now)
}
}
}
func (w *Worker) protectionTraffic(ctx context.Context, client cloud.Client, account app.Account, cmsTraffic float64) (float64, string) {
traffic, source, _ := w.protectionTrafficStatus(ctx, client, account, cmsTraffic, true)
return traffic, source
}
// protectionTrafficStatus combines the independent CMS and CDT signals. CMS
// remains the value shown on an instance card, while the larger available
// value is used for the safety threshold. If both APIs are unavailable,
// automation must pause instead of trusting an old value.
func (w *Worker) protectionTrafficStatus(ctx context.Context, client cloud.Client, account app.Account, cmsTraffic float64, cmsAvailable bool) (float64, string, bool) {
cycle := time.Now().Format("2006-01")
if account.ID > 0 {
if cached, ok := w.Store.GetBillingCache(account.ID, "cdt_protection", cycle, 5*time.Minute); ok {
cdtTraffic := trafficFloat(cached["traffic"])
if !cmsAvailable {
return cdtTraffic, "CDT", true
}
if cdtTraffic >= cmsTraffic {
return cdtTraffic, "CDT", true
}
return cmsTraffic, "CMS", true
}
}
cdtTraffic, err := client.GetTraffic(ctx, account.RegionID)
if err == nil {
if account.ID > 0 {
_ = w.Store.SetBillingCache(account.ID, "cdt_protection", cycle, map[string]any{"traffic": cdtTraffic})
}
if !cmsAvailable || cdtTraffic >= cmsTraffic {
return cdtTraffic, "CDT", true
}
return cmsTraffic, "CMS", true
}
if cmsAvailable {
return cmsTraffic, "CMS", true
}
return 0, "", false
}
func trafficFloat(value any) float64 {
switch typed := value.(type) {
case float64:
return typed
case float32:
return float64(typed)
case int:
return float64(typed)
case int64:
return float64(typed)
case json.Number:
parsed, _ := typed.Float64()
return parsed
case string:
parsed, _ := strconv.ParseFloat(typed, 64)
return parsed
default:
return 0
}
}
func (w *Worker) runCachedAutomation(ctx context.Context, client cloud.Client, account *app.Account, now time.Time, threshold float64, thresholdAction, shutdownMode string, keepAlive, monthlyAutoStart bool) {
protectionTraffic, protectionSource, available := w.protectionTrafficStatus(ctx, client, *account, account.TrafficUsed, account.TrafficAPIStatus != "error" && !account.ProtectionSuspended)
if !available {
account.ProtectionSuspended = true
if account.ProtectionSuspendReason == "" {
account.ProtectionSuspendReason = "traffic_api_error"
}
if err := w.Store.UpsertAccount(*account); err != nil {
w.Store.AddLog("error", "保存流量保护暂停状态失败: "+err.Error())
}
return
}
account.ProtectionSuspended, account.ProtectionSuspendReason = false, ""
usagePercent := 0.0
if account.MaxTraffic > 0 {
usagePercent = protectionTraffic / account.MaxTraffic * 100
}
requiresProtection := account.MaxTraffic > 0 && usagePercent >= threshold
if requiresProtection {
if thresholdAction == "stop_and_notify" && account.InstanceStatus == "Running" {
if err := client.StopInstance(ctx, account.RegionID, account.InstanceID, shutdownMode); err != nil {
w.Store.AddLog("error", "流量阈值停机失败: "+err.Error())
} else {
old := account.InstanceStatus
account.InstanceStatus = "Stopping"
account.ScheduleBlockedByTraffic = true
_ = w.Store.SetGroupScheduleBlocked(account.GroupKey, true)
w.Store.AddLog("warning", fmt.Sprintf("实例已达到流量保护阈值,已发起停机: %s (%.2f%%, 来源: %s)", account.InstanceID, usagePercent, protectionSource))
w.dispatchEvent(ctx, statusEvent(*account, old, "Stopping", fmt.Sprintf("%s 流量达到保护阈值,已提交停机。", protectionSource)))
w.dispatchEvent(ctx, trafficEvent(*account, protectionTraffic, usagePercent, threshold, fmt.Sprintf("已达到阈值,已提交停机(来源:%s", protectionSource)))
account.ProtectionNotifiedAt = now.Unix()
}
} else if thresholdAction == "notify_only" && (account.ProtectionNotifiedAt == 0 || now.Unix()-account.ProtectionNotifiedAt >= 6*60*60) {
w.dispatchEvent(ctx, trafficEvent(*account, protectionTraffic, usagePercent, threshold, fmt.Sprintf("仅发送告警(来源:%s", protectionSource)))
account.ProtectionNotifiedAt = now.Unix()
}
} else if !account.ScheduleBlockedByTraffic {
w.runSchedule(ctx, client, account, now, shutdownMode)
if monthlyAutoStart && now.Day() == 1 && account.InstanceStatus == "Stopped" && !account.AutoStartBlocked && !scheduledStopBlocksAutomaticStart(*account) && !sameMonth(account.LastKeepAliveAt, now) {
if err := client.StartInstance(ctx, account.RegionID, account.InstanceID); err == nil {
account.InstanceStatus = "Starting"
account.LastKeepAliveAt = now.Unix()
w.dispatchEvent(ctx, statusEvent(*account, "Stopped", "Starting", "每月 1 号自动开机。"))
}
}
if keepAlive && account.InstanceStatus == "Stopped" && canKeepAlive(*account, requiresProtection) {
if err := client.StartInstance(ctx, account.RegionID, account.InstanceID); err == nil {
account.InstanceStatus = "Starting"
account.LastKeepAliveAt = now.Unix()
w.dispatchEvent(ctx, statusEvent(*account, "Stopped", "Starting", "检测到实例停机,保活已提交开机。"))
}
}
}
if err := w.Store.UpsertAccount(*account); err != nil {
w.Store.AddLog("error", "保存缓存自动化状态失败: "+err.Error())
}
}
// applyTrafficProtection runs the same threshold and auto-start decisions
// after a fresh CMS sample or after CMS failed and CDT supplied the fallback.
func (w *Worker) applyTrafficProtection(ctx context.Context, client cloud.Client, account *app.Account, now time.Time, threshold float64, thresholdAction, shutdownMode string, cmsTraffic float64, cmsAvailable, keepAlive, monthlyAutoStart bool) bool {
protectionTraffic, protectionSource, available := w.protectionTrafficStatus(ctx, client, *account, cmsTraffic, cmsAvailable)
if !available {
account.ProtectionSuspended = true
if account.ProtectionSuspendReason == "" {
account.ProtectionSuspendReason = "traffic_api_error"
}
return false
}
account.ProtectionSuspended, account.ProtectionSuspendReason = false, ""
usagePercent := 0.0
if account.MaxTraffic > 0 {
usagePercent = protectionTraffic / account.MaxTraffic * 100
}
requiresProtection := account.MaxTraffic > 0 && usagePercent >= threshold
protectionAction := ""
if requiresProtection && thresholdAction == "stop_and_notify" && account.InstanceStatus == "Running" {
if err := client.StopInstance(ctx, account.RegionID, account.InstanceID, shutdownMode); err != nil {
w.Store.AddLog("error", "流量阈值停机失败: "+err.Error())
} else {
old := account.InstanceStatus
account.InstanceStatus = "Stopping"
account.ScheduleBlockedByTraffic = true
_ = w.Store.SetGroupScheduleBlocked(account.GroupKey, true)
w.Store.AddLog("warning", fmt.Sprintf("实例已达到流量保护阈值,已发起停机: %s (%.2f%%, 来源: %s)", account.InstanceID, usagePercent, protectionSource))
w.dispatchEvent(ctx, statusEvent(*account, old, "Stopping", fmt.Sprintf("%s 流量达到保护阈值,已提交停机。", protectionSource)))
protectionAction = fmt.Sprintf("已达到阈值,已提交停机(来源:%s", protectionSource)
}
}
if requiresProtection && thresholdAction == "notify_only" {
protectionAction = fmt.Sprintf("仅发送告警(来源:%s", protectionSource)
}
if protectionAction != "" && (account.ProtectionNotifiedAt == 0 || now.Unix()-account.ProtectionNotifiedAt >= 6*60*60) {
w.dispatchEvent(ctx, trafficEvent(*account, protectionTraffic, usagePercent, threshold, protectionAction))
account.ProtectionNotifiedAt = now.Unix()
}
if cmsAvailable && !requiresProtection && !account.ScheduleBlockedByTraffic {
w.runSchedule(ctx, client, account, now, shutdownMode)
if monthlyAutoStart && now.Day() == 1 && account.InstanceStatus == "Stopped" && !account.AutoStartBlocked && !scheduledStopBlocksAutomaticStart(*account) && !sameMonth(account.LastKeepAliveAt, now) {
if err := client.StartInstance(ctx, account.RegionID, account.InstanceID); err == nil {
account.InstanceStatus = "Starting"
account.LastKeepAliveAt = now.Unix()
w.dispatchEvent(ctx, statusEvent(*account, "Stopped", "Starting", "每月 1 号自动开机。"))
} else {
w.Store.AddLog("error", "月初自动开机失败: "+err.Error())
}
}
if keepAlive && account.InstanceStatus == "Stopped" && canKeepAlive(*account, requiresProtection) {
if err := client.StartInstance(ctx, account.RegionID, account.InstanceID); err == nil {
account.InstanceStatus = "Starting"
account.LastKeepAliveAt = now.Unix()
w.Store.AddLog("info", "保活已启动实例: "+account.InstanceID)
w.dispatchEvent(ctx, statusEvent(*account, "Stopped", "Starting", "检测到实例停机,保活已提交开机。"))
} else {
w.Store.AddLog("error", "保活启动失败: "+err.Error())
}
}
}
return true
}
func (w *Worker) refreshTraffic(ctx context.Context, client cloud.Client, account app.Account, now time.Time) (float64, string, string, error) {
month := now.Format("2006-01")
sample, err := w.Store.InstanceTrafficUsage(account.ID, account.InstanceID, month)
if err != nil {
return 0, "error", err.Error(), err
}
endMS := now.UnixMilli()
if monthlyClient, ok := client.(cloud.MonthlyTrafficClient); ok {
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).UnixMilli()
if monthlyBytes, points, monthlyErr := monthlyClient.GetInstanceMonthlyTraffic(ctx, account.RegionID, account.InstanceID, account.PublicIP, monthStart, endMS); monthlyErr == nil && points > 0 {
sample, err = w.Store.SetInstanceTraffic(account.ID, account.InstanceID, month, monthlyBytes, endMS)
if err != nil {
return 0, "error", err.Error(), err
}
return sample.TrafficBytes / (1024 * 1024 * 1024), "ok", "", nil
}
}
startMS := sample.LastSampleMS
if startMS <= 0 || startMS >= endMS {
startMS = endMS - int64(10*time.Minute/time.Millisecond)
}
delta, lastMS, points, _, metricErr := client.GetOutboundTrafficDelta(ctx, account.RegionID, account.InstanceID, account.PublicIP, startMS, endMS)
if metricErr != nil {
return 0, "error", "CMS 实例流量暂不可用: " + metricErr.Error(), metricErr
}
if points > 0 {
sample, err = w.Store.AddInstanceTraffic(account.ID, account.InstanceID, month, delta, lastMS)
if err != nil {
return 0, "error", err.Error(), err
}
}
return sample.TrafficBytes / (1024 * 1024 * 1024), "ok", "", nil
}
func (w *Worker) runSchedule(ctx context.Context, client cloud.Client, account *app.Account, now time.Time, shutdownMode string) {
if !account.ScheduleEnabled {
return
}
today := now.Format("2006-01-02")
stopHandled := false
if account.ScheduleStopEnabled && scheduleDue(now, account.StopTime, account.ScheduleLastStopDate) {
if account.InstanceStatus == "Running" {
if err := client.StopInstance(ctx, account.RegionID, account.InstanceID, shutdownMode); err == nil {
account.InstanceStatus = "Stopping"
account.ScheduleStopActive = true
account.ScheduleLastStopDate = today
stopHandled = true
_ = w.Store.UpdateScheduleExecutionState(account.ID, "stop", today)
w.Store.AddLog("info", "执行定时停机: "+account.InstanceID)
w.dispatchEvent(ctx, statusEvent(*account, "Running", "Stopping", "已按计划时间执行定时停机。"))
}
} else if account.InstanceStatus == "Stopped" || account.InstanceStatus == "Stopping" {
account.ScheduleStopActive = true
account.ScheduleLastStopDate = today
stopHandled = true
_ = w.Store.UpdateScheduleExecutionState(account.ID, "stop", today)
}
}
// If the controller missed today's start window, do not catch up by
// starting the instance after today's scheduled stop has already run.
if stopHandled && account.ScheduleStartEnabled && scheduleDue(now, account.StartTime, account.ScheduleLastStartDate) {
account.ScheduleLastStartDate = today
_ = w.Store.UpdateScheduleExecutionState(account.ID, "start", today)
}
if account.ScheduleStartEnabled && scheduleDue(now, account.StartTime, account.ScheduleLastStartDate) {
if account.InstanceStatus == "Stopped" {
if err := client.StartInstance(ctx, account.RegionID, account.InstanceID); err == nil {
account.InstanceStatus = "Starting"
account.ScheduleStopActive = false
account.ScheduleLastStartDate = today
_ = w.Store.UpdateScheduleExecutionState(account.ID, "start", today)
w.Store.AddLog("info", "执行定时开机: "+account.InstanceID)
w.dispatchEvent(ctx, statusEvent(*account, "Stopped", "Starting", "已按计划时间执行定时开机。"))
}
} else if account.InstanceStatus == "Running" {
account.ScheduleStopActive = false
account.ScheduleLastStartDate = today
_ = w.Store.UpdateScheduleExecutionState(account.ID, "start", today)
}
}
}
func scheduleDue(now time.Time, configured, lastDate string) bool {
if configured == "" || lastDate == now.Format("2006-01-02") {
return false
}
t, err := time.ParseInLocation("15:04", configured, now.Location())
if err != nil {
return false
}
return now.Hour() > t.Hour() || (now.Hour() == t.Hour() && now.Minute() >= t.Minute())
}
func scheduledStopBlocksAutomaticStart(account app.Account) bool {
return account.ScheduleEnabled && account.ScheduleStopEnabled && account.ScheduleStopActive
}
// canKeepAlive is intentionally independent of instance billing type. An
// externally stopped instance is recoverable unless a local safety rule says
// that the stop was intentional or traffic protection is active.
func canKeepAlive(account app.Account, requiresProtection bool) bool {
return !requiresProtection && !account.ScheduleBlockedByTraffic && !account.AutoStartBlocked && !scheduledStopBlocksAutomaticStart(account)
}
func sameMonth(unix int64, now time.Time) bool {
if unix <= 0 {
return false
}
t := time.Unix(unix, 0)
return t.Year() == now.Year() && t.Month() == now.Month()
}
func (w *Worker) Run(ctx context.Context) {
if w.Log == nil {
w.Log = log.Default()
}
for {
select {
case <-ctx.Done():
return
default:
}
job, err := w.Store.ClaimJob(2 * time.Minute)
if err != nil {
w.Log.Printf("claim job: %v", err)
sleep(ctx, 2*time.Second)
continue
}
if job == nil {
sleep(ctx, time.Second)
continue
}
if err := w.execute(ctx, job); err != nil {
w.Log.Printf("job %s: %v", job.JobID, err)
maxAttempts := 5
if job.Kind == "delete_instance" {
maxAttempts = 20
}
if job.Attempts < maxAttempts {
_ = w.Store.RetryJob(job.JobID, retryDelay(job.Attempts), err.Error())
} else {
if job.Kind == "delete_instance" {
if id, parseErr := strconv.ParseInt(job.EntityKey, 10, 64); parseErr == nil {
_ = w.Store.SetInstanceStatus(id, "ReleaseFailed")
}
}
_ = w.Store.FailJob(job.JobID, err.Error())
}
continue
}
_ = w.Store.FinishJob(job.JobID)
}
}
func (w *Worker) execute(ctx context.Context, job *store.Job) error {
switch job.Kind {
case "create_ecs":
return w.createECS(ctx, job)
case "delete_instance":
return w.deleteInstance(ctx, job)
case "delete_ddns":
return w.deleteDDNSJob(ctx, job)
default:
return fmt.Errorf("unknown job kind %q", job.Kind)
}
}
func (w *Worker) deleteDDNSJob(ctx context.Context, job *store.Job) error {
var payload struct {
Account app.Account `json:"account"`
Before []app.Account `json:"before"`
}
if err := json.Unmarshal([]byte(job.Payload), &payload); err != nil {
return err
}
if payload.Account.InstanceID == "" {
return fmt.Errorf("DDNS cleanup payload has no instance")
}
if len(payload.Before) == 0 {
payload.Before = []app.Account{payload.Account}
}
return w.deleteDDNSAccount(ctx, payload.Account, payload.Before)
}
func (w *Worker) deleteInstance(ctx context.Context, job *store.Job) error {
id, err := strconv.ParseInt(job.EntityKey, 10, 64)
if err != nil {
return err
}
account, err := w.Store.Account(id, false)
if err != nil {
return err
}
client := w.Cloud
if w.CloudFactory != nil {
client = w.CloudFactory(app.AccountGroup{AccessKeyID: account.AccessKeyID, AccessKeySecret: account.AccessKeySecret, RegionID: account.RegionID, SiteType: account.SiteType})
}
if client == nil {
return fmt.Errorf("cloud client is not configured")
}
// Capture the pre-delete group membership. Multi-instance DDNS names use
// the group count, so recomputing after the row is marked deleted would
// derive a different record name and leave the old record behind.
beforeAccounts, _ := w.Store.LoadAccounts(false)
status := account.InstanceStatus
notFound := false
if status != "Stopped" && status != "Released" {
instance, describeErr := client.DescribeInstance(ctx, account.RegionID, account.InstanceID)
if cloud.IsNotFound(describeErr) {
notFound = true
} else if describeErr != nil {
return describeErr
} else if instance == nil {
return fmt.Errorf("云端未返回实例状态,暂缓释放")
} else {
status = instance.Status
}
}
if !notFound && status != "Stopped" && status != "Released" {
if status != "Stopping" {
if err := client.StopInstance(ctx, account.RegionID, account.InstanceID, w.Store.GetSetting("shutdown_mode", "KeepCharging")); err != nil {
return err
}
_ = w.Store.SetInstanceStatus(id, "Stopping")
}
return fmt.Errorf("实例当前状态为 %s等待停机后继续释放", status)
}
if account.EIPManaged && account.EIPAllocationID != "" {
if err := client.UnassociateEIP(ctx, account.RegionID, account.EIPAllocationID); err != nil && !cloud.IsNotFound(err) {
return err
}
if err := client.ReleaseEIP(ctx, account.RegionID, account.EIPAllocationID); err != nil && !cloud.IsNotFound(err) {
return err
}
}
if !notFound {
if err := client.DeleteInstance(ctx, account.RegionID, account.InstanceID); err != nil && !cloud.IsNotFound(err) {
return err
}
}
// Keep the local row in Releasing until the external DNS record is also
// gone, so a transient Cloudflare failure remains retryable.
if err := w.deleteDDNSAccount(ctx, *account, beforeAccounts); err != nil {
return err
}
if err := w.Store.PhysicallyDelete(id); err != nil {
return err
}
before := beforeAccounts
if len(before) == 0 {
before = []app.Account{*account}
}
w.syncAllDDNS(ctx)
w.dispatchEvent(ctx, notify.Event{Title: "实例已释放", Summary: "实例已从云端释放,本地记录和 DDNS 已清理。", AccountID: accountLabel(*account), Text: fmt.Sprintf("【ECS Controller】实例已释放\n实例: %s\n实例 ID: %s\n区域: %s\n公网 IP: %s\n时间: %s", accountLabel(*account), account.InstanceID, account.RegionID, account.PublicIP, time.Now().Format("2006-01-02 15:04:05")), Fields: map[string]string{"instance_id": account.InstanceID, "region": account.RegionID, "public_ip": account.PublicIP}})
w.Store.AddLog("info", "实例已异步释放: "+account.InstanceID)
return nil
}
func (w *Worker) createECS(ctx context.Context, job *store.Job) (err error) {
if w.Cloud == nil && w.CloudFactory == nil {
return fmt.Errorf("cloud client is not configured")
}
task, err := w.Store.GetTaskForWorker(job.EntityKey)
if err != nil {
return err
}
var payload map[string]any
if err := json.Unmarshal([]byte(job.Payload), &payload); err != nil {
return err
}
groups, err := w.Store.LoadGroups()
if err != nil {
return err
}
var group *app.AccountGroup
for i := range groups {
if groups[i].GroupKey == task.GroupKey {
group = &groups[i]
break
}
}
if group == nil {
return fmt.Errorf("account group %s not found", task.GroupKey)
}
client := w.Cloud
if w.CloudFactory != nil {
client = w.CloudFactory(*group)
}
if client == nil {
return fmt.Errorf("cloud client is not configured")
}
zoneID := stringOr(payload, "zoneId", "")
if zoneID == "" || zoneID == "待由云 API 选择" {
zones, zoneErr := client.DescribeZones(ctx, task.RegionID)
if zoneErr != nil || len(zones) == 0 {
if zoneErr != nil {
return fmt.Errorf("选择可用区失败: %w", zoneErr)
}
return fmt.Errorf("区域没有可用区")
}
zoneID = stringOr(zones[0], "ZoneId", stringOr(zones[0], "zoneId", ""))
if zoneID == "" {
return fmt.Errorf("云 API 未返回可用区")
}
payload["zoneId"] = zoneID
_ = w.Store.UpdateTask(task.TaskID, map[string]any{"zone_id": zoneID})
}
var createdInstance, allocationID string
var createdVPC, createdVSwitch, createdSecurityGroup string
networkCreated := false
defer func() {
if err == nil {
return
}
rollbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Every resource created by this task is tracked locally before moving on
// to the next step, so retries can compensate known partial state.
if allocationID != "" {
_ = client.UnassociateEIP(rollbackCtx, task.RegionID, allocationID)
_ = client.ReleaseEIP(rollbackCtx, task.RegionID, allocationID)
}
if createdInstance != "" {
_ = client.DeleteInstance(rollbackCtx, task.RegionID, createdInstance)
}
if networkCreated {
_ = client.CleanupNetwork(rollbackCtx, task.RegionID, createdVPC, createdVSwitch, createdSecurityGroup)
}
_ = w.Store.UpdateTask(task.TaskID, map[string]any{"status": "failed", "step": "已回滚云资源", "error_message": err.Error(), "instance_id": createdInstance, "eip_allocation_id": allocationID})
}()
_ = w.Store.UpdateTask(task.TaskID, map[string]any{"status": "running", "step": "准备网络"})
vpcID := stringOr(payload, "vpcId", "")
vswitchID := stringOr(payload, "vswitchId", "")
securityGroupID := stringOr(payload, "securityGroupId", "")
if vpcID == "" || vswitchID == "" || securityGroupID == "" {
loginPort := intField(payload, "loginPort")
if loginPort == 0 {
loginPort = loginPortForOS(stringOr(payload, "osKey", ""))
}
if networkClient, ok := client.(cloud.NetworkClient); ok {
vpcID, vswitchID, securityGroupID, err = networkClient.PrepareNetworkForPort(ctx, task.RegionID, stringOr(payload, "cidr", "192.168.0.0/16"), zoneID, stringOr(payload, "clientCidrIp", "127.0.0.1/32"), loginPort)
} else {
vpcID, vswitchID, securityGroupID, err = client.PrepareNetwork(ctx, task.RegionID, stringOr(payload, "cidr", "192.168.0.0/16"), zoneID, stringOr(payload, "clientCidrIp", "127.0.0.1/32"))
}
createdVPC, createdVSwitch, createdSecurityGroup = vpcID, vswitchID, securityGroupID
networkCreated = true
if err != nil {
return err
}
_ = w.Store.UpdateTask(task.TaskID, map[string]any{"vpc_id": vpcID, "vswitch_id": vswitchID, "security_group_id": securityGroupID})
}
_ = w.Store.UpdateTask(task.TaskID, map[string]any{"status": "running", "step": "创建 ECS 实例"})
password := stringOr(payload, "loginPassword", task.LoginPassword)
if stringOr(payload, "imageId", "") == "" {
return fmt.Errorf("预检未返回有效镜像 ID")
}
run, err := client.RunInstances(ctx, cloud.RunRequest{RegionID: task.RegionID, ZoneID: zoneID, InstanceType: task.InstanceType, ImageID: stringOr(payload, "imageId", ""), InstanceName: stringOr(payload, "instanceName", "ecs-controller"), VPCID: vpcID, VSwitchID: vswitchID, SecurityGroupID: securityGroupID, Bandwidth: intField(payload, "internetMaxBandwidthOut"), DiskSize: intField(payload, "systemDiskSize"), DiskCategory: stringOr(payload, "systemDiskCategory", "cloud_essd"), PublicIPMode: stringOr(payload, "publicIpMode", "ecs_public_ip"), Password: password, LoginPort: intField(payload, "loginPort"), ClientToken: task.TaskID})
if err != nil {
return err
}
createdInstance = run.InstanceID
if createdInstance == "" {
return fmt.Errorf("RunInstances returned no instance id")
}
_ = w.Store.UpdateTask(task.TaskID, map[string]any{"instance_id": createdInstance, "public_ip": run.PublicIP, "login_user": stringOr(payload, "loginUser", "root"), "login_password": password, "step": "等待实例网络就绪"})
publicIP := run.PublicIP
if stringOr(payload, "publicIpMode", "ecs_public_ip") == "eip" {
allocationID, publicIP, err = allocateEIP(ctx, client, task.RegionID, intField(payload, "internetMaxBandwidthOut"))
if err != nil {
return err
}
if err = client.AssociateEIP(ctx, task.RegionID, allocationID, createdInstance); err != nil {
return err
}
_ = w.Store.UpdateTask(task.TaskID, map[string]any{"eip_allocation_id": allocationID, "eip_address": publicIP, "eip_managed": true, "public_ip": publicIP})
}
a := app.Account{AccessKeyID: group.AccessKeyID, AccessKeySecret: group.AccessKeySecret, RegionID: group.RegionID, InstanceID: createdInstance, MaxTraffic: group.MaxTraffic, ScheduleEnabled: group.ScheduleEnabled, ScheduleStartEnabled: group.ScheduleStartEnabled, ScheduleStopEnabled: group.ScheduleStopEnabled, StartTime: group.StartTime, StopTime: group.StopTime, Remark: group.Remark, SiteType: group.SiteType, GroupKey: group.GroupKey, InstanceName: stringOr(payload, "instanceName", ""), InstanceType: task.InstanceType, InternetBandwidth: intField(payload, "internetMaxBandwidthOut"), PublicIP: publicIP, PublicIPMode: stringOr(payload, "publicIpMode", "ecs_public_ip"), EIPAllocationID: allocationID, EIPAddress: publicIP, EIPManaged: allocationID != "", InstanceStatus: "Running", UpdatedAt: time.Now().Unix()}
if err = w.Store.UpsertAccount(a); err != nil {
return err
}
w.syncDDNSAccount(ctx, a)
_ = w.Store.UpdateTask(task.TaskID, map[string]any{"status": "success", "step": "创建完成", "public_ip": publicIP, "login_user": stringOr(payload, "loginUser", "root"), "login_password": password, "error_message": ""})
w.dispatchEvent(ctx, notify.Event{Title: "ECS 创建成功", Summary: "实例已创建并启动,请保存一次性登录密码。", AccountID: group.Remark, Text: fmt.Sprintf("【ECS Controller】ECS 创建成功\n账号: %s\n实例 ID: %s\n区域: %s\n规格: %s\n公网 IP: %s\n登录用户: %s\n初始密码: %s\n请立即保存并修改初始密码。", group.Remark, createdInstance, task.RegionID, task.InstanceType, publicIP, stringOr(payload, "loginUser", "root"), password), Fields: map[string]string{"instance_id": createdInstance, "region": task.RegionID, "public_ip": publicIP, "password": password}})
return nil
}
func allocateEIP(ctx context.Context, client cloud.Client, region string, bandwidth int) (string, string, error) {
if bandwidthClient, ok := client.(cloud.BandwidthEIPClient); ok {
return bandwidthClient.AllocateEIPWithBandwidth(ctx, region, bandwidth)
}
return client.AllocateEIP(ctx, region)
}
func retryDelay(attempt int) time.Duration {
if attempt < 1 {
attempt = 1
}
d := time.Duration(1<<min(attempt, 5)) * time.Second
return d
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func sleep(ctx context.Context, d time.Duration) {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
case <-t.C:
}
}
func stringField(m map[string]any, key string) (string, bool) {
v, ok := m[key]
if !ok {
return "", false
}
s, ok := v.(string)
return s, ok
}
func stringOr(m map[string]any, key, fallback string) string {
if v, ok := stringField(m, key); ok && v != "" {
return v
}
return fallback
}
func intField(m map[string]any, key string) int {
switch v := m[key].(type) {
case float64:
return int(v)
case int:
return v
case string:
var n int
_, _ = fmt.Sscanf(v, "%d", &n)
return n
}
return 0
}
func loginPortForOS(osKey string) int {
if strings.HasPrefix(strings.ToLower(osKey), "windows") {
return 3389
}
return 22
}