物联网软件
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="GoResourceLeak" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<functions>
|
||||
<function importPath="github.com/docker/docker/client" name="NewClientWithOpts" />
|
||||
</functions>
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
@@ -0,0 +1,5 @@
|
||||
version_code: 0
|
||||
pid: 0
|
||||
need_update: false
|
||||
control_state: CONNECTED
|
||||
last_alive_at: 1767926721
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
version_code: 26
|
||||
pid: 26612
|
||||
need_update: false
|
||||
control_state: CONNECTED
|
||||
last_alive_at: 1769564518
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,31 @@
|
||||
[Unit]
|
||||
Description=Sentinel Updater Service
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
# 启动前确保可执行权限
|
||||
ExecStartPre=/bin/chmod +x /opt/edge/updater
|
||||
|
||||
ExecStart=/opt/edge/updater
|
||||
|
||||
WorkingDirectory=/opt/edge/
|
||||
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
# 崩溃自动拉起
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# 日志直接进 journalctl
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# 可选:限制资源
|
||||
LimitNOFILE=65536
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+19
-1
@@ -3,9 +3,18 @@ module sentinel
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/docker v28.5.2+incompatible // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
@@ -16,7 +25,11 @@ require (
|
||||
github.com/minio/crc64nvme v1.1.0 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/minio/minio-go/v7 v7.0.97 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
|
||||
@@ -25,6 +38,11 @@ require (
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||
go.opentelemetry.io/otel v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||
golang.org/x/crypto v0.42.0 // indirect
|
||||
golang.org/x/net v0.44.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
|
||||
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
@@ -26,8 +47,20 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
|
||||
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/moby/api v1.52.0 h1:00BtlJY4MXkkt84WhUZPRqt5TvPbgig2FZvTbe3igYg=
|
||||
github.com/moby/moby/api v1.52.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
|
||||
github.com/moby/moby/client v0.2.1 h1:1Grh1552mvv6i+sYOdY+xKKVTvzJegcVMhuXocyDz/k=
|
||||
github.com/moby/moby/client v0.2.1/go.mod h1:O+/tw5d4a1Ha/ZA/tPxIZJapJRUS6LNZ1wiVRxYHyUE=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
@@ -44,6 +77,16 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
|
||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||
|
||||
@@ -1,109 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"fmt"
|
||||
"sentinel/pkg/config"
|
||||
"sentinel/pkg/device"
|
||||
"sentinel/pkg/docker"
|
||||
"sentinel/pkg/log"
|
||||
model2 "sentinel/pkg/model"
|
||||
"sentinel/pkg/utils"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type BusinessService struct {
|
||||
mqtt *MQTTService
|
||||
dockerManager docker.DockerManager
|
||||
deviceID string
|
||||
deptId string
|
||||
cmdTopic string
|
||||
deviceType string
|
||||
subscriptions map[string]struct{} // 记录已订阅 topic
|
||||
}
|
||||
|
||||
func NewBusinessService(m *MQTTService, deviceID string) *BusinessService {
|
||||
func NewBusinessService(m *MQTTService, deviceID string, dm docker.DockerManager) *BusinessService {
|
||||
return &BusinessService{
|
||||
mqtt: m,
|
||||
dockerManager: dm,
|
||||
deviceID: deviceID,
|
||||
subscriptions: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SubscribeTopic 订阅指定 topic,并记录可取消
|
||||
func (b *BusinessService) SubscribeTopic(topic string, qos byte) error {
|
||||
if err := b.mqtt.Subscribe(topic, qos); err != nil {
|
||||
func (b *BusinessService) Start() error {
|
||||
if err := b.mqtt.SubscribeTopic(getInitTopic(b.deviceID), 1); err != nil {
|
||||
return err
|
||||
}
|
||||
b.subscriptions[topic] = struct{}{}
|
||||
|
||||
b.mqtt.SetMessageHandler(b.onMQTTMessage)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getInitTopic(deviceID string) string {
|
||||
return "+/+/+/" + deviceID + "/#"
|
||||
}
|
||||
|
||||
func (b *BusinessService) getOwnTopic(deviceID string) string {
|
||||
return b.deptId + "/cmd/" + b.deviceType + "/" + deviceID + "/#"
|
||||
}
|
||||
|
||||
func (b *BusinessService) Start() error {
|
||||
if err := b.SubscribeTopic(getInitTopic(b.deviceID), 1); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.mqtt.SetMessageHandler(b.onMQTTMessage)
|
||||
|
||||
// 第一次连接就发送状态信息
|
||||
b.SendStatusInfo()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnsubscribeTopic 取消订阅指定 topic
|
||||
func (b *BusinessService) UnsubscribeTopic(topic string) error {
|
||||
token := b.mqtt.client.Unsubscribe(topic)
|
||||
if token.Wait() && token.Error() != nil {
|
||||
return token.Error()
|
||||
}
|
||||
delete(b.subscriptions, topic)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnsubscribeAll 取消所有已订阅 topic
|
||||
func (b *BusinessService) UnsubscribeAll() {
|
||||
for topic := range b.subscriptions {
|
||||
_ = b.mqtt.client.Unsubscribe(topic)
|
||||
delete(b.subscriptions, topic)
|
||||
}
|
||||
}
|
||||
|
||||
// 消息处理
|
||||
func (b *BusinessService) onMQTTMessage(topic string, payload []byte) {
|
||||
model := model2.FromStringToMqttTopic(topic)
|
||||
payload_json, err := utils.PayloadToMap(payload)
|
||||
if err != nil {
|
||||
fmt.Println("解析失败:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 指令
|
||||
if model.Domain == "cmd" && model.DeviceType == b.deviceType {
|
||||
log.Println("收到指令:", model.Resource)
|
||||
switch model.Resource {
|
||||
case "ping":
|
||||
log.Println("pong")
|
||||
case "shutdown":
|
||||
b.handleShutdown()
|
||||
payload_json["massage"] = b.handleShutdown()
|
||||
case "restart":
|
||||
b.handleRestart()
|
||||
payload_json["massage"] = b.handleRestart()
|
||||
case "check_update":
|
||||
b.handleCheckUpdate()
|
||||
payload_json["massage"] = b.handleCheckUpdate()
|
||||
default:
|
||||
log.Println("未知的命令:", model.Resource)
|
||||
payload_json["massage"] = "未知的命令"
|
||||
}
|
||||
topic := "x/receipt/x/" + device.GetDeviceID() + "/info"
|
||||
payload := utils.JSONToString(payload_json)
|
||||
b.mqtt.PublicMsg(topic, payload, false) // 回执
|
||||
} else if model.Domain == "status" && model.Resource == "receipt" {
|
||||
b.deviceType = model.DeviceType
|
||||
b.deptId = model.DeptId
|
||||
// 取消订阅之前的初始化主题
|
||||
if b.UnsubscribeTopic(getInitTopic(b.deviceID)) != nil {
|
||||
if b.mqtt.UnsubscribeTopic(getInitTopic(b.deviceID)) != nil {
|
||||
log.Error("无法取消初始化主题")
|
||||
return
|
||||
}
|
||||
// 新订阅属于自己的主题
|
||||
if b.SubscribeTopic(b.getOwnTopic(b.deviceID), 1) != nil {
|
||||
if b.mqtt.SubscribeTopic(b.getOwnTopic(b.deviceID), 1) != nil {
|
||||
log.Error("无法定于属于自己的主题")
|
||||
return
|
||||
}
|
||||
@@ -111,10 +91,10 @@ func (b *BusinessService) onMQTTMessage(topic string, payload []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BusinessService) SendStatusInfo() {
|
||||
func getStatusInfoTopicPayload(isOnline bool) (string, string) {
|
||||
info := map[string]interface{}{
|
||||
"version": config.APP_VERSION,
|
||||
"online": true,
|
||||
"online": isOnline,
|
||||
"ip": utils.GetLocalIP(),
|
||||
"hostname": utils.GetHostname(),
|
||||
"mac": utils.GetMacAddress(),
|
||||
@@ -124,86 +104,60 @@ func (b *BusinessService) SendStatusInfo() {
|
||||
"disk_total": utils.GetDisk(),
|
||||
"last_seen": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(info)
|
||||
topic := "x/status/x/" + b.deviceID + "/info"
|
||||
qos := byte(1)
|
||||
retained := true
|
||||
log.Println("发送消息:", topic)
|
||||
|
||||
if err := b.mqtt.Publish(topic, qos, retained, payload); err != nil {
|
||||
log.Println("发送状态信息出错:", err)
|
||||
} else {
|
||||
log.Println("发送状态信息:", string(payload))
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(info)
|
||||
payload := string(payloadBytes) // 转成 string
|
||||
return "x/status/x/" + device.GetDeviceID() + "/info", payload
|
||||
}
|
||||
|
||||
// 关闭程序(立即退出)
|
||||
func (b *BusinessService) handleShutdown() {
|
||||
os.Exit(0)
|
||||
func (b *BusinessService) handleShutdown() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
b.dockerManager.StopAndRemoveContainer(ctx)
|
||||
return "程序已退出"
|
||||
}
|
||||
|
||||
// 重启程序
|
||||
func (b *BusinessService) handleRestart() {
|
||||
exe, _ := os.Executable()
|
||||
cmd := exec.Command(exe)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
_ = cmd.Start()
|
||||
os.Exit(0)
|
||||
}
|
||||
func (b *BusinessService) handleCheckUpdate() {
|
||||
|
||||
args := []string{
|
||||
"--version", strconv.Itoa(config.APP_VERSION),
|
||||
func (b *BusinessService) handleRestart() string {
|
||||
log.Println("正在拉取镜像:", config.DOCKER_IMAGE)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if err := b.dockerManager.PullImage(ctx, config.DOCKER_IMAGE); err != nil {
|
||||
log.Fatalf("镜像拉取失败:%v", err)
|
||||
}
|
||||
|
||||
launcher := newUpdaterLauncher()
|
||||
|
||||
if err := launcher.Start(args); err != nil {
|
||||
log.Println("[BUS] failed to start updater:", err)
|
||||
return
|
||||
err := b.dockerManager.RunContainer(ctx)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
log.Println(
|
||||
"[BUS] updater started, exiting main program",
|
||||
)
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
os.Exit(0)
|
||||
return "程序已重启"
|
||||
}
|
||||
|
||||
func (b *BusinessService) handleCheckUpdate() string {
|
||||
cfg := config.LoadConfig()
|
||||
cfg.NeedUpdate = true
|
||||
if err := config.WriteLocalConfig(&cfg); err != nil {
|
||||
log.Println("load config failed:", err)
|
||||
}
|
||||
return "程序更新机制已触发"
|
||||
}
|
||||
|
||||
// handleCheckUpdate 触发更新流程(主程序侧)
|
||||
//func (b *BusinessService) handleCheckUpdate() {
|
||||
//
|
||||
// args := []string{
|
||||
// "--version", strconv.Itoa(config.APP_VERSION),
|
||||
// }
|
||||
//
|
||||
// cmd := exec.Command("./updater.exe", args...)
|
||||
// cmd.Stdout = os.Stdout
|
||||
// cmd.Stderr = os.Stderr
|
||||
// launcher := newUpdaterLauncher()
|
||||
//
|
||||
// // OS 级脱离父进程
|
||||
// switch runtime.GOOS {
|
||||
// case "windows":
|
||||
// cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
// CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if err := cmd.Start(); err != nil {
|
||||
// if err := launcher.Start(args); err != nil {
|
||||
// log.Println("[BUS] failed to start updater:", err)
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// log.Println(
|
||||
// "[BUS] updater started (pid=%d), exiting main program\n",
|
||||
// cmd.Process.Pid,
|
||||
// "[BUS] updater started, exiting main program",
|
||||
// )
|
||||
//
|
||||
// // 给 updater 留出启动窗口(尤其是 systemd / docker 环境)
|
||||
// time.Sleep(500 * time.Millisecond)
|
||||
//
|
||||
// os.Exit(0)
|
||||
//}
|
||||
|
||||
+80
-22
@@ -1,40 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"sentinel/pkg/config"
|
||||
"sentinel/pkg/device"
|
||||
"sentinel/pkg/docker"
|
||||
"sentinel/pkg/log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
banner := `
|
||||
==========================================================================
|
||||
|
||||
██████ █████ █████ ██████ ██ █████ ██████ ██ ██ ██████
|
||||
██ ██ ██ ██ ██ ████ ██ ██ ███ ██ ██
|
||||
█████ ██ ██ ██ ██ █████ ██ ██ ██ ██ █████ ████ ██ ██
|
||||
██ ██ ██ ██ ██ ██ ██████ ██ ██ ██ ██ ████ ██
|
||||
██████ █████ █████ ██████ ██ ██ █████ ██████ ██ ██ ██
|
||||
|
||||
==========================================================================
|
||||
`
|
||||
fmt.Println(banner)
|
||||
deviceID := device.GetDeviceID()
|
||||
log.Init(config.Log_file_dic) // 初始化日志目录
|
||||
log.Info("Device id: " + deviceID) // 第一次启动记录
|
||||
log.Println("版本号: ", config.APP_VERSION) // 第一次启动记录
|
||||
|
||||
// APP Logo
|
||||
InitPrint(deviceID)
|
||||
// Config
|
||||
InitConfigFile()
|
||||
// MQTT
|
||||
InitMQTT(deviceID)
|
||||
|
||||
}
|
||||
func InitConfigFile() {
|
||||
// 第一次写入默认配置
|
||||
cfg := config.AppConfig{
|
||||
VersionCode: config.APP_VERSION,
|
||||
PID: os.Getpid(),
|
||||
NeedUpdate: false,
|
||||
ControlState: "DEGRADED",
|
||||
LastAliveAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
// 写入文件
|
||||
if err := config.WriteLocalConfig(&cfg); err != nil {
|
||||
log.Println("[config] 写入初始配置失败:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func InitPrint(deviceID string) {
|
||||
banner := `
|
||||
==============================================================================
|
||||
|
||||
██████ █████ ██████ ██████ ██ ██████ ██████ ██ ██ ██████
|
||||
██ ██ ██ ██ ██ ████ ██ ██ ███ ██ ██
|
||||
█████ ██ ██ ██ ██ █████ ██ ██ ██ ██ █████ ████ ██ ██
|
||||
██ ██ ██ ██ ██ ██ ██████ ██ ██ ██ ██ ████ ██
|
||||
██████ █████ █████ ██████ ██ ██ █████ ██████ ██ ██ ██
|
||||
|
||||
==============================================================================
|
||||
`
|
||||
log.Println(banner)
|
||||
log.Println("Device id: "+deviceID, "\t版本号: ", config.APP_VERSION) // 第一次启动记录
|
||||
}
|
||||
|
||||
func InitMQTT(deviceID string) {
|
||||
var mqttSvc *MQTTService
|
||||
firstFail := true // 标记是否第一次失败
|
||||
mqttSvc = NewMQTTService(config.MQTT_BROKER, deviceID, deviceID, config.PASSWORD, 60)
|
||||
for {
|
||||
mqttSvc = NewMQTTService(config.MQTT_BROKER, deviceID, deviceID, config.PASSWORD, 60)
|
||||
err := mqttSvc.Connect()
|
||||
if err != nil {
|
||||
if firstFail {
|
||||
log.Error("物联网服务连接失败,如未注册设备,请先注册: " + deviceID)
|
||||
log.Error("物联网服务器连接失败,如未注册设备,请先注册: " + deviceID)
|
||||
firstFail = false
|
||||
}
|
||||
time.Sleep(3 * time.Second) // 5秒后重试
|
||||
@@ -44,7 +73,13 @@ func main() {
|
||||
}
|
||||
defer mqttSvc.Close()
|
||||
|
||||
biz := NewBusinessService(mqttSvc, deviceID)
|
||||
dm, err := docker.NewDockerManager()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
biz := NewBusinessService(mqttSvc, deviceID, *dm)
|
||||
|
||||
for {
|
||||
// MQTT业务
|
||||
err := biz.Start()
|
||||
@@ -56,9 +91,32 @@ func main() {
|
||||
}
|
||||
break
|
||||
}
|
||||
// 第一次运行直接启动
|
||||
biz.handleRestart()
|
||||
|
||||
// 主线程循环,可做心跳或状态上报
|
||||
for {
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
// 4️⃣ T2 超时自毁检查
|
||||
tickerLost := time.NewTicker(10 * time.Second)
|
||||
defer tickerLost.Stop()
|
||||
go func() {
|
||||
for range tickerLost.C {
|
||||
cfg := config.LoadConfig()
|
||||
now := time.Now().Unix()
|
||||
if cfg.ControlState == "DEGRADED" && now-cfg.LastAliveAt > config.DAEMON_ALIVE_GAP_SECONDS {
|
||||
log.Println("[main] MQTT 长期失联,进入 CONTROL_LOST")
|
||||
cfg.ControlState = "CONTROL_LOST"
|
||||
cfg.LastAliveAt = time.Now().Unix()
|
||||
if err := config.WriteLocalConfig(cfg); err != nil {
|
||||
log.Println("[main] 写状态失败:", err)
|
||||
}
|
||||
// 停止业务容器
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
dm.StopAndRemoveContainer(ctx)
|
||||
// 自杀退出,让 daemon 重启
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
<-make(chan struct{})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"sentinel/pkg/config"
|
||||
"sentinel/pkg/log"
|
||||
"time"
|
||||
|
||||
@@ -9,8 +10,9 @@ import (
|
||||
)
|
||||
|
||||
type MQTTService struct {
|
||||
client mqtt.Client
|
||||
handler func(topic string, payload []byte)
|
||||
client mqtt.Client
|
||||
handler func(topic string, payload []byte)
|
||||
subscriptions map[string]struct{} // 记录已订阅 topic
|
||||
}
|
||||
|
||||
func NewMQTTService(broker, clientID, username, password string, keepalive int) *MQTTService {
|
||||
@@ -19,20 +21,45 @@ func NewMQTTService(broker, clientID, username, password string, keepalive int)
|
||||
opts.SetClientID(clientID)
|
||||
opts.SetUsername(username)
|
||||
opts.SetPassword(password)
|
||||
opts.AutoReconnect = true
|
||||
opts.OnReconnecting = func(c mqtt.Client, opts *mqtt.ClientOptions) {
|
||||
log.Println("正在重连到物联网服务器...")
|
||||
}
|
||||
opts.SetKeepAlive(time.Duration(keepalive) * time.Second)
|
||||
opts.SetTLSConfig(&tls.Config{InsecureSkipVerify: true})
|
||||
|
||||
ms := &MQTTService{}
|
||||
// 设置遗嘱消息
|
||||
lwtTopic, lwtPayload := getStatusInfoTopicPayload(false)
|
||||
opts.SetWill(
|
||||
lwtTopic, // topic
|
||||
lwtPayload, // payload
|
||||
1, // qos
|
||||
false, // retained
|
||||
)
|
||||
ms := &MQTTService{subscriptions: make(map[string]struct{})}
|
||||
opts.SetDefaultPublishHandler(func(c mqtt.Client, m mqtt.Message) {
|
||||
if ms.handler != nil {
|
||||
ms.handler(m.Topic(), m.Payload())
|
||||
}
|
||||
})
|
||||
opts.OnConnect = func(c mqtt.Client) {
|
||||
log.Println("物联网服务已连接")
|
||||
log.Println("物联网服务器已连接")
|
||||
cfg := config.LoadConfig()
|
||||
cfg.ControlState = "CONNECTED"
|
||||
cfg.LastAliveAt = time.Now().Unix()
|
||||
config.WriteLocalConfig(cfg)
|
||||
// 连接成功 发送状态信息
|
||||
topic, payload := getStatusInfoTopicPayload(true)
|
||||
ms.PublicMsg(topic, payload, false)
|
||||
}
|
||||
opts.OnConnectionLost = func(c mqtt.Client, err error) {
|
||||
log.Println("物联网服务已断开:", err)
|
||||
log.Println("物联网服务器已断开:", err)
|
||||
cfg := config.LoadConfig()
|
||||
cfg.ControlState = "DEGRADED" // 表示短暂断开
|
||||
cfg.LastAliveAt = time.Now().Unix() // 更新这个时间戳,让 daemon 与 主程序看到尚可忍耐
|
||||
|
||||
if err := config.WriteLocalConfig(cfg); err != nil {
|
||||
log.Println("[main] 写状态失败:", err)
|
||||
}
|
||||
}
|
||||
|
||||
ms.client = mqtt.NewClient(opts)
|
||||
@@ -52,7 +79,7 @@ func (m *MQTTService) Subscribe(topic string, qos byte) error {
|
||||
if token.Wait() && token.Error() != nil {
|
||||
return token.Error()
|
||||
}
|
||||
log.Println("物联网服务消息订阅:", topic)
|
||||
log.Debug("物联网服务消息订阅:", topic)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -71,3 +98,48 @@ func (m *MQTTService) Close() {
|
||||
m.client.Disconnect(250)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MQTTService) UnsubscribeTopic(topic string) error {
|
||||
token := m.client.Unsubscribe(topic)
|
||||
go func() {
|
||||
if token.Wait() && token.Error() != nil {
|
||||
log.Println("取消订阅失败:", token.Error())
|
||||
} else {
|
||||
delete(m.subscriptions, topic)
|
||||
log.Debug("取消订阅成功:", topic)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnsubscribeAll 取消所有已订阅 topic
|
||||
func (m *MQTTService) UnsubscribeAll() {
|
||||
for topic := range m.subscriptions {
|
||||
_ = m.client.Unsubscribe(topic)
|
||||
delete(m.subscriptions, topic)
|
||||
}
|
||||
}
|
||||
|
||||
// SubscribeTopic 订阅指定 topic,并记录可取消
|
||||
func (m *MQTTService) SubscribeTopic(topic string, qos byte) error {
|
||||
token := m.client.Subscribe(topic, qos, nil)
|
||||
go func() {
|
||||
if token.Wait() && token.Error() != nil {
|
||||
log.Println("订阅失败:", token.Error())
|
||||
} else {
|
||||
m.subscriptions[topic] = struct{}{}
|
||||
log.Debug("订阅成功:", topic)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MQTTService) PublicMsg(topic string, payload string, retained bool) {
|
||||
qos := byte(1)
|
||||
log.Debug("发送消息:", topic)
|
||||
if err := m.Publish(topic, qos, retained, payload); err != nil {
|
||||
log.Println("发送信息出错:", err)
|
||||
} else {
|
||||
log.Debug("发送信息:", string(payload))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,105 @@
|
||||
package config
|
||||
|
||||
// 变动
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// 常量
|
||||
const (
|
||||
// 版本号
|
||||
APP_VERSION = 1
|
||||
APP_VERSION = 26
|
||||
|
||||
BASE_URL = "https://ai.ronsunny.cn:8090"
|
||||
//BASE_URL = "http://127.0.0.1:13011"
|
||||
Log_file_dic = "./logs"
|
||||
MQTT_BROKER = "tls://ai.ronsunny.cn:8093"
|
||||
PASSWORD = "123456"
|
||||
//BASE_URL = "https://ai.ronsunny.cn:8090"
|
||||
BASE_URL = "http://127.0.0.1:13011"
|
||||
LOG_FILE_DIC = "./logs"
|
||||
LOG_CHECK_INTERVAL_HOURS = 10 // 文件轮询检查间隔(秒)
|
||||
MQTT_BROKER = "tls://ai.ronsunny.cn:8093"
|
||||
PASSWORD = "123456"
|
||||
|
||||
DOCKER_REGISTRY = "ai.ronsunny.cn:13011"
|
||||
DOCKER_USERNAME = "iot_device"
|
||||
DOCKER_PASSWORD = "Bbit000000"
|
||||
DOCKER_IMAGE = "ai.ronsunny.cn:13011/bbit_iot/ce_sentinel"
|
||||
DOCKER_CONTAINER_NAME = "BBIT_Project"
|
||||
DOCKER_TIME_OUT = 30 * time.Second
|
||||
|
||||
DAEMON_UPDATE_CHECK_INTERVAL_SECONDS = 5 // 文件轮询检查间隔(秒)
|
||||
DAEMON_ALIVE_GAP_SECONDS = 30
|
||||
)
|
||||
|
||||
var (
|
||||
// DeviceType string
|
||||
// DeptId string
|
||||
DOCKER_CONTAINER_BINDS = []string{
|
||||
"/opt/sentinel/logs:/app/logs:rw",
|
||||
}
|
||||
)
|
||||
|
||||
type AppConfig struct {
|
||||
VersionCode int `yaml:"version_code"`
|
||||
PID int `yaml:"pid"`
|
||||
NeedUpdate bool `yaml:"need_update"`
|
||||
/**
|
||||
状态 语义
|
||||
CONNECTED MQTT 已连,设备可控
|
||||
DEGRADED MQTT 短暂失联,容忍期
|
||||
CONTROL_LOST MQTT 长期失联,不应继续运行
|
||||
*/
|
||||
ControlState string `yaml:"control_state"` // CONNECTED / DEGRADED / CONTROL_LOST
|
||||
LastAliveAt int64 `yaml:"last_alive_at"` // Unix 秒
|
||||
}
|
||||
|
||||
func LoadConfig() *AppConfig {
|
||||
cfg := &AppConfig{}
|
||||
if err := loadLocalConfig(cfg); err != nil {
|
||||
print("[daemon] 加载配置失败,当作新状态处理:", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func loadLocalConfig[T any](cfg *T) error {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(exePath)
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
|
||||
// 不存在就创建一个默认的
|
||||
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
||||
cfg := &AppConfig{
|
||||
VersionCode: -1,
|
||||
PID: -1,
|
||||
NeedUpdate: false,
|
||||
}
|
||||
return WriteLocalConfig(cfg)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
func WriteLocalConfig(cfg any) error {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(exePath)
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(cfgPath, data, 0644)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"sentinel/pkg/config"
|
||||
"time"
|
||||
|
||||
"sentinel/pkg/log"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
type DockerManager struct {
|
||||
registry string
|
||||
username string
|
||||
password string
|
||||
cli *client.Client
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func NewDockerManager() (*DockerManager, error) {
|
||||
cli, err := client.NewClientWithOpts(
|
||||
client.FromEnv,
|
||||
client.WithAPIVersionNegotiation(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DockerManager{
|
||||
cli: cli,
|
||||
registry: config.DOCKER_REGISTRY,
|
||||
username: config.DOCKER_USERNAME,
|
||||
password: config.DOCKER_PASSWORD,
|
||||
timeout: config.DOCKER_TIME_OUT,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DockerManager) PullImage(ctx context.Context, refStr string) error {
|
||||
auth := registry.AuthConfig{
|
||||
ServerAddress: d.registry,
|
||||
Username: d.username,
|
||||
Password: d.password,
|
||||
}
|
||||
authBytes, _ := json.Marshal(auth)
|
||||
authStr := base64.StdEncoding.EncodeToString(authBytes)
|
||||
|
||||
reader, err := d.cli.ImagePull(ctx, refStr, image.PullOptions{
|
||||
RegistryAuth: authStr,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
// 消费输出,避免挂起
|
||||
_, _ = io.Copy(io.Discard, reader)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DockerManager) RunContainer(ctx context.Context) error {
|
||||
// 停止并删除已有容器
|
||||
if err := d.StopAndRemoveContainer(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建容器
|
||||
log.Println("正在启动名为<", config.DOCKER_CONTAINER_NAME, ">的容器")
|
||||
resp, err := d.cli.ContainerCreate(
|
||||
ctx,
|
||||
&container.Config{
|
||||
Image: config.DOCKER_IMAGE,
|
||||
Healthcheck: &container.HealthConfig{
|
||||
Test: []string{"CMD-SHELL", "echo ok"},
|
||||
Interval: 5 * time.Second,
|
||||
Timeout: 2 * time.Second,
|
||||
Retries: 3,
|
||||
},
|
||||
},
|
||||
&container.HostConfig{
|
||||
Binds: config.DOCKER_CONTAINER_BINDS,
|
||||
NetworkMode: "host", // <-- 使用宿主机网络
|
||||
},
|
||||
nil, // NetworkConfig
|
||||
nil, // Platform
|
||||
config.DOCKER_CONTAINER_NAME,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 启动容器
|
||||
if err := d.cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Println("容器已成功运行")
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopAndRemoveContainer 停止并删除指定容器
|
||||
func (d *DockerManager) StopAndRemoveContainer(ctx context.Context) error {
|
||||
|
||||
containers, err := d.cli.ContainerList(ctx, container.ListOptions{
|
||||
All: true,
|
||||
Filters: filters.NewArgs(
|
||||
filters.KeyValuePair{Key: "name", Value: config.DOCKER_CONTAINER_NAME},
|
||||
),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, c := range containers {
|
||||
log.Println("正在停止名为<", config.DOCKER_CONTAINER_NAME, ">的容器:", c.ID)
|
||||
timeout := 10 * time.Second
|
||||
seconds := int(timeout.Seconds())
|
||||
if err := d.cli.ContainerStop(ctx, c.ID, container.StopOptions{
|
||||
Timeout: &seconds,
|
||||
}); err != nil {
|
||||
log.Println("Failed to stop container %s: %v", c.ID, err)
|
||||
return err
|
||||
}
|
||||
if err := d.cli.ContainerRemove(ctx, c.ID, container.RemoveOptions{}); err != nil {
|
||||
log.Println("Failed to remove container %s: %v", c.ID, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DockerManager) CheckContainerHealth(ctx context.Context, containerName string) (string, error) {
|
||||
if containerName == "" {
|
||||
return "", errors.New("containerName must not be empty")
|
||||
}
|
||||
containers, err := d.cli.ContainerList(ctx, container.ListOptions{
|
||||
All: true,
|
||||
Filters: filters.NewArgs(
|
||||
filters.KeyValuePair{Key: "name", Value: containerName},
|
||||
),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(containers) == 0 {
|
||||
return "", errors.New("container not found")
|
||||
}
|
||||
|
||||
inspect, err := d.cli.ContainerInspect(ctx, containers[0].ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if inspect.State.Health != nil {
|
||||
return inspect.State.Health.Status, nil
|
||||
}
|
||||
return "unknown", nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sentinel/pkg/config"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -17,7 +18,14 @@ func Init(dir string) {
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
fmt.Println("create log dir failed:", err)
|
||||
}
|
||||
cleanupOldLogs()
|
||||
go func() {
|
||||
ticker := time.NewTicker(config.LOG_CHECK_INTERVAL_HOURS * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
cleanupOldLogs()
|
||||
}
|
||||
}()
|
||||
|
||||
}
|
||||
|
||||
// Cleanup 删除超过7天的日志文件
|
||||
@@ -73,6 +81,11 @@ func Println(v ...interface{}) {
|
||||
msg := fmt.Sprint(v...)
|
||||
logToFile("INFO", msg)
|
||||
}
|
||||
|
||||
func Debug(v ...interface{}) {
|
||||
//msg := fmt.Sprint(v...)
|
||||
//logToFile("DEBUF", msg)
|
||||
}
|
||||
func Warn(msg string) {
|
||||
logToFile("WARN", msg)
|
||||
}
|
||||
|
||||
@@ -8,4 +8,8 @@ type MainProgramStarter interface {
|
||||
Start(targetExe string) error
|
||||
|
||||
GetMainName() string
|
||||
|
||||
IsProcessRunning(pid int) (bool, error)
|
||||
|
||||
KillProcess(pid int) error
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
@@ -98,3 +99,19 @@ func GetDisk() string {
|
||||
}
|
||||
return fmt.Sprintf("%dGB", usage.Total/1024/1024/1024)
|
||||
}
|
||||
|
||||
func PayloadToMap(payload []byte) (map[string]interface{}, error) {
|
||||
var result map[string]interface{}
|
||||
err := json.Unmarshal(payload, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func JSONToString(data map[string]interface{}) string {
|
||||
jsonBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
+150
-93
@@ -1,132 +1,189 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sentinel/pkg/config"
|
||||
"sentinel/pkg/device"
|
||||
"sentinel/pkg/log"
|
||||
"sentinel/pkg/net"
|
||||
"strconv"
|
||||
"sentinel/pkg/platform"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 定义命令行参数
|
||||
version := flag.String("version", "", "current version of main program")
|
||||
log.Println("[daemon] 设备ID:", device.GetDeviceID())
|
||||
|
||||
flag.Parse()
|
||||
log.Init(config.LOG_FILE_DIC)
|
||||
|
||||
if *version == "" {
|
||||
// updater 视角:-1 表示“未知版本”,一定触发更新检测
|
||||
*version = "0"
|
||||
log.Println("[updater] --version not provided, fallback to -1")
|
||||
fmt.Println("[updater] 主程序版本号:", *version)
|
||||
}
|
||||
starter := newMainProgramStarter()
|
||||
// 启动主程序
|
||||
initMainProcess(starter)
|
||||
ticker := time.NewTicker(config.DAEMON_UPDATE_CHECK_INTERVAL_SECONDS * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
deviceID := device.GetDeviceID()
|
||||
fmt.Printf("[updater] 当前设备id: %s\n", deviceID)
|
||||
versionInt, err := strconv.Atoi(*version)
|
||||
if err != nil {
|
||||
log.Println("[updater] invalid version:", *version)
|
||||
versionInt = 0
|
||||
}
|
||||
if err := RunUpdate(deviceID, versionInt); err != nil {
|
||||
log.Fatalf("[updater] 更新失败: %v", err)
|
||||
}
|
||||
fmt.Println("[updater] 更新程序结束")
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
//log.Println("[daemon] 定时检查主程序运行状态")
|
||||
handleProcessCheck(starter)
|
||||
|
||||
cfg := config.LoadConfig()
|
||||
if cfg.NeedUpdate {
|
||||
log.Println("[daemon] 配置文件标记需要更新,触发更新")
|
||||
cfg.NeedUpdate = false
|
||||
if err := config.WriteLocalConfig(cfg); err != nil {
|
||||
log.Println("[daemon] 写回配置文件失败:", err)
|
||||
}
|
||||
handleUpdate(starter)
|
||||
}
|
||||
}
|
||||
}()
|
||||
select {}
|
||||
}
|
||||
func RunUpdate(deviceID string, version int) error {
|
||||
// 1. 检查更新
|
||||
info, err := api.CheckUpdate(deviceID)
|
||||
if err != nil {
|
||||
fmt.Println("[updater] 请求错误,请检查网络")
|
||||
return err
|
||||
}
|
||||
fmt.Println("[updater] 新版本:", info.Version)
|
||||
fmt.Println("[updater] 新内容:", info.Notes)
|
||||
fmt.Println("[updater] 下载地址:", info.DownloadURL)
|
||||
|
||||
func initMainProcess(starter platform.MainProgramStarter) {
|
||||
if err := startMainProgram(starter); err != nil {
|
||||
log.Println("[daemon] 启动失败,回退到更新:", err)
|
||||
handleUpdate(starter)
|
||||
}
|
||||
}
|
||||
|
||||
var lastState string
|
||||
var lastPID int
|
||||
|
||||
func handleProcessCheck(starter platform.MainProgramStarter) {
|
||||
cfg := config.LoadConfig()
|
||||
|
||||
// 1. PID 是否存在
|
||||
running, err := starter.IsProcessRunning(cfg.PID)
|
||||
if cfg.PID <= 0 || err != nil || !running {
|
||||
if lastState != "NOT_RUNNING" {
|
||||
log.Println("[daemon] 主程序未运行,尝试启动")
|
||||
lastState = "NOT_RUNNING"
|
||||
lastPID = 0
|
||||
}
|
||||
restartMain(starter)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 语义健康判断
|
||||
switch cfg.ControlState {
|
||||
case "DEGRADED":
|
||||
if lastState != "DEGRADED" {
|
||||
log.Println("[daemon] 主程序存在错误,等待主程序自行处理")
|
||||
lastState = "DEGRADED"
|
||||
lastPID = cfg.PID
|
||||
}
|
||||
return
|
||||
case "CONTROL_LOST":
|
||||
if lastState != "CONTROL_LOST" {
|
||||
log.Println("[daemon] 主程序心跳超时,已失控,强制重启")
|
||||
lastState = "CONTROL_LOST"
|
||||
lastPID = cfg.PID
|
||||
}
|
||||
restartMain(starter)
|
||||
return
|
||||
default:
|
||||
// 3. 健康
|
||||
if lastState != "HEALTHY" || lastPID != cfg.PID {
|
||||
log.Println("[daemon] 主程序健康运行,PID:", cfg.PID)
|
||||
lastState = "HEALTHY"
|
||||
lastPID = cfg.PID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restartMain(starter platform.MainProgramStarter) {
|
||||
cfg := config.LoadConfig()
|
||||
if cfg.PID > 0 {
|
||||
_ = starter.KillProcess(cfg.PID)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}
|
||||
_ = startMainProgram(starter)
|
||||
}
|
||||
|
||||
func handleUpdate(starter platform.MainProgramStarter) {
|
||||
cfg := config.LoadConfig()
|
||||
info, err := api.CheckUpdate(device.GetDeviceID())
|
||||
if err != nil {
|
||||
log.Println("[daemon] 检查更新失败:", err)
|
||||
return
|
||||
}
|
||||
|
||||
if info.Version <= cfg.VersionCode {
|
||||
log.Println("[daemon] 没有新版本,跳过")
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[daemon] 发现新版本 %d\n", info.Version)
|
||||
|
||||
// 停止主程序
|
||||
if cfg.PID > 0 {
|
||||
_ = starter.KillProcess(cfg.PID)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
if err := downloadAndReplace(starter, info.DownloadURL); err != nil {
|
||||
log.Println("[daemon] 更新失败:", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := startMainProgram(starter); err != nil {
|
||||
log.Println("[daemon] 更新后启动失败:", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[daemon] 更新完成")
|
||||
}
|
||||
|
||||
func startMainProgram(starter platform.MainProgramStarter) error {
|
||||
selfPath, _ := os.Executable()
|
||||
selfDir := filepath.Dir(selfPath)
|
||||
targetExe := filepath.Join(selfDir, starter.GetMainName())
|
||||
|
||||
if _, err := os.Stat(targetExe); os.IsNotExist(err) {
|
||||
log.Println("[daemon] 主程序文件不存在,触发更新")
|
||||
handleUpdate(starter)
|
||||
return nil // 先不启动,等更新完再启动
|
||||
}
|
||||
|
||||
err := starter.Start(targetExe)
|
||||
return err
|
||||
}
|
||||
|
||||
func downloadAndReplace(
|
||||
starter platform.MainProgramStarter,
|
||||
downloadURL string,
|
||||
) error {
|
||||
// 获取主程序路径
|
||||
selfPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取自身路径失败: %w", err)
|
||||
}
|
||||
selfDir := filepath.Dir(selfPath)
|
||||
|
||||
starter := newMainProgramStarter()
|
||||
targetExe := filepath.Join(selfDir, starter.GetMainName())
|
||||
|
||||
// 2. 对比版本号,没有新版本则直接启动原程序
|
||||
if info.Version <= version {
|
||||
fmt.Println("[updater] 暂未发现新版本,启动原程序")
|
||||
if err := starter.Start(targetExe); err != nil {
|
||||
return err
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// 3. 有新版本则先备份到 ./tmp/old_app/
|
||||
backupDir := filepath.Join(selfDir, "tmp", "old_app")
|
||||
_ = os.MkdirAll(backupDir, 0755)
|
||||
backupFile := filepath.Join(backupDir, "main_"+strconv.Itoa(version)+".bak")
|
||||
if err := os.Rename(targetExe, backupFile); err != nil {
|
||||
fmt.Println("[updater] 备份失败,但继续更新:", err)
|
||||
}
|
||||
|
||||
// 4. 下载新版本到 ./tmp
|
||||
tmpDir := filepath.Join(selfDir, "tmp")
|
||||
_ = os.MkdirAll(tmpDir, 0755)
|
||||
|
||||
u, err := url.Parse(info.DownloadURL)
|
||||
outFile, err := os.Create(targetExe)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("创建目标文件失败: %w", err)
|
||||
}
|
||||
base := filepath.Base(u.Path)
|
||||
ext := filepath.Ext(base)
|
||||
|
||||
tmpFile, err := os.CreateTemp(tmpDir, "main_*"+ext)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tmpFile.Close()
|
||||
defer outFile.Close()
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(info.DownloadURL)
|
||||
resp, err := client.Get(downloadURL)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("下载新程序失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
|
||||
return err
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("下载失败, HTTP状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 5. 重命名新文件到 ./main.exe
|
||||
tmpFile.Close() // 关闭临时文件才能重命名
|
||||
maxRetry := 20
|
||||
for i := 0; i < maxRetry; i++ {
|
||||
err := os.Rename(tmpFile.Name(), targetExe)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
fmt.Println("[updater] 文件被占用,等待 500ms 再尝试...")
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if i == maxRetry-1 {
|
||||
return fmt.Errorf("替换失败: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(outFile, resp.Body); err != nil {
|
||||
return fmt.Errorf("写入目标文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 6. 启动主程序,同时完全退出自己
|
||||
if err := starter.Start(targetExe); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("[updater] 更新完成,新程序已启动,退出更新程序")
|
||||
os.Exit(0)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package main
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type mainProgramStarter struct{}
|
||||
@@ -25,3 +26,29 @@ func (s *mainProgramStarter) Start(targetExe string) error {
|
||||
// Linux 下:先保持最简单,保证能跑
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// 判断进程是否在运行
|
||||
func (s *mainProgramStarter) IsProcessRunning(pid int) (bool, error) {
|
||||
if pid <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
procPath := "/proc/" + strconv.Itoa(pid)
|
||||
_, err := os.Stat(procPath)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 其他错误(例如权限问题)
|
||||
return false, err
|
||||
}
|
||||
|
||||
// 杀掉进程
|
||||
func (l *mainProgramStarter) KillProcess(pid int) error {
|
||||
cmd := exec.Command("kill", "-9", strconv.Itoa(pid))
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ package main
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
type mainProgramStarter struct{}
|
||||
@@ -27,3 +30,34 @@ func (s *mainProgramStarter) Start(targetExe string) error {
|
||||
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// 判断进程是否在运行
|
||||
func (s *mainProgramStarter) IsProcessRunning(pid int) (bool, error) {
|
||||
if pid <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 打开进程句柄
|
||||
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
||||
if err != nil {
|
||||
// 权限不足,但进程存在
|
||||
if err == windows.ERROR_ACCESS_DENIED {
|
||||
return true, nil
|
||||
}
|
||||
// 进程不存在
|
||||
if err == windows.ERROR_INVALID_PARAMETER {
|
||||
return false, nil
|
||||
}
|
||||
// 其他错误
|
||||
return false, err
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// 杀掉进程
|
||||
func (w *mainProgramStarter) KillProcess(pid int) error {
|
||||
cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/F")
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
+8
-32
@@ -1,36 +1,12 @@
|
||||
# ==========================
|
||||
# Stage 1: Build the Go binary
|
||||
# ==========================
|
||||
FROM golang:1.21-alpine AS builder
|
||||
FROM scratch
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /build
|
||||
|
||||
# 复制 go.mod 和 go.sum,先做依赖缓存
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# 下载依赖(缓存层)
|
||||
RUN go mod download
|
||||
|
||||
# 复制源代码
|
||||
COPY app/ ./app
|
||||
|
||||
# 编译可执行文件
|
||||
RUN go build -o sentinel ./app/main.go
|
||||
|
||||
# ==========================
|
||||
# Stage 2: 生成最终运行镜像
|
||||
# ==========================
|
||||
FROM alpine:3.18
|
||||
|
||||
# 安装 ca-certificates,如果程序需要访问 HTTPS
|
||||
RUN apk add --no-cache ca-certificates
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制从 builder 构建好的二进制
|
||||
COPY --from=builder /build/sentinel .
|
||||
# buildx 自动注入
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
# 设置默认执行命令
|
||||
CMD ["./sentinel"]
|
||||
COPY build/release/${TARGETOS}_${TARGETARCH} /app
|
||||
COPY ca-certificates.crt /etc/ssl/certs/
|
||||
|
||||
CMD ["./main"]
|
||||
|
||||
@@ -30,6 +30,10 @@ func main() {
|
||||
uploadFile(record.LicensePlateImage, record.VehicleImage)
|
||||
// 2. 调用分析请求
|
||||
analytics(record)
|
||||
<-make(chan struct{})
|
||||
|
||||
//fatal error: all goroutines are asleep - deadlock!
|
||||
|
||||
}
|
||||
|
||||
func loadData(deviceID string) model.Record {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# build_all.ps1
|
||||
# 一键打包 main 和 updater,三平台: Windows 64, Linux amd64, Linux arm64
|
||||
|
||||
# -------------------------
|
||||
# 项目根目录
|
||||
$RootDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
Set-Location $RootDir
|
||||
|
||||
# -------------------------
|
||||
# 定义要打包的平台
|
||||
$targets = @(
|
||||
@{ OS="windows"; ARCH="amd64"; Dir="build/release/win"; MainOut="main.exe"; },
|
||||
@{ OS="linux"; ARCH="amd64"; Dir="build/release/linux_amd64"; MainOut="main"; },
|
||||
@{ OS="linux"; ARCH="arm64"; Dir="build/release/linux_arm64"; MainOut="main"; }
|
||||
)
|
||||
|
||||
# -------------------------
|
||||
# 循环打包
|
||||
foreach ($t in $targets) {
|
||||
Write-Host "=== Build $($t.OS)-$($t.ARCH) Version ==="
|
||||
|
||||
# 设置环境变量
|
||||
$env:GOOS = $t.OS
|
||||
$env:GOARCH = $t.ARCH
|
||||
$env:CGO_ENABLED = "0"
|
||||
|
||||
# 创建输出目录
|
||||
if (!(Test-Path $t.Dir)) {
|
||||
New-Item -ItemType Directory -Path $t.Dir | Out-Null
|
||||
}
|
||||
|
||||
# 编译 main
|
||||
Write-Host "Building main..."
|
||||
go build -ldflags="-s -w" -o "$($t.Dir)/$($t.MainOut)" ./app
|
||||
|
||||
Write-Host "$($t.OS)-$($t.ARCH) Build complete, Output dir: $($t.Dir)"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Write-Host "All builds finished."
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
# -----------------------------
|
||||
# 版本号
|
||||
# -----------------------------
|
||||
$env:VERSION = "0.0.1"
|
||||
|
||||
# 仓库与镜像名
|
||||
$IMAGE = "ai.ronsunny.cn:13011/bbit_iot/ce_sentinel"
|
||||
|
||||
# -----------------------------
|
||||
# 确保 buildx builder 存在
|
||||
# -----------------------------
|
||||
docker buildx inspect multiarch-builder > $null 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
docker buildx create --name multiarch-builder --use
|
||||
} else {
|
||||
docker buildx use multiarch-builder
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# 构建 + 推送 多架构镜像
|
||||
# -----------------------------
|
||||
docker buildx build `
|
||||
--platform linux/amd64,linux/arm64 `
|
||||
-t ${IMAGE}:$env:VERSION `
|
||||
-t ${IMAGE}:latest `
|
||||
--push `
|
||||
.
|
||||
Reference in New Issue
Block a user