初始提交:OCI 面板后端(含 GenAI 网关一期)
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# 构建产物
|
||||||
|
/bin/
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
/onsspike
|
||||||
|
|
||||||
|
# 本地数据与环境
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,90 @@
|
|||||||
|
// oci-portal 后端服务启动入口。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"oci-portal/internal/api"
|
||||||
|
"oci-portal/internal/config"
|
||||||
|
"oci-portal/internal/crypto"
|
||||||
|
"oci-portal/internal/database"
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootstrap 完成配置加载、数据库打开与加密组件构建。
|
||||||
|
func bootstrap() (*config.Config, *gorm.DB, *crypto.Cipher, error) {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
db, err := database.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
cipher, err := crypto.NewCipher(cfg.DataKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
return cfg, db, cipher, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
cfg, db, cipher, err := bootstrap()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
auth := service.NewAuthService(db, cfg.JWTSecret)
|
||||||
|
auth.SetCipher(cipher)
|
||||||
|
if err := auth.EnsureAdmin(cfg.AdminUsername, cfg.AdminPassword); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ociClient := oci.NewCachedClient(oci.NewClient())
|
||||||
|
ociConfigs := service.NewOciConfigService(db, cipher, ociClient)
|
||||||
|
settings := service.NewSettingService(db, cipher)
|
||||||
|
settings.SetEnvPublicURL(cfg.PublicURL)
|
||||||
|
if err := settings.ReloadSecurity(context.Background()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
notifier := service.NewNotifier(settings)
|
||||||
|
auth.SetNotifier(notifier, settings)
|
||||||
|
oauth := service.NewOAuthService(db, settings, auth)
|
||||||
|
systemLogs := service.NewSystemLogService(db)
|
||||||
|
logEvents := service.NewLogEventService(db)
|
||||||
|
logEvents.SetRelayDeps(ociConfigs, ociClient, cfg.PublicURL)
|
||||||
|
logEvents.SetNotifier(notifier, settings)
|
||||||
|
cleanupCtx, stopCleanup := context.WithCancel(context.Background())
|
||||||
|
systemLogs.StartCleanup(cleanupCtx)
|
||||||
|
logEvents.StartParser(cleanupCtx)
|
||||||
|
logEvents.StartCleanup(cleanupCtx)
|
||||||
|
aiGateway := service.NewAiGatewayService(db, ociConfigs, ociClient)
|
||||||
|
aiGateway.StartCleanup(cleanupCtx)
|
||||||
|
// defer 为 LIFO:先停调度与清理,再等在途通知与日志写完
|
||||||
|
defer logEvents.Wait()
|
||||||
|
defer systemLogs.Wait()
|
||||||
|
defer notifier.Wait()
|
||||||
|
defer aiGateway.Wait()
|
||||||
|
defer stopCleanup()
|
||||||
|
tasks := service.NewTaskService(db, ociConfigs, notifier, settings)
|
||||||
|
tasks.AttachAiGateway(aiGateway)
|
||||||
|
aiGateway.SetOnChannelsChanged(tasks.SyncAiProbeTask)
|
||||||
|
if err := tasks.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tasks.Stop()
|
||||||
|
// 启动时对齐一次探测任务(已有渠道而任务缺失/暂停时自动建立或激活)
|
||||||
|
tasks.SyncAiProbeTask(context.Background())
|
||||||
|
console := service.NewConsoleService(ociConfigs)
|
||||||
|
proxies := service.NewProxyService(db, cipher)
|
||||||
|
defer proxies.Wait()
|
||||||
|
return api.NewRouter(auth, oauth, ociConfigs, tasks, console, settings, notifier, systemLogs, logEvents, proxies, aiGateway).Run(cfg.Addr)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
module oci-portal
|
||||||
|
|
||||||
|
go 1.26.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
github.com/glebarez/sqlite v1.11.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/oracle/oci-go-sdk/v65 v65.119.0
|
||||||
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
|
golang.org/x/crypto v0.53.0
|
||||||
|
gorm.io/gorm v1.31.2
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/coreos/go-oidc/v3 v3.19.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/gofrs/flock v0.10.0 // indirect
|
||||||
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/pquerna/otp v1.5.0 // indirect
|
||||||
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/sony/gobreaker/v2 v2.4.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
|
golang.org/x/net v0.56.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
|
golang.org/x/text v0.38.0 // indirect
|
||||||
|
golang.org/x/time v0.15.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
modernc.org/libc v1.22.5 // indirect
|
||||||
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
|
modernc.org/memory v1.5.0 // indirect
|
||||||
|
modernc.org/sqlite v1.23.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||||
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/gofrs/flock v0.10.0 h1:SHMXenfaB03KbroETaCMtbBg3Yn29v4w1r+tgy4ff4k=
|
||||||
|
github.com/gofrs/flock v0.10.0/go.mod h1:FirDy1Ing0mI2+kB6wk+vyyAH+e6xiE+EYA0jnzV9jc=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/oracle/oci-go-sdk/v65 v65.119.0 h1:0u9ujtEACjk3Sr72bnbTyJlCquhJiiEceIyMDhN9zcs=
|
||||||
|
github.com/oracle/oci-go-sdk/v65 v65.119.0/go.mod h1:nv7HqsLpM/5aH66gu6JD1oqMftTxGfxo3ow1eYKPSmI=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
|
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||||
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
|
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
|
github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg=
|
||||||
|
github.com/sony/gobreaker/v2 v2.4.0/go.mod h1:pTyFJgcZ3h2tdQVLZZruK2C0eoFL1fb/G83wK1ZQl+s=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||||
|
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||||
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
|
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||||
|
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
|
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||||
|
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||||
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||||
|
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
|
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||||
|
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package aiwire
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MessagesRequest 是 Anthropic /v1/messages 请求体。
|
||||||
|
type MessagesRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
System json.RawMessage `json:"system,omitempty"`
|
||||||
|
Messages []AnthMessage `json:"messages"`
|
||||||
|
StopSequences []string `json:"stop_sequences,omitempty"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
TopK *int `json:"top_k,omitempty"`
|
||||||
|
Stream bool `json:"stream,omitempty"`
|
||||||
|
Tools []AnthTool `json:"tools,omitempty"`
|
||||||
|
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||||
|
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||||
|
Thinking json.RawMessage `json:"thinking,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemText 把顶层 system(string 或块数组)拍平为文本。
|
||||||
|
func (r MessagesRequest) SystemText() string {
|
||||||
|
if len(r.System) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(r.System, &s); err == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var blocks []AnthBlock
|
||||||
|
if err := json.Unmarshal(r.System, &blocks); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, b := range blocks {
|
||||||
|
if b.Type == "text" {
|
||||||
|
sb.WriteString(b.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthMessage 是 Anthropic 消息;Content 兼容 string 与块数组。
|
||||||
|
type AnthMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content AnthContent `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthContent 是 string | []AnthBlock 联合。
|
||||||
|
type AnthContent struct {
|
||||||
|
Text string
|
||||||
|
Blocks []AnthBlock
|
||||||
|
isArray bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AnthContent) UnmarshalJSON(b []byte) error {
|
||||||
|
trimmed := strings.TrimSpace(string(b))
|
||||||
|
if trimmed == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(trimmed) > 0 && trimmed[0] == '[' {
|
||||||
|
c.isArray = true
|
||||||
|
return json.Unmarshal(b, &c.Blocks)
|
||||||
|
}
|
||||||
|
return json.Unmarshal(b, &c.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c AnthContent) MarshalJSON() ([]byte, error) {
|
||||||
|
if c.isArray {
|
||||||
|
return json.Marshal(c.Blocks)
|
||||||
|
}
|
||||||
|
return json.Marshal(c.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllBlocks 统一为块数组视图(字符串形态包装为单 text 块)。
|
||||||
|
func (c AnthContent) AllBlocks() []AnthBlock {
|
||||||
|
if c.isArray {
|
||||||
|
return c.Blocks
|
||||||
|
}
|
||||||
|
if c.Text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []AnthBlock{{Type: "text", Text: c.Text}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthBlock 是内容块;按 Type 取字段(text / tool_use / tool_result / image / thinking)。
|
||||||
|
type AnthBlock struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
// tool_use
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Input json.RawMessage `json:"input,omitempty"`
|
||||||
|
// tool_result
|
||||||
|
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||||
|
Content json.RawMessage `json:"content,omitempty"`
|
||||||
|
IsError bool `json:"is_error,omitempty"`
|
||||||
|
// image
|
||||||
|
Source json.RawMessage `json:"source,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResultText 把 tool_result 的 content(string 或块数组)拍平为文本。
|
||||||
|
func (b AnthBlock) ResultText() string {
|
||||||
|
if len(b.Content) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(b.Content, &s); err == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var blocks []AnthBlock
|
||||||
|
if err := json.Unmarshal(b.Content, &blocks); err != nil {
|
||||||
|
return string(b.Content)
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, blk := range blocks {
|
||||||
|
if blk.Type == "text" {
|
||||||
|
sb.WriteString(blk.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthTool 是 Anthropic 工具定义(input_schema 即 JSON Schema)。
|
||||||
|
type AnthTool struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
InputSchema json.RawMessage `json:"input_schema,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessagesResponse 是非流式响应体。
|
||||||
|
type MessagesResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Content []AnthBlock `json:"content"`
|
||||||
|
StopReason string `json:"stop_reason"`
|
||||||
|
StopSequence *string `json:"stop_sequence"`
|
||||||
|
Usage AnthUsage `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthUsage 是 Anthropic 用量结构。
|
||||||
|
type AnthUsage struct {
|
||||||
|
InputTokens int `json:"input_tokens"`
|
||||||
|
OutputTokens int `json:"output_tokens"`
|
||||||
|
// CacheReadInputTokens 是缓存命中读取数;OCI 隐式缓存无「写入」计数,cache_creation 恒缺
|
||||||
|
CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthErrorBody 是 Anthropic 格式错误响应。
|
||||||
|
type AnthErrorBody struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Error AnthErrorDetail `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthErrorDetail 是错误明细。
|
||||||
|
type AnthErrorDetail struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package aiwire
|
||||||
|
|
||||||
|
// EmbeddingsRequest 是 OpenAI /v1/embeddings 请求体(input 兼容 string 与 []string)。
|
||||||
|
type EmbeddingsRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Input StringList `json:"input"`
|
||||||
|
EncodingFormat string `json:"encoding_format,omitempty"`
|
||||||
|
Dimensions *int `json:"dimensions,omitempty"`
|
||||||
|
User string `json:"user,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embedding 是一条向量结果。
|
||||||
|
type Embedding struct {
|
||||||
|
Object string `json:"object"`
|
||||||
|
Index int `json:"index"`
|
||||||
|
Embedding []float32 `json:"embedding"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbeddingsResponse 是 /v1/embeddings 响应体。
|
||||||
|
type EmbeddingsResponse struct {
|
||||||
|
Object string `json:"object"`
|
||||||
|
Data []Embedding `json:"data"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Usage *EmbedUsage `json:"usage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbedUsage 是 embeddings 用量(无输出侧计数)。
|
||||||
|
type EmbedUsage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
// Package aiwire 定义 AI 网关的线格式类型:OpenAI Chat Completions(兼作网关内部
|
||||||
|
// 中间表示 IR,因 OCI GENERIC 格式即其镜像)与 Anthropic Messages。零内部依赖,
|
||||||
|
// oci 层与 service 层共同引用。
|
||||||
|
package aiwire
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatRequest 是 OpenAI /v1/chat/completions 请求体;TopK 为 OCI/Anthropic 扩展字段。
|
||||||
|
type ChatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []ChatMessage `json:"messages"`
|
||||||
|
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||||
|
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
TopK *int `json:"top_k,omitempty"`
|
||||||
|
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
|
||||||
|
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
|
||||||
|
Stop StringList `json:"stop,omitempty"`
|
||||||
|
Seed *int `json:"seed,omitempty"`
|
||||||
|
N *int `json:"n,omitempty"`
|
||||||
|
Stream bool `json:"stream,omitempty"`
|
||||||
|
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
|
||||||
|
Tools []Tool `json:"tools,omitempty"`
|
||||||
|
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||||
|
ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
|
||||||
|
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StringList 兼容 OpenAI stop 的 string 与 []string 两种线格式。
|
||||||
|
type StringList []string
|
||||||
|
|
||||||
|
func (s *StringList) UnmarshalJSON(b []byte) error {
|
||||||
|
if len(b) > 0 && b[0] == '"' {
|
||||||
|
var one string
|
||||||
|
if err := json.Unmarshal(b, &one); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*s = StringList{one}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var many []string
|
||||||
|
if err := json.Unmarshal(b, &many); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*s = many
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamOptions 控制流式末尾是否附带 usage 事件。
|
||||||
|
type StreamOptions struct {
|
||||||
|
IncludeUsage bool `json:"include_usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatMessage 是请求与非流式响应共用的消息结构。
|
||||||
|
type ChatMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content Content `json:"content"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content 兼容 string 与多模态块数组两种线格式;一期仅透传文本。
|
||||||
|
type Content struct {
|
||||||
|
// Text 在原始值为字符串时填充
|
||||||
|
Text string
|
||||||
|
// Parts 在原始值为数组时填充
|
||||||
|
Parts []ContentPart
|
||||||
|
// isArray 记录原始形态,序列化时保形
|
||||||
|
isArray bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentPart 是块数组中的一段;text 与 image_url 被网关处理,其余类型拒绝。
|
||||||
|
type ContentPart struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ImageURL *ImageURL `json:"image_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageURL 是 image_url 块负载;url 为 data URI(base64)或公网地址,与 OCI ImageUrl 同构。
|
||||||
|
type ImageURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Content) UnmarshalJSON(b []byte) error {
|
||||||
|
trimmed := strings.TrimSpace(string(b))
|
||||||
|
if trimmed == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(trimmed) > 0 && trimmed[0] == '[' {
|
||||||
|
c.isArray = true
|
||||||
|
return json.Unmarshal(b, &c.Parts)
|
||||||
|
}
|
||||||
|
return json.Unmarshal(b, &c.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Content) MarshalJSON() ([]byte, error) {
|
||||||
|
if c.isArray {
|
||||||
|
return json.Marshal(c.Parts)
|
||||||
|
}
|
||||||
|
return json.Marshal(c.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTextContent 构造字符串形态的内容。
|
||||||
|
func NewTextContent(text string) Content {
|
||||||
|
return Content{Text: text}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPartsContent 构造块数组形态的内容(多模态消息用)。
|
||||||
|
func NewPartsContent(parts []ContentPart) Content {
|
||||||
|
return Content{Parts: parts, isArray: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JoinText 返回全部文本内容(数组形态拼接 text 块)。
|
||||||
|
func (c Content) JoinText() string {
|
||||||
|
if !c.isArray {
|
||||||
|
return c.Text
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, p := range c.Parts {
|
||||||
|
if p.Type == "" || p.Type == "text" {
|
||||||
|
sb.WriteString(p.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasUnsupported 报告是否含网关无法承接的内容块(text / image_url 之外的类型)。
|
||||||
|
func (c Content) HasUnsupported() bool {
|
||||||
|
for _, p := range c.Parts {
|
||||||
|
switch p.Type {
|
||||||
|
case "", "text":
|
||||||
|
case "image_url":
|
||||||
|
if p.ImageURL == nil || p.ImageURL.URL == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool 是 function 工具定义。
|
||||||
|
type Tool struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function FunctionDef `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FunctionDef 描述工具的名称与 JSON Schema 参数。
|
||||||
|
type FunctionDef struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Parameters json.RawMessage `json:"parameters,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolCall 是助手消息中的工具调用。
|
||||||
|
type ToolCall struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function FunctionCall `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FunctionCall 携带调用名与 JSON 编码的实参。
|
||||||
|
type FunctionCall struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponseFormat 对应 response_format(json_object / json_schema)。
|
||||||
|
type ResponseFormat struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
JSONSchema json.RawMessage `json:"json_schema,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatResponse 是非流式响应体。
|
||||||
|
type ChatResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Choices []Choice `json:"choices"`
|
||||||
|
Usage *Usage `json:"usage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choice 是一条候选回复。
|
||||||
|
type Choice struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Message ChatMessage `json:"message"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage 是 token 用量。
|
||||||
|
type Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
// PromptTokensDetails 携带缓存命中细分;OCI 为隐式 prompt 缓存,只有读取计数
|
||||||
|
PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptTokensDetails 是输入 token 细分(OpenAI prompt_tokens_details 同构)。
|
||||||
|
type PromptTokensDetails struct {
|
||||||
|
CachedTokens int `json:"cached_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CachedTokens 返回缓存命中读取的 token 数;无细分时为 0。
|
||||||
|
func (u *Usage) CachedTokens() int {
|
||||||
|
if u == nil || u.PromptTokensDetails == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return u.PromptTokensDetails.CachedTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatChunk 是流式增量事件体(chat.completion.chunk)。
|
||||||
|
type ChatChunk struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Choices []ChunkChoice `json:"choices"`
|
||||||
|
Usage *Usage `json:"usage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChunkChoice 是流式事件中的增量候选。
|
||||||
|
type ChunkChoice struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Delta Delta `json:"delta"`
|
||||||
|
FinishReason *string `json:"finish_reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delta 是流式增量内容;Content 为纯文本片段。
|
||||||
|
type Delta struct {
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
ToolCalls []ToolCallDelta `json:"tool_calls,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolCallDelta 是工具调用的流式片段;Index 标识聚合目标。
|
||||||
|
type ToolCallDelta struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Function FunctionCallDelta `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FunctionCallDelta 是函数名 / 实参的增量片段。
|
||||||
|
type FunctionCallDelta struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Arguments string `json:"arguments,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model 是 /v1/models 列表项。
|
||||||
|
type Model struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
OwnedBy string `json:"owned_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelList 是 /v1/models 响应体。
|
||||||
|
type ModelList struct {
|
||||||
|
Object string `json:"object"`
|
||||||
|
Data []Model `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorBody 是 OpenAI 格式错误响应。
|
||||||
|
type ErrorBody struct {
|
||||||
|
Error ErrorDetail `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorDetail 是错误明细。
|
||||||
|
type ErrorDetail struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package aiwire
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RespRequest 是 POST /responses 请求体(无状态子集;有状态字段仅用于拒绝判定)。
|
||||||
|
type RespRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Input RespInput `json:"input"`
|
||||||
|
Instructions string `json:"instructions,omitempty"`
|
||||||
|
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
Tools []RespTool `json:"tools,omitempty"`
|
||||||
|
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||||
|
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
|
||||||
|
Text *RespText `json:"text,omitempty"`
|
||||||
|
Reasoning *RespReasoning `json:"reasoning,omitempty"`
|
||||||
|
Stream bool `json:"stream,omitempty"`
|
||||||
|
Store *bool `json:"store,omitempty"`
|
||||||
|
PreviousResponseID string `json:"previous_response_id,omitempty"`
|
||||||
|
Conversation json.RawMessage `json:"conversation,omitempty"`
|
||||||
|
Background *bool `json:"background,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespInput 兼容 string 与 item 数组两种形态。
|
||||||
|
type RespInput struct {
|
||||||
|
Text string
|
||||||
|
Items []RespItem
|
||||||
|
IsArray bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (in *RespInput) UnmarshalJSON(b []byte) error {
|
||||||
|
t := strings.TrimSpace(string(b))
|
||||||
|
if strings.HasPrefix(t, "\"") {
|
||||||
|
return json.Unmarshal(b, &in.Text)
|
||||||
|
}
|
||||||
|
in.IsArray = true
|
||||||
|
return json.Unmarshal(b, &in.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespItem 承载 input 数组的各形态:message / function_call / function_call_output。
|
||||||
|
type RespItem struct {
|
||||||
|
Type string `json:"type,omitempty"` // 缺省视为 message
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
Content RespContent `json:"content,omitempty"`
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
CallID string `json:"call_id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Arguments string `json:"arguments,omitempty"`
|
||||||
|
Output json.RawMessage `json:"output,omitempty"` // string 或部件数组
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutputText 拍平 function_call_output 的 output(string 或部件数组)。
|
||||||
|
func (it RespItem) OutputText() string {
|
||||||
|
if len(it.Output) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(it.Output, &s); err == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var parts []RespPart
|
||||||
|
if err := json.Unmarshal(it.Output, &parts); err != nil {
|
||||||
|
return string(it.Output)
|
||||||
|
}
|
||||||
|
texts := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
texts = append(texts, p.Text)
|
||||||
|
}
|
||||||
|
return strings.Join(texts, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespContent 兼容 string 与 part 数组两种形态。
|
||||||
|
type RespContent struct {
|
||||||
|
Text string
|
||||||
|
Parts []RespPart
|
||||||
|
IsArray bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RespContent) UnmarshalJSON(b []byte) error {
|
||||||
|
t := strings.TrimSpace(string(b))
|
||||||
|
if strings.HasPrefix(t, "\"") {
|
||||||
|
return json.Unmarshal(b, &c.Text)
|
||||||
|
}
|
||||||
|
c.IsArray = true
|
||||||
|
return json.Unmarshal(b, &c.Parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JoinText 拍平内容为纯文本(input_text / output_text / refusal)。
|
||||||
|
func (c RespContent) JoinText() string {
|
||||||
|
if !c.IsArray {
|
||||||
|
return c.Text
|
||||||
|
}
|
||||||
|
texts := make([]string, 0, len(c.Parts))
|
||||||
|
for _, p := range c.Parts {
|
||||||
|
if p.Text != "" {
|
||||||
|
texts = append(texts, p.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(texts, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasUnsupported 报告是否含网关无法承接的部件(文本 / input_image 之外,或依赖服务端文件存储)。
|
||||||
|
func (c RespContent) HasUnsupported() bool {
|
||||||
|
for _, p := range c.Parts {
|
||||||
|
switch p.Type {
|
||||||
|
case "", "input_text", "output_text", "text", "refusal":
|
||||||
|
case "input_image":
|
||||||
|
if p.ImageURL == "" || p.FileID != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespPart 是 content 数组里的一个部件;input_image 的 image_url 为字符串(URL 或 data URI)。
|
||||||
|
type RespPart struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
FileID string `json:"file_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespTool 是 Responses 扁平工具定义(仅支持 type=function)。
|
||||||
|
type RespTool struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Parameters json.RawMessage `json:"parameters,omitempty"`
|
||||||
|
Strict *bool `json:"strict,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespText 收纳输出格式配置(text.format)。
|
||||||
|
type RespText struct {
|
||||||
|
Format *RespTextFormat `json:"format,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespTextFormat 是扁平的 format 对象(text / json_object / json_schema)。
|
||||||
|
type RespTextFormat struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Schema json.RawMessage `json:"schema,omitempty"`
|
||||||
|
Strict *bool `json:"strict,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespReasoning 只透传 effort;summary 等忽略。
|
||||||
|
type RespReasoning struct {
|
||||||
|
Effort string `json:"effort,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 是 /responses 响应对象。
|
||||||
|
type Response struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Output []RespOutItem `json:"output"`
|
||||||
|
Usage *RespUsage `json:"usage,omitempty"`
|
||||||
|
Error *RespError `json:"error"`
|
||||||
|
IncompleteDetails *RespIncomplete `json:"incomplete_details"`
|
||||||
|
Store bool `json:"store"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespOutItem 是 output 数组项:message 或 function_call。
|
||||||
|
type RespOutItem struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
Content []RespOutPart `json:"content,omitempty"`
|
||||||
|
CallID string `json:"call_id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Arguments string `json:"arguments,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespOutPart 是 message item 的内容部件(output_text)。
|
||||||
|
type RespOutPart struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Annotations []any `json:"annotations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespUsage 是 Responses 口径的用量(input/output 命名)。
|
||||||
|
type RespUsage struct {
|
||||||
|
InputTokens int `json:"input_tokens"`
|
||||||
|
OutputTokens int `json:"output_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
InputTokensDetails RespInDetails `json:"input_tokens_details"`
|
||||||
|
OutputTokensDetails RespOutDetails `json:"output_tokens_details"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespInDetails / RespOutDetails 恒零值输出(OCI 不提供细分)。
|
||||||
|
type RespInDetails struct {
|
||||||
|
CachedTokens int `json:"cached_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RespOutDetails struct {
|
||||||
|
ReasoningTokens int `json:"reasoning_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespError 是 Response.error 字段。
|
||||||
|
type RespError struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespIncomplete 说明 status=incomplete 的原因。
|
||||||
|
type RespIncomplete struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 构建信息由编译参数注入:
|
||||||
|
// go build -ldflags "-X oci-portal/internal/api.buildVersion=v1.0.0 -X oci-portal/internal/api.buildTime=2026-07-09T12:00Z"
|
||||||
|
var (
|
||||||
|
buildVersion = "dev"
|
||||||
|
buildTime = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
// about 返回构建与运行环境信息,「设置 · 关于」页展示。
|
||||||
|
func about(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"version": buildVersion,
|
||||||
|
"buildTime": buildTime,
|
||||||
|
"goVersion": runtime.Version(),
|
||||||
|
"platform": runtime.GOOS + "/" + runtime.GOARCH,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// aiAdminHandler 处理面板侧 AI 网关管理接口(secured 组,自动进系统日志)。
|
||||||
|
type aiAdminHandler struct {
|
||||||
|
gw *service.AiGatewayService
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 密钥 ----
|
||||||
|
|
||||||
|
func (h *aiAdminHandler) listKeys(c *gin.Context) {
|
||||||
|
keys, err := h.gw.Keys(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": keys})
|
||||||
|
}
|
||||||
|
|
||||||
|
// createKey 生成密钥;明文仅在本响应返回一次。
|
||||||
|
func (h *aiAdminHandler) createKey(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plaintext, key, err := h.gw.CreateKey(c.Request.Context(), req.Name, req.Value, req.Group)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"key": plaintext, "item": key})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *aiAdminHandler) updateKey(c *gin.Context) {
|
||||||
|
id, ok := aiPathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
Group *string `json:"group"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.gw.UpdateKey(c.Request.Context(), id, req.Name, req.Enabled, req.Group); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *aiAdminHandler) deleteKey(c *gin.Context) {
|
||||||
|
id, ok := aiPathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.gw.DeleteKey(c.Request.Context(), id); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateKeyContentLog 设置密钥内容日志窗口(红线例外:显式限时开启,0 关闭)。
|
||||||
|
func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) {
|
||||||
|
id, ok := aiPathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Hours int `json:"hours"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, err := h.gw.UpdateKeyContentLog(c.Request.Context(), id, req.Hours)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listContentLogs 分页查询内容日志(可选 keyId / callLogId 过滤)。
|
||||||
|
func (h *aiAdminHandler) listContentLogs(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||||
|
keyID, _ := strconv.Atoi(c.DefaultQuery("keyId", "0"))
|
||||||
|
callLogID, _ := strconv.Atoi(c.DefaultQuery("callLogId", "0"))
|
||||||
|
items, total, err := h.gw.ContentLogs(c.Request.Context(), uint(keyID), uint(callLogID), page, size)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 渠道 ----
|
||||||
|
|
||||||
|
func (h *aiAdminHandler) listChannels(c *gin.Context) {
|
||||||
|
chs, err := h.gw.Channels(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": chs})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *aiAdminHandler) createChannel(c *gin.Context) {
|
||||||
|
var req service.ChannelInput
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch, err := h.gw.CreateChannel(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *aiAdminHandler) updateChannel(c *gin.Context) {
|
||||||
|
id, ok := aiPathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req service.ChannelInput
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.gw.UpdateChannel(c.Request.Context(), id, req); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *aiAdminHandler) deleteChannel(c *gin.Context) {
|
||||||
|
id, ok := aiPathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.gw.DeleteChannel(c.Request.Context(), id); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeChannel 触发探测:服务可见性 + 模型同步 + 配额试调,返回更新后的渠道。
|
||||||
|
func (h *aiAdminHandler) probeChannel(c *gin.Context) {
|
||||||
|
id, ok := aiPathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch, err := h.gw.ProbeChannel(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *aiAdminHandler) syncChannelModels(c *gin.Context) {
|
||||||
|
id, ok := aiPathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
models, err := h.gw.SyncModels(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": models})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 聚合模型与调用日志 ----
|
||||||
|
|
||||||
|
func (h *aiAdminHandler) gatewayModels(c *gin.Context) {
|
||||||
|
list, err := h.gw.GatewayModels(c.Request.Context(), "")
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": list.Data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *aiAdminHandler) listLogs(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
size, _ := strconv.Atoi(c.DefaultQuery("size", "50"))
|
||||||
|
items, total, err := h.gw.CallLogs(c.Request.Context(), page, size)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
func aiPathID(c *gin.Context) (uint, bool) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return uint(id), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/aiwire"
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// aiGatewayHandler 处理 /ai/v1/* 对外网关端点(OpenAI / Anthropic 协议)。
|
||||||
|
type aiGatewayHandler struct {
|
||||||
|
gw *service.AiGatewayService
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiKeyCtx = "aiKey"
|
||||||
|
|
||||||
|
// auth 是网关鉴权中间件:Bearer 或 x-api-key 双头识别,失败按端点协议返回错误体。
|
||||||
|
func (h *aiGatewayHandler) auth(c *gin.Context) {
|
||||||
|
key, err := h.gw.VerifyKey(c.Request.Context(), extractAiKey(c))
|
||||||
|
if err != nil {
|
||||||
|
aiError(c, http.StatusUnauthorized, "authentication_error", "无效或已禁用的 API 密钥")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set(aiKeyCtx, key)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractAiKey(c *gin.Context) string {
|
||||||
|
if header := c.GetHeader("Authorization"); strings.HasPrefix(header, "Bearer ") {
|
||||||
|
return strings.TrimSpace(header[7:])
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(c.GetHeader("x-api-key"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// aiError 按端点协议输出错误体:/ai/v1/messages 用 Anthropic 格式,其余 OpenAI 格式。
|
||||||
|
func aiError(c *gin.Context, status int, code, msg string) {
|
||||||
|
if strings.HasSuffix(c.FullPath(), "/messages") {
|
||||||
|
c.JSON(status, aiwire.AnthErrorBody{Type: "error", Error: aiwire.AnthErrorDetail{Type: code, Message: msg}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(status, aiwire.ErrorBody{Error: aiwire.ErrorDetail{Message: msg, Type: code}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// upstreamError 把编排层错误映射为网关响应(未知模型 404 / 无渠道 503 / 上游状态透传)。
|
||||||
|
func upstreamError(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrAiUnknownModel):
|
||||||
|
aiError(c, http.StatusNotFound, "model_not_found", err.Error())
|
||||||
|
case errors.Is(err, service.ErrAiNoChannel):
|
||||||
|
aiError(c, http.StatusServiceUnavailable, "overloaded_error", err.Error())
|
||||||
|
case errors.Is(err, service.ErrAiUnsupportedBlock):
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
default:
|
||||||
|
if status, ok := oci.ServiceStatus(err); ok {
|
||||||
|
aiError(c, status, "upstream_error", oci.CompactError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aiError(c, http.StatusBadGateway, "upstream_error", oci.CompactError(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func aiRandID(prefix string) string {
|
||||||
|
buf := make([]byte, 12)
|
||||||
|
_, _ = rand.Read(buf)
|
||||||
|
return prefix + hex.EncodeToString(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyGroup 取当前请求密钥的分组(空 = 不限分组)。
|
||||||
|
func keyGroup(c *gin.Context) string {
|
||||||
|
if key, ok := c.Get(aiKeyCtx); ok {
|
||||||
|
return key.(*model.AiKey).Group
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// logEntry 组装调用日志骨架;usage / status 由调用方补齐。
|
||||||
|
func (h *aiGatewayHandler) logEntry(c *gin.Context, endpoint, modelName string, stream bool, meta service.ChatMeta, start time.Time) model.AiCallLog {
|
||||||
|
entry := model.AiCallLog{
|
||||||
|
Endpoint: endpoint, Model: modelName, Stream: stream,
|
||||||
|
ChannelID: meta.ChannelID, ChannelName: meta.ChannelName,
|
||||||
|
Retries: meta.Retries, LatencyMs: time.Since(start).Milliseconds(),
|
||||||
|
ClientIP: requestIP(c),
|
||||||
|
}
|
||||||
|
if key, ok := c.Get(aiKeyCtx); ok {
|
||||||
|
k := key.(*model.AiKey)
|
||||||
|
entry.KeyID, entry.KeyName = k.ID, k.Name
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateIR 做协议无关的入参检查(模型名、消息、不支持的内容块)。
|
||||||
|
func validateIR(ir aiwire.ChatRequest) error {
|
||||||
|
if strings.TrimSpace(ir.Model) == "" {
|
||||||
|
return fmt.Errorf("model 不能为空")
|
||||||
|
}
|
||||||
|
if len(ir.Messages) == 0 {
|
||||||
|
return fmt.Errorf("messages 不能为空")
|
||||||
|
}
|
||||||
|
for _, m := range ir.Messages {
|
||||||
|
if m.Content.HasUnsupported() {
|
||||||
|
return service.ErrAiUnsupportedBlock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeLogContent 在调用密钥显式开启内容日志且未过期时写正文(红线例外);
|
||||||
|
// respBody 为 nil 时只记请求(流式响应与向量结果不记录);callLogID 关联同次调用日志。
|
||||||
|
func (h *aiGatewayHandler) maybeLogContent(c *gin.Context, callLogID uint, endpoint, modelName string, stream bool, reqBody, respBody any) {
|
||||||
|
keyVal, ok := c.Get(aiKeyCtx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := keyVal.(*model.AiKey)
|
||||||
|
if key.ContentLogUntil == nil || time.Now().After(*key.ContentLogUntil) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry := model.AiContentLog{CallLogID: callLogID, KeyID: key.ID, KeyName: key.Name, Endpoint: endpoint, Model: modelName, Stream: stream}
|
||||||
|
if b, err := json.Marshal(reqBody); err == nil {
|
||||||
|
entry.RequestBody = string(b)
|
||||||
|
}
|
||||||
|
if respBody != nil {
|
||||||
|
if b, err := json.Marshal(respBody); err == nil {
|
||||||
|
entry.ResponseBody = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.gw.LogContent(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// logFailure 记失败调用日志,并在密钥内容日志开启时留请求正文用于排障(错误信息已在 ErrMsg,不记响应)。
|
||||||
|
func (h *aiGatewayHandler) logFailure(c *gin.Context, entry model.AiCallLog, reqBody any) {
|
||||||
|
entry.Status = c.Writer.Status()
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, entry.Endpoint, entry.Model, entry.Stream, reqBody, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatCompletions 是 OpenAI /ai/v1/chat/completions 端点。
|
||||||
|
func (h *aiGatewayHandler) chatCompletions(c *gin.Context) {
|
||||||
|
var ir aiwire.ChatRequest
|
||||||
|
if err := c.ShouldBindJSON(&ir); err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validateIR(ir); err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ir.Stream {
|
||||||
|
h.streamOpenAI(c, ir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
resp, meta, err := h.gw.Chat(c.Request.Context(), ir, keyGroup(c))
|
||||||
|
entry := h.logEntry(c, "openai", ir.Model, false, meta, start)
|
||||||
|
if err != nil {
|
||||||
|
upstreamError(c, err)
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
h.logFailure(c, entry, ir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.ID = aiRandID("chatcmpl-")
|
||||||
|
if resp.Created == 0 {
|
||||||
|
resp.Created = time.Now().Unix()
|
||||||
|
}
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
fillUsage(&entry, resp.Usage)
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, "openai", ir.Model, false, ir, resp)
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillUsage(entry *model.AiCallLog, u *aiwire.Usage) {
|
||||||
|
if u == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry.PromptTokens, entry.CompletionTokens, entry.TotalTokens = u.PromptTokens, u.CompletionTokens, u.TotalTokens
|
||||||
|
entry.CachedTokens = u.CachedTokens()
|
||||||
|
}
|
||||||
|
|
||||||
|
// embeddings 是 OpenAI /ai/v1/embeddings 端点(非流式)。
|
||||||
|
func (h *aiGatewayHandler) embeddings(c *gin.Context) {
|
||||||
|
var req aiwire.EmbeddingsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Model) == "" || len(req.Input) == 0 {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", "model 与 input 不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.EncodingFormat != "" && req.EncodingFormat != "float" {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", "仅支持 encoding_format=float")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
resp, meta, err := h.gw.Embeddings(c.Request.Context(), req, keyGroup(c))
|
||||||
|
entry := h.logEntry(c, "embeddings", req.Model, false, meta, start)
|
||||||
|
if err != nil {
|
||||||
|
upstreamError(c, err)
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
h.logFailure(c, entry, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
if resp.Usage != nil {
|
||||||
|
entry.PromptTokens, entry.TotalTokens = resp.Usage.PromptTokens, resp.Usage.TotalTokens
|
||||||
|
}
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, "embeddings", req.Model, false, req, nil)
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamOpenAI 以 OpenAI SSE 直通流式响应,末尾发 [DONE]。
|
||||||
|
func (h *aiGatewayHandler) streamOpenAI(c *gin.Context, ir aiwire.ChatRequest) {
|
||||||
|
start := time.Now()
|
||||||
|
stream, meta, err := h.gw.OpenStream(c.Request.Context(), ir, keyGroup(c))
|
||||||
|
entry := h.logEntry(c, "openai", ir.Model, true, meta, start)
|
||||||
|
if err != nil {
|
||||||
|
upstreamError(c, err)
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
h.logFailure(c, entry, ir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
sseHeaders(c)
|
||||||
|
id, created := aiRandID("chatcmpl-"), time.Now().Unix()
|
||||||
|
var usage *aiwire.Usage
|
||||||
|
for {
|
||||||
|
chunk, err := stream.Next()
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, io.EOF) {
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
chunk.ID, chunk.Created = id, created
|
||||||
|
if chunk.Usage != nil {
|
||||||
|
usage = chunk.Usage
|
||||||
|
}
|
||||||
|
writeSSEData(c, chunk)
|
||||||
|
}
|
||||||
|
c.Writer.WriteString("data: [DONE]\n\n")
|
||||||
|
c.Writer.Flush()
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||||
|
fillUsage(&entry, usage)
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, "openai", ir.Model, true, ir, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sseHeaders(c *gin.Context) {
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("X-Accel-Buffering", "no")
|
||||||
|
c.Writer.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSSEData(c *gin.Context, v any) {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Writer.WriteString("data: ")
|
||||||
|
c.Writer.Write(b)
|
||||||
|
c.Writer.WriteString("\n\n")
|
||||||
|
c.Writer.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// messages 是 Anthropic /ai/v1/messages 端点。
|
||||||
|
func (h *aiGatewayHandler) messages(c *gin.Context) {
|
||||||
|
var req aiwire.MessagesRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.MaxTokens <= 0 {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", "max_tokens 必填且需大于 0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ir, err := service.AnthropicToIR(req)
|
||||||
|
if err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validateIR(ir); err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Stream {
|
||||||
|
h.streamAnthropic(c, ir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
resp, meta, err := h.gw.Chat(c.Request.Context(), ir, keyGroup(c))
|
||||||
|
entry := h.logEntry(c, "anthropic", ir.Model, false, meta, start)
|
||||||
|
if err != nil {
|
||||||
|
upstreamError(c, err)
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
h.logFailure(c, entry, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
fillUsage(&entry, resp.Usage)
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
out := service.IRRespToAnthropic(resp, aiRandID("msg_"))
|
||||||
|
h.maybeLogContent(c, callID, "anthropic", ir.Model, false, req, out)
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamAnthropic 经状态机把 IR chunk 流聚合为 Anthropic SSE 事件流。
|
||||||
|
func (h *aiGatewayHandler) streamAnthropic(c *gin.Context, ir aiwire.ChatRequest) {
|
||||||
|
start := time.Now()
|
||||||
|
stream, meta, err := h.gw.OpenStream(c.Request.Context(), ir, keyGroup(c))
|
||||||
|
entry := h.logEntry(c, "anthropic", ir.Model, true, meta, start)
|
||||||
|
if err != nil {
|
||||||
|
upstreamError(c, err)
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
h.logFailure(c, entry, ir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
sseHeaders(c)
|
||||||
|
st := service.NewAnthStream(aiRandID("msg_"), ir.Model)
|
||||||
|
for {
|
||||||
|
chunk, err := stream.Next()
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, io.EOF) {
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
writeAnthEvents(c, st.Feed(chunk))
|
||||||
|
}
|
||||||
|
writeAnthEvents(c, st.Finish())
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||||
|
usage := st.Usage()
|
||||||
|
entry.PromptTokens, entry.CompletionTokens = usage.InputTokens, usage.OutputTokens
|
||||||
|
entry.TotalTokens = usage.InputTokens + usage.OutputTokens
|
||||||
|
entry.CachedTokens = usage.CacheReadInputTokens
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, "anthropic", ir.Model, true, ir, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAnthEvents(c *gin.Context, events []service.AnthEvent) {
|
||||||
|
for _, ev := range events {
|
||||||
|
b, err := json.Marshal(ev.Data)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.Writer.WriteString("event: " + ev.Event + "\ndata: ")
|
||||||
|
c.Writer.Write(b)
|
||||||
|
c.Writer.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
c.Writer.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// listModels 是 /ai/v1/models 端点(OpenAI 格式,从启用渠道的模型缓存聚合)。
|
||||||
|
func (h *aiGatewayHandler) listModels(c *gin.Context) {
|
||||||
|
list, err := h.gw.GatewayModels(c.Request.Context(), keyGroup(c))
|
||||||
|
if err != nil {
|
||||||
|
aiError(c, http.StatusInternalServerError, "api_error", "查询模型列表失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// responses 是 OpenAI /ai/v1/responses 端点(无状态子集)。
|
||||||
|
func (h *aiGatewayHandler) responses(c *gin.Context) {
|
||||||
|
var req aiwire.RespRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ir, err := service.ResponsesToIR(req)
|
||||||
|
if err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validateIR(ir); err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Stream {
|
||||||
|
h.streamResponses(c, ir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
resp, meta, err := h.gw.Chat(c.Request.Context(), ir, keyGroup(c))
|
||||||
|
entry := h.logEntry(c, "responses", ir.Model, false, meta, start)
|
||||||
|
if err != nil {
|
||||||
|
upstreamError(c, err)
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
h.logFailure(c, entry, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
fillUsage(&entry, resp.Usage)
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
out := service.IRRespToResponses(resp, aiRandID("resp_"), time.Now().Unix())
|
||||||
|
h.maybeLogContent(c, callID, "responses", ir.Model, false, req, out)
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamResponses 经状态机把 IR chunk 流聚合为 Responses 语义事件流。
|
||||||
|
func (h *aiGatewayHandler) streamResponses(c *gin.Context, ir aiwire.ChatRequest) {
|
||||||
|
start := time.Now()
|
||||||
|
stream, meta, err := h.gw.OpenStream(c.Request.Context(), ir, keyGroup(c))
|
||||||
|
entry := h.logEntry(c, "responses", ir.Model, true, meta, start)
|
||||||
|
if err != nil {
|
||||||
|
upstreamError(c, err)
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
h.logFailure(c, entry, ir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
sseHeaders(c)
|
||||||
|
st := service.NewRespStream(aiRandID("resp_"), ir.Model, time.Now().Unix())
|
||||||
|
for {
|
||||||
|
chunk, err := stream.Next()
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, io.EOF) {
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
writeRespEvents(c, st.Feed(chunk))
|
||||||
|
}
|
||||||
|
writeRespEvents(c, st.Finish())
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||||
|
fillUsage(&entry, st.Usage())
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, "responses", ir.Model, true, ir, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRespEvents(c *gin.Context, events []service.RespEvent) {
|
||||||
|
for _, ev := range events {
|
||||||
|
b, err := json.Marshal(ev.Data)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.Writer.WriteString("event: " + ev.Event + "\ndata: ")
|
||||||
|
c.Writer.Write(b)
|
||||||
|
c.Writer.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
c.Writer.Flush()
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- 实例 IP ----
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) changePublicIP(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
ip, err := h.svc.ChangeInstancePublicIP(c.Request.Context(), id, req.Region, c.Param("instanceId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"publicIp": ip})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) addInstanceIpv6(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
address, err := h.svc.AddInstanceIpv6(c.Request.Context(), id, req.Region, c.Param("instanceId"), req.Address)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"ipv6Address": address})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) deleteInstanceIpv6(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := h.svc.DeleteInstanceIpv6(c.Request.Context(), id, c.Query("region"), c.Param("instanceId"), c.Query("address"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- VNIC 管理 ----
|
||||||
|
|
||||||
|
type attachVnicRequest struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
oci.AttachVnicInput
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) listInstanceVnics(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vnics, err := h.svc.InstanceVnics(c.Request.Context(), id, c.Query("region"), c.Param("instanceId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, vnics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) attachVnic(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req attachVnicRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vnic, err := h.svc.AttachVnic(c.Request.Context(), id, req.Region, c.Param("instanceId"), req.AttachVnicInput)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, vnic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) detachVnic(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.DetachVnic(c.Request.Context(), id, c.Query("region"), c.Param("attachmentId")); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addVnicIpv6 为指定 VNIC 添加 IPv6;body.address 留空自动分配。
|
||||||
|
func (h *ociConfigHandler) addVnicIpv6(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addr, err := h.svc.AddVnicIpv6(c.Request.Context(), id, req.Region, c.Param("vnicId"), req.Address)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"address": addr})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 引导卷挂载 ----
|
||||||
|
|
||||||
|
type attachBootVolumeRequest struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
BootVolumeID string `json:"bootVolumeId" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) listBootVolumeAttachments(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
atts, err := h.svc.BootVolumeAttachments(c.Request.Context(), id, c.Query("region"), c.Param("instanceId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, atts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) attachBootVolume(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req attachBootVolumeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
att, err := h.svc.AttachBootVolume(c.Request.Context(), id, req.Region, c.Param("instanceId"), req.BootVolumeID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, att)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) replaceBootVolume(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req attachBootVolumeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
att, err := h.svc.ReplaceBootVolume(c.Request.Context(), id, req.Region, c.Param("instanceId"), req.BootVolumeID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, att)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) detachBootVolume(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.DetachBootVolume(c.Request.Context(), id, c.Query("region"), c.Param("attachmentId")); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 块卷挂载 ----
|
||||||
|
|
||||||
|
type attachVolumeRequest struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
VolumeID string `json:"volumeId" binding:"required"`
|
||||||
|
ReadOnly bool `json:"readOnly"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) listBlockVolumes(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
volumes, err := h.svc.BlockVolumes(c.Request.Context(), id, c.Query("region"), c.Query("availabilityDomain"), c.Query("compartmentId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, volumes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) listVolumeAttachments(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
atts, err := h.svc.VolumeAttachments(c.Request.Context(), id, c.Query("region"), c.Param("instanceId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, atts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) attachVolume(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req attachVolumeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
att, err := h.svc.AttachVolume(c.Request.Context(), id, req.Region, c.Param("instanceId"), req.VolumeID, req.ReadOnly)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, att)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) detachVolume(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.DetachVolume(c.Request.Context(), id, c.Query("region"), c.Param("attachmentId")); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type authHandler struct {
|
||||||
|
svc *service.AuthService
|
||||||
|
logs *service.SystemLogService
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginRequest struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
// 两步验证码;账号已启用 TOTP 时必填,缺失返回 428
|
||||||
|
TotpCode string `json:"totpCode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *authHandler) login(c *gin.Context) {
|
||||||
|
var req loginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
token, expires, err := h.svc.Login(c.Request.Context(), req.Username, req.Password, requestIP(c), req.TotpCode)
|
||||||
|
if errors.Is(err, service.ErrTotpRequired) {
|
||||||
|
// 密码已通过,引导前端弹出二次验证输入;不算失败不留痕
|
||||||
|
c.JSON(http.StatusPreconditionRequired, gin.H{"error": err.Error(), "totpRequired": true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, service.ErrPasswordLoginDisabled) {
|
||||||
|
h.recordLogin(c, req.Username, http.StatusForbidden, start)
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, service.ErrLoginLocked) {
|
||||||
|
h.recordLogin(c, req.Username, http.StatusTooManyRequests, start)
|
||||||
|
c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, service.ErrInvalidCredentials) {
|
||||||
|
h.recordLogin(c, req.Username, http.StatusUnauthorized, start)
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.recordLogin(c, req.Username, http.StatusOK, start)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires})
|
||||||
|
}
|
||||||
|
|
||||||
|
// logout 把当前令牌拉黑至自然过期(secured 组内,写请求由系统日志中间件留痕)。
|
||||||
|
func (h *authHandler) logout(c *gin.Context) {
|
||||||
|
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||||
|
if ok && token != "" {
|
||||||
|
h.svc.Logout(token)
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordLogin 显式记录一次登录成败——登录不在 secured 组内,
|
||||||
|
// 系统日志中间件覆盖不到;登录失败是安全关键事件,必须留痕。
|
||||||
|
func (h *authHandler) recordLogin(c *gin.Context, username string, status int, start time.Time) {
|
||||||
|
errMsg := ""
|
||||||
|
if status == http.StatusUnauthorized {
|
||||||
|
errMsg = "用户名、密码或验证码错误"
|
||||||
|
} else if status == http.StatusTooManyRequests {
|
||||||
|
errMsg = "连续失败已锁定"
|
||||||
|
} else if status == http.StatusForbidden {
|
||||||
|
errMsg = "密码登录已禁用"
|
||||||
|
}
|
||||||
|
h.logs.Record(model.SystemLog{
|
||||||
|
Username: username,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: requestPath(c),
|
||||||
|
Status: status,
|
||||||
|
DurationMs: time.Since(start).Milliseconds(),
|
||||||
|
ClientIP: requestIP(c),
|
||||||
|
UserAgent: truncateLogField(c.Request.UserAgent(), 256),
|
||||||
|
ErrMsg: errMsg,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// authxHandler 处理两步验证与外部身份(OAuth)接口。
|
||||||
|
type authxHandler struct {
|
||||||
|
auth *service.AuthService
|
||||||
|
oauth *service.OAuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- TOTP(JWT 组内) ----
|
||||||
|
|
||||||
|
// totpStatus 返回当前账号两步验证启用状态。
|
||||||
|
func (h *authxHandler) totpStatus(c *gin.Context) {
|
||||||
|
enabled, err := h.auth.TotpStatus(c.Request.Context(), c.GetString(usernameKey))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"enabled": enabled})
|
||||||
|
}
|
||||||
|
|
||||||
|
// totpSetup 生成待激活密钥,返回手动输入码与 otpauth URI(前端渲染二维码)。
|
||||||
|
func (h *authxHandler) totpSetup(c *gin.Context) {
|
||||||
|
secret, uri, err := h.auth.SetupTotp(c.Request.Context(), c.GetString(usernameKey))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrTotpAlreadyOn) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"secret": secret, "otpauthUri": uri})
|
||||||
|
}
|
||||||
|
|
||||||
|
// totpActivate 校验验证码并正式启用两步验证。
|
||||||
|
func (h *authxHandler) totpActivate(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Code string `json:"code" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := h.auth.ActivateTotp(c.Request.Context(), c.GetString(usernameKey), req.Code)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrTotpInvalid) || errors.Is(err, service.ErrTotpNotSetup) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// totpDisable 停用两步验证;需当前验证码或登录密码任一确认。
|
||||||
|
func (h *authxHandler) totpDisable(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := h.auth.DisableTotp(c.Request.Context(), c.GetString(usernameKey), req.Password, req.Code)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrTotpConfirm) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 登录凭据(JWT 组内) ----
|
||||||
|
|
||||||
|
// getCredentials 返回登录凭据摘要:当前用户名与密码登录禁用态。
|
||||||
|
func (h *authxHandler) getCredentials(c *gin.Context) {
|
||||||
|
disabled, err := h.auth.PasswordLoginDisabled(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"username": c.GetString(usernameKey),
|
||||||
|
"passwordLoginDisabled": disabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateCredentials 修改用户名 / 密码;当前密码必验,改名后旧 JWT 随即失效(前端应重新登录)。
|
||||||
|
func (h *authxHandler) updateCredentials(c *gin.Context) {
|
||||||
|
var req service.UpdateCredentialsInput
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := h.auth.UpdateCredentials(c.Request.Context(), c.GetString(usernameKey), req)
|
||||||
|
if errors.Is(err, service.ErrCredentialConfirm) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, service.ErrCredentialInvalid) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updatePasswordLogin 保存密码登录禁用开关;开启需至少绑定一个外部身份。
|
||||||
|
func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Disabled *bool `json:"disabled" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := h.auth.SetPasswordLoginDisabled(c.Request.Context(), c.GetString(usernameKey), *req.Disabled)
|
||||||
|
if errors.Is(err, service.ErrNeedIdentity) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- OAuth ----
|
||||||
|
|
||||||
|
// oauthProviders 返回已配置的 provider 列表(公开,登录页展示按钮),
|
||||||
|
// 并附带密码登录禁用开关:开启且存在可用外部身份时,登录页隐藏密码表单;
|
||||||
|
// provider 清空时不下发禁用,保证界面始终留有登录入口(后端 Login 仍会拒绝)。
|
||||||
|
func (h *authxHandler) oauthProviders(c *gin.Context) {
|
||||||
|
providers := h.oauth.Providers(c.Request.Context())
|
||||||
|
disabled := false
|
||||||
|
if off, err := h.auth.PasswordLoginDisabled(c.Request.Context()); err == nil && off && len(providers) > 0 {
|
||||||
|
disabled = true
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"providers": providers, "passwordLoginDisabled": disabled})
|
||||||
|
}
|
||||||
|
|
||||||
|
// oauthAuthorize 返回授权跳转 URL;mode=bind 需有效 JWT(绑定到当前账号),login 公开。
|
||||||
|
func (h *authxHandler) oauthAuthorize(c *gin.Context) {
|
||||||
|
provider := c.Param("provider")
|
||||||
|
if provider != "oidc" && provider != "github" {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "unknown provider"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mode, username := "login", ""
|
||||||
|
if c.Query("mode") == "bind" {
|
||||||
|
name, ok := h.bearerUser(c)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "绑定需要先登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mode, username = "bind", name
|
||||||
|
}
|
||||||
|
authURL, err := h.oauth.AuthorizeURL(c.Request.Context(), provider, mode, username)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrOAuthNotConfigured) || errors.Is(err, service.ErrOAuthNoAppURL) || errors.Is(err, service.ErrOAuthDisabled) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"url": authURL})
|
||||||
|
}
|
||||||
|
|
||||||
|
// bearerUser 从 Authorization 头解析用户名(authorize 在公开组,绑定模式手动校验)。
|
||||||
|
func (h *authxHandler) bearerUser(c *gin.Context) (string, bool) {
|
||||||
|
header := c.GetHeader("Authorization")
|
||||||
|
if len(header) < 8 || header[:7] != "Bearer " {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
username, err := h.auth.ParseToken(header[7:])
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return username, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// oauthCallback 是 provider 回调落点(浏览器直达):完成登录或绑定后 302 回前端。
|
||||||
|
func (h *authxHandler) oauthCallback(c *gin.Context) {
|
||||||
|
provider := c.Param("provider")
|
||||||
|
token, _, mode, err := h.oauth.HandleCallback(
|
||||||
|
c.Request.Context(), provider, c.Query("state"), c.Query("code"))
|
||||||
|
if err != nil {
|
||||||
|
target := "/login"
|
||||||
|
if mode == "bind" {
|
||||||
|
target = "/settings"
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, target+"?oauthError="+url.QueryEscape(oauthErrText(err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
// 绑定模式:回设置页安全 tab
|
||||||
|
c.Redirect(http.StatusFound, "/settings?oauth=bound")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 登录模式:token 放 fragment,不进服务端日志与 Referer
|
||||||
|
c.Redirect(http.StatusFound, "/login#oauthToken="+url.QueryEscape(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
// oauthErrText 把流程错误转为用户可读文案;内部错误不透出细节。
|
||||||
|
func oauthErrText(err error) string {
|
||||||
|
for _, known := range []error{service.ErrOAuthNotConfigured, service.ErrOAuthNoAppURL, service.ErrOAuthDisabled, service.ErrOAuthState, service.ErrOAuthNotBound, service.ErrOAuthBound} {
|
||||||
|
if errors.Is(err, known) {
|
||||||
|
return known.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "登录失败,请重试"
|
||||||
|
}
|
||||||
|
|
||||||
|
// identities 列出当前账号绑定的外部身份。
|
||||||
|
func (h *authxHandler) identities(c *gin.Context) {
|
||||||
|
items, err := h.oauth.Identities(c.Request.Context(), c.GetString(usernameKey))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// unbindIdentity 解绑外部身份;密码登录禁用期间不允许解绑最后一个身份。
|
||||||
|
func (h *authxHandler) unbindIdentity(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.oauth.Unbind(c.Request.Context(), c.GetString(usernameKey), uint(id)); err != nil {
|
||||||
|
if errors.Is(err, service.ErrLastIdentity) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- OAuth provider 设置(JWT 组内) ----
|
||||||
|
|
||||||
|
// getOAuthSettings 返回 provider 配置(secret 只回设置态)。
|
||||||
|
func (h *authxHandler) getOAuthSettings(c *gin.Context, settings *service.SettingService) {
|
||||||
|
view, err := settings.OAuthView(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateOAuthSettings 保存 provider 配置;secret 缺省沿用、空串清除。
|
||||||
|
func (h *authxHandler) updateOAuthSettings(c *gin.Context, settings *service.SettingService) {
|
||||||
|
var req service.UpdateOAuthInput
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := settings.UpdateOAuth(c.Request.Context(), req); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.getOAuthSettings(c, settings)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- 控制台连接(VNC / 串口) ----
|
||||||
|
|
||||||
|
type createConsoleConnectionRequest struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
SSHPublicKey string `json:"sshPublicKey" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) createConsoleConnection(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createConsoleConnectionRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn, err := h.svc.CreateConsoleConnection(c.Request.Context(), id, req.Region, c.Param("instanceId"), req.SSHPublicKey)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) listConsoleConnections(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connections, err := h.svc.ConsoleConnections(c.Request.Context(), id, c.Query("region"), c.Param("instanceId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, connections)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) deleteConsoleConnection(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.DeleteConsoleConnection(c.Request.Context(), id, c.Query("region"), c.Param("connectionId")); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Package api 承载 HTTP 路由、参数绑定、响应和中间件。
|
||||||
|
package api
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Federation:SAML IdP 与 sign-on 免 MFA ----
|
||||||
|
|
||||||
|
// createIdpRequest 的映射 / JIT 字段全部可缺省:布尔用指针区分「未传」,
|
||||||
|
// 缺省即控制台默认(JIT 开启建用户不更新、分配 Administrators 组、
|
||||||
|
// 名称 ID 格式无、NameID 映射用户名)。
|
||||||
|
type createIdpRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Metadata string `json:"metadata" binding:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IconURL string `json:"iconUrl"`
|
||||||
|
|
||||||
|
NameIDFormat string `json:"nameIdFormat"`
|
||||||
|
AssertionAttribute string `json:"assertionAttribute"`
|
||||||
|
UserStoreAttribute string `json:"userStoreAttribute"`
|
||||||
|
|
||||||
|
JitEnabled *bool `json:"jitEnabled"`
|
||||||
|
JitCreate *bool `json:"jitCreate"`
|
||||||
|
JitUpdate *bool `json:"jitUpdate"`
|
||||||
|
JitAssignAdminGroup *bool `json:"jitAssignAdminGroup"`
|
||||||
|
JitMapEmail bool `json:"jitMapEmail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolOr(v *bool, def bool) bool {
|
||||||
|
if v == nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return *v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idps, err := h.svc.IdentityProviders(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, idps)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createIdpRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idp, err := h.svc.CreateIdentityProvider(c.Request.Context(), id, oci.CreateIdpInput{
|
||||||
|
Name: req.Name,
|
||||||
|
Metadata: req.Metadata,
|
||||||
|
Description: req.Description,
|
||||||
|
IconURL: req.IconURL,
|
||||||
|
NameIDFormat: req.NameIDFormat,
|
||||||
|
AssertionAttribute: req.AssertionAttribute,
|
||||||
|
UserStoreAttribute: req.UserStoreAttribute,
|
||||||
|
JitEnabled: boolOr(req.JitEnabled, true),
|
||||||
|
JitCreate: boolOr(req.JitCreate, true),
|
||||||
|
JitUpdate: boolOr(req.JitUpdate, false),
|
||||||
|
JitAssignAdminGroup: boolOr(req.JitAssignAdminGroup, true),
|
||||||
|
JitMapEmail: req.JitMapEmail,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, idp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) activateIdentityProvider(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Enabled *bool `json:"enabled" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idp, err := h.svc.SetIdentityProviderEnabled(c.Request.Context(), id, c.Param("idpId"), *req.Enabled)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, idp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) deleteIdentityProvider(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.DeleteIdentityProvider(c.Request.Context(), id, c.Param("idpId")); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
metadata, err := h.svc.DomainSamlMetadata(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Disposition", `attachment; filename="oci-domain-saml-metadata.xml"`)
|
||||||
|
c.Data(http.StatusOK, "application/xml", metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) listSignOnRules(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rules, err := h.svc.ConsoleSignOnRules(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) createMfaExemption(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
IdentityProviderID string `json:"identityProviderId" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rule, err := h.svc.CreateMfaExemption(c.Request.Context(), id, req.IdentityProviderID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) deleteMfaExemption(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.DeleteMfaExemption(c.Request.Context(), id, c.Param("ruleId")); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) availabilityDomains(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ads, err := h.svc.AvailabilityDomains(c.Request.Context(), id, c.Query("region"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, ads)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 实例 ----
|
||||||
|
|
||||||
|
type createInstanceRequest struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
CompartmentID string `json:"compartmentId"` // 为空时建在租户根
|
||||||
|
AvailabilityDomain string `json:"availabilityDomain"` // 为空时默认使用第一个可用域
|
||||||
|
DisplayName string `json:"displayName" binding:"required"`
|
||||||
|
Shape string `json:"shape" binding:"required"`
|
||||||
|
Count int `json:"count"` // 批量创建数量,缺省 1,上限 10
|
||||||
|
Ocpus float32 `json:"ocpus"`
|
||||||
|
MemoryInGBs float32 `json:"memoryInGBs"`
|
||||||
|
ImageID string `json:"imageId"`
|
||||||
|
BootVolumeID string `json:"bootVolumeId"`
|
||||||
|
BootVolumeSizeGBs int64 `json:"bootVolumeSizeGBs"`
|
||||||
|
BootVolumeVpusPerGB int64 `json:"bootVolumeVpusPerGB"`
|
||||||
|
SubnetID string `json:"subnetId"` // 为空时自动创建 VCN 与子网
|
||||||
|
AssignPublicIP bool `json:"assignPublicIp"`
|
||||||
|
AssignIpv6 bool `json:"assignIpv6"`
|
||||||
|
SSHPublicKey string `json:"sshPublicKey"`
|
||||||
|
RootPassword string `json:"rootPassword"`
|
||||||
|
GenerateRootPassword bool `json:"generateRootPassword"` // 服务端生成随机 root 密码并写入 TAG RootPassword
|
||||||
|
UserData string `json:"userData"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateInstanceRequest struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Shape string `json:"shape"` // 更换 shape,运行中实例会自动重启
|
||||||
|
Ocpus float32 `json:"ocpus"`
|
||||||
|
MemoryInGBs float32 `json:"memoryInGBs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type instanceActionRequest struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
Action string `json:"action" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) listInstances(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
instances, err := h.svc.Instances(c.Request.Context(), id, c.Query("region"), c.Query("compartmentId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, instances)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) createInstance(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createInstanceRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count := req.Count
|
||||||
|
if count == 0 {
|
||||||
|
count = 1
|
||||||
|
}
|
||||||
|
instances, failures, err := h.svc.CreateInstances(c.Request.Context(), id, oci.CreateInstanceInput{
|
||||||
|
Region: req.Region,
|
||||||
|
CompartmentID: req.CompartmentID,
|
||||||
|
AvailabilityDomain: req.AvailabilityDomain,
|
||||||
|
DisplayName: req.DisplayName,
|
||||||
|
Shape: req.Shape,
|
||||||
|
Ocpus: req.Ocpus,
|
||||||
|
MemoryInGBs: req.MemoryInGBs,
|
||||||
|
ImageID: req.ImageID,
|
||||||
|
BootVolumeID: req.BootVolumeID,
|
||||||
|
BootVolumeSizeGBs: req.BootVolumeSizeGBs,
|
||||||
|
BootVolumeVpusPerGB: req.BootVolumeVpusPerGB,
|
||||||
|
SubnetID: req.SubnetID,
|
||||||
|
AssignPublicIP: req.AssignPublicIP,
|
||||||
|
AssignIpv6: req.AssignIpv6,
|
||||||
|
SSHPublicKey: req.SSHPublicKey,
|
||||||
|
RootPassword: req.RootPassword,
|
||||||
|
GenerateRootPassword: req.GenerateRootPassword,
|
||||||
|
UserData: req.UserData,
|
||||||
|
}, count)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 全部失败视为执行失败,部分成功仍返回 201 并附带逐台失败信息
|
||||||
|
status := http.StatusCreated
|
||||||
|
if len(instances) == 0 && len(failures) > 0 {
|
||||||
|
status = http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
// nil 切片会序列化为 null,显式空数组免去前端判空
|
||||||
|
if instances == nil {
|
||||||
|
instances = []oci.Instance{}
|
||||||
|
}
|
||||||
|
if failures == nil {
|
||||||
|
failures = []string{}
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"instances": instances, "errors": failures})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) getInstance(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
instance, err := h.svc.Instance(c.Request.Context(), id, c.Query("region"), c.Param("instanceId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) updateInstance(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req updateInstanceRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
instance, err := h.svc.UpdateInstance(c.Request.Context(), id, c.Param("instanceId"), oci.UpdateInstanceInput{
|
||||||
|
Region: req.Region,
|
||||||
|
DisplayName: req.DisplayName,
|
||||||
|
Shape: req.Shape,
|
||||||
|
Ocpus: req.Ocpus,
|
||||||
|
MemoryInGBs: req.MemoryInGBs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) terminateInstance(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
preserve := c.Query("preserveBootVolume") == "true"
|
||||||
|
if err := h.svc.TerminateInstance(c.Request.Context(), id, c.Query("region"), c.Param("instanceId"), preserve); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) instanceAction(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req instanceActionRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
instance, err := h.svc.InstanceAction(c.Request.Context(), id, req.Region, c.Param("instanceId"), req.Action)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 引导卷 ----
|
||||||
|
|
||||||
|
type updateBootVolumeRequest struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
SizeInGBs int64 `json:"sizeInGBs"`
|
||||||
|
VpusPerGB int64 `json:"vpusPerGB"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) listBootVolumes(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
volumes, err := h.svc.BootVolumes(c.Request.Context(), id, c.Query("region"), c.Query("availabilityDomain"), c.Query("compartmentId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, volumes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) getBootVolume(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
volume, err := h.svc.BootVolume(c.Request.Context(), id, c.Query("region"), c.Param("bootVolumeId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, volume)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) updateBootVolume(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req updateBootVolumeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
volume, err := h.svc.UpdateBootVolume(c.Request.Context(), id, c.Param("bootVolumeId"), oci.UpdateBootVolumeInput{
|
||||||
|
Region: req.Region,
|
||||||
|
DisplayName: req.DisplayName,
|
||||||
|
SizeInGBs: req.SizeInGBs,
|
||||||
|
VpusPerGB: req.VpusPerGB,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, volume)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) deleteBootVolume(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.DeleteBootVolume(c.Request.Context(), id, c.Query("region"), c.Param("bootVolumeId")); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// logEventHandler 处理回传事件查询与每租户回调地址管理(JWT 组内)。
|
||||||
|
type logEventHandler struct {
|
||||||
|
svc *service.LogEventService
|
||||||
|
}
|
||||||
|
|
||||||
|
// getWebhook 查询配置的回调地址;未生成时 exists 为 false 且不含 secret。
|
||||||
|
func (h *logEventHandler) getWebhook(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, exists, err := h.svc.SecretInfo(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"exists": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"exists": true, "webhook": info})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureWebhook 生成(或幂等返回)配置的回传 secret 与回调路径。
|
||||||
|
func (h *logEventHandler) ensureWebhook(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := h.svc.EnsureSecret(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// revokeWebhook 撤销配置的回传 secret,旧回调地址随即失效。
|
||||||
|
func (h *logEventHandler) revokeWebhook(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.RevokeSecret(c.Request.Context(), id); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// list 分页查询回传事件;cfgId 缺省为全部租户。
|
||||||
|
func (h *logEventHandler) list(c *gin.Context) {
|
||||||
|
cfgID, _ := strconv.ParseUint(c.Query("cfgId"), 10, 64)
|
||||||
|
page, _ := strconv.Atoi(c.Query("page"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.Query("pageSize"))
|
||||||
|
items, total, err := h.svc.List(c.Request.Context(), service.LogEventQuery{
|
||||||
|
CfgID: uint(cfgID),
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRelay 查询 OCI 侧链路状态与关键事件清单。
|
||||||
|
func (h *logEventHandler) getRelay(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view, err := h.svc.RelayStatus(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondRelayError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupRelay 一键建立回传链路(P1 引导创建);同步执行,前端以长超时等待。
|
||||||
|
func (h *logEventHandler) setupRelay(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view, err := h.svc.SetupRelay(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondRelayError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// teardownRelay 逆序销毁链路并撤销 secret。
|
||||||
|
func (h *logEventHandler) teardownRelay(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.TeardownRelay(c.Request.Context(), id); err != nil {
|
||||||
|
respondRelayError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondRelayError 在通用映射之上补充引导创建专属错误:依赖未配置映射 409。
|
||||||
|
func respondRelayError(c *gin.Context, err error) {
|
||||||
|
if errors.Is(err, service.ErrRelayNotConfigured) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(c, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// usernameKey 是鉴权通过后写入 gin.Context 的用户名键。
|
||||||
|
const usernameKey = "username"
|
||||||
|
|
||||||
|
// RequireAuth 校验 Authorization: Bearer 令牌,通过后把用户名放进上下文。
|
||||||
|
func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||||
|
if !ok || token == "" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username, err := auth.ParseToken(token)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set(usernameKey, username)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemLogMiddleware 把写请求(POST/PUT/DELETE)异步记入系统日志,只读请求跳过。
|
||||||
|
// 只记方法、路径等元数据,绝不读请求体——请求体可能含私钥 / 口令等敏感数据。
|
||||||
|
func systemLogMiddleware(logs *service.SystemLogService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if !isWriteMethod(c.Request.Method) {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
tee := &teeWriter{ResponseWriter: c.Writer}
|
||||||
|
c.Writer = tee
|
||||||
|
c.Next()
|
||||||
|
logs.Record(model.SystemLog{
|
||||||
|
Username: c.GetString(usernameKey),
|
||||||
|
Method: c.Request.Method,
|
||||||
|
Path: requestPath(c),
|
||||||
|
Status: c.Writer.Status(),
|
||||||
|
DurationMs: time.Since(start).Milliseconds(),
|
||||||
|
ClientIP: requestIP(c),
|
||||||
|
UserAgent: truncateLogField(c.Request.UserAgent(), 256),
|
||||||
|
ErrMsg: errMsgOf(c.Writer.Status(), tee.head),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// teeWriter 旁路捕获响应体前 512 字节,供失败留痕提取 error 文案;不改写响应。
|
||||||
|
type teeWriter struct {
|
||||||
|
gin.ResponseWriter
|
||||||
|
head []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *teeWriter) Write(b []byte) (int, error) {
|
||||||
|
if room := 512 - len(w.head); room > 0 {
|
||||||
|
if len(b) < room {
|
||||||
|
room = len(b)
|
||||||
|
}
|
||||||
|
w.head = append(w.head, b[:room]...)
|
||||||
|
}
|
||||||
|
return w.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// errMsgOf 从失败响应(>=400)的 JSON 中提取 error 字段;错误响应均由
|
||||||
|
// respondError 系列生成、不含敏感信息,成功响应一律不记。
|
||||||
|
func errMsgOf(status int, head []byte) string {
|
||||||
|
if status < 400 || len(head) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(head, &body) != nil || body.Error == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return truncateLogField(body.Error, 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateLogField 按字节截断留痕字段,防超长 UA / 错误串撑爆列宽。
|
||||||
|
func truncateLogField(s string, max int) string {
|
||||||
|
if len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:max]
|
||||||
|
}
|
||||||
|
|
||||||
|
// isWriteMethod 判断是否需要留痕的写方法。
|
||||||
|
func isWriteMethod(m string) bool {
|
||||||
|
return m == http.MethodPost || m == http.MethodPut || m == http.MethodDelete
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestPath 优先取注册的路由模板(含 :id 占位符,避免把敏感 query 记入日志),
|
||||||
|
// 未匹配到路由时退回原始路径;webhook 前缀的原始路径把 secret 段脱敏。
|
||||||
|
func requestPath(c *gin.Context) string {
|
||||||
|
if p := c.FullPath(); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return sanitizeLogPath(c.Request.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeLogPath 把回传 webhook 路径的最后一段(secret)替换为 ***;
|
||||||
|
// 正常留痕走路由模板不经此处,这里是未匹配路由时的防御兜底。
|
||||||
|
func sanitizeLogPath(path string) string {
|
||||||
|
const prefix = "/api/v1/webhooks/"
|
||||||
|
if !strings.HasPrefix(path, prefix) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
if i := strings.LastIndex(path, "/"); i >= len(prefix) {
|
||||||
|
return path[:i+1] + "***"
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) shapes(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shapes, err := h.svc.Shapes(c.Request.Context(), id, c.Query("region"), c.Query("availabilityDomain"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, shapes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) images(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
images, err := h.svc.Images(c.Request.Context(), id, oci.ImagesQuery{
|
||||||
|
Region: c.Query("region"),
|
||||||
|
OperatingSystem: c.Query("operatingSystem"),
|
||||||
|
Shape: c.Query("shape"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, images)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) getImage(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
image, err := h.svc.Image(c.Request.Context(), id, c.Query("region"), c.Param("imageId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, image)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- VCN ----
|
||||||
|
|
||||||
|
type createVCNRequest struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
CompartmentID string `json:"compartmentId"` // 为空时建在租户根
|
||||||
|
DisplayName string `json:"displayName" binding:"required"`
|
||||||
|
CidrBlock string `json:"cidrBlock" binding:"required"`
|
||||||
|
DnsLabel string `json:"dnsLabel"`
|
||||||
|
EnableIPv6 *bool `json:"enableIpv6"` // 缺省默认启用
|
||||||
|
}
|
||||||
|
|
||||||
|
type renameRequest struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
DisplayName string `json:"displayName" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) listVCNs(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vcns, err := h.svc.VCNs(c.Request.Context(), id, c.Query("region"), c.Query("compartmentId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, vcns)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) createVCN(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createVCNRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vcn, err := h.svc.CreateVCN(c.Request.Context(), id, oci.CreateVCNInput{
|
||||||
|
Region: req.Region,
|
||||||
|
CompartmentID: req.CompartmentID,
|
||||||
|
DisplayName: req.DisplayName,
|
||||||
|
CidrBlock: req.CidrBlock,
|
||||||
|
DnsLabel: req.DnsLabel,
|
||||||
|
EnableIPv6: req.EnableIPv6 == nil || *req.EnableIPv6,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, vcn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) getVCN(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vcn, err := h.svc.VCN(c.Request.Context(), id, c.Query("region"), c.Param("vcnId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, vcn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) updateVCN(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req renameRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vcn, err := h.svc.UpdateVCN(c.Request.Context(), id, req.Region, c.Param("vcnId"), req.DisplayName)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, vcn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) deleteVCN(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.DeleteVCN(c.Request.Context(), id, c.Query("region"), c.Param("vcnId")); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
type enableIPv6Request struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) enableVCNIPv6(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req enableIPv6Request
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil && c.Request.ContentLength > 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
steps, err := h.svc.EnableVCNIPv6(c.Request.Context(), id, req.Region, c.Param("vcnId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"steps": steps})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Subnet ----
|
||||||
|
|
||||||
|
type createSubnetRequest struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
VcnID string `json:"vcnId" binding:"required"`
|
||||||
|
DisplayName string `json:"displayName" binding:"required"`
|
||||||
|
CidrBlock string `json:"cidrBlock" binding:"required"`
|
||||||
|
DnsLabel string `json:"dnsLabel"`
|
||||||
|
Ipv6CidrBlock string `json:"ipv6CidrBlock"`
|
||||||
|
ProhibitPublicIP bool `json:"prohibitPublicIp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) listSubnets(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subnets, err := h.svc.Subnets(c.Request.Context(), id, c.Query("region"), c.Query("vcnId"), c.Query("compartmentId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, subnets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) createSubnet(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createSubnetRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subnet, err := h.svc.CreateSubnet(c.Request.Context(), id, oci.CreateSubnetInput{
|
||||||
|
Region: req.Region,
|
||||||
|
VcnID: req.VcnID,
|
||||||
|
DisplayName: req.DisplayName,
|
||||||
|
CidrBlock: req.CidrBlock,
|
||||||
|
DnsLabel: req.DnsLabel,
|
||||||
|
Ipv6CidrBlock: req.Ipv6CidrBlock,
|
||||||
|
ProhibitPublicIP: req.ProhibitPublicIP,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, subnet)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) getSubnet(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subnet, err := h.svc.Subnet(c.Request.Context(), id, c.Query("region"), c.Param("subnetId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, subnet)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) updateSubnet(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req renameRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subnet, err := h.svc.UpdateSubnet(c.Request.Context(), id, req.Region, c.Param("subnetId"), req.DisplayName)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, subnet)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) deleteSubnet(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.DeleteSubnet(c.Request.Context(), id, c.Query("region"), c.Param("subnetId")); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Security List ----
|
||||||
|
|
||||||
|
type createSecurityListRequest struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
VcnID string `json:"vcnId" binding:"required"`
|
||||||
|
DisplayName string `json:"displayName" binding:"required"`
|
||||||
|
IngressRules []oci.SecurityRule `json:"ingressRules"`
|
||||||
|
EgressRules []oci.SecurityRule `json:"egressRules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateSecurityListRequest struct {
|
||||||
|
Region string `json:"region"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
IngressRules *[]oci.SecurityRule `json:"ingressRules"`
|
||||||
|
EgressRules *[]oci.SecurityRule `json:"egressRules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) listSecurityLists(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lists, err := h.svc.SecurityLists(c.Request.Context(), id, c.Query("region"), c.Query("vcnId"), c.Query("compartmentId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, lists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) createSecurityList(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createSecurityListRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, err := h.svc.CreateSecurityList(c.Request.Context(), id, oci.CreateSecurityListInput{
|
||||||
|
Region: req.Region,
|
||||||
|
VcnID: req.VcnID,
|
||||||
|
DisplayName: req.DisplayName,
|
||||||
|
IngressRules: req.IngressRules,
|
||||||
|
EgressRules: req.EgressRules,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) getSecurityList(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, err := h.svc.SecurityList(c.Request.Context(), id, c.Query("region"), c.Param("securityListId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) updateSecurityList(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req updateSecurityListRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, err := h.svc.UpdateSecurityList(c.Request.Context(), id, c.Param("securityListId"), oci.UpdateSecurityListInput{
|
||||||
|
Region: req.Region,
|
||||||
|
DisplayName: req.DisplayName,
|
||||||
|
IngressRules: req.IngressRules,
|
||||||
|
EgressRules: req.EgressRules,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) deleteSecurityList(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.DeleteSecurityList(c.Request.Context(), id, c.Query("region"), c.Param("securityListId")); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ociConfigHandler struct {
|
||||||
|
svc *service.OciConfigService
|
||||||
|
}
|
||||||
|
|
||||||
|
// importRequest 中 configIni 与显式字段二选一,私钥内容必填。
|
||||||
|
type importRequest struct {
|
||||||
|
Alias string `json:"alias" binding:"required"`
|
||||||
|
Group string `json:"group"` // 面板内自定义分组,可空
|
||||||
|
ConfigINI string `json:"configIni"`
|
||||||
|
TenancyOCID string `json:"tenancyOcid"`
|
||||||
|
UserOCID string `json:"userOcid"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
PrivateKey string `json:"privateKey" binding:"required"`
|
||||||
|
Passphrase string `json:"passphrase"`
|
||||||
|
MultiRegion bool `json:"multiRegion"` // 开启后订阅区域入库缓存
|
||||||
|
MultiCompartment bool `json:"multiCompartment"` // 开启后 compartment 入库缓存
|
||||||
|
ProxyID *uint `json:"proxyId"` // 关联出站代理,null 直连
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) create(c *gin.Context) {
|
||||||
|
var req importRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, err := h.svc.Import(c.Request.Context(), service.ImportInput{
|
||||||
|
Alias: req.Alias,
|
||||||
|
Group: req.Group,
|
||||||
|
ConfigINI: req.ConfigINI,
|
||||||
|
TenancyOCID: req.TenancyOCID,
|
||||||
|
UserOCID: req.UserOCID,
|
||||||
|
Region: req.Region,
|
||||||
|
Fingerprint: req.Fingerprint,
|
||||||
|
PrivateKey: req.PrivateKey,
|
||||||
|
Passphrase: req.Passphrase,
|
||||||
|
MultiRegion: req.MultiRegion,
|
||||||
|
MultiCompartment: req.MultiCompartment,
|
||||||
|
ProxyID: req.ProxyID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) list(c *gin.Context) {
|
||||||
|
items, err := h.svc.List(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) get(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, err := h.svc.Get(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) verify(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, changes, err := h.svc.Verify(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"config": cfg, "changes": changes})
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateConfigRequest 空字段不变更;替换私钥须同时给新指纹;
|
||||||
|
// passphrase / group 一经提供整体覆盖(空串分别表示清除口令 / 取消分组)。
|
||||||
|
type updateConfigRequest struct {
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
Group *string `json:"group"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
PrivateKey string `json:"privateKey"`
|
||||||
|
Passphrase *string `json:"passphrase"`
|
||||||
|
MultiRegion *bool `json:"multiRegion"` // 非 null 时切换多区域支持
|
||||||
|
MultiCompartment *bool `json:"multiCompartment"` // 非 null 时切换多区间支持
|
||||||
|
ProxyID *uint `json:"proxyId"` // 非 null 切换代理:0 解除,>0 关联
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) update(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req updateConfigRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, changes, err := h.svc.Update(c.Request.Context(), id, service.UpdateInput{
|
||||||
|
Alias: req.Alias,
|
||||||
|
Group: req.Group,
|
||||||
|
Region: req.Region,
|
||||||
|
Fingerprint: req.Fingerprint,
|
||||||
|
PrivateKey: req.PrivateKey,
|
||||||
|
Passphrase: req.Passphrase,
|
||||||
|
MultiRegion: req.MultiRegion,
|
||||||
|
MultiCompartment: req.MultiCompartment,
|
||||||
|
ProxyID: req.ProxyID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"config": cfg, "changes": changes})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) remove(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.Delete(c.Request.Context(), id); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// compartments 列出租户下全部 ACTIVE compartment(不含租户根)。
|
||||||
|
func (h *ociConfigHandler) compartments(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.svc.Compartments(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachedRegions 返回筛选器用区域列表:未开多区域支持只含默认区域;
|
||||||
|
// 缓存存在非 READY 时实时刷新。
|
||||||
|
func (h *ociConfigHandler) cachedRegions(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.svc.CachedRegions(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachedCompartments 返回筛选器用区间列表:未开多区间支持返回空数组。
|
||||||
|
func (h *ociConfigHandler) cachedCompartments(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.svc.CachedCompartments(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathID(c *gin.Context) (uint, bool) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return uint(id), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func respondError(c *gin.Context, err error) {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
status = http.StatusNotFound
|
||||||
|
}
|
||||||
|
var svcErr common.ServiceError
|
||||||
|
if errors.As(err, &svcErr) {
|
||||||
|
respondOCIError(c, err, svcErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondOCIError 提炼 OCI 服务错误:透传 HTTP 状态码,
|
||||||
|
// error 只保留操作前缀 + 错误码 + 服务端消息(oci.CompactError)。
|
||||||
|
func respondOCIError(c *gin.Context, err error, svcErr common.ServiceError) {
|
||||||
|
status := svcErr.GetHTTPStatusCode()
|
||||||
|
if status < http.StatusBadRequest {
|
||||||
|
status = http.StatusBadGateway
|
||||||
|
}
|
||||||
|
// 面板的 401 专属本地 JWT 失效(前端收到即登出);
|
||||||
|
// 上游 OCI 的 401 是代理调用被拒,改用 502 透出
|
||||||
|
if status == http.StatusUnauthorized {
|
||||||
|
status = http.StatusBadGateway
|
||||||
|
}
|
||||||
|
body := gin.H{
|
||||||
|
"error": oci.CompactError(err),
|
||||||
|
"ociCode": svcErr.GetCode(),
|
||||||
|
"opcRequestId": svcErr.GetOpcRequestID(),
|
||||||
|
}
|
||||||
|
if hint := oci.ErrorHint(err); hint != "" {
|
||||||
|
body["hint"] = hint
|
||||||
|
}
|
||||||
|
c.JSON(status, body)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// overview 返回总览页聚合数据(本地快照,不发云端请求)。
|
||||||
|
func (h *ociConfigHandler) overview(c *gin.Context) {
|
||||||
|
out, err := h.svc.Overview(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// proxyHandler 处理出站代理配置的增删改查。
|
||||||
|
type proxyHandler struct {
|
||||||
|
svc *service.ProxyService
|
||||||
|
}
|
||||||
|
|
||||||
|
// list 返回全部代理(脱敏视图,含引用计数)。
|
||||||
|
func (h *proxyHandler) list(c *gin.Context) {
|
||||||
|
items, err := h.svc.List(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// create 新建代理。
|
||||||
|
func (h *proxyHandler) create(c *gin.Context) {
|
||||||
|
var req service.ProxyInput
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view, err := h.svc.Create(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
h.respond(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// update 更新代理;密码缺省沿用、空串清除。
|
||||||
|
func (h *proxyHandler) update(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req service.ProxyInput
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view, err := h.svc.Update(c.Request.Context(), id, req)
|
||||||
|
if err != nil {
|
||||||
|
h.respond(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove 删除代理;仍被租户引用时返回 409。
|
||||||
|
func (h *proxyHandler) remove(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.Delete(c.Request.Context(), id); err != nil {
|
||||||
|
h.respond(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// importBatch 批量导入代理;逐行解析,返回成功与脱敏后的失败明细。
|
||||||
|
func (h *proxyHandler) importBatch(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Text string `json:"text" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := h.svc.Import(c.Request.Context(), req.Text)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// probe 手动重测代理出口地区,同步返回最新视图。
|
||||||
|
func (h *proxyHandler) probe(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view, err := h.svc.Probe(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
h.respond(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// respond 把代理业务错误映射到语义状态码。
|
||||||
|
func (h *proxyHandler) respond(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrProxyInvalid):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, service.ErrProxyInUse):
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
respondError(c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ipEvictInterval 是陈旧限速条目的回收周期。
|
||||||
|
const ipEvictInterval = 10 * time.Minute
|
||||||
|
|
||||||
|
// ipRateLimiter 维护每 IP 令牌桶;速率与突发取安全设置快照,
|
||||||
|
// 设置变更后既有桶就地调参;陈旧条目由后台 ticker 惰性回收。
|
||||||
|
type ipRateLimiter struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
limiters map[string]*ipEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type ipEntry struct {
|
||||||
|
limiter *rate.Limiter
|
||||||
|
lastSeen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newIPRateLimiter() *ipRateLimiter {
|
||||||
|
l := &ipRateLimiter{limiters: make(map[string]*ipEntry)}
|
||||||
|
go l.evictLoop()
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// get 返回 ip 对应的令牌桶,参数与当前配置不一致时就地调整。
|
||||||
|
func (l *ipRateLimiter) get(ip string, rps, burst int) *rate.Limiter {
|
||||||
|
l.mu.RLock()
|
||||||
|
entry, ok := l.limiters[ip]
|
||||||
|
l.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
l.mu.Lock()
|
||||||
|
if entry, ok = l.limiters[ip]; !ok {
|
||||||
|
entry = &ipEntry{limiter: rate.NewLimiter(rate.Limit(rps), burst)}
|
||||||
|
l.limiters[ip] = entry
|
||||||
|
}
|
||||||
|
l.mu.Unlock()
|
||||||
|
}
|
||||||
|
entry.lastSeen = time.Now()
|
||||||
|
if entry.limiter.Limit() != rate.Limit(rps) || entry.limiter.Burst() != burst {
|
||||||
|
entry.limiter.SetLimit(rate.Limit(rps))
|
||||||
|
entry.limiter.SetBurst(burst)
|
||||||
|
}
|
||||||
|
return entry.limiter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ipRateLimiter) evictLoop() {
|
||||||
|
ticker := time.NewTicker(ipEvictInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
l.mu.Lock()
|
||||||
|
cutoff := time.Now().Add(-ipEvictInterval)
|
||||||
|
for ip, e := range l.limiters {
|
||||||
|
if e.lastSeen.Before(cutoff) {
|
||||||
|
delete(l.limiters, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPRateMiddleware 返回全局 IP 限速中间件;超限返回 429,参数随安全设置热更新。
|
||||||
|
func IPRateMiddleware(settings *service.SettingService) gin.HandlerFunc {
|
||||||
|
lm := newIPRateLimiter()
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
sec := settings.SecurityCached()
|
||||||
|
if !lm.get(requestIP(c), sec.IPRateRPS, sec.IPRateBurst).Allow() {
|
||||||
|
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ctxRealIPKey 是真实IP中间件写入 gin.Context 的键。
|
||||||
|
const ctxRealIPKey = "realIP"
|
||||||
|
|
||||||
|
// RealIPMiddleware 按安全设置解析客户端真实 IP 写入上下文,
|
||||||
|
// 全链路(IP 限速 / 登录守卫 / 系统日志留痕)统一消费;gin 内置
|
||||||
|
// TrustedProxies 已禁用,信任边界完全由该配置决定。
|
||||||
|
func RealIPMiddleware(settings *service.SettingService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Set(ctxRealIPKey, resolveRealIP(c.Request, settings.SecurityCached().RealIPHeader))
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestIP 取中间件解析的真实 IP;未经中间件(单测直调 handler)时回退 RemoteAddr。
|
||||||
|
func requestIP(c *gin.Context) string {
|
||||||
|
if ip := c.GetString(ctxRealIPKey); ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
return remoteHost(c.Request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRealIP 依配置头解析:X-Forwarded-For 取链尾(直连反代的对端,
|
||||||
|
// 反代会把对端追加到链尾,链首可被客户端伪造);X-Real-IP 等单值头直取;
|
||||||
|
// 头缺失或未配置(直连部署)回退 RemoteAddr。
|
||||||
|
func resolveRealIP(r *http.Request, header string) string {
|
||||||
|
switch header {
|
||||||
|
case "":
|
||||||
|
case "X-Forwarded-For":
|
||||||
|
if v := r.Header.Get(header); v != "" {
|
||||||
|
parts := strings.Split(v, ",")
|
||||||
|
if ip := strings.TrimSpace(parts[len(parts)-1]); ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if ip := strings.TrimSpace(r.Header.Get(header)); ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return remoteHost(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// remoteHost 取 RemoteAddr 的 host 部分。
|
||||||
|
func remoteHost(r *http.Request) string {
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveRealIP(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
header string // 配置的真实IP请求头
|
||||||
|
reqHdr map[string]string
|
||||||
|
remote string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "未配置头取 RemoteAddr", header: "", reqHdr: map[string]string{"X-Forwarded-For": "1.2.3.4"}, remote: "127.0.0.1:5000", want: "127.0.0.1"},
|
||||||
|
{name: "XFF 单值", header: "X-Forwarded-For", reqHdr: map[string]string{"X-Forwarded-For": "203.0.113.7"}, remote: "127.0.0.1:5000", want: "203.0.113.7"},
|
||||||
|
{name: "XFF 链取尾防伪造", header: "X-Forwarded-For", reqHdr: map[string]string{"X-Forwarded-For": "6.6.6.6, 203.0.113.7"}, remote: "127.0.0.1:5000", want: "203.0.113.7"},
|
||||||
|
{name: "XFF 缺失回退 RemoteAddr", header: "X-Forwarded-For", reqHdr: nil, remote: "192.0.2.9:1234", want: "192.0.2.9"},
|
||||||
|
{name: "X-Real-IP 单值直取", header: "X-Real-IP", reqHdr: map[string]string{"X-Real-IP": "203.0.113.8"}, remote: "127.0.0.1:5000", want: "203.0.113.8"},
|
||||||
|
{name: "CF-Connecting-IP", header: "CF-Connecting-IP", reqHdr: map[string]string{"CF-Connecting-IP": "203.0.113.9", "X-Forwarded-For": "6.6.6.6"}, remote: "127.0.0.1:5000", want: "203.0.113.9"},
|
||||||
|
{name: "RemoteAddr 无端口原样返回", header: "", reqHdr: nil, remote: "unix", want: "unix"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.RemoteAddr = tt.remote
|
||||||
|
for k, v := range tt.reqHdr {
|
||||||
|
r.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
if got := resolveRealIP(r, tt.header); got != tt.want {
|
||||||
|
t.Errorf("resolveRealIP = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
// listRegions 返回本地维护的完整区域表(含友好名称),不发起云端请求。
|
||||||
|
func listRegions(c *gin.Context) {
|
||||||
|
regions, err := oci.AllRegions()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, regions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) regionSubscriptions(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subs, err := h.svc.RegionSubscriptions(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, subs)
|
||||||
|
}
|
||||||
|
|
||||||
|
type subscribeRegionRequest struct {
|
||||||
|
RegionKey string `json:"regionKey" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) subscribeRegion(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req subscribeRegionRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subs, err := h.svc.SubscribeRegion(c.Request.Context(), id, req.RegionKey)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, subs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) limits(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scopeType := strings.ToUpper(c.Query("scopeType"))
|
||||||
|
if scopeType != "" && scopeType != "GLOBAL" && scopeType != "REGION" && scopeType != "AD" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "scopeType must be GLOBAL / REGION / AD"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := oci.LimitsQuery{
|
||||||
|
Region: c.Query("region"),
|
||||||
|
Service: c.DefaultQuery("service", "compute"),
|
||||||
|
Name: c.Query("name"),
|
||||||
|
ScopeType: scopeType,
|
||||||
|
AvailabilityDomain: c.Query("availabilityDomain"),
|
||||||
|
NonZero: c.Query("nonZero") == "true",
|
||||||
|
WithAvailability: c.Query("withAvailability") == "true",
|
||||||
|
}
|
||||||
|
values, err := h.svc.Limits(c.Request.Context(), id, q)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, values)
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewRouter 组装全部 HTTP 路由:/auth/login 公开,业务路由要求 JWT。
|
||||||
|
func NewRouter(auth *service.AuthService, oauth *service.OAuthService, ociConfigs *service.OciConfigService, tasks *service.TaskService, console *service.ConsoleService, settings *service.SettingService, notifier *service.Notifier, systemLogs *service.SystemLogService, logEvents *service.LogEventService, proxies *service.ProxyService, aiGateway *service.AiGatewayService) *gin.Engine {
|
||||||
|
r := gin.New()
|
||||||
|
// 禁用 gin 内置代理信任:真实 IP 统一由 RealIPMiddleware 按安全设置解析
|
||||||
|
_ = r.SetTrustedProxies(nil)
|
||||||
|
|
||||||
|
// webhook 注册在全局 Logger 之前:访问日志不落含 secret 的路径;
|
||||||
|
// 不挂系统日志中间件——投递高频且已在回传日志留痕,避免刷屏
|
||||||
|
wh := &webhookHandler{events: logEvents}
|
||||||
|
r.POST("/api/v1/webhooks/oci-logs/:secret",
|
||||||
|
gin.Recovery(), RealIPMiddleware(settings), wh.handle)
|
||||||
|
|
||||||
|
r.Use(gin.Logger(), RealIPMiddleware(settings), IPRateMiddleware(settings), gin.Recovery())
|
||||||
|
|
||||||
|
v1 := r.Group("/api/v1")
|
||||||
|
ah := &authHandler{svc: auth, logs: systemLogs}
|
||||||
|
v1.POST("/auth/login", ah.login)
|
||||||
|
|
||||||
|
// 外部身份登录(公开):provider 列表 / 授权跳转(bind 模式 handler 内校验 JWT)/ 回调
|
||||||
|
ax := &authxHandler{auth: auth, oauth: oauth}
|
||||||
|
v1.GET("/auth/oauth/providers", ax.oauthProviders)
|
||||||
|
v1.GET("/auth/oauth/:provider/authorize", ax.oauthAuthorize)
|
||||||
|
v1.GET("/auth/oauth/:provider/callback", ax.oauthCallback)
|
||||||
|
|
||||||
|
// 控制台数据面:浏览器 WebSocket 无法带自定义头,token 经 query 在 handler 内校验
|
||||||
|
ch := &consoleHandler{svc: console, auth: auth}
|
||||||
|
v1.GET("/console-sessions/:sessionId/ws", ch.ws)
|
||||||
|
|
||||||
|
// AI 网关对外端点:独立密钥鉴权(Bearer / x-api-key),不挂 JWT 与系统日志
|
||||||
|
// (高频调用自有 AiCallLog);挂在全局 Use 之后,仍受 IP 限速与 Recovery 保护
|
||||||
|
aih := &aiGatewayHandler{gw: aiGateway}
|
||||||
|
ai := r.Group("/ai/v1", aih.auth)
|
||||||
|
ai.POST("/chat/completions", aih.chatCompletions)
|
||||||
|
ai.POST("/responses", aih.responses)
|
||||||
|
ai.POST("/messages", aih.messages)
|
||||||
|
ai.POST("/embeddings", aih.embeddings)
|
||||||
|
ai.GET("/models", aih.listModels)
|
||||||
|
|
||||||
|
// 系统日志中间件挂在鉴权之后,写请求自动留痕(webconsole ws 为 GET,天然跳过)
|
||||||
|
secured := v1.Group("", RequireAuth(auth), systemLogMiddleware(systemLogs))
|
||||||
|
secured.POST("/auth/logout", ah.logout)
|
||||||
|
secured.GET("/regions", listRegions)
|
||||||
|
secured.GET("/system-logs", (&systemLogHandler{svc: systemLogs}).list)
|
||||||
|
secured.POST("/oci-configs/:id/instances/:instanceId/console-sessions", ch.create)
|
||||||
|
secured.DELETE("/console-sessions/:sessionId", ch.remove)
|
||||||
|
|
||||||
|
st := &settingsHandler{svc: settings, notifier: notifier}
|
||||||
|
secured.GET("/about", about)
|
||||||
|
secured.GET("/settings/telegram", st.getTelegram)
|
||||||
|
secured.PUT("/settings/telegram", st.updateTelegram)
|
||||||
|
secured.POST("/settings/telegram/test", st.testTelegram)
|
||||||
|
secured.GET("/settings/notify-events", st.getNotifyEvents)
|
||||||
|
secured.PUT("/settings/notify-events", st.updateNotifyEvents)
|
||||||
|
secured.GET("/settings/notify-templates", st.listNotifyTemplates)
|
||||||
|
secured.PUT("/settings/notify-templates/:kind", st.updateNotifyTemplate)
|
||||||
|
secured.POST("/settings/notify-templates/:kind/test", st.testNotifyTemplate)
|
||||||
|
secured.GET("/settings/task", st.getTaskSettings)
|
||||||
|
secured.PUT("/settings/task", st.updateTaskSettings)
|
||||||
|
secured.GET("/settings/security", st.getSecurity)
|
||||||
|
secured.PUT("/settings/security", st.updateSecurity)
|
||||||
|
|
||||||
|
// 两步验证与外部身份管理(JWT 组内)
|
||||||
|
secured.GET("/auth/totp", ax.totpStatus)
|
||||||
|
secured.POST("/auth/totp/setup", ax.totpSetup)
|
||||||
|
secured.POST("/auth/totp/activate", ax.totpActivate)
|
||||||
|
secured.POST("/auth/totp/disable", ax.totpDisable)
|
||||||
|
secured.GET("/auth/credentials", ax.getCredentials)
|
||||||
|
secured.PUT("/auth/credentials", ax.updateCredentials)
|
||||||
|
secured.PUT("/auth/password-login", ax.updatePasswordLogin)
|
||||||
|
secured.GET("/auth/identities", ax.identities)
|
||||||
|
secured.DELETE("/auth/identities/:id", ax.unbindIdentity)
|
||||||
|
secured.GET("/settings/oauth", func(c *gin.Context) { ax.getOAuthSettings(c, settings) })
|
||||||
|
secured.PUT("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) })
|
||||||
|
|
||||||
|
px := &proxyHandler{svc: proxies}
|
||||||
|
secured.GET("/proxies", px.list)
|
||||||
|
secured.POST("/proxies", px.create)
|
||||||
|
secured.POST("/proxies/import", px.importBatch)
|
||||||
|
secured.PUT("/proxies/:id", px.update)
|
||||||
|
secured.DELETE("/proxies/:id", px.remove)
|
||||||
|
secured.POST("/proxies/:id/probe", px.probe)
|
||||||
|
|
||||||
|
le := &logEventHandler{svc: logEvents}
|
||||||
|
secured.GET("/log-events", le.list)
|
||||||
|
secured.GET("/oci-configs/:id/log-webhook", le.getWebhook)
|
||||||
|
secured.POST("/oci-configs/:id/log-webhook", le.ensureWebhook)
|
||||||
|
secured.DELETE("/oci-configs/:id/log-webhook", le.revokeWebhook)
|
||||||
|
secured.GET("/oci-configs/:id/log-relay", le.getRelay)
|
||||||
|
secured.POST("/oci-configs/:id/log-relay", le.setupRelay)
|
||||||
|
secured.DELETE("/oci-configs/:id/log-relay", le.teardownRelay)
|
||||||
|
|
||||||
|
t := &taskHandler{svc: tasks}
|
||||||
|
secured.POST("/tasks", t.create)
|
||||||
|
secured.GET("/tasks", t.list)
|
||||||
|
secured.GET("/tasks/:id", t.get)
|
||||||
|
secured.PUT("/tasks/:id", t.update)
|
||||||
|
secured.DELETE("/tasks/:id", t.remove)
|
||||||
|
secured.GET("/tasks/:id/logs", t.logs)
|
||||||
|
secured.POST("/tasks/:id/run", t.run)
|
||||||
|
|
||||||
|
h := &ociConfigHandler{svc: ociConfigs}
|
||||||
|
secured.GET("/overview", h.overview)
|
||||||
|
secured.POST("/oci-configs", h.create)
|
||||||
|
secured.GET("/oci-configs", h.list)
|
||||||
|
secured.GET("/oci-configs/:id", h.get)
|
||||||
|
secured.PUT("/oci-configs/:id", h.update)
|
||||||
|
secured.POST("/oci-configs/:id/verify", h.verify)
|
||||||
|
secured.DELETE("/oci-configs/:id", h.remove)
|
||||||
|
secured.GET("/oci-configs/:id/compartments", h.compartments)
|
||||||
|
secured.GET("/oci-configs/:id/cached-regions", h.cachedRegions)
|
||||||
|
secured.GET("/oci-configs/:id/cached-compartments", h.cachedCompartments)
|
||||||
|
secured.GET("/oci-configs/:id/region-subscriptions", h.regionSubscriptions)
|
||||||
|
secured.POST("/oci-configs/:id/region-subscriptions", h.subscribeRegion)
|
||||||
|
secured.GET("/oci-configs/:id/limits", h.limits)
|
||||||
|
secured.GET("/oci-configs/:id/limits/services", h.limitServices)
|
||||||
|
secured.GET("/oci-configs/:id/subscriptions", h.subscriptions)
|
||||||
|
secured.GET("/oci-configs/:id/subscriptions/:subscriptionId", h.subscriptionDetail)
|
||||||
|
secured.GET("/oci-configs/:id/shapes", h.shapes)
|
||||||
|
secured.GET("/oci-configs/:id/images", h.images)
|
||||||
|
secured.GET("/oci-configs/:id/images/:imageId", h.getImage)
|
||||||
|
secured.GET("/oci-configs/:id/vcns", h.listVCNs)
|
||||||
|
secured.POST("/oci-configs/:id/vcns", h.createVCN)
|
||||||
|
secured.GET("/oci-configs/:id/vcns/:vcnId", h.getVCN)
|
||||||
|
secured.PUT("/oci-configs/:id/vcns/:vcnId", h.updateVCN)
|
||||||
|
secured.DELETE("/oci-configs/:id/vcns/:vcnId", h.deleteVCN)
|
||||||
|
secured.POST("/oci-configs/:id/vcns/:vcnId/enable-ipv6", h.enableVCNIPv6)
|
||||||
|
secured.GET("/oci-configs/:id/subnets", h.listSubnets)
|
||||||
|
secured.POST("/oci-configs/:id/subnets", h.createSubnet)
|
||||||
|
secured.GET("/oci-configs/:id/subnets/:subnetId", h.getSubnet)
|
||||||
|
secured.PUT("/oci-configs/:id/subnets/:subnetId", h.updateSubnet)
|
||||||
|
secured.DELETE("/oci-configs/:id/subnets/:subnetId", h.deleteSubnet)
|
||||||
|
secured.GET("/oci-configs/:id/security-lists", h.listSecurityLists)
|
||||||
|
secured.POST("/oci-configs/:id/security-lists", h.createSecurityList)
|
||||||
|
secured.GET("/oci-configs/:id/security-lists/:securityListId", h.getSecurityList)
|
||||||
|
secured.PUT("/oci-configs/:id/security-lists/:securityListId", h.updateSecurityList)
|
||||||
|
secured.DELETE("/oci-configs/:id/security-lists/:securityListId", h.deleteSecurityList)
|
||||||
|
secured.GET("/oci-configs/:id/availability-domains", h.availabilityDomains)
|
||||||
|
secured.GET("/oci-configs/:id/instances", h.listInstances)
|
||||||
|
secured.POST("/oci-configs/:id/instances", h.createInstance)
|
||||||
|
secured.GET("/oci-configs/:id/instances/:instanceId", h.getInstance)
|
||||||
|
secured.PUT("/oci-configs/:id/instances/:instanceId", h.updateInstance)
|
||||||
|
secured.DELETE("/oci-configs/:id/instances/:instanceId", h.terminateInstance)
|
||||||
|
secured.POST("/oci-configs/:id/instances/:instanceId/action", h.instanceAction)
|
||||||
|
secured.GET("/oci-configs/:id/boot-volumes", h.listBootVolumes)
|
||||||
|
secured.GET("/oci-configs/:id/boot-volumes/:bootVolumeId", h.getBootVolume)
|
||||||
|
secured.PUT("/oci-configs/:id/boot-volumes/:bootVolumeId", h.updateBootVolume)
|
||||||
|
secured.DELETE("/oci-configs/:id/boot-volumes/:bootVolumeId", h.deleteBootVolume)
|
||||||
|
secured.POST("/oci-configs/:id/instances/:instanceId/change-public-ip", h.changePublicIP)
|
||||||
|
secured.POST("/oci-configs/:id/instances/:instanceId/ipv6-addresses", h.addInstanceIpv6)
|
||||||
|
secured.DELETE("/oci-configs/:id/instances/:instanceId/ipv6-addresses", h.deleteInstanceIpv6)
|
||||||
|
secured.POST("/oci-configs/:id/instances/:instanceId/console-connections", h.createConsoleConnection)
|
||||||
|
secured.GET("/oci-configs/:id/instances/:instanceId/console-connections", h.listConsoleConnections)
|
||||||
|
secured.DELETE("/oci-configs/:id/console-connections/:connectionId", h.deleteConsoleConnection)
|
||||||
|
secured.GET("/oci-configs/:id/instances/:instanceId/vnics", h.listInstanceVnics)
|
||||||
|
secured.POST("/oci-configs/:id/instances/:instanceId/vnics", h.attachVnic)
|
||||||
|
secured.DELETE("/oci-configs/:id/vnic-attachments/:attachmentId", h.detachVnic)
|
||||||
|
secured.POST("/oci-configs/:id/vnics/:vnicId/ipv6-addresses", h.addVnicIpv6)
|
||||||
|
secured.GET("/oci-configs/:id/instances/:instanceId/boot-volume-attachments", h.listBootVolumeAttachments)
|
||||||
|
secured.POST("/oci-configs/:id/instances/:instanceId/boot-volume-attachments", h.attachBootVolume)
|
||||||
|
secured.POST("/oci-configs/:id/instances/:instanceId/replace-boot-volume", h.replaceBootVolume)
|
||||||
|
secured.DELETE("/oci-configs/:id/boot-volume-attachments/:attachmentId", h.detachBootVolume)
|
||||||
|
secured.GET("/oci-configs/:id/volumes", h.listBlockVolumes)
|
||||||
|
secured.GET("/oci-configs/:id/instances/:instanceId/volume-attachments", h.listVolumeAttachments)
|
||||||
|
secured.POST("/oci-configs/:id/instances/:instanceId/volume-attachments", h.attachVolume)
|
||||||
|
secured.DELETE("/oci-configs/:id/volume-attachments/:attachmentId", h.detachVolume)
|
||||||
|
secured.GET("/oci-configs/:id/instances/:instanceId/traffic", h.instanceTraffic)
|
||||||
|
secured.GET("/oci-configs/:id/costs", h.costs)
|
||||||
|
// AI 网关面板管理:密钥 / 渠道(号池)/ 聚合模型 / 调用日志
|
||||||
|
aiadmin := &aiAdminHandler{gw: aiGateway}
|
||||||
|
secured.GET("/ai-keys", aiadmin.listKeys)
|
||||||
|
secured.POST("/ai-keys", aiadmin.createKey)
|
||||||
|
secured.PUT("/ai-keys/:id", aiadmin.updateKey)
|
||||||
|
secured.PUT("/ai-keys/:id/content-log", aiadmin.updateKeyContentLog)
|
||||||
|
secured.DELETE("/ai-keys/:id", aiadmin.deleteKey)
|
||||||
|
secured.GET("/ai-channels", aiadmin.listChannels)
|
||||||
|
secured.POST("/ai-channels", aiadmin.createChannel)
|
||||||
|
secured.PUT("/ai-channels/:id", aiadmin.updateChannel)
|
||||||
|
secured.DELETE("/ai-channels/:id", aiadmin.deleteChannel)
|
||||||
|
secured.POST("/ai-channels/:id/probe", aiadmin.probeChannel)
|
||||||
|
secured.POST("/ai-channels/:id/sync-models", aiadmin.syncChannelModels)
|
||||||
|
secured.GET("/ai-models", aiadmin.gatewayModels)
|
||||||
|
secured.GET("/ai-logs", aiadmin.listLogs)
|
||||||
|
secured.GET("/ai-content-logs", aiadmin.listContentLogs)
|
||||||
|
secured.GET("/oci-configs/:id/audit-events", h.getAuditEvents)
|
||||||
|
secured.GET("/oci-configs/:id/audit-events/detail", h.getAuditEventDetail)
|
||||||
|
secured.GET("/oci-configs/:id/users", h.listTenantUsers)
|
||||||
|
secured.POST("/oci-configs/:id/users", h.createTenantUser)
|
||||||
|
secured.GET("/oci-configs/:id/users/:userId", h.getTenantUserDetail)
|
||||||
|
secured.PUT("/oci-configs/:id/users/:userId", h.updateTenantUser)
|
||||||
|
secured.DELETE("/oci-configs/:id/users/:userId", h.deleteTenantUser)
|
||||||
|
secured.POST("/oci-configs/:id/users/:userId/reset-password", h.resetTenantUserPassword)
|
||||||
|
secured.DELETE("/oci-configs/:id/users/:userId/mfa-devices", h.deleteTenantUserMfa)
|
||||||
|
secured.DELETE("/oci-configs/:id/users/:userId/api-keys", h.deleteTenantUserApiKeys)
|
||||||
|
secured.GET("/oci-configs/:id/notification-recipients", h.getNotificationRecipients)
|
||||||
|
secured.PUT("/oci-configs/:id/notification-recipients", h.updateNotificationRecipients)
|
||||||
|
secured.GET("/oci-configs/:id/password-policies", h.listPasswordPolicies)
|
||||||
|
secured.PUT("/oci-configs/:id/password-policies/:policyId", h.updatePasswordPolicy)
|
||||||
|
secured.GET("/oci-configs/:id/identity-settings", h.getIdentitySetting)
|
||||||
|
secured.PUT("/oci-configs/:id/identity-settings", h.updateIdentitySetting)
|
||||||
|
secured.GET("/oci-configs/:id/identity-providers", h.listIdentityProviders)
|
||||||
|
secured.POST("/oci-configs/:id/identity-providers", h.createIdentityProvider)
|
||||||
|
secured.POST("/oci-configs/:id/identity-providers/:idpId/activate", h.activateIdentityProvider)
|
||||||
|
secured.DELETE("/oci-configs/:id/identity-providers/:idpId", h.deleteIdentityProvider)
|
||||||
|
secured.GET("/oci-configs/:id/saml-metadata", h.downloadSamlMetadata)
|
||||||
|
secured.GET("/oci-configs/:id/sign-on-rules", h.listSignOnRules)
|
||||||
|
secured.POST("/oci-configs/:id/sign-on-exemptions", h.createMfaExemption)
|
||||||
|
secured.DELETE("/oci-configs/:id/sign-on-exemptions/:ruleId", h.deleteMfaExemption)
|
||||||
|
return r
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
|
"oci-portal/internal/crypto"
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// nullClient 是 oci.Client 的空实现,路由测试不触发任何云调用。
|
||||||
|
// 内嵌接口以自动满足 oci.Client,未覆写的方法不会被这些测试调用。
|
||||||
|
type nullClient struct{ oci.Client }
|
||||||
|
|
||||||
|
func (nullClient) ValidateKey(context.Context, oci.Credentials) (oci.TenancyInfo, error) {
|
||||||
|
return oci.TenancyInfo{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (nullClient) FetchAccountProfile(context.Context, oci.Credentials) (oci.AccountProfile, error) {
|
||||||
|
return oci.AccountProfile{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (nullClient) ListRegionSubscriptions(context.Context, oci.Credentials) ([]oci.RegionSubscription, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (nullClient) SubscribeRegion(context.Context, oci.Credentials, string, string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (nullClient) ListLimits(context.Context, oci.Credentials, oci.LimitsQuery) ([]oci.LimitValue, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (nullClient) ListLimitServices(context.Context, oci.Credentials, string) ([]oci.LimitService, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (nullClient) ListSubscriptions(context.Context, oci.Credentials) ([]oci.SubscriptionInfo, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (nullClient) GetSubscription(context.Context, oci.Credentials, string) (oci.SubscriptionDetail, error) {
|
||||||
|
return oci.SubscriptionDetail{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestRouter(t *testing.T) (*gin.Engine, *service.AuthService, *service.SystemLogService) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open in-memory sqlite: %v", err)
|
||||||
|
}
|
||||||
|
// :memory: 库每个连接彼此独立,异步日志写入会触发池内新连接而丢表,锁单连接
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("db handle: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
if err := db.AutoMigrate(&model.User{}, &model.OciConfig{}, &model.Task{}, &model.TaskLog{}, &model.Setting{}, &model.SystemLog{}, &model.LogEvent{}, &model.Proxy{}, &model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{}); err != nil {
|
||||||
|
t.Fatalf("auto migrate: %v", err)
|
||||||
|
}
|
||||||
|
cipher, err := crypto.NewCipher("test-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new cipher: %v", err)
|
||||||
|
}
|
||||||
|
auth := service.NewAuthService(db, "test-secret")
|
||||||
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||||
|
t.Fatalf("ensure admin: %v", err)
|
||||||
|
}
|
||||||
|
ociConfigs := service.NewOciConfigService(db, cipher, nullClient{})
|
||||||
|
settings := service.NewSettingService(db, cipher)
|
||||||
|
notifier := service.NewNotifier(settings)
|
||||||
|
tasks := service.NewTaskService(db, ociConfigs, notifier, settings)
|
||||||
|
systemLogs := service.NewSystemLogService(db)
|
||||||
|
logEvents := service.NewLogEventService(db)
|
||||||
|
oauth := service.NewOAuthService(db, settings, auth)
|
||||||
|
r := NewRouter(auth, oauth, ociConfigs, tasks, service.NewConsoleService(ociConfigs), settings, notifier, systemLogs, logEvents, service.NewProxyService(db, cipher), service.NewAiGatewayService(db, ociConfigs, nullClient{}))
|
||||||
|
return r, auth, systemLogs
|
||||||
|
}
|
||||||
|
|
||||||
|
func doRequest(t *testing.T, r *gin.Engine, method, path, token, body string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginEndpoint(t *testing.T) {
|
||||||
|
r, _, _ := newTestRouter(t)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{name: "正确凭据", body: `{"username":"admin","password":"pass123"}`, wantStatus: http.StatusOK},
|
||||||
|
{name: "密码错误", body: `{"username":"admin","password":"wrong"}`, wantStatus: http.StatusUnauthorized},
|
||||||
|
{name: "缺少字段", body: `{"username":"admin"}`, wantStatus: http.StatusBadRequest},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
w := doRequest(t, r, http.MethodPost, "/api/v1/auth/login", "", tt.body)
|
||||||
|
if w.Code != tt.wantStatus {
|
||||||
|
t.Errorf("status = %d, want %d, body %s", w.Code, tt.wantStatus, w.Body.String())
|
||||||
|
}
|
||||||
|
if tt.wantStatus == http.StatusOK && !strings.Contains(w.Body.String(), "token") {
|
||||||
|
t.Errorf("body = %s, want token field", w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecuredRoutesRequireToken(t *testing.T) {
|
||||||
|
r, auth, _ := newTestRouter(t)
|
||||||
|
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{name: "无令牌", token: "", wantStatus: http.StatusUnauthorized},
|
||||||
|
{name: "伪造令牌", token: "forged.token.value", wantStatus: http.StatusUnauthorized},
|
||||||
|
{name: "有效令牌", token: token, wantStatus: http.StatusOK},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
w := doRequest(t, r, http.MethodGet, "/api/v1/oci-configs", tt.token, "")
|
||||||
|
if w.Code != tt.wantStatus {
|
||||||
|
t.Errorf("status = %d, want %d, body %s", w.Code, tt.wantStatus, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemLogsEndpoint(t *testing.T) {
|
||||||
|
r, auth, logs := newTestRouter(t)
|
||||||
|
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
// 经 HTTP 触发一次登录失败:埋点应异步落一条 401 记录
|
||||||
|
doRequest(t, r, http.MethodPost, "/api/v1/auth/login", "", `{"username":"admin","password":"wrong"}`)
|
||||||
|
logs.Wait()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
wantTotal string
|
||||||
|
}{
|
||||||
|
{name: "无关键字返回全部", query: "", wantTotal: `"total":1`},
|
||||||
|
{name: "关键字命中登录路径", query: "?keyword=login", wantTotal: `"total":1`},
|
||||||
|
{name: "关键字不命中返回空", query: "?keyword=no-such", wantTotal: `"total":0`},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
w := doRequest(t, r, http.MethodGet, "/api/v1/system-logs"+tt.query, token, "")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "items") || !strings.Contains(body, tt.wantTotal) {
|
||||||
|
t.Errorf("body = %s, want items and %s", body, tt.wantTotal)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// settingsHandler 处理系统设置(Telegram 通知)相关请求。
|
||||||
|
type settingsHandler struct {
|
||||||
|
svc *service.SettingService
|
||||||
|
notifier *service.Notifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTelegram 返回脱敏后的 Telegram 配置,绝不回 token 明文。
|
||||||
|
func (h *settingsHandler) getTelegram(c *gin.Context) {
|
||||||
|
view, err := h.svc.TelegramView(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTelegramRequest 是保存 Telegram 配置的请求体;
|
||||||
|
// botToken 缺省表示沿用已存 token,空串表示清除。
|
||||||
|
type updateTelegramRequest struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
BotToken *string `json:"botToken"`
|
||||||
|
ChatID string `json:"chatId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTelegram 保存配置并返回最新脱敏视图。
|
||||||
|
func (h *settingsHandler) updateTelegram(c *gin.Context) {
|
||||||
|
var req updateTelegramRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := h.svc.UpdateTelegram(c.Request.Context(), service.UpdateTelegramInput{
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
BotToken: req.BotToken,
|
||||||
|
ChatID: req.ChatID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.getTelegram(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// testTelegram 用当前已保存配置同步发送一条测试消息。
|
||||||
|
func (h *settingsHandler) testTelegram(c *gin.Context) {
|
||||||
|
if err := h.notifier.Test(c.Request.Context()); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNotifyEvents 返回全部通知事件开关;键缺省(存量部署)视为开启。
|
||||||
|
func (h *settingsHandler) getNotifyEvents(c *gin.Context) {
|
||||||
|
view, err := h.svc.NotifyEvents(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateNotifyEvents 全量保存五个事件开关并返回最新值;
|
||||||
|
// 请求体为全量语义,缺字段按 JSON 零值 false 处理。
|
||||||
|
func (h *settingsHandler) updateNotifyEvents(c *gin.Context) {
|
||||||
|
var req service.NotifyEventsView
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.UpdateNotifyEvents(c.Request.Context(), req); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.getNotifyEvents(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listNotifyTemplates 返回全部通知模板(默认模板 + 自定义现值 + 可用变量)。
|
||||||
|
func (h *settingsHandler) listNotifyTemplates(c *gin.Context) {
|
||||||
|
items, err := h.svc.NotifyTemplates(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateNotifyTemplate 保存单个通知模板;空模板清除自定义(恢复默认)。
|
||||||
|
func (h *settingsHandler) updateNotifyTemplate(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Template string `json:"template"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.UpdateNotifyTemplate(c.Request.Context(), c.Param("kind"), req.Template); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// testNotifyTemplate 用示例变量渲染模板并同步发送测试消息;
|
||||||
|
// template 非空时按编辑中内容渲染(不落库)。
|
||||||
|
func (h *settingsHandler) testNotifyTemplate(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Template string `json:"template"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.notifier.SendTemplateTest(c.Request.Context(), c.Param("kind"), req.Template); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTaskSettings 返回任务行为设置(抢机熔断阈值),阈值未保存过时为默认值。
|
||||||
|
func (h *settingsHandler) getTaskSettings(c *gin.Context) {
|
||||||
|
view, err := h.svc.TaskSettings(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTaskSettings 保存任务行为设置并返回最新值;阈值越界返回 400。
|
||||||
|
func (h *settingsHandler) updateTaskSettings(c *gin.Context) {
|
||||||
|
var req service.TaskSettingsView
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.UpdateTaskSettings(c.Request.Context(), req); err != nil {
|
||||||
|
if errors.Is(err, service.ErrInvalidAuthFailLimit) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.getTaskSettings(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSecurity 返回安全设置(WAF 参数 / 真实IP请求头 / 面板地址),未保存过时为默认值。
|
||||||
|
func (h *settingsHandler) getSecurity(c *gin.Context) {
|
||||||
|
view, err := h.svc.Security(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateSecurity 保存安全设置并返回最新值;越界或非法返回 400,保存后立即生效。
|
||||||
|
func (h *settingsHandler) updateSecurity(c *gin.Context) {
|
||||||
|
var req service.SecuritySettings
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.UpdateSecurity(c.Request.Context(), req); err != nil {
|
||||||
|
if errors.Is(err, service.ErrInvalidSecurity) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.getSecurity(c)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) subscriptions(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subs, err := h.svc.Subscriptions(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, subs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) subscriptionDetail(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail, err := h.svc.SubscriptionDetail(c.Request.Context(), id, c.Param("subscriptionId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) limitServices(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
services, err := h.svc.LimitServices(c.Request.Context(), id, c.Query("region"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, services)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// systemLogHandler 处理面板系统操作日志的查询请求。
|
||||||
|
type systemLogHandler struct {
|
||||||
|
svc *service.SystemLogService
|
||||||
|
}
|
||||||
|
|
||||||
|
// list 分页倒序返回系统日志,支持路径 / 用户名关键字模糊过滤。
|
||||||
|
func (h *systemLogHandler) list(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||||
|
items, total, err := h.svc.List(c.Request.Context(), service.SystemLogQuery{
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Keyword: c.Query("keyword"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// taskHandler 处理后台任务(定时测活、抢机)相关请求。
|
||||||
|
type taskHandler struct {
|
||||||
|
svc *service.TaskService
|
||||||
|
}
|
||||||
|
|
||||||
|
type createTaskRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Type string `json:"type" binding:"required"`
|
||||||
|
CronExpr string `json:"cronExpr" binding:"required"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateTaskRequest struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
CronExpr *string `json:"cronExpr"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
Status *string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *taskHandler) create(c *gin.Context) {
|
||||||
|
var req createTaskRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task, err := h.svc.CreateTask(c.Request.Context(), service.CreateTaskInput{
|
||||||
|
Name: req.Name,
|
||||||
|
Type: req.Type,
|
||||||
|
CronExpr: req.CronExpr,
|
||||||
|
Payload: req.Payload,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *taskHandler) list(c *gin.Context) {
|
||||||
|
tasks, err := h.svc.ListTasks(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *taskHandler) get(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task, err := h.svc.GetTask(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *taskHandler) update(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req updateTaskRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task, err := h.svc.UpdateTask(c.Request.Context(), id, service.UpdateTaskInput{
|
||||||
|
Name: req.Name,
|
||||||
|
CronExpr: req.CronExpr,
|
||||||
|
Payload: req.Payload,
|
||||||
|
Status: req.Status,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *taskHandler) remove(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.DeleteTask(c.Request.Context(), id); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *taskHandler) logs(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||||
|
logs, err := h.svc.TaskLogs(c.Request.Context(), id, limit)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, logs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *taskHandler) run(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry, err := h.svc.RunTaskNow(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, entry)
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- 流量与成本 ----
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) instanceTraffic(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
days, _ := strconv.Atoi(c.DefaultQuery("days", "30"))
|
||||||
|
traffic, err := h.svc.InstanceTraffic(c.Request.Context(), id, c.Query("region"), c.Param("instanceId"), days)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, traffic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) costs(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := oci.CostQuery{
|
||||||
|
Granularity: c.Query("granularity"),
|
||||||
|
QueryType: c.Query("queryType"),
|
||||||
|
GroupBy: c.Query("groupBy"),
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, c.Query("startTime")); err == nil {
|
||||||
|
q.StartTime = t
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, c.Query("endTime")); err == nil {
|
||||||
|
q.EndTime = t
|
||||||
|
}
|
||||||
|
items, err := h.svc.Costs(c.Request.Context(), id, q)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 租户审计日志 ----
|
||||||
|
|
||||||
|
// getAuditEvents 实时查询租户 OCI 审计事件:hours 首查(缺省 24);
|
||||||
|
// start/end/page 为截断后的同窗续查;窗口越界或非法返回 400。
|
||||||
|
func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hours, _ := strconv.Atoi(c.DefaultQuery("hours", "24"))
|
||||||
|
q := service.AuditQuery{
|
||||||
|
Region: c.Query("region"), Hours: hours,
|
||||||
|
Start: c.Query("start"), End: c.Query("end"), Page: c.Query("page"),
|
||||||
|
}
|
||||||
|
result, err := h.svc.AuditEvents(c.Request.Context(), id, q)
|
||||||
|
if errors.Is(err, service.ErrInvalidAuditHours) || errors.Is(err, service.ErrInvalidAuditWindow) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAuditEventDetail 按 eventId 取回原始事件 JSON:缓存命中秒开,
|
||||||
|
// 过期走事件时间小窗重查;仍不可得返回 404,前端降级展示精简字段。
|
||||||
|
func (h *ociConfigHandler) getAuditEventDetail(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
eventID := c.Query("eventId")
|
||||||
|
eventTime, terr := time.Parse(time.RFC3339, c.Query("eventTime"))
|
||||||
|
if eventID == "" || terr != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "eventId 与 eventTime(RFC3339)必填"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, err := h.svc.AuditEventDetail(c.Request.Context(), id, c.Query("region"), eventID, eventTime)
|
||||||
|
if errors.Is(err, service.ErrAuditEventGone) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"raw": raw})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 租户用户管理 ----
|
||||||
|
|
||||||
|
type createTenantUserRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
GivenName string `json:"givenName" binding:"required"`
|
||||||
|
FamilyName string `json:"familyName" binding:"required"`
|
||||||
|
// 同时勾选时先授身份域管理员,再加入管理员组
|
||||||
|
GrantDomainAdmin bool `json:"grantDomainAdmin"`
|
||||||
|
AddToAdminGroup bool `json:"addToAdminGroup"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) listTenantUsers(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users, err := h.svc.TenantUsers(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, users)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) getTenantUserDetail(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail, err := h.svc.TenantUserDetail(c.Request.Context(), id, c.Param("userId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) createTenantUser(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createTenantUserRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := h.svc.CreateTenantUser(c.Request.Context(), id, oci.CreateTenantUserInput{
|
||||||
|
Name: req.Name,
|
||||||
|
Description: req.Description,
|
||||||
|
Email: req.Email,
|
||||||
|
GivenName: req.GivenName,
|
||||||
|
FamilyName: req.FamilyName,
|
||||||
|
GrantDomainAdmin: req.GrantDomainAdmin,
|
||||||
|
AddToAdminGroup: req.AddToAdminGroup,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTenantUserRequest 的字段全部可缺省:nil 不修改,传空串表示清空。
|
||||||
|
// 管理员字段为三态:true 授予/加入,false 撤销/移出,缺省不动。
|
||||||
|
type updateTenantUserRequest struct {
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Email *string `json:"email"`
|
||||||
|
GivenName *string `json:"givenName"`
|
||||||
|
FamilyName *string `json:"familyName"`
|
||||||
|
GrantDomainAdmin *bool `json:"grantDomainAdmin"`
|
||||||
|
AddToAdminGroup *bool `json:"addToAdminGroup"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) updateTenantUser(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req updateTenantUserRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := h.svc.UpdateTenantUser(c.Request.Context(), id, c.Param("userId"), oci.UpdateTenantUserInput{
|
||||||
|
Description: req.Description,
|
||||||
|
Email: req.Email,
|
||||||
|
GivenName: req.GivenName,
|
||||||
|
FamilyName: req.FamilyName,
|
||||||
|
GrantDomainAdmin: req.GrantDomainAdmin,
|
||||||
|
AddToAdminGroup: req.AddToAdminGroup,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) deleteTenantUser(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.DeleteTenantUser(c.Request.Context(), id, c.Param("userId")); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) resetTenantUserPassword(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
password, err := h.svc.ResetTenantUserPassword(c.Request.Context(), id, c.Param("userId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"password": password})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) deleteTenantUserMfa(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deleted, err := h.svc.DeleteTenantUserMfa(c.Request.Context(), id, c.Param("userId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"deletedTotpDevices": deleted})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
includeCurrent := c.Query("includeCurrent") == "true"
|
||||||
|
deleted, err := h.svc.DeleteTenantUserApiKeys(c.Request.Context(), id, c.Param("userId"), includeCurrent)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"deletedApiKeys": deleted})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 域通知收件人与密码策略 ----
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) getNotificationRecipients(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recipients, err := h.svc.NotificationRecipients(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, recipients)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) updateNotificationRecipients(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Recipients []string `json:"recipients"` // 空列表表示关闭 test mode 恢复默认发送
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recipients, err := h.svc.UpdateNotificationRecipients(c.Request.Context(), id, req.Recipients)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, recipients)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) listPasswordPolicies(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
policies, err := h.svc.PasswordPolicies(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, policies)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) updatePasswordPolicy(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
PasswordExpiresAfter *int `json:"passwordExpiresAfter"`
|
||||||
|
MinLength *int `json:"minLength"`
|
||||||
|
NumPasswordsInHistory *int `json:"numPasswordsInHistory"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
policy, err := h.svc.UpdatePasswordPolicy(c.Request.Context(), id, c.Param("policyId"), oci.UpdatePasswordPolicyInput{
|
||||||
|
PasswordExpiresAfter: req.PasswordExpiresAfter,
|
||||||
|
MinLength: req.MinLength,
|
||||||
|
NumPasswordsInHistory: req.NumPasswordsInHistory,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, policy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) getIdentitySetting(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setting, err := h.svc.IdentitySetting(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, setting)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ociConfigHandler) updateIdentitySetting(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
PrimaryEmailRequired *bool `json:"primaryEmailRequired" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setting, err := h.svc.UpdateIdentitySetting(c.Request.Context(), id, *req.PrimaryEmailRequired)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, setting)
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// consoleHandler 提供网页控制台(串行 / VNC):创建会话 + WebSocket 数据面。
|
||||||
|
type consoleHandler struct {
|
||||||
|
svc *service.ConsoleService
|
||||||
|
auth *service.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
type createConsoleSessionRequest struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// create 创建控制台会话(内部清理残留连接并创建 OCI 控制台连接,约 30-90 秒)。
|
||||||
|
func (h *consoleHandler) create(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createConsoleSessionRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess, err := h.svc.CreateSession(c.Request.Context(), id, c.Param("instanceId"), req.Region, req.Type)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"sessionId": sess.ID, "type": sess.Type})
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove 结束会话并删除云端控制台连接。
|
||||||
|
func (h *consoleHandler) remove(c *gin.Context) {
|
||||||
|
h.svc.CloseSession(c.Param("sessionId"))
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsUpgrader 同源校验交给 token(浏览器 WebSocket 无法自定义头,token 走 query)。
|
||||||
|
var wsUpgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 32 * 1024,
|
||||||
|
WriteBufferSize: 32 * 1024,
|
||||||
|
CheckOrigin: func(*http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ws 是控制台数据面:校验 token 后建立两跳 SSH 隧道,按会话类型分派数据泵。
|
||||||
|
func (h *consoleHandler) ws(c *gin.Context) {
|
||||||
|
if _, err := h.auth.ParseToken(c.Query("token")); err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess := h.svc.Get(c.Param("sessionId"))
|
||||||
|
if sess == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "console session not found or expired"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wsConn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer wsConn.Close()
|
||||||
|
sess.MarkUse(true)
|
||||||
|
defer sess.MarkUse(false)
|
||||||
|
if sess.Type == service.ConsoleTypeSerial {
|
||||||
|
h.serveSerial(c, sess, wsConn)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.serveVnc(c, sess, wsConn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *consoleHandler) serveVnc(c *gin.Context, sess *service.ConsoleSession, wsConn *websocket.Conn) {
|
||||||
|
vnc, err := sess.DialVNC(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
closeWs(wsConn, "tunnel: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer vnc.Close()
|
||||||
|
proxyVnc(wsConn, vnc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serialQuery 读取 ws 建连时的初始终端尺寸(缺省 80x24)。
|
||||||
|
func serialQuery(c *gin.Context, key string, def int) int {
|
||||||
|
v := 0
|
||||||
|
for _, ch := range c.Query(key) {
|
||||||
|
if ch < '0' || ch > '9' {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
v = v*10 + int(ch-'0')
|
||||||
|
}
|
||||||
|
if v < 1 || v > 1000 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *consoleHandler) serveSerial(c *gin.Context, sess *service.ConsoleSession, wsConn *websocket.Conn) {
|
||||||
|
term, err := sess.OpenSerial(c.Request.Context(),
|
||||||
|
serialQuery(c, "rows", 24), serialQuery(c, "cols", 80))
|
||||||
|
if err != nil {
|
||||||
|
closeWs(wsConn, "tunnel: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer term.Close()
|
||||||
|
proxySerial(wsConn, term)
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeWs(wsConn *websocket.Conn, reason string) {
|
||||||
|
// CloseMessage 载荷上限 125 字节,超长原因截断避免发送失败
|
||||||
|
if len(reason) > 120 {
|
||||||
|
reason = reason[:120]
|
||||||
|
}
|
||||||
|
_ = wsConn.WriteMessage(websocket.CloseMessage,
|
||||||
|
websocket.FormatCloseMessage(websocket.ClosePolicyViolation, reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
// serialControl 是串行会话的文本帧控制消息(二进制帧为终端原始字节)。
|
||||||
|
type serialControl struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Cols int `json:"cols"`
|
||||||
|
Rows int `json:"rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxySerial 串行数据泵:二进制帧 ↔ 终端字节流,文本帧解析 resize 控制消息;
|
||||||
|
// ws 写只发生在本 goroutine,无并发写。
|
||||||
|
func proxySerial(wsConn *websocket.Conn, term *service.SerialTerminal) {
|
||||||
|
go func() {
|
||||||
|
defer term.Close()
|
||||||
|
for {
|
||||||
|
t, data, err := wsConn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch t {
|
||||||
|
case websocket.BinaryMessage:
|
||||||
|
if _, err := term.Write(data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case websocket.TextMessage:
|
||||||
|
var ctl serialControl
|
||||||
|
if json.Unmarshal(data, &ctl) == nil && ctl.Type == "resize" {
|
||||||
|
_ = term.Resize(ctl.Rows, ctl.Cols)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
pumpToWs(wsConn, term)
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyVnc VNC 数据泵:双向透传 RFB 字节流直到任一侧断开。
|
||||||
|
func proxyVnc(wsConn *websocket.Conn, vnc io.ReadWriteCloser) {
|
||||||
|
go func() {
|
||||||
|
defer vnc.Close()
|
||||||
|
for {
|
||||||
|
t, data, err := wsConn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t == websocket.BinaryMessage || t == websocket.TextMessage {
|
||||||
|
if _, err := vnc.Write(data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
pumpToWs(wsConn, vnc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pumpToWs 把数据源持续泵入 ws 二进制帧,直到任一侧断开。
|
||||||
|
func pumpToWs(wsConn *websocket.Conn, src io.Reader) {
|
||||||
|
buf := make([]byte, 32*1024)
|
||||||
|
for {
|
||||||
|
n, err := src.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if werr := wsConn.WriteMessage(websocket.BinaryMessage, buf[:n]); werr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ONS 消息头(方案A §2.2/§2.3,官方 X-OCI-NS-* 字段)。
|
||||||
|
const (
|
||||||
|
onsHeaderMessageType = "X-OCI-NS-MessageType"
|
||||||
|
onsHeaderMessageID = "X-OCI-NS-MessageId"
|
||||||
|
onsHeaderTimestamp = "X-OCI-NS-Timestamp"
|
||||||
|
onsHeaderConfirmURL = "X-OCI-NS-ConfirmationURL"
|
||||||
|
)
|
||||||
|
|
||||||
|
// webhook 同步路径的防御参数:body 上限高于 ONS 链路 128KB 留余量,
|
||||||
|
// 时间戳偏差超窗拒收防重放。
|
||||||
|
const (
|
||||||
|
webhookBodyLimit = 256 << 10
|
||||||
|
webhookMaxTimeSkew = 5 * time.Minute
|
||||||
|
msgTypeSubscription = "SubscriptionConfirmation"
|
||||||
|
msgTypeUnsubscribe = "UnsubscribeConfirmation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// webhookEvents 是 webhook 端点对回传服务的依赖切面,测试以假实现替换。
|
||||||
|
type webhookEvents interface {
|
||||||
|
ResolveSecret(ctx context.Context, secret string) (uint, bool)
|
||||||
|
ConfirmAsync(confirmURL string)
|
||||||
|
Ingest(ctx context.Context, cfgID uint, messageID string, payload []byte, truncated bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhookHandler 处理 OCI Notifications 的 HTTPS 订阅投递(JWT 组之外,靠 secret 鉴权)。
|
||||||
|
type webhookHandler struct {
|
||||||
|
events webhookEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle 是 webhook 入口:secret 反查租户后按消息类型分派;
|
||||||
|
// 同步路径只做 secret→分派→读body→幂等入库,5 秒投递预算内返回(方案A §2.4)。
|
||||||
|
func (h *webhookHandler) handle(c *gin.Context) {
|
||||||
|
cfgID, ok := h.events.ResolveSecret(c.Request.Context(), c.Param("secret"))
|
||||||
|
if !ok {
|
||||||
|
c.Status(http.StatusNotFound) // 不暴露端点存在性,响应无 body
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch c.GetHeader(onsHeaderMessageType) {
|
||||||
|
case msgTypeSubscription:
|
||||||
|
h.confirmSubscription(c)
|
||||||
|
case msgTypeUnsubscribe:
|
||||||
|
log.Printf("ons unsubscribe confirmation received: cfg %d", cfgID)
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
default:
|
||||||
|
h.ingest(c, cfgID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmSubscription 校验确认链接域名白名单后异步 GET 激活订阅,立即 200。
|
||||||
|
func (h *webhookHandler) confirmSubscription(c *gin.Context) {
|
||||||
|
confirmURL := c.GetHeader(onsHeaderConfirmURL)
|
||||||
|
parsed, err := url.Parse(confirmURL)
|
||||||
|
if err != nil || parsed.Scheme != "https" || !service.IsOracleHost(parsed.Host) {
|
||||||
|
log.Printf("ons confirmation url rejected: host not in oracle domain")
|
||||||
|
c.Status(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.events.ConfirmAsync(confirmURL)
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ingest 校验时间戳与消息 ID 后读 body 入库;重复投递靠唯一索引幂等。
|
||||||
|
func (h *webhookHandler) ingest(c *gin.Context, cfgID uint) {
|
||||||
|
if !timestampFresh(c.GetHeader(onsHeaderTimestamp)) {
|
||||||
|
c.Status(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messageID := c.GetHeader(onsHeaderMessageID)
|
||||||
|
if messageID == "" {
|
||||||
|
c.Status(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := verifySignature(c.Request.Header); err != nil {
|
||||||
|
c.Status(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(c.Request.Body, webhookBodyLimit+1))
|
||||||
|
if err != nil {
|
||||||
|
c.Status(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
truncated := len(body) > webhookBodyLimit
|
||||||
|
if truncated {
|
||||||
|
body = body[:webhookBodyLimit]
|
||||||
|
}
|
||||||
|
if err := h.events.Ingest(c.Request.Context(), cfgID, messageID, body, truncated); err != nil {
|
||||||
|
log.Printf("webhook ingest: %v", err)
|
||||||
|
c.Status(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// timestampFresh 校验消息时间戳与本地时钟偏差在防重放窗口内;
|
||||||
|
// 头缺失时放行——ONS 是否恒带此头待验签 spike 确认(方案A §2.3),先不误杀。
|
||||||
|
func timestampFresh(value string) bool {
|
||||||
|
if value == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ts, ok := parseOnsTime(value)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
skew := time.Since(ts)
|
||||||
|
if skew < 0 {
|
||||||
|
skew = -skew
|
||||||
|
}
|
||||||
|
return skew <= webhookMaxTimeSkew
|
||||||
|
}
|
||||||
|
|
||||||
|
// onsTimestampLayouts 覆盖 ONS 实测时间戳格式与标准 RFC3339:
|
||||||
|
// 2026-07-07 spike 抓包为 2026-07-07T05:30:34.779+0000(时区无冒号,严格 RFC3339 解析失败)。
|
||||||
|
var onsTimestampLayouts = []string{
|
||||||
|
time.RFC3339,
|
||||||
|
"2006-01-02T15:04:05.999Z0700",
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseOnsTime 按已知格式解析 ONS 时间戳。
|
||||||
|
func parseOnsTime(value string) (time.Time, bool) {
|
||||||
|
for _, layout := range onsTimestampLayouts {
|
||||||
|
if ts, err := time.Parse(layout, value); err == nil {
|
||||||
|
return ts, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifySignature 校验 ONS 消息签名。
|
||||||
|
// TODO(方案A §2.3 spike):官方未公开签名 canonical form,待 test-server 抓取
|
||||||
|
// 真实消息确认输入格式后实现;当前按方案约定以 secret+时间戳+幂等先行。
|
||||||
|
func verifySignature(http.Header) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeWebhookEvents 记录 webhook handler 对回传服务的调用,便于断言分派行为。
|
||||||
|
type fakeWebhookEvents struct {
|
||||||
|
secret string
|
||||||
|
cfgID uint
|
||||||
|
confirmed []string
|
||||||
|
ingested []ingestCall
|
||||||
|
ingestErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ingestCall struct {
|
||||||
|
cfgID uint
|
||||||
|
messageID string
|
||||||
|
payload string
|
||||||
|
truncated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeWebhookEvents) ResolveSecret(_ context.Context, secret string) (uint, bool) {
|
||||||
|
if secret == f.secret {
|
||||||
|
return f.cfgID, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeWebhookEvents) ConfirmAsync(confirmURL string) {
|
||||||
|
f.confirmed = append(f.confirmed, confirmURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeWebhookEvents) Ingest(_ context.Context, cfgID uint, messageID string, payload []byte, truncated bool) error {
|
||||||
|
if f.ingestErr != nil {
|
||||||
|
return f.ingestErr
|
||||||
|
}
|
||||||
|
f.ingested = append(f.ingested, ingestCall{cfgID, messageID, string(payload), truncated})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newWebhookEnv 只挂 webhook 路由(与生产同链:Recovery+系统日志中间件),
|
||||||
|
// 返回引擎、假回传服务与系统日志服务(断言留痕脱敏用)。
|
||||||
|
func newWebhookEnv(t *testing.T) (*gin.Engine, *fakeWebhookEvents, *service.SystemLogService, *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open in-memory sqlite: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("db handle: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
if err := db.AutoMigrate(&model.SystemLog{}); err != nil {
|
||||||
|
t.Fatalf("auto migrate: %v", err)
|
||||||
|
}
|
||||||
|
fake := &fakeWebhookEvents{secret: strings.Repeat("ab", 32), cfgID: 7}
|
||||||
|
systemLogs := service.NewSystemLogService(db)
|
||||||
|
r := gin.New()
|
||||||
|
r.POST("/api/v1/webhooks/oci-logs/:secret",
|
||||||
|
gin.Recovery(), systemLogMiddleware(systemLogs), (&webhookHandler{events: fake}).handle)
|
||||||
|
return r, fake, systemLogs, db
|
||||||
|
}
|
||||||
|
|
||||||
|
// postWebhook 以 ONS 头发起一次投递请求。
|
||||||
|
func postWebhook(t *testing.T, r *gin.Engine, secret, body string, headers map[string]string) int {
|
||||||
|
t.Helper()
|
||||||
|
req := httptestNewRequest(secret, body, headers)
|
||||||
|
w := doRawRequest(r, req)
|
||||||
|
return w.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
func httptestNewRequest(secret, body string, headers map[string]string) *http.Request {
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, "/api/v1/webhooks/oci-logs/"+secret, strings.NewReader(body))
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// doRawRequest 直接以 *http.Request 走引擎,便于携带任意头。
|
||||||
|
func doRawRequest(r *gin.Engine, req *http.Request) *httptest.ResponseRecorder {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookDispatch(t *testing.T) {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
stale := time.Now().Add(-10 * time.Minute).UTC().Format(time.RFC3339)
|
||||||
|
oracleURL := "https://cell1.notification.ap-tokyo-1.oci.oraclecloud.com/confirm?token=x"
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
secret string
|
||||||
|
headers map[string]string
|
||||||
|
body string
|
||||||
|
wantStatus int
|
||||||
|
wantConfirm int
|
||||||
|
wantIngest int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "secret 不匹配 404", secret: "wrong",
|
||||||
|
headers: map[string]string{onsHeaderMessageID: "m1"}, wantStatus: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "订阅确认白名单内异步激活", secret: "",
|
||||||
|
headers: map[string]string{onsHeaderMessageType: msgTypeSubscription, onsHeaderConfirmURL: oracleURL},
|
||||||
|
wantStatus: http.StatusOK, wantConfirm: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "订阅确认白名单外拒绝", secret: "",
|
||||||
|
headers: map[string]string{onsHeaderMessageType: msgTypeSubscription, onsHeaderConfirmURL: "https://evil.example.com/confirm"},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "订阅确认伪装 host 后缀拒绝", secret: "",
|
||||||
|
headers: map[string]string{onsHeaderMessageType: msgTypeSubscription, onsHeaderConfirmURL: "https://eviloraclecloud.com/confirm"},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "订阅确认非 https 拒绝", secret: "",
|
||||||
|
headers: map[string]string{onsHeaderMessageType: msgTypeSubscription, onsHeaderConfirmURL: "http://x.oraclecloud.com/confirm"},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "退订确认仅记录", secret: "",
|
||||||
|
headers: map[string]string{onsHeaderMessageType: msgTypeUnsubscribe},
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "通知消息入库", secret: "",
|
||||||
|
headers: map[string]string{onsHeaderMessageID: "m1", onsHeaderTimestamp: now},
|
||||||
|
body: `{"eventType":"t"}`,
|
||||||
|
wantStatus: http.StatusOK, wantIngest: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ONS 无冒号时区格式放行", secret: "",
|
||||||
|
headers: map[string]string{
|
||||||
|
onsHeaderMessageID: "m1",
|
||||||
|
onsHeaderTimestamp: time.Now().UTC().Format("2006-01-02T15:04:05.999") + "+0000",
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusOK, wantIngest: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "时间戳过期拒收", secret: "",
|
||||||
|
headers: map[string]string{onsHeaderMessageID: "m1", onsHeaderTimestamp: stale},
|
||||||
|
wantStatus: http.StatusForbidden,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "时间戳非法拒收", secret: "",
|
||||||
|
headers: map[string]string{onsHeaderMessageID: "m1", onsHeaderTimestamp: "yesterday"},
|
||||||
|
wantStatus: http.StatusForbidden,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "缺 MessageId 拒收", secret: "",
|
||||||
|
headers: map[string]string{onsHeaderTimestamp: now},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "缺时间戳头放行入库", secret: "",
|
||||||
|
headers: map[string]string{onsHeaderMessageID: "m2"},
|
||||||
|
wantStatus: http.StatusOK, wantIngest: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r, fake, _, _ := newWebhookEnv(t)
|
||||||
|
secret := tt.secret
|
||||||
|
if secret == "" {
|
||||||
|
secret = fake.secret
|
||||||
|
}
|
||||||
|
code := postWebhook(t, r, secret, tt.body, tt.headers)
|
||||||
|
if code != tt.wantStatus {
|
||||||
|
t.Fatalf("status = %d, want %d", code, tt.wantStatus)
|
||||||
|
}
|
||||||
|
if len(fake.confirmed) != tt.wantConfirm {
|
||||||
|
t.Errorf("confirm calls = %d, want %d", len(fake.confirmed), tt.wantConfirm)
|
||||||
|
}
|
||||||
|
if len(fake.ingested) != tt.wantIngest {
|
||||||
|
t.Errorf("ingest calls = %d, want %d", len(fake.ingested), tt.wantIngest)
|
||||||
|
}
|
||||||
|
if tt.wantIngest > 0 && fake.ingested[0].cfgID != fake.cfgID {
|
||||||
|
t.Errorf("ingest cfg = %d, want %d", fake.ingested[0].cfgID, fake.cfgID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookBodyTruncation(t *testing.T) {
|
||||||
|
r, fake, _, _ := newWebhookEnv(t)
|
||||||
|
big := strings.Repeat("x", webhookBodyLimit+100)
|
||||||
|
code := postWebhook(t, r, fake.secret, big, map[string]string{onsHeaderMessageID: "m-big"})
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", code)
|
||||||
|
}
|
||||||
|
got := fake.ingested[0]
|
||||||
|
if !got.truncated || len(got.payload) != webhookBodyLimit {
|
||||||
|
t.Errorf("truncated=%v len=%d, want true/%d", got.truncated, len(got.payload), webhookBodyLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookSystemLogHidesSecret(t *testing.T) {
|
||||||
|
r, fake, systemLogs, db := newWebhookEnv(t)
|
||||||
|
postWebhook(t, r, fake.secret, "{}", map[string]string{onsHeaderMessageID: "m1"})
|
||||||
|
systemLogs.Wait()
|
||||||
|
var entry model.SystemLog
|
||||||
|
if err := db.First(&entry).Error; err != nil {
|
||||||
|
t.Fatalf("system log row: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(entry.Path, fake.secret) {
|
||||||
|
t.Errorf("system log path leaks secret: %s", entry.Path)
|
||||||
|
}
|
||||||
|
if !strings.Contains(entry.Path, ":secret") {
|
||||||
|
t.Errorf("path = %s, want route template with :secret", entry.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeLogPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "webhook 路径脱敏末段", in: "/api/v1/webhooks/oci-logs/abcdef123456", want: "/api/v1/webhooks/oci-logs/***"},
|
||||||
|
{name: "非 webhook 路径原样", in: "/api/v1/tasks/3", want: "/api/v1/tasks/3"},
|
||||||
|
{name: "webhook 前缀本身原样", in: "/api/v1/webhooks/", want: "/api/v1/webhooks/"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := sanitizeLogPath(tt.in); got != tt.want {
|
||||||
|
t.Errorf("sanitizeLogPath(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+129
@@ -0,0 +1,129 @@
|
|||||||
|
// Package cache 提供进程内 TTL 缓存与 singleflight 合并,服务 OCI 读放大
|
||||||
|
// 与审计原始事件暂存等场景;单进程部署,无跨进程共享需求(调研①方案 A)。
|
||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand/v2"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type entry struct {
|
||||||
|
val any
|
||||||
|
exp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// flight 是一次进行中的回源;后到的同 key 调用等待它完成。
|
||||||
|
type flight struct {
|
||||||
|
wg sync.WaitGroup
|
||||||
|
val any
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache 是带键数上限的进程内 TTL 缓存;并发 miss 同 key 只回源一次。
|
||||||
|
type Cache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
items map[string]entry
|
||||||
|
flights map[string]*flight
|
||||||
|
max int
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 创建缓存;max 为键数上限,到限时先清过期项、仍满则任意驱逐。
|
||||||
|
func New(max int) *Cache {
|
||||||
|
return &Cache{items: map[string]entry{}, flights: map[string]*flight{}, max: max}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 返回未过期的缓存值。
|
||||||
|
func (c *Cache) Get(key string) (any, bool) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
e, ok := c.items[key]
|
||||||
|
if !ok || time.Now().After(e.exp) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return e.val, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set 写入缓存;TTL 附加至多 1/10 的随机抖动,避免整批同时过期。
|
||||||
|
func (c *Cache) Set(key string, val any, ttl time.Duration) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if len(c.items) >= c.max {
|
||||||
|
c.evictLocked()
|
||||||
|
}
|
||||||
|
jitter := time.Duration(rand.Int64N(int64(ttl)/10 + 1))
|
||||||
|
c.items[key] = entry{val: val, exp: time.Now().Add(ttl + jitter)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictLocked 先清全部过期项,仍到上限则任意删除腾出空位。
|
||||||
|
func (c *Cache) evictLocked() {
|
||||||
|
now := time.Now()
|
||||||
|
for k, e := range c.items {
|
||||||
|
if now.After(e.exp) {
|
||||||
|
delete(c.items, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k := range c.items {
|
||||||
|
if len(c.items) < c.max {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(c.items, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePrefix 删除全部以 prefix 开头的键(写操作后按租户前缀失效)。
|
||||||
|
func (c *Cache) DeletePrefix(prefix string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
for k := range c.items {
|
||||||
|
if strings.HasPrefix(k, prefix) {
|
||||||
|
delete(c.items, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// join 登记或加入 key 的进行中回源;返回 true 表示已有同 key 回源在跑。
|
||||||
|
func (c *Cache) join(key string) (*flight, bool) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if f, ok := c.flights[key]; ok {
|
||||||
|
return f, true
|
||||||
|
}
|
||||||
|
f := &flight{}
|
||||||
|
f.wg.Add(1)
|
||||||
|
c.flights[key] = f
|
||||||
|
return f, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// settle 结束回源登记并唤醒等待者。
|
||||||
|
func (c *Cache) settle(key string, f *flight) {
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.flights, key)
|
||||||
|
c.mu.Unlock()
|
||||||
|
f.wg.Done()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do 返回缓存值;miss 时经 singleflight 回源,成功写缓存,错误不缓存
|
||||||
|
// (等待者共享同一错误,下次调用重新回源)。
|
||||||
|
func Do[T any](c *Cache, key string, ttl time.Duration, fn func() (T, error)) (T, error) {
|
||||||
|
var zero T
|
||||||
|
if v, ok := c.Get(key); ok {
|
||||||
|
return v.(T), nil
|
||||||
|
}
|
||||||
|
f, joined := c.join(key)
|
||||||
|
if joined {
|
||||||
|
f.wg.Wait()
|
||||||
|
if f.err != nil {
|
||||||
|
return zero, f.err
|
||||||
|
}
|
||||||
|
return f.val.(T), nil
|
||||||
|
}
|
||||||
|
f.val, f.err = fn()
|
||||||
|
c.settle(key, f)
|
||||||
|
if f.err != nil {
|
||||||
|
return zero, f.err
|
||||||
|
}
|
||||||
|
c.Set(key, f.val, ttl)
|
||||||
|
return f.val.(T), nil
|
||||||
|
}
|
||||||
Vendored
+95
@@ -0,0 +1,95 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetSetExpiry(t *testing.T) {
|
||||||
|
c := New(10)
|
||||||
|
if _, ok := c.Get("k"); ok {
|
||||||
|
t.Fatal("空缓存不应命中")
|
||||||
|
}
|
||||||
|
c.Set("k", "v", 50*time.Millisecond)
|
||||||
|
if v, ok := c.Get("k"); !ok || v.(string) != "v" {
|
||||||
|
t.Fatalf("Get = %v %v, want v true", v, ok)
|
||||||
|
}
|
||||||
|
// jitter ≤ TTL/10,60ms 覆盖 50+5ms 上限
|
||||||
|
time.Sleep(60 * time.Millisecond)
|
||||||
|
if _, ok := c.Get("k"); ok {
|
||||||
|
t.Error("过期后不应命中")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeletePrefix(t *testing.T) {
|
||||||
|
c := New(10)
|
||||||
|
c.Set("t1|a", 1, time.Minute)
|
||||||
|
c.Set("t1|b", 2, time.Minute)
|
||||||
|
c.Set("t2|a", 3, time.Minute)
|
||||||
|
c.DeletePrefix("t1|")
|
||||||
|
if _, ok := c.Get("t1|a"); ok {
|
||||||
|
t.Error("t1|a 应被前缀删除")
|
||||||
|
}
|
||||||
|
if _, ok := c.Get("t2|a"); !ok {
|
||||||
|
t.Error("t2|a 不应被删除")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvictAtMax(t *testing.T) {
|
||||||
|
c := New(2)
|
||||||
|
c.Set("a", 1, time.Minute)
|
||||||
|
c.Set("b", 2, time.Minute)
|
||||||
|
c.Set("c", 3, time.Minute)
|
||||||
|
if n := len(c.items); n > 2 {
|
||||||
|
t.Errorf("键数 = %d, 应不超过上限 2", n)
|
||||||
|
}
|
||||||
|
if _, ok := c.Get("c"); !ok {
|
||||||
|
t.Error("最新写入的键应存在")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoSingleflight(t *testing.T) {
|
||||||
|
c := New(10)
|
||||||
|
var calls atomic.Int32
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
v, err := Do(c, "k", time.Minute, func() (int, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
return 42, nil
|
||||||
|
})
|
||||||
|
if err != nil || v != 42 {
|
||||||
|
t.Errorf("Do = %d, %v", v, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if n := calls.Load(); n != 1 {
|
||||||
|
t.Errorf("并发 miss 回源 %d 次, want 1", n)
|
||||||
|
}
|
||||||
|
// 命中缓存不再回源
|
||||||
|
if _, err := Do(c, "k", time.Minute, func() (int, error) { calls.Add(1); return 0, nil }); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := calls.Load(); n != 1 {
|
||||||
|
t.Errorf("命中后回源 %d 次, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoErrorNotCached(t *testing.T) {
|
||||||
|
c := New(10)
|
||||||
|
boom := errors.New("boom")
|
||||||
|
if _, err := Do(c, "k", time.Minute, func() (int, error) { return 0, boom }); !errors.Is(err, boom) {
|
||||||
|
t.Fatalf("err = %v, want boom", err)
|
||||||
|
}
|
||||||
|
v, err := Do(c, "k", time.Minute, func() (int, error) { return 7, nil })
|
||||||
|
if err != nil || v != 7 {
|
||||||
|
t.Errorf("错误不应入缓存,重试 = %d, %v", v, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config 保存进程运行所需的全部配置。
|
||||||
|
type Config struct {
|
||||||
|
Addr string // HTTP 监听地址
|
||||||
|
DBPath string // SQLite 文件路径
|
||||||
|
DataKey string // 敏感字段加密主密钥
|
||||||
|
JWTSecret string // JWT 签名密钥
|
||||||
|
AdminUsername string // 初始管理员用户名
|
||||||
|
AdminPassword string // 初始管理员密码,仅在该用户不存在时用于创建
|
||||||
|
PublicURL string // 面板公网基址(如 https://demo.example.com),日志回传引导创建用
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load 从环境变量读取配置;DATA_KEY、JWT_SECRET 无默认值,缺失即失败。
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
dataKey := os.Getenv("DATA_KEY")
|
||||||
|
if dataKey == "" {
|
||||||
|
return nil, fmt.Errorf("load config: DATA_KEY is required")
|
||||||
|
}
|
||||||
|
jwtSecret := os.Getenv("JWT_SECRET")
|
||||||
|
if jwtSecret == "" {
|
||||||
|
return nil, fmt.Errorf("load config: JWT_SECRET is required")
|
||||||
|
}
|
||||||
|
return &Config{
|
||||||
|
Addr: envOr("ADDR", ":8080"),
|
||||||
|
DBPath: envOr("DB_PATH", "oci-portal.db"),
|
||||||
|
DataKey: dataKey,
|
||||||
|
JWTSecret: jwtSecret,
|
||||||
|
AdminUsername: envOr("ADMIN_USERNAME", "admin"),
|
||||||
|
AdminPassword: os.Getenv("ADMIN_PASSWORD"),
|
||||||
|
PublicURL: os.Getenv("PUBLIC_URL"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(name, def string) string {
|
||||||
|
if v := os.Getenv(name); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Package config 负责读取环境变量并生成运行配置。
|
||||||
|
package config
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cipher 用主密钥派生的 AES-256-GCM 做敏感字段加解密。
|
||||||
|
type Cipher struct {
|
||||||
|
aead cipher.AEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCipher 以 SHA-256 从主密钥字符串派生 32 字节密钥,
|
||||||
|
// 允许 DATA_KEY 是任意长度的强随机字符串。
|
||||||
|
func NewCipher(masterKey string) (*Cipher, error) {
|
||||||
|
if masterKey == "" {
|
||||||
|
return nil, fmt.Errorf("new cipher: empty master key")
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(masterKey))
|
||||||
|
block, err := aes.NewCipher(sum[:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("new cipher: %w", err)
|
||||||
|
}
|
||||||
|
aead, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("new cipher: %w", err)
|
||||||
|
}
|
||||||
|
return &Cipher{aead: aead}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptString 加密明文,返回 base64(nonce | ciphertext)。
|
||||||
|
func (c *Cipher) EncryptString(plaintext string) (string, error) {
|
||||||
|
nonce := make([]byte, c.aead.NonceSize())
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return "", fmt.Errorf("encrypt: %w", err)
|
||||||
|
}
|
||||||
|
out := c.aead.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||||
|
return base64.StdEncoding.EncodeToString(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptString 解密 EncryptString 的输出。
|
||||||
|
func (c *Cipher) DecryptString(encoded string) (string, error) {
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("decrypt: %w", err)
|
||||||
|
}
|
||||||
|
ns := c.aead.NonceSize()
|
||||||
|
if len(raw) < ns {
|
||||||
|
return "", fmt.Errorf("decrypt: ciphertext too short")
|
||||||
|
}
|
||||||
|
plain, err := c.aead.Open(nil, raw[:ns], raw[ns:], nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("decrypt: %w", err)
|
||||||
|
}
|
||||||
|
return string(plain), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCipherRoundTrip(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
plaintext string
|
||||||
|
}{
|
||||||
|
{name: "普通文本", plaintext: "hello oci-portal"},
|
||||||
|
{name: "空串", plaintext: ""},
|
||||||
|
{name: "PEM 多行", plaintext: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----"},
|
||||||
|
}
|
||||||
|
c, err := NewCipher("test-master-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCipher: %v", err)
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
enc, err := c.EncryptString(tt.plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncryptString: %v", err)
|
||||||
|
}
|
||||||
|
got, err := c.DecryptString(enc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecryptString: %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.plaintext {
|
||||||
|
t.Errorf("roundtrip: got %q, want %q", got, tt.plaintext)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCipherWrongKeyFails(t *testing.T) {
|
||||||
|
c1, _ := NewCipher("key-one")
|
||||||
|
c2, _ := NewCipher("key-two")
|
||||||
|
enc, err := c1.EncryptString("secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncryptString: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := c2.DecryptString(enc); err == nil {
|
||||||
|
t.Error("decrypt with wrong key: got nil error, want failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCipherRejectsGarbage(t *testing.T) {
|
||||||
|
c, _ := NewCipher("key")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
}{
|
||||||
|
{name: "非 base64", input: "not-base64!!"},
|
||||||
|
{name: "过短密文", input: "YWJj"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if _, err := c.DecryptString(tt.input); err == nil {
|
||||||
|
t.Errorf("DecryptString(%q): got nil error, want failure", tt.input)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewCipherEmptyKey(t *testing.T) {
|
||||||
|
if _, err := NewCipher(""); err == nil {
|
||||||
|
t.Error("NewCipher(\"\"): got nil error, want failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Package crypto 提供敏感字段的 AES-GCM 加解密。
|
||||||
|
package crypto
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Open 打开 SQLite 数据库并自动迁移全部模型。
|
||||||
|
func Open(path string) (*gorm.DB, error) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Warn),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open sqlite %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&model.User{}, &model.UserIdentity{}, &model.OciConfig{}, &model.Task{}, &model.TaskLog{},
|
||||||
|
&model.CheckSnapshot{}, &model.CostSnapshot{},
|
||||||
|
&model.RegionCache{}, &model.CompartmentCache{}, &model.Setting{},
|
||||||
|
&model.SystemLog{}, &model.LogEvent{}, &model.Proxy{},
|
||||||
|
&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{},
|
||||||
|
&model.AiContentLog{},
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("auto migrate: %w", err)
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Package database 管理 SQLite 连接和 AutoMigrate。
|
||||||
|
package database
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Package model 定义 GORM 数据模型。
|
||||||
|
package model
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// 账户类别取值。
|
||||||
|
const (
|
||||||
|
AccountTypePaid = "paid"
|
||||||
|
AccountTypeTrial = "trial"
|
||||||
|
AccountTypeFree = "free"
|
||||||
|
AccountTypeUnknown = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 测活状态取值。
|
||||||
|
const (
|
||||||
|
AliveStatusAlive = "alive"
|
||||||
|
AliveStatusDead = "dead"
|
||||||
|
AliveStatusUnknown = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Setting 是系统级键值配置;敏感值(如 telegram_bot_token)以 AES-GCM 密文存储。
|
||||||
|
type Setting struct {
|
||||||
|
Key string `gorm:"primaryKey;size:64" json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy 是出站代理配置(socks5 / http / https),供租户 SDK 调用按关联走代理;
|
||||||
|
// 密码 AES-GCM 加密存储,任何响应不回明文。
|
||||||
|
type Proxy struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"uniqueIndex;size:64" json:"name"`
|
||||||
|
Type string `gorm:"size:16" json:"type"`
|
||||||
|
Host string `gorm:"size:256" json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Username string `gorm:"size:128" json:"username"`
|
||||||
|
PasswordEnc string `json:"-"`
|
||||||
|
// Country / City 为经代理出口实测的地理位置;GeoAt 为最近一次探测时间,nil 表示尚未探测
|
||||||
|
Country string `gorm:"size:64" json:"country"`
|
||||||
|
City string `gorm:"size:64" json:"city"`
|
||||||
|
GeoAt *time.Time `json:"geoAt"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemLog 是面板自身的操作审计(区别于 OCI 云端审计事件);
|
||||||
|
// 只存方法、路径等元数据,绝不存请求体,避免私钥 / 口令入库。
|
||||||
|
type SystemLog struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Username string `gorm:"size:64;index" json:"username"`
|
||||||
|
Method string `gorm:"size:8" json:"method"`
|
||||||
|
Path string `gorm:"size:256" json:"path"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
DurationMs int64 `json:"durationMs"`
|
||||||
|
ClientIP string `gorm:"size:64" json:"clientIp"`
|
||||||
|
UserAgent string `gorm:"size:256" json:"userAgent"`
|
||||||
|
// ErrMsg 仅失败请求(>=400)存响应的 error 文案,便于免翻服务端日志定位原因
|
||||||
|
ErrMsg string `gorm:"size:256" json:"errMsg"`
|
||||||
|
CreatedAt time.Time `gorm:"index" json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// User 是可登录面板的账号,密码只存 bcrypt 哈希。
|
||||||
|
type User struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Username string `gorm:"uniqueIndex;size:64" json:"username"`
|
||||||
|
PasswordHash string `json:"-"`
|
||||||
|
// TOTP 共享密钥 AES-GCM 密文;空串表示未启用两步验证
|
||||||
|
TotpSecretEnc string `json:"-"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserIdentity 是管理员绑定的外部登录身份(OIDC / GitHub);
|
||||||
|
// 绑定动作只发生在已登录会话内,未绑定的外部身份一律拒绝登录。
|
||||||
|
type UserIdentity struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
UserID uint `gorm:"index" json:"-"`
|
||||||
|
Provider string `gorm:"size:16;uniqueIndex:idx_identity_provider_subject" json:"provider"`
|
||||||
|
// OIDC sub / GitHub 数字 id;不回给前端
|
||||||
|
Subject string `gorm:"size:255;uniqueIndex:idx_identity_provider_subject" json:"-"`
|
||||||
|
// 展示名:邮箱 / GitHub login
|
||||||
|
Display string `gorm:"size:255" json:"display"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务类型取值。
|
||||||
|
const (
|
||||||
|
TaskTypeHealthCheck = "health_check" // 定时测活
|
||||||
|
TaskTypeSnatch = "snatch" // 抢机:反复尝试创建实例直到成功
|
||||||
|
TaskTypeCost = "cost" // 每日成本同步:拉 Usage API 写本地快照
|
||||||
|
TaskTypeAiProbe = "ai_probe" // AI 渠道周期探测:随渠道数量自动创建/激活/暂停
|
||||||
|
)
|
||||||
|
|
||||||
|
// 任务状态取值。
|
||||||
|
const (
|
||||||
|
TaskStatusActive = "active" // 按 cron 调度中
|
||||||
|
TaskStatusPaused = "paused" // 手动暂停
|
||||||
|
TaskStatusSucceeded = "succeeded" // 抢机成功后自动停止
|
||||||
|
TaskStatusFailed = "failed" // 熔断停止(如抢机连续鉴权失败)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Task 是一个按 cron 表达式周期执行的后台任务。
|
||||||
|
// Payload 按任务类型存 JSON:测活为 {"ociConfigIds":[...]}(空为全部),
|
||||||
|
// 抢机为 {"ociConfigId":1,"instance":{...},"count":1}。
|
||||||
|
type Task struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"size:128" json:"name"`
|
||||||
|
Type string `gorm:"size:32;index" json:"type"`
|
||||||
|
CronExpr string `gorm:"size:64" json:"cronExpr"`
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
Status string `gorm:"size:16;index" json:"status"`
|
||||||
|
LastRunAt *time.Time `json:"lastRunAt"`
|
||||||
|
LastError string `json:"lastError"`
|
||||||
|
RunCount int `json:"runCount"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskLog 是任务的一次执行记录。
|
||||||
|
type TaskLog struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
TaskID uint `gorm:"index" json:"taskId"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
DurationMs int64 `json:"durationMs"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckSnapshot 是测活任务为每个配置留下的最新资源快照(每配置一行,覆盖更新)。
|
||||||
|
// InstanceCount 只统计配置默认区域,用于总览 KPI 与覆盖度标注。
|
||||||
|
type CheckSnapshot struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
OciConfigID uint `gorm:"uniqueIndex" json:"ociConfigId"`
|
||||||
|
AliveStatus string `gorm:"size:16" json:"aliveStatus"`
|
||||||
|
InstanceCount int `json:"instanceCount"`
|
||||||
|
CheckedAt time.Time `json:"checkedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CostSnapshot 是成本任务写入的每配置每日成本(UTC 日界,重复同步覆盖更新)。
|
||||||
|
type CostSnapshot struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
OciConfigID uint `gorm:"uniqueIndex:idx_cost_cfg_day" json:"ociConfigId"`
|
||||||
|
Day string `gorm:"size:10;uniqueIndex:idx_cost_cfg_day" json:"day"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Currency string `gorm:"size:8" json:"currency"`
|
||||||
|
SyncedAt time.Time `json:"syncedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OciConfig 保存一份 OCI API Key 配置及其最近一次云端快照。
|
||||||
|
// 私钥和口令只以 AES-GCM 密文落库,且不出现在 JSON 响应中。
|
||||||
|
type OciConfig struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Alias string `gorm:"uniqueIndex;size:64" json:"alias"`
|
||||||
|
// Group 是面板内的自定义分组标签,空串表示未分组;
|
||||||
|
// 列名避开 SQL 保留字 GROUP
|
||||||
|
Group string `gorm:"column:group_name;size:64;index" json:"group"`
|
||||||
|
|
||||||
|
// ProxyID 关联出站代理,nil 表示直连;该租户全部 SDK 调用经此代理
|
||||||
|
ProxyID *uint `gorm:"index" json:"proxyId"`
|
||||||
|
|
||||||
|
UserOCID string `json:"userOcid"`
|
||||||
|
TenancyOCID string `json:"tenancyOcid"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
|
||||||
|
PrivateKeyEnc string `json:"-"`
|
||||||
|
PassphraseEnc string `json:"-"`
|
||||||
|
|
||||||
|
TenancyName string `json:"tenancyName"`
|
||||||
|
HomeRegionKey string `json:"homeRegionKey"`
|
||||||
|
|
||||||
|
AccountType string `json:"accountType"`
|
||||||
|
SubscriptionID string `json:"subscriptionId"`
|
||||||
|
PaymentModel string `json:"paymentModel"`
|
||||||
|
SubscriptionTier string `json:"subscriptionTier"`
|
||||||
|
PromotionStatus string `json:"promotionStatus"`
|
||||||
|
PromotionAmount float32 `json:"promotionAmount"`
|
||||||
|
PromotionExpires *time.Time `json:"promotionExpires"`
|
||||||
|
|
||||||
|
AliveStatus string `json:"aliveStatus"`
|
||||||
|
LastError string `json:"lastError"`
|
||||||
|
LastVerifiedAt *time.Time `json:"lastVerifiedAt"`
|
||||||
|
|
||||||
|
// 多区域 / 多区间支持:开启后订阅区域与 compartment 列表入库缓存,
|
||||||
|
// 供筛选器免实时调 OCI;未开启的租户筛选器锁定主区域 / 根区间。
|
||||||
|
MultiRegion bool `json:"multiRegion"`
|
||||||
|
MultiCompartment bool `json:"multiCompartment"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogEvent 是 OCI 日志回传事件(方案A:Connector Hub → Notifications → HTTPS 订阅);
|
||||||
|
// Payload 存 ONS 消息原文(链路单条 ≤128KB,面板另设 256KB 防御上限,超限截断并标记)。
|
||||||
|
type LogEvent struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
OciConfigID uint `gorm:"index" json:"ociConfigId"` // 经 secret 反查归属租户
|
||||||
|
MessageID string `gorm:"uniqueIndex;size:128" json:"messageId"` // 幂等键(X-OCI-NS-MessageId)
|
||||||
|
EventType string `gorm:"size:128;index" json:"eventType"` // 异步解析回填,如 com.oraclecloud.ComputeApi.TerminateInstance
|
||||||
|
Source string `gorm:"size:64" json:"source"`
|
||||||
|
SourceIP string `gorm:"size:64" json:"sourceIp"` // 异步解析回填,Audit 事件的发起方 IP
|
||||||
|
EventTime *time.Time `gorm:"index" json:"eventTime"`
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
Truncated bool `json:"truncated"`
|
||||||
|
Processed bool `gorm:"index" json:"processed"`
|
||||||
|
ReceivedAt time.Time `json:"receivedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegionCache 是开启多区域支持的配置缓存的订阅区域(每配置多行,整组覆盖)。
|
||||||
|
// Status 非 READY(新订阅进行中)时读取接口会实时刷新,直到全部 READY。
|
||||||
|
type RegionCache struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"-"`
|
||||||
|
OciConfigID uint `gorm:"index;uniqueIndex:idx_region_cache" json:"-"`
|
||||||
|
Key string `gorm:"size:8;uniqueIndex:idx_region_cache" json:"key"`
|
||||||
|
Name string `gorm:"size:64" json:"name"`
|
||||||
|
Status string `gorm:"size:16" json:"status"`
|
||||||
|
IsHomeRegion bool `json:"isHomeRegion"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompartmentCache 是开启多区间支持的配置缓存的 compartment(每配置多行,整组覆盖)。
|
||||||
|
type CompartmentCache struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"-"`
|
||||||
|
OciConfigID uint `gorm:"index;uniqueIndex:idx_comp_cache" json:"-"`
|
||||||
|
OCID string `gorm:"size:128;uniqueIndex:idx_comp_cache" json:"id"`
|
||||||
|
Name string `gorm:"size:128" json:"name"`
|
||||||
|
State string `gorm:"size:16" json:"lifecycleState"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AiKey 是 AI 网关的访问密钥;明文不落库,仅存 SHA-256 与尾 4 位。
|
||||||
|
// Group 非空时该密钥只在同分组渠道内负载均衡(列名避开 SQL 保留字 group)。
|
||||||
|
type AiKey struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"uniqueIndex;size:64" json:"name"`
|
||||||
|
KeyHash string `gorm:"uniqueIndex;size:64" json:"-"`
|
||||||
|
Tail string `gorm:"size:4" json:"tail"`
|
||||||
|
Group string `gorm:"column:key_group;size:32" json:"group"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LastUsedAt *time.Time `json:"lastUsedAt"`
|
||||||
|
// ContentLogUntil 是内容日志开启截止时间;nil = 永关(默认,红线),
|
||||||
|
// 开启必须限时,到期自动停写
|
||||||
|
ContentLogUntil *time.Time `json:"contentLogUntil"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AiChannel 是号池渠道 = 租户配置 × 区域;Priority 数字小优先级高,组内按 Weight 加权随机。
|
||||||
|
// Group 是分组名(列名避开 SQL 保留字 group),空串归属默认分组。
|
||||||
|
type AiChannel struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"size:96" json:"name"`
|
||||||
|
OciConfigID uint `gorm:"uniqueIndex:idx_ai_channel" json:"ociConfigId"`
|
||||||
|
Region string `gorm:"size:32;uniqueIndex:idx_ai_channel" json:"region"`
|
||||||
|
Group string `gorm:"column:channel_group;size:32;index" json:"group"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Weight int `json:"weight"`
|
||||||
|
// FailCount 连续失败计数;达阈值后 DisabledUntil 指数退避熔断,成功/探测通过复位
|
||||||
|
FailCount int `json:"failCount"`
|
||||||
|
DisabledUntil *time.Time `json:"disabledUntil"`
|
||||||
|
LastProbeAt *time.Time `json:"lastProbeAt"`
|
||||||
|
ProbeStatus string `gorm:"size:16" json:"probeStatus"` // ok / no_service / no_quota / error
|
||||||
|
ProbeError string `gorm:"size:512" json:"probeError"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AiModelCache 是渠道区域的可用模型缓存(整渠道覆盖式同步)。
|
||||||
|
// Capability 为 CHAT / EMBEDDING;存量空串视为 CHAT(加列前只同步对话模型)。
|
||||||
|
type AiModelCache struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"-"`
|
||||||
|
ChannelID uint `gorm:"index" json:"channelId"`
|
||||||
|
ModelOcid string `gorm:"size:160" json:"modelOcid"`
|
||||||
|
Name string `gorm:"size:96;index" json:"name"`
|
||||||
|
Vendor string `gorm:"size:32" json:"vendor"`
|
||||||
|
Capability string `gorm:"size:16" json:"capability"`
|
||||||
|
SyncedAt time.Time `json:"syncedAt"`
|
||||||
|
// DeprecatedAt 是 OCI 宣布的模型弃用时间;nil 表示未宣布。
|
||||||
|
// 弃用后仍可调用,直到 RetiredAt(按需推理退役,同步层已剔除过期项)。
|
||||||
|
DeprecatedAt *time.Time `json:"deprecatedAt"`
|
||||||
|
RetiredAt *time.Time `json:"retiredAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AiCallLog 是网关调用日志:仅元数据与 token 用量,绝不记录 prompt / 响应正文。
|
||||||
|
type AiCallLog struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
KeyID uint `json:"keyId"`
|
||||||
|
KeyName string `gorm:"size:64" json:"keyName"`
|
||||||
|
Endpoint string `gorm:"size:16" json:"endpoint"` // openai / anthropic
|
||||||
|
Model string `gorm:"size:96" json:"model"`
|
||||||
|
ChannelID uint `json:"channelId"`
|
||||||
|
ChannelName string `gorm:"size:96" json:"channelName"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
// ClientIP 是网关调用方来源 IP(尊重「真实 IP 头」安全设置,与系统日志同源)
|
||||||
|
ClientIP string `gorm:"size:64" json:"clientIp"`
|
||||||
|
PromptTokens int `json:"promptTokens"`
|
||||||
|
CompletionTokens int `json:"completionTokens"`
|
||||||
|
TotalTokens int `json:"totalTokens"`
|
||||||
|
// CachedTokens 是输入中缓存命中(读取)的部分;OCI 隐式缓存无写入计数
|
||||||
|
CachedTokens int `json:"cachedTokens"`
|
||||||
|
LatencyMs int64 `json:"latencyMs"`
|
||||||
|
Retries int `json:"retries"`
|
||||||
|
ErrMsg string `gorm:"size:512" json:"errMsg"`
|
||||||
|
CreatedAt time.Time `gorm:"index" json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AiContentLog 是网关调用正文抽样——「不落正文」红线的唯一显式例外:
|
||||||
|
// 仅当调用密钥显式开启内容日志且未过期时写入,到期自动停写;
|
||||||
|
// 请求正文与非流式响应正文各截断至 64KB,流式响应不记录;保留 7 天 / 1 万行。
|
||||||
|
type AiContentLog struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
// CallLogID 关联同次调用的 AiCallLog,调用日志详情据此反查正文
|
||||||
|
CallLogID uint `gorm:"index" json:"callLogId"`
|
||||||
|
KeyID uint `gorm:"index" json:"keyId"`
|
||||||
|
KeyName string `gorm:"size:64" json:"keyName"`
|
||||||
|
Endpoint string `gorm:"size:16" json:"endpoint"`
|
||||||
|
Model string `gorm:"size:96" json:"model"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
// RequestBody / ResponseBody 是截断后的正文 JSON
|
||||||
|
RequestBody string `json:"requestBody"`
|
||||||
|
ResponseBody string `json:"responseBody"`
|
||||||
|
CreatedAt time.Time `gorm:"index" json:"createdAt"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccountProfile 是一次云端订阅查询得到的账户信息快照。
|
||||||
|
type AccountProfile struct {
|
||||||
|
AccountType string
|
||||||
|
SubscriptionID string
|
||||||
|
ServiceName string
|
||||||
|
PaymentModel string
|
||||||
|
SubscriptionTier string
|
||||||
|
PromotionStatus string
|
||||||
|
PromotionAmount float32
|
||||||
|
PromotionExpires *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifyAccount 按订阅字段判定账户类别:
|
||||||
|
// - paymentModel 非空且不是 FREE_TRIAL:付费;
|
||||||
|
// - paymentModel 为 FREE_TRIAL 时,promotion ACTIVE 或 tier FREE_AND_TRIAL
|
||||||
|
// 判为试用;promotion EXPIRED 或 tier FREE 判为免费(试用权益已结束);
|
||||||
|
// - 其余情况无法判定。
|
||||||
|
func ClassifyAccount(paymentModel, subscriptionTier, promotionStatus string) string {
|
||||||
|
pm := strings.ToUpper(strings.TrimSpace(paymentModel))
|
||||||
|
tier := strings.ToUpper(strings.TrimSpace(subscriptionTier))
|
||||||
|
promo := strings.ToUpper(strings.TrimSpace(promotionStatus))
|
||||||
|
if pm != "" && pm != "FREE_TRIAL" {
|
||||||
|
return model.AccountTypePaid
|
||||||
|
}
|
||||||
|
if pm == "FREE_TRIAL" {
|
||||||
|
switch {
|
||||||
|
case promo == "ACTIVE" || tier == "FREE_AND_TRIAL":
|
||||||
|
return model.AccountTypeTrial
|
||||||
|
case promo == "EXPIRED" || tier == "FREE":
|
||||||
|
return model.AccountTypeFree
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return model.AccountTypeUnknown
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClassifyAccount(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
paymentModel string
|
||||||
|
tier string
|
||||||
|
promotionStatus string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "按量付费为付费账户", paymentModel: "Pay as you go", want: model.AccountTypePaid},
|
||||||
|
{name: "月度账单为付费账户", paymentModel: "Monthly", want: model.AccountTypePaid},
|
||||||
|
{name: "试用中促销激活", paymentModel: "FREE_TRIAL", promotionStatus: "ACTIVE", want: model.AccountTypeTrial},
|
||||||
|
{name: "试用中按层级判定", paymentModel: "FREE_TRIAL", tier: "FREE_AND_TRIAL", want: model.AccountTypeTrial},
|
||||||
|
{name: "促销过期为免费账户", paymentModel: "FREE_TRIAL", promotionStatus: "EXPIRED", want: model.AccountTypeFree},
|
||||||
|
{name: "免费层级为免费账户", paymentModel: "FREE_TRIAL", tier: "FREE", want: model.AccountTypeFree},
|
||||||
|
{name: "促销激活优先于免费层级", paymentModel: "FREE_TRIAL", tier: "FREE", promotionStatus: "ACTIVE", want: model.AccountTypeTrial},
|
||||||
|
{name: "试用但无促销无层级", paymentModel: "FREE_TRIAL", want: model.AccountTypeUnknown},
|
||||||
|
{name: "全部为空无法判定", want: model.AccountTypeUnknown},
|
||||||
|
{name: "大小写不敏感", paymentModel: "free_trial", promotionStatus: "active", want: model.AccountTypeTrial},
|
||||||
|
{name: "促销初始化状态不判定", paymentModel: "FREE_TRIAL", promotionStatus: "INITIALIZED", want: model.AccountTypeUnknown},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := ClassifyAccount(tt.paymentModel, tt.tier, tt.promotionStatus)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("ClassifyAccount(%q, %q, %q) = %q, want %q",
|
||||||
|
tt.paymentModel, tt.tier, tt.promotionStatus, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BootVolumeAttachment 是引导卷与实例的挂载关系。
|
||||||
|
type BootVolumeAttachment struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
InstanceID string `json:"instanceId"`
|
||||||
|
BootVolumeID string `json:"bootVolumeId"`
|
||||||
|
LifecycleState string `json:"lifecycleState"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
AvailabilityDomain string `json:"availabilityDomain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VolumeAttachment 是块存储卷与实例的挂载关系。
|
||||||
|
type VolumeAttachment struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
InstanceID string `json:"instanceId"`
|
||||||
|
VolumeID string `json:"volumeId"`
|
||||||
|
VolumeName string `json:"volumeName,omitempty"` // 卷本身的名称(attachment 名是自动生成的)
|
||||||
|
LifecycleState string `json:"lifecycleState"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Device string `json:"device"`
|
||||||
|
IsReadOnly bool `json:"isReadOnly"`
|
||||||
|
AvailabilityDomain string `json:"availabilityDomain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockVolume 是一个块存储卷。
|
||||||
|
type BlockVolume struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
LifecycleState string `json:"lifecycleState"`
|
||||||
|
AvailabilityDomain string `json:"availabilityDomain"`
|
||||||
|
SizeInGBs int64 `json:"sizeInGBs"`
|
||||||
|
VpusPerGB int64 `json:"vpusPerGB"`
|
||||||
|
TimeCreated *time.Time `json:"timeCreated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 引导卷挂载 ----
|
||||||
|
|
||||||
|
// ListBootVolumeAttachments 实现 Client:列出实例的引导卷挂载。
|
||||||
|
func (c *RealClient) ListBootVolumeAttachments(ctx context.Context, cred Credentials, region, instanceID string) ([]BootVolumeAttachment, error) {
|
||||||
|
cc, err := c.computeClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
instResp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get instance %s: %w", instanceID, err)
|
||||||
|
}
|
||||||
|
// 挂载记录在实例所在 compartment,复用上面查到的实例定位,传租户根会漏查
|
||||||
|
resp, err := cc.ListBootVolumeAttachments(ctx, core.ListBootVolumeAttachmentsRequest{
|
||||||
|
AvailabilityDomain: instResp.AvailabilityDomain,
|
||||||
|
CompartmentId: hostCompartment(cred, deref(instResp.CompartmentId)),
|
||||||
|
InstanceId: &instanceID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list boot volume attachments: %w", err)
|
||||||
|
}
|
||||||
|
atts := make([]BootVolumeAttachment, 0, len(resp.Items))
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
atts = append(atts, toBootVolumeAttachment(item))
|
||||||
|
}
|
||||||
|
return atts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachBootVolume 实现 Client;实例需处于 STOPPED,引导卷与实例同可用域。
|
||||||
|
func (c *RealClient) AttachBootVolume(ctx context.Context, cred Credentials, region, instanceID, bootVolumeID string) (BootVolumeAttachment, error) {
|
||||||
|
cc, err := c.computeClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return BootVolumeAttachment{}, err
|
||||||
|
}
|
||||||
|
resp, err := cc.AttachBootVolume(ctx, core.AttachBootVolumeRequest{
|
||||||
|
AttachBootVolumeDetails: core.AttachBootVolumeDetails{
|
||||||
|
InstanceId: &instanceID,
|
||||||
|
BootVolumeId: &bootVolumeID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return BootVolumeAttachment{}, fmt.Errorf("attach boot volume: %w", err)
|
||||||
|
}
|
||||||
|
return toBootVolumeAttachment(resp.BootVolumeAttachment), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetachBootVolume 实现 Client;实例需处于 STOPPED。
|
||||||
|
func (c *RealClient) DetachBootVolume(ctx context.Context, cred Credentials, region, attachmentID string) error {
|
||||||
|
cc, err := c.computeClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := cc.DetachBootVolume(ctx, core.DetachBootVolumeRequest{BootVolumeAttachmentId: &attachmentID}); err != nil {
|
||||||
|
return fmt.Errorf("detach boot volume %s: %w", attachmentID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceBootVolume 实现 Client:分离实例当前引导卷,挂载新引导卷。
|
||||||
|
// 实例必须处于 STOPPED,全程等待分离完成后再挂载。
|
||||||
|
func (c *RealClient) ReplaceBootVolume(ctx context.Context, cred Credentials, region, instanceID, newBootVolumeID string) (BootVolumeAttachment, error) {
|
||||||
|
cc, err := c.computeClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return BootVolumeAttachment{}, err
|
||||||
|
}
|
||||||
|
instResp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID})
|
||||||
|
if err != nil {
|
||||||
|
return BootVolumeAttachment{}, fmt.Errorf("get instance %s: %w", instanceID, err)
|
||||||
|
}
|
||||||
|
if instResp.LifecycleState != core.InstanceLifecycleStateStopped {
|
||||||
|
return BootVolumeAttachment{}, fmt.Errorf("replace boot volume: instance must be STOPPED, current %s", instResp.LifecycleState)
|
||||||
|
}
|
||||||
|
atts, err := c.ListBootVolumeAttachments(ctx, cred, region, instanceID)
|
||||||
|
if err != nil {
|
||||||
|
return BootVolumeAttachment{}, err
|
||||||
|
}
|
||||||
|
for _, att := range atts {
|
||||||
|
if att.LifecycleState != string(core.BootVolumeAttachmentLifecycleStateDetached) {
|
||||||
|
if err := c.DetachBootVolume(ctx, cred, region, att.ID); err != nil {
|
||||||
|
return BootVolumeAttachment{}, err
|
||||||
|
}
|
||||||
|
if err := waitBootVolumeDetached(ctx, cc, att.ID); err != nil {
|
||||||
|
return BootVolumeAttachment{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.AttachBootVolume(ctx, cred, region, instanceID, newBootVolumeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitBootVolumeDetached 轮询等待引导卷挂载进入 DETACHED;
|
||||||
|
// OCI 可能在分离完成后回收挂载记录,404 同样视为已分离。
|
||||||
|
func waitBootVolumeDetached(ctx context.Context, cc core.ComputeClient, attachmentID string) error {
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
resp, err := cc.GetBootVolumeAttachment(ctx, core.GetBootVolumeAttachmentRequest{BootVolumeAttachmentId: &attachmentID})
|
||||||
|
if err != nil {
|
||||||
|
var svcErr common.ServiceError
|
||||||
|
if errors.As(err, &svcErr) && svcErr.GetHTTPStatusCode() == 404 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("poll boot volume attachment: %w", err)
|
||||||
|
}
|
||||||
|
if resp.LifecycleState == core.BootVolumeAttachmentLifecycleStateDetached {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fmt.Errorf("poll boot volume detached: %w", ctx.Err())
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("poll boot volume detached: timed out")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 块卷挂载 ----
|
||||||
|
|
||||||
|
// ListVolumeAttachments 实现 Client:列出实例的块卷挂载。
|
||||||
|
func (c *RealClient) ListVolumeAttachments(ctx context.Context, cred Credentials, region, instanceID string) ([]VolumeAttachment, error) {
|
||||||
|
cc, err := c.computeClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req := core.ListVolumeAttachmentsRequest{CompartmentId: cred.EffectiveCompartment()}
|
||||||
|
if instanceID != "" {
|
||||||
|
// 挂载记录在实例所在 compartment,先取实例定位,传租户根会漏查
|
||||||
|
instResp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get instance %s: %w", instanceID, err)
|
||||||
|
}
|
||||||
|
req.CompartmentId = hostCompartment(cred, deref(instResp.CompartmentId))
|
||||||
|
req.InstanceId = &instanceID
|
||||||
|
}
|
||||||
|
resp, err := cc.ListVolumeAttachments(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list volume attachments: %w", err)
|
||||||
|
}
|
||||||
|
atts := make([]VolumeAttachment, 0, len(resp.Items))
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
atts = append(atts, toVolumeAttachment(item))
|
||||||
|
}
|
||||||
|
c.attachVolumeNames(ctx, cred, region, atts)
|
||||||
|
return atts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachVolumeNames 补全每条挂载的卷名(attachment 名是自动生成的无意义串);
|
||||||
|
// 引导卷可作数据卷附加,按 OCID 类型分别查询,查询失败留空不影响列表。
|
||||||
|
func (c *RealClient) attachVolumeNames(ctx context.Context, cred Credentials, region string, atts []VolumeAttachment) {
|
||||||
|
bc, err := c.blockstorageClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
names := map[string]string{}
|
||||||
|
for i := range atts {
|
||||||
|
id := atts[i].VolumeID
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, ok := names[id]
|
||||||
|
if !ok {
|
||||||
|
name = lookupVolumeName(ctx, bc, id)
|
||||||
|
names[id] = name
|
||||||
|
}
|
||||||
|
atts[i].VolumeName = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupVolumeName(ctx context.Context, bc core.BlockstorageClient, id string) string {
|
||||||
|
if strings.Contains(id, ".bootvolume.") {
|
||||||
|
resp, err := bc.GetBootVolume(ctx, core.GetBootVolumeRequest{BootVolumeId: &id})
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return deref(resp.DisplayName)
|
||||||
|
}
|
||||||
|
resp, err := bc.GetVolume(ctx, core.GetVolumeRequest{VolumeId: &id})
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return deref(resp.DisplayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachVolume 实现 Client:以半虚拟化方式挂载块卷(无需 iSCSI 配置)。
|
||||||
|
func (c *RealClient) AttachVolume(ctx context.Context, cred Credentials, region, instanceID, volumeID string, readOnly bool) (VolumeAttachment, error) {
|
||||||
|
cc, err := c.computeClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return VolumeAttachment{}, err
|
||||||
|
}
|
||||||
|
resp, err := cc.AttachVolume(ctx, core.AttachVolumeRequest{
|
||||||
|
AttachVolumeDetails: core.AttachParavirtualizedVolumeDetails{
|
||||||
|
InstanceId: &instanceID,
|
||||||
|
VolumeId: &volumeID,
|
||||||
|
IsReadOnly: &readOnly,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return VolumeAttachment{}, fmt.Errorf("attach volume: %w", err)
|
||||||
|
}
|
||||||
|
return toVolumeAttachment(resp.VolumeAttachment), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetachVolume 实现 Client。
|
||||||
|
func (c *RealClient) DetachVolume(ctx context.Context, cred Credentials, region, attachmentID string) error {
|
||||||
|
cc, err := c.computeClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := cc.DetachVolume(ctx, core.DetachVolumeRequest{VolumeAttachmentId: &attachmentID}); err != nil {
|
||||||
|
return fmt.Errorf("detach volume %s: %w", attachmentID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBlockVolumes 实现 Client;availabilityDomain 为空时列出全区域块卷。
|
||||||
|
func (c *RealClient) ListBlockVolumes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]BlockVolume, error) {
|
||||||
|
bc, err := c.blockstorageClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var volumes []BlockVolume
|
||||||
|
var page *string
|
||||||
|
for {
|
||||||
|
req := core.ListVolumesRequest{CompartmentId: cred.EffectiveCompartment(), Page: page}
|
||||||
|
if availabilityDomain != "" {
|
||||||
|
req.AvailabilityDomain = &availabilityDomain
|
||||||
|
}
|
||||||
|
resp, err := bc.ListVolumes(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list volumes: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
volumes = append(volumes, toBlockVolume(item))
|
||||||
|
}
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
return orEmpty(volumes), nil
|
||||||
|
}
|
||||||
|
page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toBootVolumeAttachment(a core.BootVolumeAttachment) BootVolumeAttachment {
|
||||||
|
return BootVolumeAttachment{
|
||||||
|
ID: deref(a.Id),
|
||||||
|
InstanceID: deref(a.InstanceId),
|
||||||
|
BootVolumeID: deref(a.BootVolumeId),
|
||||||
|
LifecycleState: string(a.LifecycleState),
|
||||||
|
DisplayName: deref(a.DisplayName),
|
||||||
|
AvailabilityDomain: deref(a.AvailabilityDomain),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toVolumeAttachment(a core.VolumeAttachment) VolumeAttachment {
|
||||||
|
out := VolumeAttachment{
|
||||||
|
ID: deref(a.GetId()),
|
||||||
|
InstanceID: deref(a.GetInstanceId()),
|
||||||
|
VolumeID: deref(a.GetVolumeId()),
|
||||||
|
LifecycleState: string(a.GetLifecycleState()),
|
||||||
|
DisplayName: deref(a.GetDisplayName()),
|
||||||
|
Device: deref(a.GetDevice()),
|
||||||
|
AvailabilityDomain: deref(a.GetAvailabilityDomain()),
|
||||||
|
}
|
||||||
|
if ro := a.GetIsReadOnly(); ro != nil {
|
||||||
|
out.IsReadOnly = *ro
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func toBlockVolume(v core.Volume) BlockVolume {
|
||||||
|
out := BlockVolume{
|
||||||
|
ID: deref(v.Id),
|
||||||
|
DisplayName: deref(v.DisplayName),
|
||||||
|
LifecycleState: string(v.LifecycleState),
|
||||||
|
AvailabilityDomain: deref(v.AvailabilityDomain),
|
||||||
|
TimeCreated: sdkTime(v.TimeCreated),
|
||||||
|
}
|
||||||
|
if v.SizeInGBs != nil {
|
||||||
|
out.SizeInGBs = *v.SizeInGBs
|
||||||
|
}
|
||||||
|
if v.VpusPerGB != nil {
|
||||||
|
out.VpusPerGB = *v.VpusPerGB
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/audit"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxAuditPages 限制单次查询的翻页数:繁忙租户单日事件可上千,
|
||||||
|
// 到限即返回 Truncated=true,由调用方收窄时间窗。
|
||||||
|
// 默认过滤(噪声事件/内网发起)后有效结果变少,页数放宽到 10 缓解截断。
|
||||||
|
const maxAuditPages = 10
|
||||||
|
|
||||||
|
// AuditEvent 是审计事件的列表精简视图;EventId 为 CloudEvents 全局唯一 id,
|
||||||
|
// 详情反查的键。Raw 为 SDK 原始事件的 JSON 序列化,由 service 层剥离进缓存,
|
||||||
|
// 列表响应不再携带(详情接口按 eventId 取回)。
|
||||||
|
type AuditEvent struct {
|
||||||
|
EventId string `json:"eventId"`
|
||||||
|
EventTime *time.Time `json:"eventTime"`
|
||||||
|
EventName string `json:"eventName"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
ResourceName string `json:"resourceName"`
|
||||||
|
CompartmentName string `json:"compartmentName"`
|
||||||
|
PrincipalName string `json:"principalName"`
|
||||||
|
IPAddress string `json:"ipAddress"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
RequestAction string `json:"requestAction"`
|
||||||
|
RequestPath string `json:"requestPath"`
|
||||||
|
Raw json.RawMessage `json:"raw,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditEventsResult 是一次审计查询的结果;Truncated 表示翻页到限被截断,
|
||||||
|
// 此时 NextPage 携带 opc-next-page 游标,同一时间窗回传可断点续翻。
|
||||||
|
type AuditEventsResult struct {
|
||||||
|
Items []AuditEvent `json:"items"`
|
||||||
|
Truncated bool `json:"truncated"`
|
||||||
|
NextPage string `json:"nextPage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RealClient) auditClient(cred Credentials, region string) (audit.AuditClient, error) {
|
||||||
|
ac, err := audit.NewAuditClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return ac, fmt.Errorf("new audit client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&ac.BaseClient, cred)
|
||||||
|
if region != "" {
|
||||||
|
ac.SetRegion(normalizeRegion(region))
|
||||||
|
}
|
||||||
|
return ac, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAuditEvents 实现 Client:实时查询租户根 compartment 在 [start, end) 内的
|
||||||
|
// 审计事件,最多翻 maxAuditPages 页,结果按发生时间倒序;纯读不落库。
|
||||||
|
// page 非空时从该游标断点续翻(必须配同一时间窗);到限截断时回传 NextPage。
|
||||||
|
func (c *RealClient) ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error) {
|
||||||
|
ac, err := c.auditClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return AuditEventsResult{}, err
|
||||||
|
}
|
||||||
|
// Audit API 只接受分钟粒度:起止时间的秒与毫秒必须为 0
|
||||||
|
req := audit.ListEventsRequest{
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
StartTime: &common.SDKTime{Time: start.UTC().Truncate(time.Minute)},
|
||||||
|
EndTime: &common.SDKTime{Time: end.UTC().Truncate(time.Minute)},
|
||||||
|
}
|
||||||
|
if page != "" {
|
||||||
|
req.Page = &page
|
||||||
|
}
|
||||||
|
result := AuditEventsResult{Items: []AuditEvent{}}
|
||||||
|
for i := 0; i < maxAuditPages; i++ {
|
||||||
|
resp, err := ac.ListEvents(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return AuditEventsResult{}, fmt.Errorf("list audit events: %w", err)
|
||||||
|
}
|
||||||
|
appendAuditEvents(&result, resp.Items)
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
sortAuditEvents(result.Items)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
req.Page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
result.Truncated = true
|
||||||
|
result.NextPage = deref(req.Page)
|
||||||
|
sortAuditEvents(result.Items)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendAuditEvents 过滤噪声后追加一页事件;原始事件只对保留条目序列化。
|
||||||
|
func appendAuditEvents(result *AuditEventsResult, items []audit.AuditEvent) {
|
||||||
|
for _, ev := range items {
|
||||||
|
out := toAuditEvent(ev)
|
||||||
|
if !keepAuditEvent(out) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if raw, mErr := json.Marshal(ev); mErr == nil {
|
||||||
|
out.Raw = raw
|
||||||
|
}
|
||||||
|
result.Items = append(result.Items, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditInternalCIDRs 是 OCI 服务内部互调的发起方网段(RFC1918 + CGNAT)。
|
||||||
|
var auditInternalCIDRs = func() []*net.IPNet {
|
||||||
|
out := make([]*net.IPNet, 0, 4)
|
||||||
|
for _, cidr := range []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10"} {
|
||||||
|
_, block, _ := net.ParseCIDR(cidr)
|
||||||
|
out = append(out, block)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}()
|
||||||
|
|
||||||
|
// keepAuditEvent 保留有展示价值的事件:Audit API 无服务端过滤参数(仅时间窗),
|
||||||
|
// 在翻页循环内排除高频遥测噪声(SummarizeMetricsData)与内网地址发起的服务互调;
|
||||||
|
// 无 IP 的事件(控制面内部)保留。
|
||||||
|
func keepAuditEvent(ev AuditEvent) bool {
|
||||||
|
if ev.EventName == "SummarizeMetricsData" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if ev.IPAddress == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(ev.IPAddress)
|
||||||
|
if ip == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, block := range auditInternalCIDRs {
|
||||||
|
if block.Contains(ip) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// toAuditEvent 把 SDK 审计事件压平为列表 DTO;SDK 字段全为指针,逐层判 nil。
|
||||||
|
func toAuditEvent(ev audit.AuditEvent) AuditEvent {
|
||||||
|
out := AuditEvent{EventId: deref(ev.EventId), Source: deref(ev.Source)}
|
||||||
|
if ev.EventTime != nil {
|
||||||
|
out.EventTime = &ev.EventTime.Time
|
||||||
|
}
|
||||||
|
if ev.Data == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out.EventName = deref(ev.Data.EventName)
|
||||||
|
out.ResourceName = deref(ev.Data.ResourceName)
|
||||||
|
out.CompartmentName = deref(ev.Data.CompartmentName)
|
||||||
|
if id := ev.Data.Identity; id != nil {
|
||||||
|
out.PrincipalName = deref(id.PrincipalName)
|
||||||
|
out.IPAddress = deref(id.IpAddress)
|
||||||
|
}
|
||||||
|
if req := ev.Data.Request; req != nil {
|
||||||
|
out.RequestAction = deref(req.Action)
|
||||||
|
out.RequestPath = deref(req.Path)
|
||||||
|
}
|
||||||
|
if resp := ev.Data.Response; resp != nil {
|
||||||
|
out.Status = deref(resp.Status)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortAuditEvents 按发生时间倒序排列;服务端返回顺序不保证,nil 时间排最后。
|
||||||
|
func sortAuditEvents(items []AuditEvent) {
|
||||||
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
|
ti, tj := items[i].EventTime, items[j].EventTime
|
||||||
|
switch {
|
||||||
|
case ti == nil:
|
||||||
|
return false
|
||||||
|
case tj == nil:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return ti.After(*tj)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/audit"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestToAuditEvent(t *testing.T) {
|
||||||
|
eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ev audit.AuditEvent
|
||||||
|
want AuditEvent
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "全字段齐全",
|
||||||
|
ev: audit.AuditEvent{
|
||||||
|
EventId: common.String("evt-abc"),
|
||||||
|
Source: common.String("ComputeApi"),
|
||||||
|
EventTime: &common.SDKTime{Time: eventTime},
|
||||||
|
Data: &audit.Data{
|
||||||
|
EventName: common.String("TerminateInstance"),
|
||||||
|
ResourceName: common.String("web-1"),
|
||||||
|
CompartmentName: common.String("prod"),
|
||||||
|
Identity: &audit.Identity{
|
||||||
|
PrincipalName: common.String("api-admin"),
|
||||||
|
IpAddress: common.String("1.2.3.4"),
|
||||||
|
},
|
||||||
|
Request: &audit.Request{
|
||||||
|
Action: common.String("DELETE"),
|
||||||
|
Path: common.String("/20160918/instances/ocid1..."),
|
||||||
|
},
|
||||||
|
Response: &audit.Response{Status: common.String("204")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: AuditEvent{
|
||||||
|
EventId: "evt-abc",
|
||||||
|
EventTime: &eventTime,
|
||||||
|
EventName: "TerminateInstance",
|
||||||
|
Source: "ComputeApi",
|
||||||
|
ResourceName: "web-1",
|
||||||
|
CompartmentName: "prod",
|
||||||
|
PrincipalName: "api-admin",
|
||||||
|
IPAddress: "1.2.3.4",
|
||||||
|
Status: "204",
|
||||||
|
RequestAction: "DELETE",
|
||||||
|
RequestPath: "/20160918/instances/ocid1...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Data 为 nil 时只保留信封字段",
|
||||||
|
ev: audit.AuditEvent{
|
||||||
|
Source: common.String("VcnApi"),
|
||||||
|
EventTime: &common.SDKTime{Time: eventTime},
|
||||||
|
},
|
||||||
|
want: AuditEvent{EventTime: &eventTime, Source: "VcnApi"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "嵌套局部 nil 各自安全跳过",
|
||||||
|
ev: audit.AuditEvent{
|
||||||
|
Data: &audit.Data{
|
||||||
|
EventName: common.String("GetInstance"),
|
||||||
|
Identity: nil,
|
||||||
|
Request: &audit.Request{Path: common.String("/instances")},
|
||||||
|
Response: nil,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: AuditEvent{EventName: "GetInstance", RequestPath: "/instances"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "空事件全部零值",
|
||||||
|
ev: audit.AuditEvent{},
|
||||||
|
want: AuditEvent{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := toAuditEvent(tt.ev); !auditEventEqual(got, tt.want) {
|
||||||
|
t.Errorf("toAuditEvent() = %+v, want %+v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditEventEqual 比较两个 DTO:EventTime 按值比较,Raw 不参与,其余反射比较。
|
||||||
|
func auditEventEqual(a, b AuditEvent) bool {
|
||||||
|
if (a.EventTime == nil) != (b.EventTime == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if a.EventTime != nil && !a.EventTime.Equal(*b.EventTime) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
a.EventTime, b.EventTime = nil, nil
|
||||||
|
a.Raw, b.Raw = nil, nil
|
||||||
|
return reflect.DeepEqual(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSortAuditEvents(t *testing.T) {
|
||||||
|
t1 := time.Date(2026, 7, 6, 8, 0, 0, 0, time.UTC)
|
||||||
|
t2 := time.Date(2026, 7, 6, 9, 0, 0, 0, time.UTC)
|
||||||
|
items := []AuditEvent{
|
||||||
|
{EventName: "old", EventTime: &t1},
|
||||||
|
{EventName: "no-time", EventTime: nil},
|
||||||
|
{EventName: "new", EventTime: &t2},
|
||||||
|
}
|
||||||
|
sortAuditEvents(items)
|
||||||
|
got := []string{items[0].EventName, items[1].EventName, items[2].EventName}
|
||||||
|
want := []string{"new", "old", "no-time"}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("sortAuditEvents() order = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeepAuditEvent(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
event AuditEvent
|
||||||
|
keep bool
|
||||||
|
}{
|
||||||
|
{"公网IP的普通事件保留", AuditEvent{EventName: "CreateUser", IPAddress: "203.0.113.7"}, true},
|
||||||
|
{"SummarizeMetricsData 噪声排除", AuditEvent{EventName: "SummarizeMetricsData", IPAddress: "203.0.113.7"}, false},
|
||||||
|
{"内网10段发起排除", AuditEvent{EventName: "ListInstances", IPAddress: "10.1.2.3"}, false},
|
||||||
|
{"内网172.16段发起排除", AuditEvent{EventName: "ListInstances", IPAddress: "172.20.0.1"}, false},
|
||||||
|
{"172.15不属内网保留", AuditEvent{EventName: "ListInstances", IPAddress: "172.15.0.1"}, true},
|
||||||
|
{"内网192.168段发起排除", AuditEvent{EventName: "ListInstances", IPAddress: "192.168.1.1"}, false},
|
||||||
|
{"CGNAT 100.64段排除", AuditEvent{EventName: "ListInstances", IPAddress: "100.100.0.1"}, false},
|
||||||
|
{"100.128不属CGNAT保留", AuditEvent{EventName: "ListInstances", IPAddress: "100.128.0.1"}, true},
|
||||||
|
{"IP为空保留(控制面事件)", AuditEvent{EventName: "TerminateInstance", IPAddress: ""}, true},
|
||||||
|
{"IP不可解析保留", AuditEvent{EventName: "ListInstances", IPAddress: "not-an-ip"}, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := keepAuditEvent(tc.event); got != tc.keep {
|
||||||
|
t.Fatalf("keepAuditEvent(%+v) = %v, want %v", tc.event, got, tc.keep)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BootVolume 是一个引导卷;引导卷由创建实例产生,本面板不单独创建。
|
||||||
|
// AttachedInstanceID / AttachedInstanceName 与 ImageName 在列表与详情查询时都会补全。
|
||||||
|
type BootVolume struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
LifecycleState string `json:"lifecycleState"`
|
||||||
|
AvailabilityDomain string `json:"availabilityDomain"`
|
||||||
|
CompartmentID string `json:"compartmentId"`
|
||||||
|
SizeInGBs int64 `json:"sizeInGBs"`
|
||||||
|
VpusPerGB int64 `json:"vpusPerGB"`
|
||||||
|
ImageID string `json:"imageId"`
|
||||||
|
ImageName string `json:"imageName,omitempty"`
|
||||||
|
AttachedInstanceID string `json:"attachedInstanceId,omitempty"`
|
||||||
|
AttachedInstanceName string `json:"attachedInstanceName,omitempty"`
|
||||||
|
TimeCreated *time.Time `json:"timeCreated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBootVolumeInput 是更新引导卷的输入;零值字段不更新。
|
||||||
|
// SizeInGBs 只能扩容,VpusPerGB 调整卷性能(10 均衡 / 20 高性能)。
|
||||||
|
type UpdateBootVolumeInput struct {
|
||||||
|
Region string
|
||||||
|
DisplayName string
|
||||||
|
SizeInGBs int64
|
||||||
|
VpusPerGB int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RealClient) blockstorageClient(cred Credentials, region string) (core.BlockstorageClient, error) {
|
||||||
|
bc, err := core.NewBlockstorageClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return bc, fmt.Errorf("new blockstorage client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&bc.BaseClient, cred)
|
||||||
|
if region != "" {
|
||||||
|
bc.SetRegion(normalizeRegion(region))
|
||||||
|
}
|
||||||
|
return bc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBootVolumes 实现 Client;availabilityDomain 为空时列出全区域引导卷。
|
||||||
|
func (c *RealClient) ListBootVolumes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]BootVolume, error) {
|
||||||
|
bc, err := c.blockstorageClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var volumes []BootVolume
|
||||||
|
var page *string
|
||||||
|
for {
|
||||||
|
req := core.ListBootVolumesRequest{CompartmentId: cred.EffectiveCompartment(), Page: page}
|
||||||
|
if availabilityDomain != "" {
|
||||||
|
req.AvailabilityDomain = &availabilityDomain
|
||||||
|
}
|
||||||
|
resp, err := bc.ListBootVolumes(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list boot volumes: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
volumes = append(volumes, toBootVolume(item))
|
||||||
|
}
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
c.attachBootVolumeInstances(ctx, cred, region, volumes)
|
||||||
|
c.attachBootVolumeImages(ctx, cred, region, volumes)
|
||||||
|
return orEmpty(volumes), nil
|
||||||
|
}
|
||||||
|
page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachBootVolumeImages 批量补全来源镜像名:按 imageId 去重后逐个查询,
|
||||||
|
// 镜像已删除或查询失败时保持为空。
|
||||||
|
func (c *RealClient) attachBootVolumeImages(ctx context.Context, cred Credentials, region string, volumes []BootVolume) {
|
||||||
|
names := map[string]string{}
|
||||||
|
for _, v := range volumes {
|
||||||
|
if v.ImageID != "" {
|
||||||
|
names[v.ImageID] = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id := range names {
|
||||||
|
img, err := c.GetImage(ctx, cred, region, id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names[id] = img.DisplayName
|
||||||
|
}
|
||||||
|
for i := range volumes {
|
||||||
|
volumes[i].ImageName = names[volumes[i].ImageID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachBootVolumeInstances 批量补全挂载实例:按 AD + compartment 分组各拉一次
|
||||||
|
// 全量挂载记录(免逐卷查询),构建 bootVolumeId → instanceId 映射后回填。
|
||||||
|
func (c *RealClient) attachBootVolumeInstances(ctx context.Context, cred Credentials, region string, volumes []BootVolume) {
|
||||||
|
cc, err := c.computeClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type scope struct{ ad, comp string }
|
||||||
|
attached := map[string]string{}
|
||||||
|
seen := map[scope]bool{}
|
||||||
|
for _, v := range volumes {
|
||||||
|
s := scope{v.AvailabilityDomain, v.CompartmentID}
|
||||||
|
if seen[s] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[s] = true
|
||||||
|
collectBootVolumeAttachments(ctx, cc, s.ad, hostCompartment(cred, s.comp), attached)
|
||||||
|
collectVolumeAttachedBootVolumes(ctx, cc, s.ad, hostCompartment(cred, s.comp), attached)
|
||||||
|
}
|
||||||
|
names := instanceNames(ctx, cc, attached)
|
||||||
|
for i := range volumes {
|
||||||
|
id := attached[volumes[i].ID]
|
||||||
|
volumes[i].AttachedInstanceID = id
|
||||||
|
volumes[i].AttachedInstanceName = names[id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// instanceNames 按去重后的实例 ID 批量查询显示名;查询失败的留空。
|
||||||
|
func instanceNames(ctx context.Context, cc core.ComputeClient, attached map[string]string) map[string]string {
|
||||||
|
names := map[string]string{}
|
||||||
|
for _, instID := range attached {
|
||||||
|
if instID == "" || names[instID] != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instID})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names[instID] = deref(resp.DisplayName)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectVolumeAttachedBootVolumes 补齐「引导卷作数据卷附加」的挂载:这类挂载
|
||||||
|
// 记录在块卷挂载列表(volumeId 为引导卷 OCID),引导卷挂载列表查不到。
|
||||||
|
func collectVolumeAttachedBootVolumes(ctx context.Context, cc core.ComputeClient, ad string, compartmentID *string, out map[string]string) {
|
||||||
|
var page *string
|
||||||
|
for {
|
||||||
|
resp, err := cc.ListVolumeAttachments(ctx, core.ListVolumeAttachmentsRequest{
|
||||||
|
AvailabilityDomain: &ad,
|
||||||
|
CompartmentId: compartmentID,
|
||||||
|
Page: page,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, att := range resp.Items {
|
||||||
|
id := deref(att.GetVolumeId())
|
||||||
|
if att.GetLifecycleState() == core.VolumeAttachmentLifecycleStateAttached &&
|
||||||
|
strings.Contains(id, ".bootvolume.") {
|
||||||
|
out[id] = deref(att.GetInstanceId())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectBootVolumeAttachments 把一个 AD + compartment 下 ATTACHED 状态的挂载写入映射。
|
||||||
|
func collectBootVolumeAttachments(ctx context.Context, cc core.ComputeClient, ad string, compartmentID *string, out map[string]string) {
|
||||||
|
var page *string
|
||||||
|
for {
|
||||||
|
resp, err := cc.ListBootVolumeAttachments(ctx, core.ListBootVolumeAttachmentsRequest{
|
||||||
|
AvailabilityDomain: &ad,
|
||||||
|
CompartmentId: compartmentID,
|
||||||
|
Page: page,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, att := range resp.Items {
|
||||||
|
if att.LifecycleState == core.BootVolumeAttachmentLifecycleStateAttached {
|
||||||
|
out[deref(att.BootVolumeId)] = deref(att.InstanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBootVolume 实现 Client:查询引导卷并补全挂载的实例。
|
||||||
|
func (c *RealClient) GetBootVolume(ctx context.Context, cred Credentials, region, bootVolumeID string) (BootVolume, error) {
|
||||||
|
bc, err := c.blockstorageClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return BootVolume{}, err
|
||||||
|
}
|
||||||
|
resp, err := bc.GetBootVolume(ctx, core.GetBootVolumeRequest{BootVolumeId: &bootVolumeID})
|
||||||
|
if err != nil {
|
||||||
|
return BootVolume{}, fmt.Errorf("get boot volume %s: %w", bootVolumeID, err)
|
||||||
|
}
|
||||||
|
volume := toBootVolume(resp.BootVolume)
|
||||||
|
volume.AttachedInstanceID = c.bootVolumeAttachedInstance(ctx, cred, region, volume)
|
||||||
|
if volume.ImageID != "" {
|
||||||
|
if img, err := c.GetImage(ctx, cred, region, volume.ImageID); err == nil {
|
||||||
|
volume.ImageName = img.DisplayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return volume, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootVolumeAttachedInstance 查询引导卷当前挂载的实例;查询失败视为未挂载。
|
||||||
|
func (c *RealClient) bootVolumeAttachedInstance(ctx context.Context, cred Credentials, region string, volume BootVolume) string {
|
||||||
|
cc, err := c.computeClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// 挂载记录在引导卷所在 compartment,传租户根会漏掉子 compartment 的挂载
|
||||||
|
resp, err := cc.ListBootVolumeAttachments(ctx, core.ListBootVolumeAttachmentsRequest{
|
||||||
|
AvailabilityDomain: &volume.AvailabilityDomain,
|
||||||
|
CompartmentId: hostCompartment(cred, volume.CompartmentID),
|
||||||
|
BootVolumeId: &volume.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, att := range resp.Items {
|
||||||
|
if att.LifecycleState == core.BootVolumeAttachmentLifecycleStateAttached {
|
||||||
|
return deref(att.InstanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBootVolume 实现 Client。
|
||||||
|
func (c *RealClient) UpdateBootVolume(ctx context.Context, cred Credentials, bootVolumeID string, in UpdateBootVolumeInput) (BootVolume, error) {
|
||||||
|
bc, err := c.blockstorageClient(cred, in.Region)
|
||||||
|
if err != nil {
|
||||||
|
return BootVolume{}, err
|
||||||
|
}
|
||||||
|
details := core.UpdateBootVolumeDetails{}
|
||||||
|
if in.DisplayName != "" {
|
||||||
|
details.DisplayName = &in.DisplayName
|
||||||
|
}
|
||||||
|
if in.SizeInGBs > 0 {
|
||||||
|
details.SizeInGBs = &in.SizeInGBs
|
||||||
|
}
|
||||||
|
if in.VpusPerGB > 0 {
|
||||||
|
details.VpusPerGB = &in.VpusPerGB
|
||||||
|
}
|
||||||
|
resp, err := bc.UpdateBootVolume(ctx, core.UpdateBootVolumeRequest{
|
||||||
|
BootVolumeId: &bootVolumeID,
|
||||||
|
UpdateBootVolumeDetails: details,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return BootVolume{}, fmt.Errorf("update boot volume %s: %w", bootVolumeID, err)
|
||||||
|
}
|
||||||
|
return toBootVolume(resp.BootVolume), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBootVolume 实现 Client;仅未挂载(detached)的引导卷可删除。
|
||||||
|
func (c *RealClient) DeleteBootVolume(ctx context.Context, cred Credentials, region, bootVolumeID string) error {
|
||||||
|
bc, err := c.blockstorageClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := bc.DeleteBootVolume(ctx, core.DeleteBootVolumeRequest{BootVolumeId: &bootVolumeID}); err != nil {
|
||||||
|
return fmt.Errorf("delete boot volume %s: %w", bootVolumeID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toBootVolume(v core.BootVolume) BootVolume {
|
||||||
|
out := BootVolume{
|
||||||
|
ID: deref(v.Id),
|
||||||
|
DisplayName: deref(v.DisplayName),
|
||||||
|
LifecycleState: string(v.LifecycleState),
|
||||||
|
AvailabilityDomain: deref(v.AvailabilityDomain),
|
||||||
|
CompartmentID: deref(v.CompartmentId),
|
||||||
|
ImageID: deref(v.ImageId),
|
||||||
|
TimeCreated: sdkTime(v.TimeCreated),
|
||||||
|
}
|
||||||
|
if v.SizeInGBs != nil {
|
||||||
|
out.SizeInGBs = *v.SizeInGBs
|
||||||
|
}
|
||||||
|
if v.VpusPerGB != nil {
|
||||||
|
out.VpusPerGB = *v.VpusPerGB
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"oci-portal/internal/cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 读缓存 TTL 分级:过渡态资源短、准静态长(调研①方案 A)。
|
||||||
|
// 实例列表 10s:前端 5s 自动刷新隔次命中,OCI 调用减半,状态延迟可控。
|
||||||
|
const (
|
||||||
|
cacheTTLInstances = 10 * time.Second
|
||||||
|
cacheTTLVolatile = 30 * time.Second // 卷 / 挂载 / 网卡
|
||||||
|
cacheTTLNetwork = 2 * time.Minute // VCN / 子网 / 安全列表
|
||||||
|
cacheTTLStatic = 10 * time.Minute // 镜像 / 可用域
|
||||||
|
)
|
||||||
|
|
||||||
|
// CachedClient 用进程内 TTL 缓存包装真实客户端,削减 OCI 读放大;
|
||||||
|
// 写操作直通并失效同租户全部读缓存(粗粒度换正确性,重建仅一次回源)。
|
||||||
|
// 未覆写的方法经嵌入接口直通。
|
||||||
|
type CachedClient struct {
|
||||||
|
Client
|
||||||
|
store *cache.Cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCachedClient 包装 inner;上限 4096 键(单键几 KB,整体几 MB 级)。
|
||||||
|
func NewCachedClient(inner Client) *CachedClient {
|
||||||
|
return &CachedClient{Client: inner, store: cache.New(4096)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ckey 组缓存键;租户 OCID 在最前,写失效按前缀一锅端。
|
||||||
|
func ckey(cred Credentials, parts ...string) string {
|
||||||
|
return cred.TenancyOCID + "|" + strings.Join(parts, "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
// bust 写操作成功后失效该租户全部读缓存。
|
||||||
|
func (c *CachedClient) bust(err error, cred Credentials) {
|
||||||
|
if err == nil {
|
||||||
|
c.store.DeletePrefix(cred.TenancyOCID + "|")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachedList 缓存切片结果;命中返回浅拷贝,防调用方排序共享底层数组。
|
||||||
|
func cachedList[T any](c *CachedClient, key string, ttl time.Duration, fn func() ([]T, error)) ([]T, error) {
|
||||||
|
v, err := cache.Do(c.store, key, ttl, fn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return slices.Clone(v), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 读:按 TTL 分级缓存 ----
|
||||||
|
|
||||||
|
func (c *CachedClient) ListInstances(ctx context.Context, cred Credentials, region string) ([]Instance, error) {
|
||||||
|
return cachedList(c, ckey(cred, "instances", region), cacheTTLInstances, func() ([]Instance, error) {
|
||||||
|
return c.Client.ListInstances(ctx, cred, region)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) ListImages(ctx context.Context, cred Credentials, q ImagesQuery) ([]Image, error) {
|
||||||
|
return cachedList(c, ckey(cred, "images", q.Region, q.OperatingSystem, q.Shape), cacheTTLStatic, func() ([]Image, error) {
|
||||||
|
return c.Client.ListImages(ctx, cred, q)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) ListVCNs(ctx context.Context, cred Credentials, region string) ([]VCN, error) {
|
||||||
|
return cachedList(c, ckey(cred, "vcns", region), cacheTTLNetwork, func() ([]VCN, error) {
|
||||||
|
return c.Client.ListVCNs(ctx, cred, region)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) ListSubnets(ctx context.Context, cred Credentials, region, vcnID string) ([]Subnet, error) {
|
||||||
|
return cachedList(c, ckey(cred, "subnets", region, vcnID), cacheTTLNetwork, func() ([]Subnet, error) {
|
||||||
|
return c.Client.ListSubnets(ctx, cred, region, vcnID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) ListSecurityLists(ctx context.Context, cred Credentials, region, vcnID string) ([]SecurityList, error) {
|
||||||
|
return cachedList(c, ckey(cred, "seclists", region, vcnID), cacheTTLNetwork, func() ([]SecurityList, error) {
|
||||||
|
return c.Client.ListSecurityLists(ctx, cred, region, vcnID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) ListAvailabilityDomains(ctx context.Context, cred Credentials, region string) ([]string, error) {
|
||||||
|
return cachedList(c, ckey(cred, "ads", region), cacheTTLStatic, func() ([]string, error) {
|
||||||
|
return c.Client.ListAvailabilityDomains(ctx, cred, region)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) ListBootVolumes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]BootVolume, error) {
|
||||||
|
return cachedList(c, ckey(cred, "bootvols", region, availabilityDomain), cacheTTLVolatile, func() ([]BootVolume, error) {
|
||||||
|
return c.Client.ListBootVolumes(ctx, cred, region, availabilityDomain)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) ListBlockVolumes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]BlockVolume, error) {
|
||||||
|
return cachedList(c, ckey(cred, "blockvols", region, availabilityDomain), cacheTTLVolatile, func() ([]BlockVolume, error) {
|
||||||
|
return c.Client.ListBlockVolumes(ctx, cred, region, availabilityDomain)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) ListInstanceVnics(ctx context.Context, cred Credentials, region, instanceID string) ([]Vnic, error) {
|
||||||
|
return cachedList(c, ckey(cred, "vnics", region, instanceID), cacheTTLVolatile, func() ([]Vnic, error) {
|
||||||
|
return c.Client.ListInstanceVnics(ctx, cred, region, instanceID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) ListBootVolumeAttachments(ctx context.Context, cred Credentials, region, instanceID string) ([]BootVolumeAttachment, error) {
|
||||||
|
return cachedList(c, ckey(cred, "bvattach", region, instanceID), cacheTTLVolatile, func() ([]BootVolumeAttachment, error) {
|
||||||
|
return c.Client.ListBootVolumeAttachments(ctx, cred, region, instanceID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) ListVolumeAttachments(ctx context.Context, cred Credentials, region, instanceID string) ([]VolumeAttachment, error) {
|
||||||
|
return cachedList(c, ckey(cred, "volattach", region, instanceID), cacheTTLVolatile, func() ([]VolumeAttachment, error) {
|
||||||
|
return c.Client.ListVolumeAttachments(ctx, cred, region, instanceID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 写:直通,成功后按租户失效 ----
|
||||||
|
|
||||||
|
func (c *CachedClient) LaunchInstance(ctx context.Context, cred Credentials, in CreateInstanceInput) (Instance, error) {
|
||||||
|
out, err := c.Client.LaunchInstance(ctx, cred, in)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) UpdateInstance(ctx context.Context, cred Credentials, instanceID string, in UpdateInstanceInput) (Instance, error) {
|
||||||
|
out, err := c.Client.UpdateInstance(ctx, cred, instanceID, in)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) TerminateInstance(ctx context.Context, cred Credentials, region, instanceID string, preserveBootVolume bool) error {
|
||||||
|
err := c.Client.TerminateInstance(ctx, cred, region, instanceID, preserveBootVolume)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) InstanceAction(ctx context.Context, cred Credentials, region, instanceID, action string) (Instance, error) {
|
||||||
|
out, err := c.Client.InstanceAction(ctx, cred, region, instanceID, action)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) ChangeInstancePublicIP(ctx context.Context, cred Credentials, region, instanceID string) (string, error) {
|
||||||
|
out, err := c.Client.ChangeInstancePublicIP(ctx, cred, region, instanceID)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) AddInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) (string, error) {
|
||||||
|
out, err := c.Client.AddInstanceIpv6(ctx, cred, region, instanceID, address)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) DeleteInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) error {
|
||||||
|
err := c.Client.DeleteInstanceIpv6(ctx, cred, region, instanceID, address)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) CreateVCN(ctx context.Context, cred Credentials, in CreateVCNInput) (VCN, error) {
|
||||||
|
out, err := c.Client.CreateVCN(ctx, cred, in)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) UpdateVCN(ctx context.Context, cred Credentials, region, vcnID, displayName string) (VCN, error) {
|
||||||
|
out, err := c.Client.UpdateVCN(ctx, cred, region, vcnID, displayName)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) DeleteVCN(ctx context.Context, cred Credentials, region, vcnID string) error {
|
||||||
|
err := c.Client.DeleteVCN(ctx, cred, region, vcnID)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) EnableVCNIPv6(ctx context.Context, cred Credentials, region, vcnID string) ([]IPv6Step, error) {
|
||||||
|
out, err := c.Client.EnableVCNIPv6(ctx, cred, region, vcnID)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) CreateSubnet(ctx context.Context, cred Credentials, in CreateSubnetInput) (Subnet, error) {
|
||||||
|
out, err := c.Client.CreateSubnet(ctx, cred, in)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) UpdateSubnet(ctx context.Context, cred Credentials, region, subnetID, displayName string) (Subnet, error) {
|
||||||
|
out, err := c.Client.UpdateSubnet(ctx, cred, region, subnetID, displayName)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) DeleteSubnet(ctx context.Context, cred Credentials, region, subnetID string) error {
|
||||||
|
err := c.Client.DeleteSubnet(ctx, cred, region, subnetID)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) CreateSecurityList(ctx context.Context, cred Credentials, in CreateSecurityListInput) (SecurityList, error) {
|
||||||
|
out, err := c.Client.CreateSecurityList(ctx, cred, in)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) UpdateSecurityList(ctx context.Context, cred Credentials, securityListID string, in UpdateSecurityListInput) (SecurityList, error) {
|
||||||
|
out, err := c.Client.UpdateSecurityList(ctx, cred, securityListID, in)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) DeleteSecurityList(ctx context.Context, cred Credentials, region, securityListID string) error {
|
||||||
|
err := c.Client.DeleteSecurityList(ctx, cred, region, securityListID)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) UpdateBootVolume(ctx context.Context, cred Credentials, bootVolumeID string, in UpdateBootVolumeInput) (BootVolume, error) {
|
||||||
|
out, err := c.Client.UpdateBootVolume(ctx, cred, bootVolumeID, in)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) DeleteBootVolume(ctx context.Context, cred Credentials, region, bootVolumeID string) error {
|
||||||
|
err := c.Client.DeleteBootVolume(ctx, cred, region, bootVolumeID)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) AttachBootVolume(ctx context.Context, cred Credentials, region, instanceID, bootVolumeID string) (BootVolumeAttachment, error) {
|
||||||
|
out, err := c.Client.AttachBootVolume(ctx, cred, region, instanceID, bootVolumeID)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) DetachBootVolume(ctx context.Context, cred Credentials, region, attachmentID string) error {
|
||||||
|
err := c.Client.DetachBootVolume(ctx, cred, region, attachmentID)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) ReplaceBootVolume(ctx context.Context, cred Credentials, region, instanceID, newBootVolumeID string) (BootVolumeAttachment, error) {
|
||||||
|
out, err := c.Client.ReplaceBootVolume(ctx, cred, region, instanceID, newBootVolumeID)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) AttachVolume(ctx context.Context, cred Credentials, region, instanceID, volumeID string, readOnly bool) (VolumeAttachment, error) {
|
||||||
|
out, err := c.Client.AttachVolume(ctx, cred, region, instanceID, volumeID, readOnly)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) DetachVolume(ctx context.Context, cred Credentials, region, attachmentID string) error {
|
||||||
|
err := c.Client.DetachVolume(ctx, cred, region, attachmentID)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) AttachVnic(ctx context.Context, cred Credentials, region, instanceID string, in AttachVnicInput) (Vnic, error) {
|
||||||
|
out, err := c.Client.AttachVnic(ctx, cred, region, instanceID, in)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) DetachVnic(ctx context.Context, cred Credentials, region, attachmentID string) error {
|
||||||
|
err := c.Client.DetachVnic(ctx, cred, region, attachmentID)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) AddVnicIpv6(ctx context.Context, cred Credentials, region, vnicID, address string) (string, error) {
|
||||||
|
out, err := c.Client.AddVnicIpv6(ctx, cred, region, vnicID, address)
|
||||||
|
c.bust(err, cred)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// countingClient 只实现被测方法并计数;其余方法经嵌入 nil 接口(不被调用)。
|
||||||
|
type countingClient struct {
|
||||||
|
Client
|
||||||
|
instCalls int
|
||||||
|
adCalls int
|
||||||
|
launches int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *countingClient) ListInstances(ctx context.Context, cred Credentials, region string) ([]Instance, error) {
|
||||||
|
f.instCalls++
|
||||||
|
return []Instance{{ID: "i-1", DisplayName: "b"}, {ID: "i-2", DisplayName: "a"}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *countingClient) ListAvailabilityDomains(ctx context.Context, cred Credentials, region string) ([]string, error) {
|
||||||
|
f.adCalls++
|
||||||
|
return []string{"ad-1"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *countingClient) LaunchInstance(ctx context.Context, cred Credentials, in CreateInstanceInput) (Instance, error) {
|
||||||
|
f.launches++
|
||||||
|
return Instance{ID: "i-new"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCred(tenancy string) Credentials { return Credentials{TenancyOCID: tenancy} }
|
||||||
|
|
||||||
|
func TestCachedClientHitAndIsolation(t *testing.T) {
|
||||||
|
inner := &countingClient{}
|
||||||
|
c := NewCachedClient(inner)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if _, err := c.ListInstances(ctx, testCred("t1"), "r1"); err != nil {
|
||||||
|
t.Fatalf("ListInstances: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inner.instCalls != 1 {
|
||||||
|
t.Errorf("同 key 三连读回源 %d 次, want 1", inner.instCalls)
|
||||||
|
}
|
||||||
|
// 不同租户 / 区域各自回源
|
||||||
|
_, _ = c.ListInstances(ctx, testCred("t2"), "r1")
|
||||||
|
_, _ = c.ListInstances(ctx, testCred("t1"), "r2")
|
||||||
|
if inner.instCalls != 3 {
|
||||||
|
t.Errorf("跨租户/区域回源 %d 次, want 3", inner.instCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedClientWriteBusts(t *testing.T) {
|
||||||
|
inner := &countingClient{}
|
||||||
|
c := NewCachedClient(inner)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, _ = c.ListInstances(ctx, testCred("t1"), "r1")
|
||||||
|
_, _ = c.ListAvailabilityDomains(ctx, testCred("t1"), "r1")
|
||||||
|
_, _ = c.ListInstances(ctx, testCred("t2"), "r1")
|
||||||
|
if _, err := c.LaunchInstance(ctx, testCred("t1"), CreateInstanceInput{}); err != nil {
|
||||||
|
t.Fatalf("LaunchInstance: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = c.ListInstances(ctx, testCred("t1"), "r1")
|
||||||
|
_, _ = c.ListAvailabilityDomains(ctx, testCred("t1"), "r1")
|
||||||
|
if inner.instCalls != 3 || inner.adCalls != 2 {
|
||||||
|
t.Errorf("写后同租户应全部回源: inst=%d want 3, ad=%d want 2", inner.instCalls, inner.adCalls)
|
||||||
|
}
|
||||||
|
// 其他租户缓存不受影响
|
||||||
|
_, _ = c.ListInstances(ctx, testCred("t2"), "r1")
|
||||||
|
if inner.instCalls != 3 {
|
||||||
|
t.Errorf("t2 缓存被误失效: inst=%d want 3", inner.instCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedClientReturnsClone(t *testing.T) {
|
||||||
|
inner := &countingClient{}
|
||||||
|
c := NewCachedClient(inner)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
first, _ := c.ListInstances(ctx, testCred("t1"), "r1")
|
||||||
|
first[0], first[1] = first[1], first[0] // 调用方就地重排
|
||||||
|
second, _ := c.ListInstances(ctx, testCred("t1"), "r1")
|
||||||
|
if second[0].ID != "i-1" {
|
||||||
|
t.Errorf("缓存底层数组被调用方污染: second[0]=%s, want i-1", second[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
// Package oci 封装 Oracle Cloud Infrastructure 官方 Go SDK,
|
||||||
|
// 对上层提供统一的客户端抽象;真实云请求只允许出现在本包。
|
||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/identity"
|
||||||
|
|
||||||
|
"oci-portal/internal/aiwire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cloudcmServiceName 是 OCI 云消费(Cloud Consumption Model)订阅的服务名,
|
||||||
|
// 账户类别信息挂在该订阅之下。
|
||||||
|
const cloudcmServiceName = "CLOUDCM"
|
||||||
|
|
||||||
|
// ErrNoCloudcmSubscription 表示租户下没有 serviceName=CLOUDCM 的订阅。
|
||||||
|
var ErrNoCloudcmSubscription = errors.New("no CLOUDCM subscription found")
|
||||||
|
|
||||||
|
// TenancyInfo 是测活成功后取得的租户基本信息。
|
||||||
|
type TenancyInfo struct {
|
||||||
|
Name string
|
||||||
|
HomeRegionKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client 是真实 OCI 调用的统一入口,service 层只依赖此接口。
|
||||||
|
type Client interface {
|
||||||
|
// ValidateKey 用 Identity GetTenancy 验证凭据有效性(测活)。
|
||||||
|
ValidateKey(ctx context.Context, cred Credentials) (TenancyInfo, error)
|
||||||
|
// FetchAccountProfile 查询 CLOUDCM 订阅并判定账户类别。
|
||||||
|
FetchAccountProfile(ctx context.Context, cred Credentials) (AccountProfile, error)
|
||||||
|
// ListCompartments 递归列出租户下全部 ACTIVE compartment(不含租户根)。
|
||||||
|
ListCompartments(ctx context.Context, cred Credentials) ([]Compartment, error)
|
||||||
|
// ListRegionSubscriptions 列出租户已订阅的区域。
|
||||||
|
ListRegionSubscriptions(ctx context.Context, cred Credentials) ([]RegionSubscription, error)
|
||||||
|
// SubscribeRegion 为租户订阅新区域;请求发往 homeRegion,操作不可撤销。
|
||||||
|
SubscribeRegion(ctx context.Context, cred Credentials, homeRegion, regionKey string) error
|
||||||
|
// ListLimits 查询服务配额,name 为大小写不敏感的模糊过滤,按需附带用量。
|
||||||
|
ListLimits(ctx context.Context, cred Credentials, q LimitsQuery) ([]LimitValue, error)
|
||||||
|
// ListLimitServices 列出配额体系支持的全部服务。
|
||||||
|
ListLimitServices(ctx context.Context, cred Credentials, region string) ([]LimitService, error)
|
||||||
|
// ListSubscriptions 列出租户全部订阅摘要。
|
||||||
|
ListSubscriptions(ctx context.Context, cred Credentials) ([]SubscriptionInfo, error)
|
||||||
|
// GetSubscription 查询单个订阅的完整信息。
|
||||||
|
GetSubscription(ctx context.Context, cred Credentials, subscriptionID string) (SubscriptionDetail, error)
|
||||||
|
// ListShapes 列出租户在目标区域可用的实例规格(带缓存)。
|
||||||
|
ListShapes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]ComputeShape, error)
|
||||||
|
// ListImages 列出可用的实例镜像。
|
||||||
|
ListImages(ctx context.Context, cred Credentials, q ImagesQuery) ([]Image, error)
|
||||||
|
// GetImage 查询单个镜像。
|
||||||
|
GetImage(ctx context.Context, cred Credentials, region, imageID string) (Image, error)
|
||||||
|
// VCN 增删改查与一键 IPv6。
|
||||||
|
ListVCNs(ctx context.Context, cred Credentials, region string) ([]VCN, error)
|
||||||
|
CreateVCN(ctx context.Context, cred Credentials, in CreateVCNInput) (VCN, error)
|
||||||
|
GetVCN(ctx context.Context, cred Credentials, region, vcnID string) (VCN, error)
|
||||||
|
UpdateVCN(ctx context.Context, cred Credentials, region, vcnID, displayName string) (VCN, error)
|
||||||
|
DeleteVCN(ctx context.Context, cred Credentials, region, vcnID string) error
|
||||||
|
EnableVCNIPv6(ctx context.Context, cred Credentials, region, vcnID string) ([]IPv6Step, error)
|
||||||
|
// 子网增删改查。
|
||||||
|
ListSubnets(ctx context.Context, cred Credentials, region, vcnID string) ([]Subnet, error)
|
||||||
|
CreateSubnet(ctx context.Context, cred Credentials, in CreateSubnetInput) (Subnet, error)
|
||||||
|
GetSubnet(ctx context.Context, cred Credentials, region, subnetID string) (Subnet, error)
|
||||||
|
UpdateSubnet(ctx context.Context, cred Credentials, region, subnetID, displayName string) (Subnet, error)
|
||||||
|
DeleteSubnet(ctx context.Context, cred Credentials, region, subnetID string) error
|
||||||
|
// 安全列表增删改查。
|
||||||
|
ListSecurityLists(ctx context.Context, cred Credentials, region, vcnID string) ([]SecurityList, error)
|
||||||
|
CreateSecurityList(ctx context.Context, cred Credentials, in CreateSecurityListInput) (SecurityList, error)
|
||||||
|
GetSecurityList(ctx context.Context, cred Credentials, region, securityListID string) (SecurityList, error)
|
||||||
|
UpdateSecurityList(ctx context.Context, cred Credentials, securityListID string, in UpdateSecurityListInput) (SecurityList, error)
|
||||||
|
DeleteSecurityList(ctx context.Context, cred Credentials, region, securityListID string) error
|
||||||
|
// ListAvailabilityDomains 列出区域内可用域,创建实例的前置查询。
|
||||||
|
ListAvailabilityDomains(ctx context.Context, cred Credentials, region string) ([]string, error)
|
||||||
|
// 实例增删改查与电源操作。
|
||||||
|
ListInstances(ctx context.Context, cred Credentials, region string) ([]Instance, error)
|
||||||
|
LaunchInstance(ctx context.Context, cred Credentials, in CreateInstanceInput) (Instance, error)
|
||||||
|
GetInstance(ctx context.Context, cred Credentials, region, instanceID string) (Instance, error)
|
||||||
|
UpdateInstance(ctx context.Context, cred Credentials, instanceID string, in UpdateInstanceInput) (Instance, error)
|
||||||
|
TerminateInstance(ctx context.Context, cred Credentials, region, instanceID string, preserveBootVolume bool) error
|
||||||
|
InstanceAction(ctx context.Context, cred Credentials, region, instanceID, action string) (Instance, error)
|
||||||
|
// 引导卷查删改;引导卷由创建实例产生,不单独创建。
|
||||||
|
ListBootVolumes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]BootVolume, error)
|
||||||
|
GetBootVolume(ctx context.Context, cred Credentials, region, bootVolumeID string) (BootVolume, error)
|
||||||
|
UpdateBootVolume(ctx context.Context, cred Credentials, bootVolumeID string, in UpdateBootVolumeInput) (BootVolume, error)
|
||||||
|
DeleteBootVolume(ctx context.Context, cred Credentials, region, bootVolumeID string) error
|
||||||
|
// 实例 IP:更换主 VNIC 临时公网 IP、添加与取消分配 IPv6 地址。
|
||||||
|
ChangeInstancePublicIP(ctx context.Context, cred Credentials, region, instanceID string) (string, error)
|
||||||
|
AddInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) (string, error)
|
||||||
|
DeleteInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) error
|
||||||
|
// VNIC 管理:列出实例网卡、附加次要网卡、分离(主网卡由 OCI 拒绝)、按网卡加 IPv6。
|
||||||
|
ListInstanceVnics(ctx context.Context, cred Credentials, region, instanceID string) ([]Vnic, error)
|
||||||
|
AttachVnic(ctx context.Context, cred Credentials, region, instanceID string, in AttachVnicInput) (Vnic, error)
|
||||||
|
DetachVnic(ctx context.Context, cred Credentials, region, attachmentID string) error
|
||||||
|
AddVnicIpv6(ctx context.Context, cred Credentials, region, vnicID, address string) (string, error)
|
||||||
|
// GenAI 网关:区域模型列表、聊天(非流式/流式)、配额探测、文本向量化。
|
||||||
|
ListGenAiModels(ctx context.Context, cred Credentials, region string) ([]GenAiModel, error)
|
||||||
|
GenAiChat(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error)
|
||||||
|
GenAiChatStream(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (GenAiStream, error)
|
||||||
|
GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error)
|
||||||
|
GenAiEmbed(ctx context.Context, cred Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error)
|
||||||
|
// 控制台连接:创建(VNC/串口连接串)、列出、删除。
|
||||||
|
CreateConsoleConnection(ctx context.Context, cred Credentials, region, instanceID, sshPublicKey string) (ConsoleConnection, error)
|
||||||
|
ListConsoleConnections(ctx context.Context, cred Credentials, region, instanceID string) ([]ConsoleConnection, error)
|
||||||
|
DeleteConsoleConnection(ctx context.Context, cred Credentials, region, connectionID string) error
|
||||||
|
// 引导卷挂载:列出、挂载、分离、替换。
|
||||||
|
ListBootVolumeAttachments(ctx context.Context, cred Credentials, region, instanceID string) ([]BootVolumeAttachment, error)
|
||||||
|
AttachBootVolume(ctx context.Context, cred Credentials, region, instanceID, bootVolumeID string) (BootVolumeAttachment, error)
|
||||||
|
DetachBootVolume(ctx context.Context, cred Credentials, region, attachmentID string) error
|
||||||
|
ReplaceBootVolume(ctx context.Context, cred Credentials, region, instanceID, newBootVolumeID string) (BootVolumeAttachment, error)
|
||||||
|
// 块卷挂载:列出块卷、列出挂载、附加、分离。
|
||||||
|
ListBlockVolumes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]BlockVolume, error)
|
||||||
|
ListVolumeAttachments(ctx context.Context, cred Credentials, region, instanceID string) ([]VolumeAttachment, error)
|
||||||
|
AttachVolume(ctx context.Context, cred Credentials, region, instanceID, volumeID string, readOnly bool) (VolumeAttachment, error)
|
||||||
|
DetachVolume(ctx context.Context, cred Credentials, region, attachmentID string) error
|
||||||
|
// 租户观测:实例流量统计、成本分析。
|
||||||
|
SummarizeInstanceTraffic(ctx context.Context, cred Credentials, q TrafficQuery) (InstanceTraffic, error)
|
||||||
|
SummarizeCosts(ctx context.Context, cred Credentials, q CostQuery) ([]CostItem, error)
|
||||||
|
// ListAuditEvents 实时查询租户根 compartment 的审计事件(最多翻 maxAuditPages 页,
|
||||||
|
// 到限截断并回传续查游标);page 传上次结果的 NextPage 可从断点续翻。
|
||||||
|
ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error)
|
||||||
|
// 租户用户管理:经典 IAM 为主,新式租户自动回退 Identity Domains。
|
||||||
|
ListTenantUsers(ctx context.Context, cred Credentials) ([]TenantUser, error)
|
||||||
|
GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, userID string) (TenantUserDetail, error)
|
||||||
|
CreateTenantUser(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error)
|
||||||
|
UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string, in UpdateTenantUserInput) (TenantUser, error)
|
||||||
|
DeleteTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string) error
|
||||||
|
ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, userID string) (string, error)
|
||||||
|
DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, userID string) (int, error)
|
||||||
|
DeleteTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string, includeCurrent bool) (int, error)
|
||||||
|
// 域设置:通知收件人、密码策略、身份设置。
|
||||||
|
GetNotificationRecipients(ctx context.Context, cred Credentials, region string) (NotificationRecipients, error)
|
||||||
|
UpdateNotificationRecipients(ctx context.Context, cred Credentials, region string, emails []string) (NotificationRecipients, error)
|
||||||
|
ListPasswordPolicies(ctx context.Context, cred Credentials, region string) ([]PasswordPolicyInfo, error)
|
||||||
|
UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error)
|
||||||
|
GetIdentitySetting(ctx context.Context, cred Credentials, region string) (IdentitySettingInfo, error)
|
||||||
|
UpdateIdentitySetting(ctx context.Context, cred Credentials, region string, primaryEmailRequired bool) (IdentitySettingInfo, error)
|
||||||
|
// Federation:SAML IdP 管理、域 SP 元数据、sign-on 免 MFA 规则。
|
||||||
|
ListIdentityProviders(ctx context.Context, cred Credentials, region string) ([]IdentityProviderInfo, error)
|
||||||
|
CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region string, in CreateIdpInput) (IdentityProviderInfo, error)
|
||||||
|
SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, idpID string, enabled bool) (IdentityProviderInfo, error)
|
||||||
|
DeleteIdentityProvider(ctx context.Context, cred Credentials, region, idpID string) error
|
||||||
|
DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region string) ([]byte, error)
|
||||||
|
ListConsoleSignOnRules(ctx context.Context, cred Credentials, region string) ([]SignOnRuleInfo, error)
|
||||||
|
CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, idpID, ruleName string) (SignOnRuleInfo, error)
|
||||||
|
DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, ruleID string) error
|
||||||
|
// 日志回传链路(方案A):Topic/CUSTOM_HTTPS 订阅/IAM Policy/Service Connector
|
||||||
|
// 的幂等创建、状态聚合与销毁;IAM 写操作发往 homeRegion。
|
||||||
|
EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error)
|
||||||
|
EnsureRelaySubscription(ctx context.Context, cred Credentials, topicID, endpoint string) (RelayResource, error)
|
||||||
|
GetRelaySubscription(ctx context.Context, cred Credentials, subscriptionID string) (RelayResource, error)
|
||||||
|
EnsureRelayPolicy(ctx context.Context, cred Credentials, homeRegion string) (RelayResource, error)
|
||||||
|
EnsureRelayConnector(ctx context.Context, cred Credentials, topicID, condition string) (RelayResource, error)
|
||||||
|
RelayState(ctx context.Context, cred Credentials, endpoint string) (RelayState, error)
|
||||||
|
DeleteRelayConnector(ctx context.Context, cred Credentials, connectorID string) error
|
||||||
|
DeleteRelayPolicy(ctx context.Context, cred Credentials, homeRegion, policyID string) error
|
||||||
|
DeleteRelaySubscription(ctx context.Context, cred Credentials, subscriptionID string) error
|
||||||
|
DeleteRelayTopic(ctx context.Context, cred Credentials, topicID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealClient 基于官方 SDK 实现 Client。
|
||||||
|
type RealClient struct {
|
||||||
|
limitDefs sync.Map // tenancy|region|service → limitDefEntry,配额定义预筛缓存
|
||||||
|
shapes sync.Map // tenancy|region|ad → shapeCacheEntry,shape 清单缓存
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient 返回生产使用的真实客户端。
|
||||||
|
func NewClient() *RealClient { return &RealClient{} }
|
||||||
|
|
||||||
|
func provider(cred Credentials) common.ConfigurationProvider {
|
||||||
|
var pass *string
|
||||||
|
if cred.Passphrase != "" {
|
||||||
|
pass = &cred.Passphrase
|
||||||
|
}
|
||||||
|
return common.NewRawConfigurationProvider(
|
||||||
|
cred.TenancyOCID, cred.UserOCID, cred.Region,
|
||||||
|
cred.Fingerprint, cred.PrivateKey, pass,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeRegion 兼容区域短名:iad → us-ashburn-1;空值与全名原样返回。
|
||||||
|
// 实例等资源对象上的 region 字段是短名,直接透传时借此归一。
|
||||||
|
func normalizeRegion(region string) string {
|
||||||
|
if region == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(common.StringToRegion(region))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateKey 实现 Client。
|
||||||
|
func (c *RealClient) ValidateKey(ctx context.Context, cred Credentials) (TenancyInfo, error) {
|
||||||
|
ic, err := identity.NewIdentityClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return TenancyInfo{}, fmt.Errorf("new identity client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&ic.BaseClient, cred)
|
||||||
|
resp, err := ic.GetTenancy(ctx, identity.GetTenancyRequest{TenancyId: &cred.TenancyOCID})
|
||||||
|
if err != nil {
|
||||||
|
return TenancyInfo{}, fmt.Errorf("get tenancy: %w", err)
|
||||||
|
}
|
||||||
|
return TenancyInfo{
|
||||||
|
Name: deref(resp.Name),
|
||||||
|
HomeRegionKey: deref(resp.HomeRegionKey),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchAccountProfile 实现 Client:从订阅列表选中 CLOUDCM,
|
||||||
|
// 再查询完整订阅并按付费模式、促销状态判定账户类别。
|
||||||
|
func (c *RealClient) FetchAccountProfile(ctx context.Context, cred Credentials) (AccountProfile, error) {
|
||||||
|
subs, err := c.ListSubscriptions(ctx, cred)
|
||||||
|
if err != nil {
|
||||||
|
return AccountProfile{}, err
|
||||||
|
}
|
||||||
|
subID := ""
|
||||||
|
for _, sub := range subs {
|
||||||
|
if strings.EqualFold(sub.ServiceName, cloudcmServiceName) {
|
||||||
|
subID = sub.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if subID == "" {
|
||||||
|
return AccountProfile{}, ErrNoCloudcmSubscription
|
||||||
|
}
|
||||||
|
detail, err := c.GetSubscription(ctx, cred, subID)
|
||||||
|
if err != nil {
|
||||||
|
return AccountProfile{}, err
|
||||||
|
}
|
||||||
|
return buildAccountProfile(detail), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAccountProfile(detail SubscriptionDetail) AccountProfile {
|
||||||
|
p := AccountProfile{
|
||||||
|
SubscriptionID: detail.ID,
|
||||||
|
ServiceName: detail.ServiceName,
|
||||||
|
PaymentModel: detail.PaymentModel,
|
||||||
|
SubscriptionTier: detail.SubscriptionTier,
|
||||||
|
}
|
||||||
|
p.PromotionStatus, p.PromotionAmount, p.PromotionExpires = pickPromotion(detail.Promotions)
|
||||||
|
p.AccountType = ClassifyAccount(p.PaymentModel, p.SubscriptionTier, p.PromotionStatus)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickPromotion 从多条促销记录中选出最能代表当前状态的一条:
|
||||||
|
// 优先 ACTIVE,其次 EXPIRED,否则回退第一条。
|
||||||
|
func pickPromotion(promos []PromotionInfo) (string, float32, *time.Time) {
|
||||||
|
best, bestRank := -1, -1
|
||||||
|
for i, p := range promos {
|
||||||
|
if rank := promotionRank(p.Status); rank > bestRank {
|
||||||
|
best, bestRank = i, rank
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best < 0 {
|
||||||
|
return "", 0, nil
|
||||||
|
}
|
||||||
|
p := promos[best]
|
||||||
|
return p.Status, p.Amount, p.TimeExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
func promotionRank(status string) int {
|
||||||
|
switch strings.ToUpper(status) {
|
||||||
|
case "ACTIVE":
|
||||||
|
return 2
|
||||||
|
case "EXPIRED":
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deref(s *string) string {
|
||||||
|
if s != nil {
|
||||||
|
return *s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// orEmpty 把 nil 切片归一为空切片:JSON 序列化输出 [] 而非 null,
|
||||||
|
// 避免前端对 null 调用 .length / .map / .join 崩溃。
|
||||||
|
func orEmpty[T any](s []T) []T {
|
||||||
|
if s == nil {
|
||||||
|
return []T{}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compartment 是租户下的一个 compartment(不含租户根本身)。
|
||||||
|
type Compartment struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ParentID string `json:"parentId"`
|
||||||
|
LifecycleState string `json:"lifecycleState"`
|
||||||
|
TimeCreated *time.Time `json:"timeCreated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCompartments 实现 Client:递归列出租户下全部 ACTIVE compartment。
|
||||||
|
func (c *RealClient) ListCompartments(ctx context.Context, cred Credentials) ([]Compartment, error) {
|
||||||
|
ic, err := identity.NewIdentityClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("new identity client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&ic.BaseClient, cred)
|
||||||
|
var items []Compartment
|
||||||
|
var page *string
|
||||||
|
for {
|
||||||
|
resp, err := ic.ListCompartments(ctx, identity.ListCompartmentsRequest{
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
CompartmentIdInSubtree: common.Bool(true),
|
||||||
|
AccessLevel: identity.ListCompartmentsAccessLevelAny,
|
||||||
|
LifecycleState: identity.CompartmentLifecycleStateActive,
|
||||||
|
Page: page,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list compartments: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
items = append(items, toCompartment(item))
|
||||||
|
}
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
return orEmpty(items), nil
|
||||||
|
}
|
||||||
|
page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toCompartment(item identity.Compartment) Compartment {
|
||||||
|
out := Compartment{
|
||||||
|
ID: deref(item.Id),
|
||||||
|
Name: deref(item.Name),
|
||||||
|
Description: deref(item.Description),
|
||||||
|
ParentID: deref(item.CompartmentId),
|
||||||
|
LifecycleState: string(item.LifecycleState),
|
||||||
|
}
|
||||||
|
if item.TimeCreated != nil {
|
||||||
|
out.TimeCreated = &item.TimeCreated.Time
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConsoleConnection 是实例控制台连接。VNC 连接串在本地建立 SSH 隧道后,
|
||||||
|
// 用 VNC 客户端连 localhost:5900 即可(串口控制台直接执行 ConnectionString)。
|
||||||
|
type ConsoleConnection struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
InstanceID string `json:"instanceId"`
|
||||||
|
LifecycleState string `json:"lifecycleState"`
|
||||||
|
ConnectionString string `json:"connectionString"` // 串口控制台 SSH 命令
|
||||||
|
VncConnectionString string `json:"vncConnectionString"` // VNC over SSH 隧道命令
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateConsoleConnection 实现 Client:为实例创建控制台连接并等待就绪,
|
||||||
|
// 返回包含 VNC/串口连接串的对象;等待超时返回当前状态(可稍后再查列表)。
|
||||||
|
func (c *RealClient) CreateConsoleConnection(ctx context.Context, cred Credentials, region, instanceID, sshPublicKey string) (ConsoleConnection, error) {
|
||||||
|
cc, err := c.computeClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return ConsoleConnection{}, err
|
||||||
|
}
|
||||||
|
resp, err := cc.CreateInstanceConsoleConnection(ctx, core.CreateInstanceConsoleConnectionRequest{
|
||||||
|
CreateInstanceConsoleConnectionDetails: core.CreateInstanceConsoleConnectionDetails{
|
||||||
|
InstanceId: &instanceID,
|
||||||
|
PublicKey: &sshPublicKey,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ConsoleConnection{}, fmt.Errorf("create console connection: %w", err)
|
||||||
|
}
|
||||||
|
return c.waitConsoleConnectionActive(ctx, cc, deref(resp.Id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitConsoleConnectionActive 轮询等待控制台连接 ACTIVE,最长约 60 秒。
|
||||||
|
func (c *RealClient) waitConsoleConnectionActive(ctx context.Context, cc core.ComputeClient, connectionID string) (ConsoleConnection, error) {
|
||||||
|
var last ConsoleConnection
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
resp, err := cc.GetInstanceConsoleConnection(ctx, core.GetInstanceConsoleConnectionRequest{
|
||||||
|
InstanceConsoleConnectionId: &connectionID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return last, fmt.Errorf("poll console connection: %w", err)
|
||||||
|
}
|
||||||
|
last = toConsoleConnection(resp.InstanceConsoleConnection)
|
||||||
|
if last.LifecycleState == string(core.InstanceConsoleConnectionLifecycleStateActive) {
|
||||||
|
return last, nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return last, fmt.Errorf("poll console connection: %w", ctx.Err())
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListConsoleConnections 实现 Client:列出实例的控制台连接。
|
||||||
|
func (c *RealClient) ListConsoleConnections(ctx context.Context, cred Credentials, region, instanceID string) ([]ConsoleConnection, error) {
|
||||||
|
cc, err := c.computeClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 控制台连接记录在实例所在 compartment,先取实例定位,传租户根会漏查
|
||||||
|
instResp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get instance %s: %w", instanceID, err)
|
||||||
|
}
|
||||||
|
resp, err := cc.ListInstanceConsoleConnections(ctx, core.ListInstanceConsoleConnectionsRequest{
|
||||||
|
CompartmentId: hostCompartment(cred, deref(instResp.CompartmentId)),
|
||||||
|
InstanceId: &instanceID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list console connections: %w", err)
|
||||||
|
}
|
||||||
|
connections := make([]ConsoleConnection, 0, len(resp.Items))
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
connections = append(connections, toConsoleConnection(item))
|
||||||
|
}
|
||||||
|
return connections, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteConsoleConnection 实现 Client:删除控制台连接。
|
||||||
|
func (c *RealClient) DeleteConsoleConnection(ctx context.Context, cred Credentials, region, connectionID string) error {
|
||||||
|
cc, err := c.computeClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = cc.DeleteInstanceConsoleConnection(ctx, core.DeleteInstanceConsoleConnectionRequest{
|
||||||
|
InstanceConsoleConnectionId: &connectionID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete console connection %s: %w", connectionID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toConsoleConnection(conn core.InstanceConsoleConnection) ConsoleConnection {
|
||||||
|
return ConsoleConnection{
|
||||||
|
ID: deref(conn.Id),
|
||||||
|
InstanceID: deref(conn.InstanceId),
|
||||||
|
LifecycleState: string(conn.LifecycleState),
|
||||||
|
ConnectionString: deref(conn.ConnectionString),
|
||||||
|
VncConnectionString: deref(conn.VncConnectionString),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/usageapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CostQuery 是一次成本/用量分析的条件。
|
||||||
|
type CostQuery struct {
|
||||||
|
StartTime time.Time
|
||||||
|
EndTime time.Time
|
||||||
|
Granularity string // DAILY / MONTHLY
|
||||||
|
QueryType string // COST / USAGE
|
||||||
|
GroupBy string // service / skuName / region 等维度
|
||||||
|
}
|
||||||
|
|
||||||
|
// CostItem 是一个时间桶内某分组维度的成本或用量。
|
||||||
|
type CostItem struct {
|
||||||
|
TimeStart *time.Time `json:"timeStart"`
|
||||||
|
GroupValue string `json:"groupValue"`
|
||||||
|
ComputedAmount float32 `json:"computedAmount"`
|
||||||
|
ComputedQuantity float32 `json:"computedQuantity"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Unit string `json:"unit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SummarizeCosts 实现 Client:调用 Usage API 汇总租户成本或用量。
|
||||||
|
func (c *RealClient) SummarizeCosts(ctx context.Context, cred Credentials, q CostQuery) ([]CostItem, error) {
|
||||||
|
uc, err := usageapi.NewUsageapiClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("new usageapi client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&uc.BaseClient, cred)
|
||||||
|
details, err := buildUsageDetails(cred.TenancyOCID, q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var items []CostItem
|
||||||
|
var page *string
|
||||||
|
for {
|
||||||
|
resp, err := uc.RequestSummarizedUsages(ctx, usageapi.RequestSummarizedUsagesRequest{
|
||||||
|
RequestSummarizedUsagesDetails: details,
|
||||||
|
Page: page,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request summarized usages: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
items = append(items, toCostItem(item, q.GroupBy))
|
||||||
|
}
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
return orEmpty(items), nil
|
||||||
|
}
|
||||||
|
page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildUsageDetails(tenancyOCID string, q CostQuery) (usageapi.RequestSummarizedUsagesDetails, error) {
|
||||||
|
granularity, ok := usageapi.GetMappingRequestSummarizedUsagesDetailsGranularityEnum(strings.ToUpper(q.Granularity))
|
||||||
|
if !ok {
|
||||||
|
return usageapi.RequestSummarizedUsagesDetails{}, fmt.Errorf("cost query: unsupported granularity %q", q.Granularity)
|
||||||
|
}
|
||||||
|
queryType, ok := usageapi.GetMappingRequestSummarizedUsagesDetailsQueryTypeEnum(strings.ToUpper(q.QueryType))
|
||||||
|
if !ok {
|
||||||
|
return usageapi.RequestSummarizedUsagesDetails{}, fmt.Errorf("cost query: unsupported queryType %q", q.QueryType)
|
||||||
|
}
|
||||||
|
return usageapi.RequestSummarizedUsagesDetails{
|
||||||
|
TenantId: &tenancyOCID,
|
||||||
|
TimeUsageStarted: &common.SDKTime{Time: q.StartTime},
|
||||||
|
TimeUsageEnded: &common.SDKTime{Time: q.EndTime},
|
||||||
|
Granularity: granularity,
|
||||||
|
QueryType: queryType,
|
||||||
|
GroupBy: []string{q.GroupBy},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toCostItem 把响应条目转为本地 DTO,分组值按 groupBy 维度取对应字段。
|
||||||
|
func toCostItem(item usageapi.UsageSummary, groupBy string) CostItem {
|
||||||
|
out := CostItem{
|
||||||
|
Currency: deref(item.Currency),
|
||||||
|
Unit: deref(item.Unit),
|
||||||
|
}
|
||||||
|
if item.TimeUsageStarted != nil {
|
||||||
|
out.TimeStart = &item.TimeUsageStarted.Time
|
||||||
|
}
|
||||||
|
if item.ComputedAmount != nil {
|
||||||
|
out.ComputedAmount = *item.ComputedAmount
|
||||||
|
}
|
||||||
|
if item.ComputedQuantity != nil {
|
||||||
|
out.ComputedQuantity = *item.ComputedQuantity
|
||||||
|
}
|
||||||
|
switch strings.ToLower(groupBy) {
|
||||||
|
case "skuname":
|
||||||
|
out.GroupValue = deref(item.SkuName)
|
||||||
|
case "region":
|
||||||
|
out.GroupValue = deref(item.Region)
|
||||||
|
case "compartmentname":
|
||||||
|
out.GroupValue = deref(item.CompartmentName)
|
||||||
|
default:
|
||||||
|
out.GroupValue = deref(item.Service)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Credentials 是调用 OCI API 所需的完整凭据,私钥为 PEM 明文。
|
||||||
|
// CompartmentID 限定资源操作的目标 compartment,空表示租户根;
|
||||||
|
// 只影响 compute / 网络 / 块存储等资源查询,身份与订阅类调用始终走租户根。
|
||||||
|
type Credentials struct {
|
||||||
|
TenancyOCID string
|
||||||
|
UserOCID string
|
||||||
|
Region string
|
||||||
|
Fingerprint string
|
||||||
|
PrivateKey string
|
||||||
|
Passphrase string
|
||||||
|
CompartmentID string
|
||||||
|
// Proxy 非 nil 时该租户全部 SDK 出站请求经此代理
|
||||||
|
Proxy *ProxySpec
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveCompartment 返回资源操作的目标 compartment,未指定时为租户根。
|
||||||
|
func (c Credentials) EffectiveCompartment() *string {
|
||||||
|
if c.CompartmentID != "" {
|
||||||
|
id := c.CompartmentID
|
||||||
|
return &id
|
||||||
|
}
|
||||||
|
id := c.TenancyOCID
|
||||||
|
return &id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 检查凭据必填字段。
|
||||||
|
func (c Credentials) Validate() error {
|
||||||
|
switch {
|
||||||
|
case c.TenancyOCID == "":
|
||||||
|
return fmt.Errorf("credentials: tenancy is empty")
|
||||||
|
case c.UserOCID == "":
|
||||||
|
return fmt.Errorf("credentials: user is empty")
|
||||||
|
case c.Region == "":
|
||||||
|
return fmt.Errorf("credentials: region is empty")
|
||||||
|
case c.Fingerprint == "":
|
||||||
|
return fmt.Errorf("credentials: fingerprint is empty")
|
||||||
|
case !strings.Contains(c.PrivateKey, "PRIVATE KEY"):
|
||||||
|
return fmt.Errorf("credentials: private key is not a PEM")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseConfigINI 解析 OCI 标准 config(ini)文本的 [DEFAULT] 段。
|
||||||
|
// key_file 只在本机指向文件,导入场景私钥内容必须单独提供,因此忽略该行。
|
||||||
|
func ParseConfigINI(text string) (Credentials, error) {
|
||||||
|
var cred Credentials
|
||||||
|
section := "DEFAULT"
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||||
|
section = strings.TrimSpace(line[1 : len(line)-1])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(section, "DEFAULT") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
applyConfigLine(&cred, line)
|
||||||
|
}
|
||||||
|
if cred.TenancyOCID == "" && cred.UserOCID == "" {
|
||||||
|
return cred, fmt.Errorf("parse oci config: no recognizable fields")
|
||||||
|
}
|
||||||
|
return cred, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyConfigLine(cred *Credentials, line string) {
|
||||||
|
key, value, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// OCID、指纹、region 值里不含 #,可安全去掉行尾注释
|
||||||
|
if i := strings.Index(value, "#"); i >= 0 {
|
||||||
|
value = value[:i]
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(strings.ToLower(key))
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
switch key {
|
||||||
|
case "user":
|
||||||
|
cred.UserOCID = value
|
||||||
|
case "tenancy":
|
||||||
|
cred.TenancyOCID = value
|
||||||
|
case "fingerprint":
|
||||||
|
cred.Fingerprint = value
|
||||||
|
case "region":
|
||||||
|
cred.Region = value
|
||||||
|
case "pass_phrase":
|
||||||
|
cred.Passphrase = value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
const sampleINI = `[DEFAULT]
|
||||||
|
user=ocid1.user.oc1..aaaa
|
||||||
|
fingerprint=e2:d4:5a:95
|
||||||
|
tenancy=ocid1.tenancy.oc1..bbbb
|
||||||
|
region=eu-frankfurt-1
|
||||||
|
key_file=<path to your private keyfile> # TODO
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestParseConfigINI(t *testing.T) {
|
||||||
|
cred, err := ParseConfigINI(sampleINI)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseConfigINI: %v", err)
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
field string
|
||||||
|
got string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{field: "UserOCID", got: cred.UserOCID, want: "ocid1.user.oc1..aaaa"},
|
||||||
|
{field: "Fingerprint", got: cred.Fingerprint, want: "e2:d4:5a:95"},
|
||||||
|
{field: "TenancyOCID", got: cred.TenancyOCID, want: "ocid1.tenancy.oc1..bbbb"},
|
||||||
|
{field: "Region", got: cred.Region, want: "eu-frankfurt-1"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if tt.got != tt.want {
|
||||||
|
t.Errorf("%s = %q, want %q", tt.field, tt.got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseConfigINIIgnoresOtherSections(t *testing.T) {
|
||||||
|
text := "[OTHER]\nuser=ocid1.user.oc1..other\n[DEFAULT]\nuser=ocid1.user.oc1..default\ntenancy=ocid1.tenancy.oc1..t\n"
|
||||||
|
cred, err := ParseConfigINI(text)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseConfigINI: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := cred.UserOCID, "ocid1.user.oc1..default"; got != want {
|
||||||
|
t.Errorf("UserOCID = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseConfigINIEmpty(t *testing.T) {
|
||||||
|
if _, err := ParseConfigINI("# only comments\n"); err == nil {
|
||||||
|
t.Error("ParseConfigINI(comments only): got nil error, want failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCredentialsValidate(t *testing.T) {
|
||||||
|
valid := Credentials{
|
||||||
|
TenancyOCID: "t", UserOCID: "u", Region: "r", Fingerprint: "f",
|
||||||
|
PrivateKey: "-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----",
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*Credentials)
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "完整凭据", mutate: func(*Credentials) {}, wantErr: false},
|
||||||
|
{name: "缺租户", mutate: func(c *Credentials) { c.TenancyOCID = "" }, wantErr: true},
|
||||||
|
{name: "缺用户", mutate: func(c *Credentials) { c.UserOCID = "" }, wantErr: true},
|
||||||
|
{name: "缺区域", mutate: func(c *Credentials) { c.Region = "" }, wantErr: true},
|
||||||
|
{name: "缺指纹", mutate: func(c *Credentials) { c.Fingerprint = "" }, wantErr: true},
|
||||||
|
{name: "私钥非 PEM", mutate: func(c *Credentials) { c.PrivateKey = "not a pem" }, wantErr: true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cred := valid
|
||||||
|
tt.mutate(&cred)
|
||||||
|
err := cred.Validate()
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/identity"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/identitydomains"
|
||||||
|
)
|
||||||
|
|
||||||
|
// notificationSettingID 是 Identity Domain 通知设置的固定资源 ID。
|
||||||
|
const notificationSettingID = "NotificationSettings"
|
||||||
|
|
||||||
|
// NotificationRecipients 是域通知收件人设置:test mode 开启后
|
||||||
|
// 域内全部通知只发给 recipients 列表。
|
||||||
|
type NotificationRecipients struct {
|
||||||
|
Recipients []string `json:"recipients"`
|
||||||
|
TestModeEnabled bool `json:"testModeEnabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordPolicyInfo 是一条域密码策略的关键字段。
|
||||||
|
type PasswordPolicyInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
PasswordStrength string `json:"passwordStrength"`
|
||||||
|
PasswordExpiresAfter *int `json:"passwordExpiresAfter,omitempty"`
|
||||||
|
MinLength *int `json:"minLength,omitempty"`
|
||||||
|
NumPasswordsInHistory *int `json:"numPasswordsInHistory,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePasswordPolicyInput 是密码策略修改项;nil 字段不修改。
|
||||||
|
// PasswordExpiresAfter 为 0 表示密码永不过期;
|
||||||
|
// NumPasswordsInHistory 为 0 表示可复用以前的密码(不记历史)。
|
||||||
|
type UpdatePasswordPolicyInput struct {
|
||||||
|
PasswordExpiresAfter *int
|
||||||
|
MinLength *int
|
||||||
|
NumPasswordsInHistory *int
|
||||||
|
}
|
||||||
|
|
||||||
|
// IdentitySettingInfo 是域身份设置里本面板关心的开关。
|
||||||
|
type IdentitySettingInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
// 用户需要提供主电子邮件地址:开启后创建用户 / JIT 预配必须带邮箱
|
||||||
|
PrimaryEmailRequired bool `json:"primaryEmailRequired"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// domainURL 解析租户 Default Identity Domain 的 endpoint URL。
|
||||||
|
func (c *RealClient) domainURL(ctx context.Context, cred Credentials, region string) (string, error) {
|
||||||
|
ic, err := c.identityClientAt(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
resp, err := ic.ListDomains(ctx, identity.ListDomainsRequest{CompartmentId: &cred.TenancyOCID})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("list identity domains: %w", err)
|
||||||
|
}
|
||||||
|
url := pickDomainURL(resp.Items)
|
||||||
|
if url == "" {
|
||||||
|
return "", fmt.Errorf("no active identity domain found")
|
||||||
|
}
|
||||||
|
return url, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// domainsClient 解析租户的 Default Identity Domain 并构造 SCIM 客户端。
|
||||||
|
func (c *RealClient) domainsClient(ctx context.Context, cred Credentials, region string) (identitydomains.IdentityDomainsClient, error) {
|
||||||
|
url, err := c.domainURL(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return identitydomains.IdentityDomainsClient{}, err
|
||||||
|
}
|
||||||
|
dc, err := identitydomains.NewIdentityDomainsClientWithConfigurationProvider(provider(cred), url)
|
||||||
|
if err != nil {
|
||||||
|
return dc, fmt.Errorf("new identity domains client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&dc.BaseClient, cred)
|
||||||
|
// IDCS 错误体是 SCIM 格式,改写后 SDK 才能解析出真实错误消息
|
||||||
|
dc.HTTPClient = scimErrorDispatcher{base: dc.HTTPClient}
|
||||||
|
return dc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickDomainURL 优先返回名为 Default 的域,否则取第一个 ACTIVE 的域。
|
||||||
|
func pickDomainURL(domains []identity.DomainSummary) string {
|
||||||
|
fallback := ""
|
||||||
|
for _, d := range domains {
|
||||||
|
if d.LifecycleState != identity.DomainLifecycleStateActive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if deref(d.DisplayName) == "Default" {
|
||||||
|
return deref(d.Url)
|
||||||
|
}
|
||||||
|
if fallback == "" {
|
||||||
|
fallback = deref(d.Url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNotificationRecipients 实现 Client:查询域通知收件人设置。
|
||||||
|
func (c *RealClient) GetNotificationRecipients(ctx context.Context, cred Credentials, region string) (NotificationRecipients, error) {
|
||||||
|
dc, err := c.domainsClient(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return NotificationRecipients{}, err
|
||||||
|
}
|
||||||
|
setting, err := getNotificationSetting(ctx, dc)
|
||||||
|
if err != nil {
|
||||||
|
return NotificationRecipients{}, err
|
||||||
|
}
|
||||||
|
return NotificationRecipients{
|
||||||
|
Recipients: orEmpty(setting.TestRecipients),
|
||||||
|
TestModeEnabled: setting.TestModeEnabled != nil && *setting.TestModeEnabled,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNotificationRecipients 实现 Client:把域通知改为只发给指定收件人;
|
||||||
|
// emails 为空时关闭 test mode 恢复默认发送。其余字段原样保留。
|
||||||
|
func (c *RealClient) UpdateNotificationRecipients(ctx context.Context, cred Credentials, region string, emails []string) (NotificationRecipients, error) {
|
||||||
|
dc, err := c.domainsClient(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return NotificationRecipients{}, err
|
||||||
|
}
|
||||||
|
setting, err := getNotificationSetting(ctx, dc)
|
||||||
|
if err != nil {
|
||||||
|
return NotificationRecipients{}, err
|
||||||
|
}
|
||||||
|
enabled := len(emails) > 0
|
||||||
|
setting.TestRecipients = emails
|
||||||
|
setting.TestModeEnabled = &enabled
|
||||||
|
id := notificationSettingID
|
||||||
|
resp, err := dc.PutNotificationSetting(ctx, identitydomains.PutNotificationSettingRequest{
|
||||||
|
NotificationSettingId: &id,
|
||||||
|
NotificationSetting: setting,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return NotificationRecipients{}, fmt.Errorf("put notification setting: %w", err)
|
||||||
|
}
|
||||||
|
return NotificationRecipients{
|
||||||
|
Recipients: orEmpty(resp.TestRecipients),
|
||||||
|
TestModeEnabled: resp.TestModeEnabled != nil && *resp.TestModeEnabled,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNotificationSetting(ctx context.Context, dc identitydomains.IdentityDomainsClient) (identitydomains.NotificationSetting, error) {
|
||||||
|
id := notificationSettingID
|
||||||
|
resp, err := dc.GetNotificationSetting(ctx, identitydomains.GetNotificationSettingRequest{
|
||||||
|
NotificationSettingId: &id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return identitydomains.NotificationSetting{}, fmt.Errorf("get notification setting: %w", err)
|
||||||
|
}
|
||||||
|
return resp.NotificationSetting, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPasswordPolicies 实现 Client:列出域内全部密码策略。
|
||||||
|
func (c *RealClient) ListPasswordPolicies(ctx context.Context, cred Credentials, region string) ([]PasswordPolicyInfo, error) {
|
||||||
|
dc, err := c.domainsClient(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := dc.ListPasswordPolicies(ctx, identitydomains.ListPasswordPoliciesRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list password policies: %w", err)
|
||||||
|
}
|
||||||
|
policies := make([]PasswordPolicyInfo, 0, len(resp.Resources))
|
||||||
|
for _, p := range resp.Resources {
|
||||||
|
policies = append(policies, toPasswordPolicyInfo(p))
|
||||||
|
}
|
||||||
|
return policies, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePasswordPolicy 实现 Client:以 SCIM Patch 修改密码策略的指定字段。
|
||||||
|
// 仅 Custom 强度的策略可修改,Simple/Standard 内置策略 OCI 返回 403。
|
||||||
|
func (c *RealClient) UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error) {
|
||||||
|
ops := buildPolicyPatchOps(in)
|
||||||
|
if len(ops) == 0 {
|
||||||
|
return PasswordPolicyInfo{}, fmt.Errorf("update password policy: nothing to update")
|
||||||
|
}
|
||||||
|
dc, err := c.domainsClient(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return PasswordPolicyInfo{}, err
|
||||||
|
}
|
||||||
|
resp, err := dc.PatchPasswordPolicy(ctx, identitydomains.PatchPasswordPolicyRequest{
|
||||||
|
PasswordPolicyId: &policyID,
|
||||||
|
PatchOp: identitydomains.PatchOp{
|
||||||
|
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
||||||
|
Operations: ops,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if isForbidden(err) {
|
||||||
|
return PasswordPolicyInfo{}, fmt.Errorf("patch password policy %s: only Custom-strength policies are editable: %w", policyID, err)
|
||||||
|
}
|
||||||
|
return PasswordPolicyInfo{}, fmt.Errorf("patch password policy %s: %w", policyID, err)
|
||||||
|
}
|
||||||
|
return toPasswordPolicyInfo(resp.PasswordPolicy), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isForbidden 判断服务端返回 403(内置只读资源等场景)。
|
||||||
|
func isForbidden(err error) bool {
|
||||||
|
var svcErr common.ServiceError
|
||||||
|
return errors.As(err, &svcErr) && svcErr.GetHTTPStatusCode() == 403
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPolicyPatchOps(in UpdatePasswordPolicyInput) []identitydomains.Operations {
|
||||||
|
var ops []identitydomains.Operations
|
||||||
|
if in.PasswordExpiresAfter != nil {
|
||||||
|
ops = append(ops, intOrRemoveOp("passwordExpiresAfter", *in.PasswordExpiresAfter))
|
||||||
|
}
|
||||||
|
if in.MinLength != nil {
|
||||||
|
ops = append(ops, replaceOp("minLength", *in.MinLength))
|
||||||
|
}
|
||||||
|
if in.NumPasswordsInHistory != nil {
|
||||||
|
ops = append(ops, intOrRemoveOp("numPasswordsInHistory", *in.NumPasswordsInHistory))
|
||||||
|
}
|
||||||
|
return ops
|
||||||
|
}
|
||||||
|
|
||||||
|
// intOrRemoveOp 值为 0 时移除属性(IDCS 语义 null = 永不过期 / 可复用密码;
|
||||||
|
// numPasswordsInHistory 合法范围 1-500,replace 0 会被拒),否则 replace。
|
||||||
|
func intOrRemoveOp(path string, v int) identitydomains.Operations {
|
||||||
|
if v == 0 {
|
||||||
|
return removeOp(path)
|
||||||
|
}
|
||||||
|
return replaceOp(path, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeOp(path string) identitydomains.Operations {
|
||||||
|
return identitydomains.Operations{
|
||||||
|
Op: identitydomains.OperationsOpRemove,
|
||||||
|
Path: &path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIdentitySetting 实现 Client:读取域身份设置(IdentitySettings 单例资源)。
|
||||||
|
func (c *RealClient) GetIdentitySetting(ctx context.Context, cred Credentials, region string) (IdentitySettingInfo, error) {
|
||||||
|
dc, err := c.domainsClient(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return IdentitySettingInfo{}, err
|
||||||
|
}
|
||||||
|
resp, err := dc.ListIdentitySettings(ctx, identitydomains.ListIdentitySettingsRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return IdentitySettingInfo{}, fmt.Errorf("list identity settings: %w", err)
|
||||||
|
}
|
||||||
|
if len(resp.Resources) == 0 {
|
||||||
|
return IdentitySettingInfo{}, fmt.Errorf("identity settings resource not found")
|
||||||
|
}
|
||||||
|
s := resp.Resources[0]
|
||||||
|
info := IdentitySettingInfo{ID: deref(s.Id)}
|
||||||
|
if s.PrimaryEmailRequired != nil {
|
||||||
|
info.PrimaryEmailRequired = *s.PrimaryEmailRequired
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateIdentitySetting 实现 Client:修改「用户需要提供主电子邮件地址」开关。
|
||||||
|
func (c *RealClient) UpdateIdentitySetting(ctx context.Context, cred Credentials, region string, primaryEmailRequired bool) (IdentitySettingInfo, error) {
|
||||||
|
current, err := c.GetIdentitySetting(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return IdentitySettingInfo{}, err
|
||||||
|
}
|
||||||
|
dc, err := c.domainsClient(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return IdentitySettingInfo{}, err
|
||||||
|
}
|
||||||
|
resp, err := dc.PatchIdentitySetting(ctx, identitydomains.PatchIdentitySettingRequest{
|
||||||
|
IdentitySettingId: ¤t.ID,
|
||||||
|
PatchOp: identitydomains.PatchOp{
|
||||||
|
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
||||||
|
Operations: []identitydomains.Operations{replaceOp("primaryEmailRequired", primaryEmailRequired)},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return IdentitySettingInfo{}, fmt.Errorf("patch identity settings: %w", err)
|
||||||
|
}
|
||||||
|
info := IdentitySettingInfo{ID: deref(resp.IdentitySetting.Id)}
|
||||||
|
if resp.IdentitySetting.PrimaryEmailRequired != nil {
|
||||||
|
info.PrimaryEmailRequired = *resp.IdentitySetting.PrimaryEmailRequired
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceOp(path string, value interface{}) identitydomains.Operations {
|
||||||
|
return identitydomains.Operations{
|
||||||
|
Op: identitydomains.OperationsOpReplace,
|
||||||
|
Path: &path,
|
||||||
|
Value: &value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPasswordPolicyInfo(p identitydomains.PasswordPolicy) PasswordPolicyInfo {
|
||||||
|
info := PasswordPolicyInfo{
|
||||||
|
ID: deref(p.Id),
|
||||||
|
Name: deref(p.Name),
|
||||||
|
PasswordStrength: string(p.PasswordStrength),
|
||||||
|
PasswordExpiresAfter: p.PasswordExpiresAfter,
|
||||||
|
MinLength: p.MinLength,
|
||||||
|
NumPasswordsInHistory: p.NumPasswordsInHistory,
|
||||||
|
}
|
||||||
|
if p.Priority != nil {
|
||||||
|
info.Priority = *p.Priority
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ociErrorHints 把常见 OCI 错误码翻译成给用户看的中文提示。
|
||||||
|
var ociErrorHints = map[string]string{
|
||||||
|
"IncorrectState": "资源当前状态不允许该操作,通常是仍被其他资源占用或正在变更中",
|
||||||
|
"NotAuthorizedOrNotFound": "无权限或资源不存在(可能已被删除)",
|
||||||
|
"LimitExceeded": "已达到服务配额上限",
|
||||||
|
"QuotaExceeded": "compartment 配额不足",
|
||||||
|
"OutOfHostCapacity": "该可用域容量不足,可稍后重试或改用抢机任务",
|
||||||
|
"TooManyRequests": "请求过于频繁,请稍后再试",
|
||||||
|
"InvalidParameter": "请求参数无效",
|
||||||
|
"InternalError": "OCI 服务内部错误,请稍后重试",
|
||||||
|
"Conflict": "资源正在变更中,请稍后重试",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ocidRe 匹配消息中的完整 OCID(unique 段 20 位以上才压缩,避免误伤短标识)。
|
||||||
|
var ocidRe = regexp.MustCompile(`ocid1\.([a-z0-9]+)\.[a-z0-9]*\.[a-z0-9-]*\.{1,2}[a-z0-9]{20,}`)
|
||||||
|
|
||||||
|
// shortenOcids 把消息里的长 OCID 压缩为「ocid1.<类型>…<尾6位>」,
|
||||||
|
// 保留资源类型与可比对的尾部,避免整条错误被 OCID 撑爆。
|
||||||
|
func shortenOcids(s string) string {
|
||||||
|
return ocidRe.ReplaceAllStringFunc(s, func(m string) string {
|
||||||
|
i := strings.Index(m, ".")
|
||||||
|
typ := m[i+1:]
|
||||||
|
if j := strings.Index(typ, "."); j > 0 {
|
||||||
|
typ = typ[:j]
|
||||||
|
}
|
||||||
|
return "ocid1." + typ + "…" + m[len(m)-6:]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompactError 把含 OCI ServiceError 的错误链提炼成短消息:
|
||||||
|
// 操作前缀 + 服务端消息,剥掉 SDK 附加的错误码、排障链接、
|
||||||
|
// 时间戳、SDK 版本等长文(错误码另经 API 的 ociCode 字段透出);
|
||||||
|
// 消息中的长 OCID 压缩为类型加尾位的短形式。非 OCI 错误原样返回。
|
||||||
|
func CompactError(err error) string {
|
||||||
|
var svcErr common.ServiceError
|
||||||
|
if !errors.As(err, &svcErr) {
|
||||||
|
return shortenOcids(err.Error())
|
||||||
|
}
|
||||||
|
prefix := err.Error()
|
||||||
|
if i := strings.Index(prefix, ": Error returned by"); i > 0 {
|
||||||
|
prefix = prefix[:i] + ": "
|
||||||
|
} else {
|
||||||
|
prefix = ""
|
||||||
|
}
|
||||||
|
return shortenOcids(prefix + svcErr.GetMessage())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorHint 返回常见 OCI 错误的中文提示;未识别返回空串。
|
||||||
|
// 容量不足常以 code=InternalError 返回,按消息内容归到容量提示。
|
||||||
|
func ErrorHint(err error) string {
|
||||||
|
var svcErr common.ServiceError
|
||||||
|
if !errors.As(err, &svcErr) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.Contains(svcErr.GetMessage(), "Out of host capacity") {
|
||||||
|
return ociErrorHints["OutOfHostCapacity"]
|
||||||
|
}
|
||||||
|
return ociErrorHints[svcErr.GetCode()]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceStatus 返回错误链中 OCI ServiceError 的 HTTP 状态码;非服务端错误 ok=false。
|
||||||
|
func ServiceStatus(err error) (int, bool) {
|
||||||
|
var svcErr common.ServiceError
|
||||||
|
if errors.As(err, &svcErr) {
|
||||||
|
return svcErr.GetHTTPStatusCode(), true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeServiceError 模拟 oci-go-sdk 的 common.ServiceError,
|
||||||
|
// Error() 按 SDK 真实格式输出长文以验证截断。
|
||||||
|
type fakeServiceError struct {
|
||||||
|
status int
|
||||||
|
code string
|
||||||
|
message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e fakeServiceError) Error() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"Error returned by Compute Service. Http Status Code: %d. Error Code: %s. Message: %s. Troubleshooting Tips: See https://docs.oracle.com/ ...",
|
||||||
|
e.status, e.code, e.message)
|
||||||
|
}
|
||||||
|
func (e fakeServiceError) GetHTTPStatusCode() int { return e.status }
|
||||||
|
func (e fakeServiceError) GetMessage() string { return e.message }
|
||||||
|
func (e fakeServiceError) GetCode() string { return e.code }
|
||||||
|
func (e fakeServiceError) GetOpcRequestID() string { return "req-1" }
|
||||||
|
|
||||||
|
func TestCompactError(t *testing.T) {
|
||||||
|
capacity := fakeServiceError{status: 500, code: "InternalError", message: "Out of host capacity."}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "非 OCI 错误原样返回",
|
||||||
|
err: errors.New("parse payload: unexpected end of JSON input"),
|
||||||
|
want: "parse payload: unexpected end of JSON input",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "带操作前缀的 OCI 错误保留前缀并剥掉长文",
|
||||||
|
err: fmt.Errorf("launch instance: %w", capacity),
|
||||||
|
want: "launch instance: Out of host capacity.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "多层包装保留完整前缀链",
|
||||||
|
err: fmt.Errorf("snatch-1: %w", fmt.Errorf("launch instance: %w", capacity)),
|
||||||
|
want: "snatch-1: launch instance: Out of host capacity.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "无前缀时只输出服务端消息",
|
||||||
|
err: error(capacity),
|
||||||
|
want: "Out of host capacity.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "消息中的长 OCID 压缩为类型加尾位",
|
||||||
|
err: fmt.Errorf("update instance: %w", fakeServiceError{
|
||||||
|
status: 500, code: "InternalError",
|
||||||
|
message: "instance ocid1.instance.oc1.phx.anyhqljtp537sbqcr2f5ed2jwkglwyd3g6dfbmgtwdp4xdarxum5q7tdcyla: Out of host capacity.",
|
||||||
|
}),
|
||||||
|
want: "update instance: instance ocid1.instance…tdcyla: Out of host capacity.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "非 OCI 错误里的 OCID 同样压缩",
|
||||||
|
err: errors.New("get instance ocid1.instance.oc1..aaaaaaaabbbbbbbbccccccccdddddddd failed"),
|
||||||
|
want: "get instance ocid1.instance…dddddd failed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := CompactError(tt.err); got != tt.want {
|
||||||
|
t.Errorf("CompactError() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorHint(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "非 OCI 错误无提示",
|
||||||
|
err: errors.New("db locked"),
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "已知错误码给出中文提示",
|
||||||
|
err: fmt.Errorf("delete vcn: %w", fakeServiceError{409, "IncorrectState", "vcn in use"}),
|
||||||
|
want: ociErrorHints["IncorrectState"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "InternalError 但消息为容量不足时归到容量提示",
|
||||||
|
err: fmt.Errorf("launch: %w", fakeServiceError{500, "InternalError", "Out of host capacity."}),
|
||||||
|
want: ociErrorHints["OutOfHostCapacity"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "未知错误码返回空串",
|
||||||
|
err: fmt.Errorf("x: %w", fakeServiceError{400, "SomethingNew", "boom"}),
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := ErrorHint(tt.err); got != tt.want {
|
||||||
|
t.Errorf("ErrorHint() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/identitydomains"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultIdpRuleID 是 Default Identity Provider Policy 的预置规则固定 ID,
|
||||||
|
// 其 return 中 SamlIDPs 数组决定登录页显示哪些 SAML IdP。
|
||||||
|
const defaultIdpRuleID = "DefaultIDPRule"
|
||||||
|
|
||||||
|
// samlMetadataPath 是 Identity Domain 的 SP SAML 元数据端点。
|
||||||
|
const samlMetadataPath = "/fed/v1/metadata"
|
||||||
|
|
||||||
|
// idpSchema 是 IdentityProvider 资源的 SCIM schema。
|
||||||
|
const idpSchema = "urn:ietf:params:scim:schemas:oracle:idcs:IdentityProvider"
|
||||||
|
|
||||||
|
// IdentityProviderInfo 是一个外部身份提供者的关键字段。
|
||||||
|
type IdentityProviderInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
PartnerProviderID string `json:"partnerProviderId"`
|
||||||
|
JitEnabled bool `json:"jitEnabled"`
|
||||||
|
TimeCreated *time.Time `json:"timeCreated,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIdpInput 是创建 SAML IdP 的输入;零值即控制台默认行为:
|
||||||
|
// 名称 ID 格式「无」、SAML 断言名称 ID 映射到用户名、JIT 开启建用户不更新、
|
||||||
|
// 静态分配 Administrators 组(service 层负责把缺省字段填成这些默认值)。
|
||||||
|
type CreateIdpInput struct {
|
||||||
|
Name string // partnerName,必填
|
||||||
|
Metadata string // IdP metadata XML,必填
|
||||||
|
Description string
|
||||||
|
IconURL string // 登录页 logo,仅接受 http(s) 外链 URL(IDCS 拒绝 data URI),空则不设置
|
||||||
|
|
||||||
|
// 映射用户身份
|
||||||
|
NameIDFormat string // 请求的名称 ID 格式,如 saml-none / saml-emailaddress
|
||||||
|
AssertionAttribute string // 非空时改用断言属性映射,值为断言中的属性名;空用 NameID
|
||||||
|
UserStoreAttribute string // 目标域用户属性:userName 或 emails.primary(主邮箱)
|
||||||
|
|
||||||
|
// JIT 预配
|
||||||
|
JitEnabled bool
|
||||||
|
JitCreate bool // JIT 时创建新用户
|
||||||
|
JitUpdate bool // JIT 时更新既有用户属性
|
||||||
|
JitAssignAdminGroup bool // 静态分配 Administrators 组
|
||||||
|
JitMapEmail bool // 属性映射额外映射主邮箱(域要求邮箱必填时需开启)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIdentityProviders 实现 Client:列出域内 SAML 身份提供者。
|
||||||
|
func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials, region string) ([]IdentityProviderInfo, error) {
|
||||||
|
dc, err := c.domainsClient(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := dc.ListIdentityProviders(ctx, identitydomains.ListIdentityProvidersRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list identity providers: %w", err)
|
||||||
|
}
|
||||||
|
idps := make([]IdentityProviderInfo, 0, len(resp.Resources))
|
||||||
|
for _, idp := range resp.Resources {
|
||||||
|
if idp.Type != identitydomains.IdentityProviderTypeSaml {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idps = append(idps, toIdpInfo(idp))
|
||||||
|
}
|
||||||
|
return idps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT。
|
||||||
|
func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region string, in CreateIdpInput) (IdentityProviderInfo, error) {
|
||||||
|
dc, err := c.domainsClient(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return IdentityProviderInfo{}, err
|
||||||
|
}
|
||||||
|
var adminGroup *identitydomains.IdentityProviderJitUserProvAssignedGroups
|
||||||
|
if in.JitEnabled && in.JitAssignAdminGroup {
|
||||||
|
if adminGroup, err = adminGroupRef(ctx, dc); err != nil {
|
||||||
|
return IdentityProviderInfo{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp, err := dc.CreateIdentityProvider(ctx, identitydomains.CreateIdentityProviderRequest{
|
||||||
|
IdentityProvider: buildSamlIdp(in, adminGroup),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return IdentityProviderInfo{}, fmt.Errorf("create identity provider %s: %w", in.Name, err)
|
||||||
|
}
|
||||||
|
if in.JitEnabled {
|
||||||
|
if err := ensureJitAttributeMappings(ctx, dc, resp.IdentityProvider, jitMappings(in)); err != nil {
|
||||||
|
return toIdpInfo(resp.IdentityProvider), err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return toIdpInfo(resp.IdentityProvider), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSamlIdp 按输入组装 SAML IdP(创建为禁用态,启用走 activate 接口)。
|
||||||
|
func buildSamlIdp(in CreateIdpInput, adminGroup *identitydomains.IdentityProviderJitUserProvAssignedGroups) identitydomains.IdentityProvider {
|
||||||
|
enabled := false
|
||||||
|
boolFalse := false
|
||||||
|
idp := identitydomains.IdentityProvider{
|
||||||
|
Schemas: []string{idpSchema},
|
||||||
|
PartnerName: &in.Name,
|
||||||
|
Enabled: &enabled,
|
||||||
|
Type: identitydomains.IdentityProviderTypeSaml,
|
||||||
|
Metadata: &in.Metadata,
|
||||||
|
NameIdFormat: &in.NameIDFormat,
|
||||||
|
SignatureHashAlgorithm: identitydomains.IdentityProviderSignatureHashAlgorithm256,
|
||||||
|
IncludeSigningCertInSignature: &boolFalse,
|
||||||
|
UserMappingMethod: identitydomains.IdentityProviderUserMappingMethodNameidtouserattribute,
|
||||||
|
UserMappingStoreAttribute: &in.UserStoreAttribute,
|
||||||
|
JitUserProvEnabled: &in.JitEnabled,
|
||||||
|
JitUserProvCreateUserEnabled: &in.JitCreate,
|
||||||
|
JitUserProvAttributeUpdateEnabled: &in.JitUpdate,
|
||||||
|
JitUserProvGroupAssertionAttributeEnabled: &boolFalse,
|
||||||
|
JitUserProvGroupStaticListEnabled: &in.JitAssignAdminGroup,
|
||||||
|
JitUserProvGroupMappingMode: identitydomains.IdentityProviderJitUserProvGroupMappingModeImplicit,
|
||||||
|
}
|
||||||
|
if in.AssertionAttribute != "" {
|
||||||
|
idp.UserMappingMethod = identitydomains.IdentityProviderUserMappingMethodAssertionattributetouserattribute
|
||||||
|
idp.AssertionAttribute = &in.AssertionAttribute
|
||||||
|
}
|
||||||
|
if in.IconURL != "" {
|
||||||
|
idp.IconUrl = &in.IconURL
|
||||||
|
}
|
||||||
|
if in.Description != "" {
|
||||||
|
idp.Description = &in.Description
|
||||||
|
}
|
||||||
|
if adminGroup != nil {
|
||||||
|
idp.JitUserProvAssignedGroups = []identitydomains.IdentityProviderJitUserProvAssignedGroups{*adminGroup}
|
||||||
|
}
|
||||||
|
return idp
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminGroupRef 查询域内 Administrators 组作为 JIT 静态分配目标;不存在时返回 nil。
|
||||||
|
func adminGroupRef(ctx context.Context, dc identitydomains.IdentityDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) {
|
||||||
|
filter := fmt.Sprintf("displayName eq %q", administratorsGroupName)
|
||||||
|
count := 1
|
||||||
|
resp, err := dc.ListGroups(ctx, identitydomains.ListGroupsRequest{
|
||||||
|
Filter: &filter,
|
||||||
|
Attributes: common.String("id,displayName"),
|
||||||
|
Count: &count,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find Administrators group: %w", err)
|
||||||
|
}
|
||||||
|
if len(resp.Resources) == 0 || resp.Resources[0].Id == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &identitydomains.IdentityProviderJitUserProvAssignedGroups{Value: resp.Resources[0].Id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// jitMappings 生成 JIT 属性映射:来源为 NameID(或指定断言属性),
|
||||||
|
// 固定映射 用户名 + 姓,按需追加主邮箱(emails[work].value)。
|
||||||
|
func jitMappings(in CreateIdpInput) []interface{} {
|
||||||
|
src := "$(assertion.fed.nameidvalue)"
|
||||||
|
if in.AssertionAttribute != "" {
|
||||||
|
src = "$(assertion." + in.AssertionAttribute + ")"
|
||||||
|
}
|
||||||
|
ms := []interface{}{
|
||||||
|
map[string]string{"managedObjectAttributeName": src, "idcsAttributeName": "userName"},
|
||||||
|
map[string]string{"managedObjectAttributeName": src, "idcsAttributeName": "name.familyName"},
|
||||||
|
}
|
||||||
|
if in.JitMapEmail {
|
||||||
|
ms = append(ms, map[string]string{"managedObjectAttributeName": src, "idcsAttributeName": "emails[work].value"})
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureJitAttributeMappings 把 IdP 自动生成的 JIT 属性映射替换为给定映射。
|
||||||
|
func ensureJitAttributeMappings(ctx context.Context, dc identitydomains.IdentityDomainsClient, idp identitydomains.IdentityProvider, mappings []interface{}) error {
|
||||||
|
ref := idp.JitUserProvAttributes
|
||||||
|
if ref == nil || ref.Value == nil {
|
||||||
|
got, err := dc.GetIdentityProvider(ctx, identitydomains.GetIdentityProviderRequest{IdentityProviderId: idp.Id})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reload idp for jit attributes: %w", err)
|
||||||
|
}
|
||||||
|
ref = got.JitUserProvAttributes
|
||||||
|
}
|
||||||
|
if ref == nil || ref.Value == nil {
|
||||||
|
return fmt.Errorf("idp %s has no jit attribute mapping resource", deref(idp.Id))
|
||||||
|
}
|
||||||
|
var value interface{} = mappings
|
||||||
|
_, err := dc.PatchMappedAttribute(ctx, identitydomains.PatchMappedAttributeRequest{
|
||||||
|
MappedAttributeId: ref.Value,
|
||||||
|
PatchOp: identitydomains.PatchOp{
|
||||||
|
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
||||||
|
Operations: []identitydomains.Operations{{
|
||||||
|
Op: identitydomains.OperationsOpReplace,
|
||||||
|
Path: common.String("attributeMappings"),
|
||||||
|
Value: &value,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("patch jit attribute mappings: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetIdentityProviderEnabled 实现 Client:启用/停用 IdP 并同步登录页显示。
|
||||||
|
// 顺序有讲究:启用后才能上登录页,停用前必须先从登录页移除。
|
||||||
|
func (c *RealClient) SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, idpID string, enabled bool) (IdentityProviderInfo, error) {
|
||||||
|
dc, err := c.domainsClient(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return IdentityProviderInfo{}, err
|
||||||
|
}
|
||||||
|
if !enabled {
|
||||||
|
if err := updateLoginPageIdps(ctx, dc, idpID, false); err != nil {
|
||||||
|
return IdentityProviderInfo{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idp, err := patchIdpEnabled(ctx, dc, idpID, enabled)
|
||||||
|
if err != nil {
|
||||||
|
return IdentityProviderInfo{}, err
|
||||||
|
}
|
||||||
|
if enabled {
|
||||||
|
if err := updateLoginPageIdps(ctx, dc, idpID, true); err != nil {
|
||||||
|
return toIdpInfo(idp), err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return toIdpInfo(idp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func patchIdpEnabled(ctx context.Context, dc identitydomains.IdentityDomainsClient, idpID string, enabled bool) (identitydomains.IdentityProvider, error) {
|
||||||
|
var value interface{} = enabled
|
||||||
|
resp, err := dc.PatchIdentityProvider(ctx, identitydomains.PatchIdentityProviderRequest{
|
||||||
|
IdentityProviderId: &idpID,
|
||||||
|
PatchOp: identitydomains.PatchOp{
|
||||||
|
Schemas: []string{scimPatchSchema},
|
||||||
|
Operations: []identitydomains.Operations{{
|
||||||
|
Op: identitydomains.OperationsOpReplace,
|
||||||
|
Path: common.String("enabled"),
|
||||||
|
Value: &value,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return identitydomains.IdentityProvider{}, fmt.Errorf("patch idp %s enabled=%v: %w", idpID, enabled, err)
|
||||||
|
}
|
||||||
|
return resp.IdentityProvider, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteIdentityProvider 实现 Client:删除 IdP;先移出登录页并停用。
|
||||||
|
func (c *RealClient) DeleteIdentityProvider(ctx context.Context, cred Credentials, region, idpID string) error {
|
||||||
|
dc, err := c.domainsClient(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := updateLoginPageIdps(ctx, dc, idpID, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := patchIdpEnabled(ctx, dc, idpID, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := dc.DeleteIdentityProvider(ctx, identitydomains.DeleteIdentityProviderRequest{
|
||||||
|
IdentityProviderId: &idpID,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("delete identity provider %s: %w", idpID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateLoginPageIdps 修改预置 DefaultIDPRule 的 SamlIDPs 数组控制登录页显示。
|
||||||
|
func updateLoginPageIdps(ctx context.Context, dc identitydomains.IdentityDomainsClient, idpID string, show bool) error {
|
||||||
|
rule, err := dc.GetRule(ctx, identitydomains.GetRuleRequest{RuleId: common.String(defaultIdpRuleID)})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get default idp rule: %w", err)
|
||||||
|
}
|
||||||
|
returns, changed, err := rebuildSamlIdpsReturn(rule.Return, idpID, show)
|
||||||
|
if err != nil || !changed {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var value interface{} = returns
|
||||||
|
_, err = dc.PatchRule(ctx, identitydomains.PatchRuleRequest{
|
||||||
|
RuleId: common.String(defaultIdpRuleID),
|
||||||
|
PatchOp: identitydomains.PatchOp{
|
||||||
|
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
||||||
|
Operations: []identitydomains.Operations{{
|
||||||
|
Op: identitydomains.OperationsOpReplace,
|
||||||
|
Path: common.String("return"),
|
||||||
|
Value: &value,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("patch default idp rule: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rebuildSamlIdpsReturn 重建规则 return 数组,增删 SamlIDPs 中的目标 IdP。
|
||||||
|
func rebuildSamlIdpsReturn(items []identitydomains.RuleReturn, idpID string, show bool) ([]interface{}, bool, error) {
|
||||||
|
returns := make([]interface{}, 0, len(items))
|
||||||
|
changed := false
|
||||||
|
for _, item := range items {
|
||||||
|
name, value := deref(item.Name), deref(item.Value)
|
||||||
|
if name == "SamlIDPs" {
|
||||||
|
next, ok, err := toggleJSONList(value, idpID, show)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("parse SamlIDPs %q: %w", value, err)
|
||||||
|
}
|
||||||
|
value, changed = next, ok
|
||||||
|
}
|
||||||
|
returns = append(returns, map[string]string{"name": name, "value": value})
|
||||||
|
}
|
||||||
|
return returns, changed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toggleJSONList 在 JSON 数组字符串中添加或移除一个元素,返回是否有变化。
|
||||||
|
func toggleJSONList(list, item string, add bool) (string, bool, error) {
|
||||||
|
var items []string
|
||||||
|
if list != "" {
|
||||||
|
if err := json.Unmarshal([]byte(list), &items); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next := make([]string, 0, len(items)+1)
|
||||||
|
found := false
|
||||||
|
for _, it := range items {
|
||||||
|
if it == item {
|
||||||
|
found = true
|
||||||
|
if !add {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next = append(next, it)
|
||||||
|
}
|
||||||
|
if add && !found {
|
||||||
|
next = append(next, item)
|
||||||
|
}
|
||||||
|
if add == found {
|
||||||
|
return list, false, nil
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(next)
|
||||||
|
return string(b), true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadDomainSamlMetadata 实现 Client:下载域的 SP SAML 元数据 XML。
|
||||||
|
// 域设置「访问签名证书」未开启时端点不可用,自动开启后重试
|
||||||
|
// (联邦配置要求 IdP 侧能读取该元数据,开启公开访问是官方标准做法)。
|
||||||
|
func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region string) ([]byte, error) {
|
||||||
|
url, err := c.domainURL(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, status, err := fetchSamlMetadata(ctx, url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if status == http.StatusOK {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
if err := c.enableSigningCertPublicAccess(ctx, cred, region); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, status, err = fetchSamlMetadata(ctx, url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("download saml metadata: HTTP %d: %s", status, truncate(string(body), 200))
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchSamlMetadata 匿名请求元数据端点;该端点只支持公开访问,不接受 OCI 签名。
|
||||||
|
func fetchSamlMetadata(ctx context.Context, url string) ([]byte, int, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url+samlMetadataPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("build metadata request: %w", err)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("download saml metadata: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("read saml metadata: %w", err)
|
||||||
|
}
|
||||||
|
return body, resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// enableSigningCertPublicAccess 开启域设置「访问签名证书」公开访问。
|
||||||
|
func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region string) error {
|
||||||
|
dc, err := c.domainsClient(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var value interface{} = true
|
||||||
|
_, err = dc.PatchSetting(ctx, identitydomains.PatchSettingRequest{
|
||||||
|
SettingId: common.String("Settings"),
|
||||||
|
PatchOp: identitydomains.PatchOp{
|
||||||
|
Schemas: []string{scimPatchSchema},
|
||||||
|
Operations: []identitydomains.Operations{{
|
||||||
|
Op: identitydomains.OperationsOpReplace,
|
||||||
|
Path: common.String("signingCertPublicAccess"),
|
||||||
|
Value: &value,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("enable signing cert public access: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func toIdpInfo(idp identitydomains.IdentityProvider) IdentityProviderInfo {
|
||||||
|
info := IdentityProviderInfo{
|
||||||
|
ID: deref(idp.Id),
|
||||||
|
Name: deref(idp.PartnerName),
|
||||||
|
Type: string(idp.Type),
|
||||||
|
Enabled: idp.Enabled != nil && *idp.Enabled,
|
||||||
|
PartnerProviderID: deref(idp.PartnerProviderId),
|
||||||
|
JitEnabled: idp.JitUserProvEnabled != nil && *idp.JitUserProvEnabled,
|
||||||
|
}
|
||||||
|
if idp.Meta != nil && idp.Meta.Created != nil {
|
||||||
|
if t, err := time.Parse(time.RFC3339, *idp.Meta.Created); err == nil {
|
||||||
|
info.TimeCreated = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/generativeai"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
||||||
|
|
||||||
|
"oci-portal/internal/aiwire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenAiModel 是区域可用基础模型的摘要(管理面 ListModels)。
|
||||||
|
type GenAiModel struct {
|
||||||
|
Ocid string `json:"ocid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Vendor string `json:"vendor"`
|
||||||
|
ChatOnly bool `json:"-"`
|
||||||
|
Caps []string `json:"capabilities"`
|
||||||
|
// Capability 是网关侧归一能力:CHAT / EMBEDDING(兼具时算 CHAT)
|
||||||
|
Capability string `json:"capability"`
|
||||||
|
// Deprecated 是 OCI 宣布的弃用时间(TimeDeprecated),nil 表示未宣布;
|
||||||
|
// 弃用后模型仍可调用,直到 Retired(TimeOnDemandRetired 按需推理退役)。
|
||||||
|
Deprecated *time.Time `json:"deprecated"`
|
||||||
|
// Retired 是按需推理退役时间;已过表示调用必 404,同步层直接剔除
|
||||||
|
Retired *time.Time `json:"retired"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenAiStream 逐事件读取流式聊天;Next 在流结束时返回 io.EOF。
|
||||||
|
type GenAiStream interface {
|
||||||
|
Next() (aiwire.ChatChunk, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RealClient) genAiClient(cred Credentials, region string) (generativeai.GenerativeAiClient, error) {
|
||||||
|
gc, err := generativeai.NewGenerativeAiClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return gc, fmt.Errorf("new generative ai client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&gc.BaseClient, cred)
|
||||||
|
if region != "" {
|
||||||
|
gc.SetRegion(normalizeRegion(region))
|
||||||
|
}
|
||||||
|
return gc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RealClient) genAiInferenceClient(cred Credentials, region string) (generativeaiinference.GenerativeAiInferenceClient, error) {
|
||||||
|
ic, err := generativeaiinference.NewGenerativeAiInferenceClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return ic, fmt.Errorf("new generative ai inference client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&ic.BaseClient, cred)
|
||||||
|
if region != "" {
|
||||||
|
ic.SetRegion(normalizeRegion(region))
|
||||||
|
}
|
||||||
|
return ic, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGenAiModels 实现 Client:列出区域的 CHAT / EMBEDDING 能力 ACTIVE 基础模型(去重按名称)。
|
||||||
|
func (c *RealClient) ListGenAiModels(ctx context.Context, cred Credentials, region string) ([]GenAiModel, error) {
|
||||||
|
gc, err := c.genAiClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := gc.ListModels(ctx, generativeai.ListModelsRequest{CompartmentId: &cred.TenancyOCID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list genai models: %w", err)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
now := time.Now()
|
||||||
|
var out []GenAiModel
|
||||||
|
for _, m := range resp.Items {
|
||||||
|
gm, ok := toGenAiModel(m, now)
|
||||||
|
if !ok || seen[gm.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[gm.Name] = true
|
||||||
|
out = append(out, gm)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toGenAiModel 压平模型摘要;无归一能力、无名称或按需推理已退役
|
||||||
|
// (调用必 404,ListModels 仍会返回且 state 为 ACTIVE)时返回 false 不入池。
|
||||||
|
func toGenAiModel(m generativeai.ModelSummary, now time.Time) (GenAiModel, bool) {
|
||||||
|
capability := modelCapability(m)
|
||||||
|
name := deref(m.DisplayName)
|
||||||
|
if capability == "" || name == "" {
|
||||||
|
return GenAiModel{}, false
|
||||||
|
}
|
||||||
|
if m.TimeOnDemandRetired != nil && m.TimeOnDemandRetired.Time.Before(now) {
|
||||||
|
return GenAiModel{}, false
|
||||||
|
}
|
||||||
|
gm := GenAiModel{Ocid: deref(m.Id), Name: name, Vendor: deref(m.Vendor),
|
||||||
|
Caps: capStrings(m.Capabilities), Capability: capability}
|
||||||
|
if m.TimeDeprecated != nil {
|
||||||
|
gm.Deprecated = &m.TimeDeprecated.Time
|
||||||
|
}
|
||||||
|
if m.TimeOnDemandRetired != nil {
|
||||||
|
gm.Retired = &m.TimeOnDemandRetired.Time
|
||||||
|
}
|
||||||
|
return gm, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelCapability 归一模型能力:ACTIVE 且具 CHAT / TEXT_EMBEDDINGS 才纳入(兼具时算 CHAT)。
|
||||||
|
func modelCapability(m generativeai.ModelSummary) string {
|
||||||
|
if m.LifecycleState != generativeai.ModelLifecycleStateActive {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
capability := ""
|
||||||
|
for _, cap := range m.Capabilities {
|
||||||
|
switch cap {
|
||||||
|
case generativeai.ModelCapabilityChat:
|
||||||
|
return "CHAT"
|
||||||
|
case generativeai.ModelCapabilityTextEmbeddings:
|
||||||
|
capability = "EMBEDDING"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return capability
|
||||||
|
}
|
||||||
|
|
||||||
|
func capStrings(caps []generativeai.ModelCapabilityEnum) []string {
|
||||||
|
out := make([]string, 0, len(caps))
|
||||||
|
for _, c := range caps {
|
||||||
|
out = append(out, string(c))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenAiChat 实现 Client:非流式聊天;modelOcid 走 on-demand serving,
|
||||||
|
// cohere.* 模型走 COHERE 请求格式,其余走 GENERIC。
|
||||||
|
func (c *RealClient) GenAiChat(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error) {
|
||||||
|
ic, err := c.genAiInferenceClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := buildChatRequest(ir, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := ic.Chat(ctx, chatRequest(cred, modelOcid, req))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("genai chat: %w", err)
|
||||||
|
}
|
||||||
|
return chatResponseToIR(resp.ChatResult.ChatResponse, ir.Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildChatRequest 按模型 vendor 组装底层请求体。
|
||||||
|
func buildChatRequest(ir aiwire.ChatRequest, stream bool) (generativeaiinference.BaseChatRequest, error) {
|
||||||
|
if isCohereModel(ir.Model) {
|
||||||
|
return irToCohereSDK(ir, stream)
|
||||||
|
}
|
||||||
|
return irToSDK(ir, stream), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatResponseToIR 按响应实际形态(GENERIC / COHERE)转回 IR。
|
||||||
|
func chatResponseToIR(resp generativeaiinference.BaseChatResponse, model string) (*aiwire.ChatResponse, error) {
|
||||||
|
switch r := resp.(type) {
|
||||||
|
case generativeaiinference.GenericChatResponse:
|
||||||
|
return sdkToIR(r, model), nil
|
||||||
|
case generativeaiinference.CohereChatResponse:
|
||||||
|
return cohereSDKToIR(r, model), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("genai chat: unexpected response format %T", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatRequest(cred Credentials, modelOcid string, req generativeaiinference.BaseChatRequest) generativeaiinference.ChatRequest {
|
||||||
|
return generativeaiinference.ChatRequest{
|
||||||
|
ChatDetails: generativeaiinference.ChatDetails{
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
ServingMode: generativeaiinference.OnDemandServingMode{ModelId: &modelOcid},
|
||||||
|
ChatRequest: req,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenAiChatStream 实现 Client:流式聊天,返回逐事件读取器。
|
||||||
|
// SDK 对 text/event-stream 跳过 unmarshal,原始流经 RawResponse 交由 SSEReader 消费。
|
||||||
|
func (c *RealClient) GenAiChatStream(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (GenAiStream, error) {
|
||||||
|
ic, err := c.genAiInferenceClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := buildChatRequest(ir, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := ic.Chat(ctx, chatRequest(cred, modelOcid, req))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("genai chat stream: %w", err)
|
||||||
|
}
|
||||||
|
reader, err := common.NewSSEReader(resp.RawResponse)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("genai chat stream: sse reader: %w", err)
|
||||||
|
}
|
||||||
|
return &genAiSSEStream{reader: reader, body: resp.RawResponse.Body, model: ir.Model}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// genAiSSEStream 把 OCI SSE 事件流适配为 IR chunk 流。
|
||||||
|
type genAiSSEStream struct {
|
||||||
|
reader *common.SseReader
|
||||||
|
body io.ReadCloser
|
||||||
|
model string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next 读取下一条有效事件;空事件与 [DONE] 哨兵跳过,流尽返回 io.EOF。
|
||||||
|
func (s *genAiSSEStream) Next() (aiwire.ChatChunk, error) {
|
||||||
|
for {
|
||||||
|
data, err := s.reader.ReadNextEvent()
|
||||||
|
if err != nil {
|
||||||
|
return aiwire.ChatChunk{}, err
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(string(data))
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if text == "[DONE]" {
|
||||||
|
return aiwire.ChatChunk{}, io.EOF
|
||||||
|
}
|
||||||
|
if chunk, ok := parseGenAiEvent([]byte(text), s.model); ok {
|
||||||
|
return chunk, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *genAiSSEStream) Close() error {
|
||||||
|
if s.body != nil {
|
||||||
|
return s.body.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenAiProbeChat 实现 Client:配额探测专用的最小聊天(maxTokens=1),返回 HTTP 状态码;
|
||||||
|
// modelName 决定请求格式(cohere.* 走 COHERE)。
|
||||||
|
func (c *RealClient) GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error) {
|
||||||
|
one := 1
|
||||||
|
ir := aiwire.ChatRequest{
|
||||||
|
Model: modelName,
|
||||||
|
Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("hi")}},
|
||||||
|
MaxTokens: &one,
|
||||||
|
}
|
||||||
|
_, err := c.GenAiChat(ctx, cred, region, modelOcid, ir)
|
||||||
|
if err == nil {
|
||||||
|
return http.StatusOK, nil
|
||||||
|
}
|
||||||
|
if status, ok := ServiceStatus(err); ok {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenAiEmbed 实现 Client:文本向量化(on-demand serving);dimensions 透传 OutputDimensions。
|
||||||
|
func (c *RealClient) GenAiEmbed(ctx context.Context, cred Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error) {
|
||||||
|
ic, err := c.genAiInferenceClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
resp, err := ic.EmbedText(ctx, generativeaiinference.EmbedTextRequest{
|
||||||
|
EmbedTextDetails: generativeaiinference.EmbedTextDetails{
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
ServingMode: generativeaiinference.OnDemandServingMode{ModelId: &modelOcid},
|
||||||
|
Inputs: inputs,
|
||||||
|
OutputDimensions: dimensions,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("genai embed: %w", err)
|
||||||
|
}
|
||||||
|
return resp.Embeddings, sdkUsageToIR(resp.Usage), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
||||||
|
|
||||||
|
"oci-portal/internal/aiwire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// isCohereModel 依据模型名前缀判定 COHERE 系(走专属请求格式)。
|
||||||
|
func isCohereModel(name string) bool {
|
||||||
|
return strings.HasPrefix(strings.ToLower(name), "cohere.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// irToCohereSDK 把 IR 转为 COHERE 聊天请求;工具与结构化输出做有损降级
|
||||||
|
// (参数仅取 JSON Schema 顶层 properties,tool_choice / json_schema 的 name、strict 无对应)。
|
||||||
|
func irToCohereSDK(ir aiwire.ChatRequest, stream bool) (generativeaiinference.CohereChatRequest, error) {
|
||||||
|
var req generativeaiinference.CohereChatRequest
|
||||||
|
if err := cohereRejectUnsupported(ir); err != nil {
|
||||||
|
return req, err
|
||||||
|
}
|
||||||
|
p, err := cohereSplitMessages(ir.Messages)
|
||||||
|
if err != nil {
|
||||||
|
return req, err
|
||||||
|
}
|
||||||
|
req = generativeaiinference.CohereChatRequest{
|
||||||
|
Message: &p.message,
|
||||||
|
ChatHistory: p.history,
|
||||||
|
ToolResults: p.toolResults,
|
||||||
|
Tools: cohereTools(ir.Tools),
|
||||||
|
ResponseFormat: cohereResponseFormat(ir.ResponseFormat),
|
||||||
|
MaxTokens: ir.MaxTokens,
|
||||||
|
Temperature: ir.Temperature,
|
||||||
|
TopP: ir.TopP,
|
||||||
|
TopK: ir.TopK,
|
||||||
|
FrequencyPenalty: ir.FrequencyPenalty,
|
||||||
|
PresencePenalty: ir.PresencePenalty,
|
||||||
|
Seed: ir.Seed,
|
||||||
|
}
|
||||||
|
if p.preamble != "" {
|
||||||
|
req.PreambleOverride = &p.preamble
|
||||||
|
}
|
||||||
|
if len(ir.Stop) > 0 {
|
||||||
|
req.StopSequences = ir.Stop
|
||||||
|
}
|
||||||
|
if stream {
|
||||||
|
req.IsStream = &stream
|
||||||
|
includeUsage := ir.StreamOptions == nil || ir.StreamOptions.IncludeUsage
|
||||||
|
req.StreamOptions = &generativeaiinference.StreamOptions{IsIncludeUsage: &includeUsage}
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cohereRejectUnsupported 拒绝 COHERE 无法承接的能力(多模态图片)。
|
||||||
|
func cohereRejectUnsupported(ir aiwire.ChatRequest) error {
|
||||||
|
for _, m := range ir.Messages {
|
||||||
|
for _, p := range m.Content.Parts {
|
||||||
|
if p.Type == "image_url" {
|
||||||
|
return fmt.Errorf("cohere 系模型暂不支持图片输入,请改用 meta/google 等多模态模型")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cohereParts 是 IR 消息拆装为 COHERE 请求的中间结果。
|
||||||
|
type cohereParts struct {
|
||||||
|
message string
|
||||||
|
history []generativeaiinference.CohereMessage
|
||||||
|
preamble string
|
||||||
|
toolResults []generativeaiinference.CohereToolResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// cohereSplitMessages 拆装消息:system 拼 preamble,末尾 user 抽为 message,
|
||||||
|
// assistant(含 toolCalls)与其余 user 进 history,tool 结果转顶层 toolResults
|
||||||
|
// (工具结果在末尾时 message 留空,COHERE 以 toolResults 续跑)。
|
||||||
|
func cohereSplitMessages(msgs []aiwire.ChatMessage) (cohereParts, error) {
|
||||||
|
lastUser, lastTool := -1, -1
|
||||||
|
for i, m := range msgs {
|
||||||
|
switch m.Role {
|
||||||
|
case "user":
|
||||||
|
lastUser = i
|
||||||
|
case "tool":
|
||||||
|
lastTool = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastUser == -1 && lastTool == -1 {
|
||||||
|
return cohereParts{}, fmt.Errorf("cohere 系模型至少需要一条 user 消息")
|
||||||
|
}
|
||||||
|
var p cohereParts
|
||||||
|
var preamble []string
|
||||||
|
calls := map[string]generativeaiinference.CohereToolCall{}
|
||||||
|
for i, m := range msgs {
|
||||||
|
text := m.Content.JoinText()
|
||||||
|
switch m.Role {
|
||||||
|
case "system", "developer":
|
||||||
|
preamble = append(preamble, text)
|
||||||
|
case "assistant":
|
||||||
|
p.history = append(p.history, cohereBotMessage(text, m.ToolCalls, calls))
|
||||||
|
case "tool":
|
||||||
|
p.toolResults = append(p.toolResults, cohereToolResult(m, calls))
|
||||||
|
default: // user
|
||||||
|
if i == lastUser && lastUser > lastTool {
|
||||||
|
p.message = text
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.history = append(p.history, generativeaiinference.CohereUserMessage{Message: &text})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.preamble = strings.Join(preamble, "\n")
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cohereBotMessage 转 assistant 消息;toolCalls 同步登记进 id→call 映射供 tool 结果回查。
|
||||||
|
func cohereBotMessage(text string, tcs []aiwire.ToolCall, calls map[string]generativeaiinference.CohereToolCall) generativeaiinference.CohereChatBotMessage {
|
||||||
|
msg := generativeaiinference.CohereChatBotMessage{}
|
||||||
|
if text != "" {
|
||||||
|
msg.Message = &text
|
||||||
|
}
|
||||||
|
for _, tc := range tcs {
|
||||||
|
name := tc.Function.Name
|
||||||
|
var params interface{}
|
||||||
|
if json.Unmarshal([]byte(tc.Function.Arguments), ¶ms) != nil || params == nil {
|
||||||
|
params = map[string]any{}
|
||||||
|
}
|
||||||
|
call := generativeaiinference.CohereToolCall{Name: &name, Parameters: ¶ms}
|
||||||
|
calls[tc.ID] = call
|
||||||
|
msg.ToolCalls = append(msg.ToolCalls, call)
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// cohereToolResult 把 IR tool 消息转顶层工具结果;COHERE 工具调用无 id,
|
||||||
|
// 靠 assistant 历史登记的映射回查,缺失时以 tool_call_id 名义调用兜底。
|
||||||
|
func cohereToolResult(m aiwire.ChatMessage, calls map[string]generativeaiinference.CohereToolCall) generativeaiinference.CohereToolResult {
|
||||||
|
call, ok := calls[m.ToolCallID]
|
||||||
|
if !ok {
|
||||||
|
name := m.ToolCallID
|
||||||
|
var params interface{} = map[string]any{}
|
||||||
|
call = generativeaiinference.CohereToolCall{Name: &name, Parameters: ¶ms}
|
||||||
|
}
|
||||||
|
text := m.Content.JoinText()
|
||||||
|
var output interface{}
|
||||||
|
if json.Unmarshal([]byte(text), &output) != nil || output == nil {
|
||||||
|
output = map[string]any{"output": text}
|
||||||
|
}
|
||||||
|
if arr, isArr := output.([]interface{}); isArr {
|
||||||
|
return generativeaiinference.CohereToolResult{Call: &call, Outputs: arr}
|
||||||
|
}
|
||||||
|
if _, isMap := output.(map[string]interface{}); !isMap {
|
||||||
|
output = map[string]any{"output": output}
|
||||||
|
}
|
||||||
|
return generativeaiinference.CohereToolResult{Call: &call, Outputs: []interface{}{output}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cohereTools 把 JSON Schema 工具定义降级为 COHERE 扁平参数表(嵌套结构有损:仅取顶层)。
|
||||||
|
func cohereTools(tools []aiwire.Tool) []generativeaiinference.CohereTool {
|
||||||
|
if len(tools) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]generativeaiinference.CohereTool, 0, len(tools))
|
||||||
|
for _, t := range tools {
|
||||||
|
name, desc := t.Function.Name, t.Function.Description
|
||||||
|
if desc == "" {
|
||||||
|
desc = name // Description 为 COHERE 必填
|
||||||
|
}
|
||||||
|
out = append(out, generativeaiinference.CohereTool{
|
||||||
|
Name: &name, Description: &desc,
|
||||||
|
ParameterDefinitions: cohereParams(t.Function.Parameters),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// cohereParams 取 JSON Schema 顶层 properties 转扁平参数定义;嵌套 schema 只保留类型名。
|
||||||
|
func cohereParams(schema json.RawMessage) map[string]generativeaiinference.CohereParameterDefinition {
|
||||||
|
var s struct {
|
||||||
|
Properties map[string]struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"properties"`
|
||||||
|
Required []string `json:"required"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(schema, &s) != nil || len(s.Properties) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
req := map[string]bool{}
|
||||||
|
for _, r := range s.Required {
|
||||||
|
req[r] = true
|
||||||
|
}
|
||||||
|
out := make(map[string]generativeaiinference.CohereParameterDefinition, len(s.Properties))
|
||||||
|
for k, v := range s.Properties {
|
||||||
|
typ, desc := v.Type, v.Description
|
||||||
|
if typ == "" {
|
||||||
|
typ = "string"
|
||||||
|
}
|
||||||
|
pd := generativeaiinference.CohereParameterDefinition{Type: &typ}
|
||||||
|
if desc != "" {
|
||||||
|
pd.Description = &desc
|
||||||
|
}
|
||||||
|
if req[k] {
|
||||||
|
t := true
|
||||||
|
pd.IsRequired = &t
|
||||||
|
}
|
||||||
|
out[k] = pd
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// cohereResponseFormat 映射 json_object / json_schema(name、strict 无对应,有损降级)。
|
||||||
|
func cohereResponseFormat(rf *aiwire.ResponseFormat) generativeaiinference.CohereResponseFormat {
|
||||||
|
if rf == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch rf.Type {
|
||||||
|
case "json_object", "json_schema":
|
||||||
|
out := generativeaiinference.CohereResponseJsonFormat{}
|
||||||
|
var in struct {
|
||||||
|
Schema json.RawMessage `json:"schema"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(rf.JSONSchema, &in) == nil && len(in.Schema) > 0 {
|
||||||
|
var schema interface{}
|
||||||
|
if json.Unmarshal(in.Schema, &schema) == nil {
|
||||||
|
out.Schema = &schema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cohereSDKToIR 把 COHERE 非流式响应转回 IR;工具调用补派生 ID,存在时结束原因归 tool_calls。
|
||||||
|
func cohereSDKToIR(resp generativeaiinference.CohereChatResponse, model string) *aiwire.ChatResponse {
|
||||||
|
msg := aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent(deref(resp.Text))}
|
||||||
|
for i, tc := range resp.ToolCalls {
|
||||||
|
msg.ToolCalls = append(msg.ToolCalls, cohereCallToIR(deref(tc.Name), tc.Parameters, i))
|
||||||
|
}
|
||||||
|
finish := mapFinishReason(string(resp.FinishReason))
|
||||||
|
if len(msg.ToolCalls) > 0 {
|
||||||
|
finish = "tool_calls"
|
||||||
|
}
|
||||||
|
return &aiwire.ChatResponse{
|
||||||
|
Object: "chat.completion",
|
||||||
|
Model: model,
|
||||||
|
Choices: []aiwire.Choice{{Message: msg, FinishReason: finish}},
|
||||||
|
Usage: sdkUsageToIR(resp.Usage),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cohereCallToIR 生成 IR 工具调用;COHERE 无调用 id,按名称+序号派生(回传时按此回查)。
|
||||||
|
func cohereCallToIR(name string, params *interface{}, idx int) aiwire.ToolCall {
|
||||||
|
args := "{}"
|
||||||
|
if params != nil && *params != nil {
|
||||||
|
if b, err := json.Marshal(*params); err == nil {
|
||||||
|
args = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aiwire.ToolCall{
|
||||||
|
ID: fmt.Sprintf("call_%s_%d", name, idx),
|
||||||
|
Type: "function",
|
||||||
|
Function: aiwire.FunctionCall{Name: name, Arguments: args},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
||||||
|
|
||||||
|
"oci-portal/internal/aiwire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// irToSDK 把 IR(OpenAI 线格式)转为 OCI GENERIC 聊天请求;字段近一一镜像。
|
||||||
|
func irToSDK(ir aiwire.ChatRequest, stream bool) generativeaiinference.GenericChatRequest {
|
||||||
|
req := generativeaiinference.GenericChatRequest{
|
||||||
|
Messages: irMessages(ir.Messages),
|
||||||
|
MaxTokens: ir.MaxTokens,
|
||||||
|
MaxCompletionTokens: ir.MaxCompletionTokens,
|
||||||
|
Temperature: ir.Temperature,
|
||||||
|
TopP: ir.TopP,
|
||||||
|
TopK: ir.TopK,
|
||||||
|
FrequencyPenalty: ir.FrequencyPenalty,
|
||||||
|
PresencePenalty: ir.PresencePenalty,
|
||||||
|
Seed: ir.Seed,
|
||||||
|
NumGenerations: ir.N,
|
||||||
|
Tools: irTools(ir.Tools),
|
||||||
|
ToolChoice: irToolChoice(ir.ToolChoice),
|
||||||
|
ResponseFormat: irResponseFormat(ir.ResponseFormat),
|
||||||
|
}
|
||||||
|
if len(ir.Stop) > 0 {
|
||||||
|
req.Stop = ir.Stop
|
||||||
|
}
|
||||||
|
if ir.ReasoningEffort != "" {
|
||||||
|
req.ReasoningEffort = generativeaiinference.GenericChatRequestReasoningEffortEnum(strings.ToUpper(ir.ReasoningEffort))
|
||||||
|
}
|
||||||
|
if stream {
|
||||||
|
req.IsStream = &stream
|
||||||
|
includeUsage := ir.StreamOptions == nil || ir.StreamOptions.IncludeUsage
|
||||||
|
req.StreamOptions = &generativeaiinference.StreamOptions{IsIncludeUsage: &includeUsage}
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// irMessages 按 role 拆装消息;user 消息保留图文块,其余角色只取文本。
|
||||||
|
func irMessages(msgs []aiwire.ChatMessage) []generativeaiinference.Message {
|
||||||
|
out := make([]generativeaiinference.Message, 0, len(msgs))
|
||||||
|
for _, m := range msgs {
|
||||||
|
switch m.Role {
|
||||||
|
case "system", "developer":
|
||||||
|
out = append(out, generativeaiinference.SystemMessage{Content: textContents(m.Content.JoinText())})
|
||||||
|
case "assistant":
|
||||||
|
out = append(out, generativeaiinference.AssistantMessage{
|
||||||
|
Content: textContents(m.Content.JoinText()),
|
||||||
|
ToolCalls: irToolCalls(m.ToolCalls),
|
||||||
|
})
|
||||||
|
case "tool":
|
||||||
|
tm := generativeaiinference.ToolMessage{Content: textContents(m.Content.JoinText())}
|
||||||
|
if m.ToolCallID != "" {
|
||||||
|
id := m.ToolCallID
|
||||||
|
tm.ToolCallId = &id
|
||||||
|
}
|
||||||
|
out = append(out, tm)
|
||||||
|
default: // user
|
||||||
|
out = append(out, generativeaiinference.UserMessage{Content: chatContents(m.Content)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatContents 把 IR 内容转 SDK 内容块;文本直通,image_url 转 IMAGE 块(data URI / 公网地址)。
|
||||||
|
func chatContents(c aiwire.Content) []generativeaiinference.ChatContent {
|
||||||
|
if len(c.Parts) == 0 {
|
||||||
|
return textContents(c.Text)
|
||||||
|
}
|
||||||
|
var out []generativeaiinference.ChatContent
|
||||||
|
for _, p := range c.Parts {
|
||||||
|
switch {
|
||||||
|
case p.Type == "image_url" && p.ImageURL != nil:
|
||||||
|
img := generativeaiinference.ImageUrl{Url: &p.ImageURL.URL}
|
||||||
|
if d, ok := generativeaiinference.GetMappingImageUrlDetailEnum(p.ImageURL.Detail); ok {
|
||||||
|
img.Detail = d
|
||||||
|
}
|
||||||
|
out = append(out, generativeaiinference.ImageContent{ImageUrl: &img})
|
||||||
|
case p.Text != "":
|
||||||
|
t := p.Text
|
||||||
|
out = append(out, generativeaiinference.TextContent{Text: &t})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// textContents 把纯文本包装为单元素 TextContent 数组;空文本返回 nil。
|
||||||
|
func textContents(text string) []generativeaiinference.ChatContent {
|
||||||
|
if text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []generativeaiinference.ChatContent{generativeaiinference.TextContent{Text: &text}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func irToolCalls(calls []aiwire.ToolCall) []generativeaiinference.ToolCall {
|
||||||
|
if len(calls) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]generativeaiinference.ToolCall, 0, len(calls))
|
||||||
|
for _, c := range calls {
|
||||||
|
id, name, args := c.ID, c.Function.Name, c.Function.Arguments
|
||||||
|
out = append(out, generativeaiinference.FunctionCall{Id: &id, Name: &name, Arguments: &args})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func irTools(tools []aiwire.Tool) []generativeaiinference.ToolDefinition {
|
||||||
|
if len(tools) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]generativeaiinference.ToolDefinition, 0, len(tools))
|
||||||
|
for _, t := range tools {
|
||||||
|
name, desc := t.Function.Name, t.Function.Description
|
||||||
|
fd := generativeaiinference.FunctionDefinition{Name: &name}
|
||||||
|
if desc != "" {
|
||||||
|
fd.Description = &desc
|
||||||
|
}
|
||||||
|
if len(t.Function.Parameters) > 0 {
|
||||||
|
var params interface{}
|
||||||
|
if json.Unmarshal(t.Function.Parameters, ¶ms) == nil {
|
||||||
|
fd.Parameters = ¶ms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, fd)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// irToolChoice 解析 "auto"/"none"/"required" 或 {type:function,function:{name}}。
|
||||||
|
func irToolChoice(raw json.RawMessage) generativeaiinference.ToolChoice {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(raw, &s) == nil {
|
||||||
|
switch s {
|
||||||
|
case "none":
|
||||||
|
return generativeaiinference.ToolChoiceNone{}
|
||||||
|
case "required":
|
||||||
|
return generativeaiinference.ToolChoiceRequired{}
|
||||||
|
case "auto":
|
||||||
|
return generativeaiinference.ToolChoiceAuto{}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var obj struct {
|
||||||
|
Function struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"function"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &obj) == nil && obj.Function.Name != "" {
|
||||||
|
return generativeaiinference.ToolChoiceFunction{Name: &obj.Function.Name}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func irResponseFormat(rf *aiwire.ResponseFormat) generativeaiinference.ResponseFormat {
|
||||||
|
if rf == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch rf.Type {
|
||||||
|
case "json_object":
|
||||||
|
return generativeaiinference.JsonObjectResponseFormat{}
|
||||||
|
case "json_schema":
|
||||||
|
return irJSONSchemaFormat(rf.JSONSchema)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// irJSONSchemaFormat 映射 OpenAI json_schema{name,description,schema,strict}。
|
||||||
|
func irJSONSchemaFormat(raw json.RawMessage) generativeaiinference.ResponseFormat {
|
||||||
|
var in struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Schema json.RawMessage `json:"schema"`
|
||||||
|
Strict *bool `json:"strict"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &in) != nil || in.Name == "" {
|
||||||
|
return generativeaiinference.JsonObjectResponseFormat{}
|
||||||
|
}
|
||||||
|
js := generativeaiinference.ResponseJsonSchema{Name: &in.Name, IsStrict: in.Strict}
|
||||||
|
if in.Description != "" {
|
||||||
|
js.Description = &in.Description
|
||||||
|
}
|
||||||
|
if len(in.Schema) > 0 {
|
||||||
|
var schema interface{}
|
||||||
|
if json.Unmarshal(in.Schema, &schema) == nil {
|
||||||
|
js.Schema = &schema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return generativeaiinference.JsonSchemaResponseFormat{JsonSchema: &js}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sdkToIR 把非流式 GenericChatResponse 转回 IR 响应;id/created 由调用方补。
|
||||||
|
func sdkToIR(resp generativeaiinference.GenericChatResponse, model string) *aiwire.ChatResponse {
|
||||||
|
out := &aiwire.ChatResponse{Object: "chat.completion", Model: model}
|
||||||
|
if resp.TimeCreated != nil {
|
||||||
|
out.Created = resp.TimeCreated.Unix()
|
||||||
|
}
|
||||||
|
for i, ch := range resp.Choices {
|
||||||
|
idx := i
|
||||||
|
if ch.Index != nil {
|
||||||
|
idx = *ch.Index
|
||||||
|
}
|
||||||
|
out.Choices = append(out.Choices, aiwire.Choice{
|
||||||
|
Index: idx,
|
||||||
|
Message: sdkMessageToIR(ch.Message),
|
||||||
|
FinishReason: mapFinishReason(deref(ch.FinishReason)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out.Usage = sdkUsageToIR(resp.Usage)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sdkUsageToIR(u *generativeaiinference.Usage) *aiwire.Usage {
|
||||||
|
if u == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := &aiwire.Usage{}
|
||||||
|
if u.PromptTokens != nil {
|
||||||
|
out.PromptTokens = *u.PromptTokens
|
||||||
|
}
|
||||||
|
if u.CompletionTokens != nil {
|
||||||
|
out.CompletionTokens = *u.CompletionTokens
|
||||||
|
}
|
||||||
|
if u.TotalTokens != nil {
|
||||||
|
out.TotalTokens = *u.TotalTokens
|
||||||
|
}
|
||||||
|
if d := u.PromptTokensDetails; d != nil && d.CachedTokens != nil && *d.CachedTokens > 0 {
|
||||||
|
out.PromptTokensDetails = &aiwire.PromptTokensDetails{CachedTokens: *d.CachedTokens}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// sdkMessageToIR 提取助手消息文本与工具调用。
|
||||||
|
func sdkMessageToIR(m generativeaiinference.Message) aiwire.ChatMessage {
|
||||||
|
out := aiwire.ChatMessage{Role: "assistant"}
|
||||||
|
am, ok := m.(generativeaiinference.AssistantMessage)
|
||||||
|
if !ok {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, c := range am.Content {
|
||||||
|
if tc, ok := c.(generativeaiinference.TextContent); ok && tc.Text != nil {
|
||||||
|
sb.WriteString(*tc.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Content = aiwire.NewTextContent(sb.String())
|
||||||
|
for _, call := range am.ToolCalls {
|
||||||
|
if fc, ok := call.(generativeaiinference.FunctionCall); ok {
|
||||||
|
out.ToolCalls = append(out.ToolCalls, aiwire.ToolCall{
|
||||||
|
ID: deref(fc.Id),
|
||||||
|
Type: "function",
|
||||||
|
Function: aiwire.FunctionCall{
|
||||||
|
Name: deref(fc.Name),
|
||||||
|
Arguments: deref(fc.Arguments),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapFinishReason 归一化结束原因;OCI 与 OpenAI 取值同名,做小写与别名兜底。
|
||||||
|
func mapFinishReason(reason string) string {
|
||||||
|
switch strings.ToLower(reason) {
|
||||||
|
case "", "null":
|
||||||
|
return ""
|
||||||
|
case "stop", "completed", "end_turn":
|
||||||
|
return "stop"
|
||||||
|
case "length", "max_tokens":
|
||||||
|
return "length"
|
||||||
|
case "tool_calls", "tool_call", "tool_use":
|
||||||
|
return "tool_calls"
|
||||||
|
case "complete":
|
||||||
|
return "stop"
|
||||||
|
case "error_toxic":
|
||||||
|
return "content_filter"
|
||||||
|
default:
|
||||||
|
return strings.ToLower(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// genAiStreamEvent 是 GENERIC 流式事件的宽容解析结构(字段按增量 choice 形状)。
|
||||||
|
type genAiStreamEvent struct {
|
||||||
|
Index *int `json:"index"`
|
||||||
|
Message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
ToolCalls []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
} `json:"toolCalls"`
|
||||||
|
} `json:"message"`
|
||||||
|
FinishReason *string `json:"finishReason"`
|
||||||
|
Usage *generativeaiinference.Usage `json:"usage"`
|
||||||
|
Choices []json.RawMessage `json:"choices"` // 兼容整包 choices 形态
|
||||||
|
Text string `json:"text"` // COHERE 流事件的顶层增量文本
|
||||||
|
// CohereCalls 是 COHERE 流事件顶层的工具调用(与 GENERIC 的 message.toolCalls 不同层级)
|
||||||
|
CohereCalls []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Parameters interface{} `json:"parameters"`
|
||||||
|
} `json:"toolCalls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGenAiEvent 把一条 SSE 事件 JSON 解析为 IR chunk;无有效负载时 ok=false。
|
||||||
|
func parseGenAiEvent(data []byte, model string) (aiwire.ChatChunk, bool) {
|
||||||
|
var ev genAiStreamEvent
|
||||||
|
if err := json.Unmarshal(data, &ev); err != nil {
|
||||||
|
return aiwire.ChatChunk{}, false
|
||||||
|
}
|
||||||
|
if len(ev.Choices) > 0 { // choices 包裹形态:取首个展开重解析
|
||||||
|
var inner genAiStreamEvent
|
||||||
|
if json.Unmarshal(ev.Choices[0], &inner) == nil {
|
||||||
|
inner.Usage = ev.Usage
|
||||||
|
ev = inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chunk := aiwire.ChatChunk{Object: "chat.completion.chunk", Model: model}
|
||||||
|
choice := aiwire.ChunkChoice{}
|
||||||
|
if ev.Index != nil {
|
||||||
|
choice.Index = *ev.Index
|
||||||
|
}
|
||||||
|
choice.Delta.Role = strings.ToLower(ev.Message.Role)
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, c := range ev.Message.Content {
|
||||||
|
sb.WriteString(c.Text)
|
||||||
|
}
|
||||||
|
if sb.Len() == 0 && ev.Text != "" { // COHERE 事件形态
|
||||||
|
sb.WriteString(ev.Text)
|
||||||
|
}
|
||||||
|
choice.Delta.Content = sb.String()
|
||||||
|
for i, tc := range ev.Message.ToolCalls {
|
||||||
|
d := aiwire.ToolCallDelta{Index: i, ID: tc.ID}
|
||||||
|
if tc.ID != "" {
|
||||||
|
d.Type = "function"
|
||||||
|
}
|
||||||
|
d.Function.Name = tc.Name
|
||||||
|
d.Function.Arguments = tc.Arguments
|
||||||
|
choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, d)
|
||||||
|
}
|
||||||
|
for i, tc := range ev.CohereCalls { // COHERE 顶层工具调用:整包出现,补派生 ID
|
||||||
|
call := cohereCallToIR(tc.Name, &tc.Parameters, i)
|
||||||
|
d := aiwire.ToolCallDelta{Index: len(choice.Delta.ToolCalls) + i, ID: call.ID, Type: "function"}
|
||||||
|
d.Function.Name = call.Function.Name
|
||||||
|
d.Function.Arguments = call.Function.Arguments
|
||||||
|
choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, d)
|
||||||
|
}
|
||||||
|
if r := mapFinishReason(deref(ev.FinishReason)); r != "" {
|
||||||
|
choice.FinishReason = &r
|
||||||
|
}
|
||||||
|
hasPayload := choice.Delta.Content != "" || len(choice.Delta.ToolCalls) > 0 || choice.FinishReason != nil
|
||||||
|
if hasPayload {
|
||||||
|
chunk.Choices = []aiwire.ChunkChoice{choice}
|
||||||
|
}
|
||||||
|
chunk.Usage = sdkUsageToIR(ev.Usage)
|
||||||
|
return chunk, hasPayload || chunk.Usage != nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/generativeai"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestToGenAiModelRetiredFilter(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 9, 0, 0, 0, 0, time.UTC)
|
||||||
|
past := common.SDKTime{Time: now.Add(-24 * time.Hour)}
|
||||||
|
future := common.SDKTime{Time: now.Add(30 * 24 * time.Hour)}
|
||||||
|
chat := []generativeai.ModelCapabilityEnum{generativeai.ModelCapabilityChat}
|
||||||
|
base := generativeai.ModelSummary{
|
||||||
|
Id: common.String("ocid1..m"), DisplayName: common.String("meta.llama-x"),
|
||||||
|
Vendor: common.String("meta"), Capabilities: chat,
|
||||||
|
LifecycleState: generativeai.ModelLifecycleStateActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已过按需退役时间:不入池(ListModels 仍返回且 state ACTIVE,调用必 404)
|
||||||
|
retired := base
|
||||||
|
retired.TimeOnDemandRetired = &past
|
||||||
|
if _, ok := toGenAiModel(retired, now); ok {
|
||||||
|
t.Error("已退役模型应被剔除")
|
||||||
|
}
|
||||||
|
// 退役时间在未来:入池并携带弃用/退役时间
|
||||||
|
upcoming := base
|
||||||
|
upcoming.TimeDeprecated = &past
|
||||||
|
upcoming.TimeOnDemandRetired = &future
|
||||||
|
gm, ok := toGenAiModel(upcoming, now)
|
||||||
|
if !ok || gm.Retired == nil || !gm.Retired.Equal(future.Time) || gm.Deprecated == nil {
|
||||||
|
t.Fatalf("未退役模型应入池并带时间: ok=%v %+v", ok, gm)
|
||||||
|
}
|
||||||
|
// 未宣布任何时间:正常入池
|
||||||
|
if _, ok := toGenAiModel(base, now); !ok {
|
||||||
|
t.Error("正常模型应入池")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
||||||
|
|
||||||
|
"oci-portal/internal/aiwire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIrToSDK 断言 IR→GENERIC 的角色拆装、采样参数与工具映射。
|
||||||
|
func TestIrToSDK(t *testing.T) {
|
||||||
|
temp, mt := 0.7, 100
|
||||||
|
ir := aiwire.ChatRequest{
|
||||||
|
Model: "meta.llama-3.3-70b-instruct",
|
||||||
|
Messages: []aiwire.ChatMessage{
|
||||||
|
{Role: "system", Content: aiwire.NewTextContent("你是助手")},
|
||||||
|
{Role: "user", Content: aiwire.NewTextContent("你好")},
|
||||||
|
{Role: "assistant", ToolCalls: []aiwire.ToolCall{{ID: "c1", Type: "function", Function: aiwire.FunctionCall{Name: "get_weather", Arguments: `{"city":"东京"}`}}}},
|
||||||
|
{Role: "tool", ToolCallID: "c1", Content: aiwire.NewTextContent("晴")},
|
||||||
|
},
|
||||||
|
Temperature: &temp,
|
||||||
|
MaxTokens: &mt,
|
||||||
|
Stop: aiwire.StringList{"END"},
|
||||||
|
Tools: []aiwire.Tool{{Type: "function", Function: aiwire.FunctionDef{Name: "get_weather", Parameters: json.RawMessage(`{"type":"object"}`)}}},
|
||||||
|
ToolChoice: json.RawMessage(`"auto"`),
|
||||||
|
}
|
||||||
|
req := irToSDK(ir, true)
|
||||||
|
if len(req.Messages) != 4 {
|
||||||
|
t.Fatalf("messages = %d, want 4", len(req.Messages))
|
||||||
|
}
|
||||||
|
if _, ok := req.Messages[0].(generativeaiinference.SystemMessage); !ok {
|
||||||
|
t.Fatalf("messages[0] = %T, want SystemMessage", req.Messages[0])
|
||||||
|
}
|
||||||
|
am, ok := req.Messages[2].(generativeaiinference.AssistantMessage)
|
||||||
|
if !ok || len(am.ToolCalls) != 1 {
|
||||||
|
t.Fatalf("assistant tool calls 未映射: %T %+v", req.Messages[2], am)
|
||||||
|
}
|
||||||
|
tm, ok := req.Messages[3].(generativeaiinference.ToolMessage)
|
||||||
|
if !ok || deref(tm.ToolCallId) != "c1" {
|
||||||
|
t.Fatalf("tool message 未映射 toolCallId: %+v", tm)
|
||||||
|
}
|
||||||
|
if req.Temperature == nil || *req.Temperature != 0.7 {
|
||||||
|
t.Fatalf("temperature 未直通")
|
||||||
|
}
|
||||||
|
if req.MaxTokens == nil || *req.MaxTokens != 100 || len(req.Stop) != 1 {
|
||||||
|
t.Fatalf("maxTokens/stop 未直通")
|
||||||
|
}
|
||||||
|
if _, ok := req.ToolChoice.(generativeaiinference.ToolChoiceAuto); !ok {
|
||||||
|
t.Fatalf("toolChoice = %T, want auto", req.ToolChoice)
|
||||||
|
}
|
||||||
|
if len(req.Tools) != 1 || req.IsStream == nil || !*req.IsStream || req.StreamOptions == nil {
|
||||||
|
t.Fatalf("tools/stream 选项未映射")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSdkToIR 断言响应文本、工具调用与用量的反向映射。
|
||||||
|
func TestSdkToIR(t *testing.T) {
|
||||||
|
text, fr := "东京晴", "tool_calls"
|
||||||
|
idx, pt, ct, tt := 0, 10, 5, 15
|
||||||
|
id, name, args := "c1", "get_weather", `{"city":"东京"}`
|
||||||
|
resp := generativeaiinference.GenericChatResponse{
|
||||||
|
Choices: []generativeaiinference.ChatChoice{{
|
||||||
|
Index: &idx,
|
||||||
|
Message: generativeaiinference.AssistantMessage{
|
||||||
|
Content: []generativeaiinference.ChatContent{generativeaiinference.TextContent{Text: &text}},
|
||||||
|
ToolCalls: []generativeaiinference.ToolCall{generativeaiinference.FunctionCall{Id: &id, Name: &name, Arguments: &args}},
|
||||||
|
},
|
||||||
|
FinishReason: &fr,
|
||||||
|
}},
|
||||||
|
Usage: &generativeaiinference.Usage{PromptTokens: &pt, CompletionTokens: &ct, TotalTokens: &tt},
|
||||||
|
}
|
||||||
|
out := sdkToIR(resp, "m1")
|
||||||
|
if len(out.Choices) != 1 || out.Choices[0].Message.Content.JoinText() != "东京晴" {
|
||||||
|
t.Fatalf("文本未映射: %+v", out)
|
||||||
|
}
|
||||||
|
if out.Choices[0].FinishReason != "tool_calls" || len(out.Choices[0].Message.ToolCalls) != 1 {
|
||||||
|
t.Fatalf("finishReason/toolCalls 未映射: %+v", out.Choices[0])
|
||||||
|
}
|
||||||
|
if out.Usage == nil || out.Usage.TotalTokens != 15 {
|
||||||
|
t.Fatalf("usage 未映射: %+v", out.Usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseGenAiEvent 断言流事件宽容解析:文本增量、结束原因与 usage 事件。
|
||||||
|
func TestParseGenAiEvent(t *testing.T) {
|
||||||
|
chunk, ok := parseGenAiEvent([]byte(`{"index":0,"message":{"role":"ASSISTANT","content":[{"type":"TEXT","text":"你"}]}}`), "m1")
|
||||||
|
if !ok || len(chunk.Choices) != 1 || chunk.Choices[0].Delta.Content != "你" {
|
||||||
|
t.Fatalf("文本增量解析失败: %+v", chunk)
|
||||||
|
}
|
||||||
|
chunk, ok = parseGenAiEvent([]byte(`{"finishReason":"stop","usage":{"promptTokens":3,"completionTokens":2,"totalTokens":5}}`), "m1")
|
||||||
|
if !ok || chunk.Choices[0].FinishReason == nil || *chunk.Choices[0].FinishReason != "stop" {
|
||||||
|
t.Fatalf("finishReason 解析失败: %+v", chunk)
|
||||||
|
}
|
||||||
|
if chunk.Usage == nil || chunk.Usage.TotalTokens != 5 {
|
||||||
|
t.Fatalf("usage 解析失败: %+v", chunk.Usage)
|
||||||
|
}
|
||||||
|
if _, ok := parseGenAiEvent([]byte(`{}`), "m1"); ok {
|
||||||
|
t.Fatal("空事件应返回 ok=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSdkUsageCachedTokens(t *testing.T) {
|
||||||
|
p, c, tot, cached := 10, 5, 15, 8
|
||||||
|
u := sdkUsageToIR(&generativeaiinference.Usage{PromptTokens: &p, CompletionTokens: &c, TotalTokens: &tot,
|
||||||
|
PromptTokensDetails: &generativeaiinference.PromptTokensDetails{CachedTokens: &cached}})
|
||||||
|
if u.CachedTokens() != 8 {
|
||||||
|
t.Errorf("CachedTokens = %d, want 8", u.CachedTokens())
|
||||||
|
}
|
||||||
|
if sdkUsageToIR(&generativeaiinference.Usage{PromptTokens: &p}).CachedTokens() != 0 {
|
||||||
|
t.Error("无细分时 CachedTokens 应为 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIrToCohereSDK(t *testing.T) {
|
||||||
|
ir := aiwire.ChatRequest{
|
||||||
|
Model: "cohere.command-r-plus",
|
||||||
|
Messages: []aiwire.ChatMessage{
|
||||||
|
{Role: "system", Content: aiwire.NewTextContent("你是助手")},
|
||||||
|
{Role: "user", Content: aiwire.NewTextContent("第一问")},
|
||||||
|
{Role: "assistant", Content: aiwire.NewTextContent("第一答")},
|
||||||
|
{Role: "user", Content: aiwire.NewTextContent("第二问")},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req, err := irToCohereSDK(ir, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("irToCohereSDK: %v", err)
|
||||||
|
}
|
||||||
|
if *req.Message != "第二问" || *req.PreambleOverride != "你是助手" || len(req.ChatHistory) != 2 {
|
||||||
|
t.Errorf("拆装错误: msg=%q preamble=%v history=%d", *req.Message, req.PreambleOverride, len(req.ChatHistory))
|
||||||
|
}
|
||||||
|
if _, ok := req.ChatHistory[0].(generativeaiinference.CohereUserMessage); !ok {
|
||||||
|
t.Errorf("history[0] 应为 user: %T", req.ChatHistory[0])
|
||||||
|
}
|
||||||
|
if _, ok := req.ChatHistory[1].(generativeaiinference.CohereChatBotMessage); !ok {
|
||||||
|
t.Errorf("history[1] 应为 chatbot: %T", req.ChatHistory[1])
|
||||||
|
}
|
||||||
|
if req.IsStream == nil || !*req.IsStream {
|
||||||
|
t.Error("IsStream 未设置")
|
||||||
|
}
|
||||||
|
if req.StreamOptions == nil || req.StreamOptions.IsIncludeUsage == nil || !*req.StreamOptions.IsIncludeUsage {
|
||||||
|
t.Error("流式应默认开启 usage 回传(StreamOptions.IsIncludeUsage)")
|
||||||
|
}
|
||||||
|
// 工具定义降级为扁平参数表(仅取 JSON Schema 顶层 properties)
|
||||||
|
ir.Tools = []aiwire.Tool{{Type: "function", Function: aiwire.FunctionDef{
|
||||||
|
Name: "get_weather", Parameters: json.RawMessage(`{"type":"object","properties":{"city":{"type":"string","description":"城市"}},"required":["city"]}`)}}}
|
||||||
|
req2, err := irToCohereSDK(ir, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("工具请求应支持: %v", err)
|
||||||
|
}
|
||||||
|
tool := req2.Tools[0]
|
||||||
|
if *tool.Name != "get_weather" || *tool.Description != "get_weather" {
|
||||||
|
t.Errorf("tool = %+v", tool)
|
||||||
|
}
|
||||||
|
if pd, ok := tool.ParameterDefinitions["city"]; !ok || *pd.Type != "string" || pd.IsRequired == nil || !*pd.IsRequired {
|
||||||
|
t.Errorf("param city = %+v", pd)
|
||||||
|
}
|
||||||
|
// 图片输入仍拒绝
|
||||||
|
ir.Tools = nil
|
||||||
|
ir.Messages = append(ir.Messages, aiwire.ChatMessage{Role: "user", Content: aiwire.NewPartsContent([]aiwire.ContentPart{
|
||||||
|
{Type: "image_url", ImageURL: &aiwire.ImageURL{URL: "data:image/png;base64,xx"}}})})
|
||||||
|
if _, err := irToCohereSDK(ir, false); err == nil {
|
||||||
|
t.Error("图片输入应被拒绝")
|
||||||
|
}
|
||||||
|
// 无 user 消息被拒
|
||||||
|
if _, err := irToCohereSDK(aiwire.ChatRequest{Model: "cohere.x", Messages: []aiwire.ChatMessage{{Role: "system", Content: aiwire.NewTextContent("s")}}}, false); err == nil {
|
||||||
|
t.Error("无 user 消息应被拒绝")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCohereSDKToIR(t *testing.T) {
|
||||||
|
text := "回答"
|
||||||
|
resp := generativeaiinference.CohereChatResponse{
|
||||||
|
Text: &text,
|
||||||
|
FinishReason: generativeaiinference.CohereChatResponseFinishReasonComplete,
|
||||||
|
}
|
||||||
|
out := cohereSDKToIR(resp, "cohere.command-r-plus")
|
||||||
|
if out.Choices[0].Message.Content.JoinText() != "回答" || out.Choices[0].FinishReason != "stop" {
|
||||||
|
t.Errorf("cohereSDKToIR = %+v", out.Choices[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseGenAiEventCohere(t *testing.T) {
|
||||||
|
chunk, ok := parseGenAiEvent([]byte(`{"apiFormat":"COHERE","text":"你好"}`), "cohere.command-r")
|
||||||
|
if !ok || chunk.Choices[0].Delta.Content != "你好" {
|
||||||
|
t.Errorf("cohere text 事件解析 = %+v, %v", chunk, ok)
|
||||||
|
}
|
||||||
|
chunk, ok = parseGenAiEvent([]byte(`{"apiFormat":"COHERE","finishReason":"COMPLETE","usage":{"promptTokens":3,"completionTokens":5,"totalTokens":8}}`), "cohere.command-r")
|
||||||
|
if !ok || *chunk.Choices[0].FinishReason != "stop" || chunk.Usage.TotalTokens != 8 {
|
||||||
|
t.Errorf("cohere 终帧解析 = %+v, %v", chunk, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Image 是一个可用的实例镜像。
|
||||||
|
type Image struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
OperatingSystem string `json:"operatingSystem"`
|
||||||
|
OperatingSystemVersion string `json:"operatingSystemVersion"`
|
||||||
|
SizeInMBs int64 `json:"sizeInMBs"`
|
||||||
|
TimeCreated *time.Time `json:"timeCreated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImagesQuery 是镜像查询条件。
|
||||||
|
type ImagesQuery struct {
|
||||||
|
Region string // 目标区域,空则用凭据默认区域
|
||||||
|
OperatingSystem string // 如 Canonical Ubuntu、Oracle Linux
|
||||||
|
Shape string // 只返回兼容该 shape 的镜像,如 VM.Standard.A1.Flex
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListImages 实现 Client:分页列出租户可用的平台镜像。
|
||||||
|
func (c *RealClient) ListImages(ctx context.Context, cred Credentials, q ImagesQuery) ([]Image, error) {
|
||||||
|
cc, err := core.NewComputeClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("new compute client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&cc.BaseClient, cred)
|
||||||
|
if q.Region != "" {
|
||||||
|
cc.SetRegion(normalizeRegion(q.Region))
|
||||||
|
}
|
||||||
|
var images []Image
|
||||||
|
var page *string
|
||||||
|
for {
|
||||||
|
resp, err := cc.ListImages(ctx, buildImagesRequest(cred, q, page))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list images: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
images = append(images, toImage(item))
|
||||||
|
}
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
return orEmpty(images), nil
|
||||||
|
}
|
||||||
|
page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetImage 实现 Client:查询单个镜像(按 imageId 补显示名等信息)。
|
||||||
|
func (c *RealClient) GetImage(ctx context.Context, cred Credentials, region, imageID string) (Image, error) {
|
||||||
|
cc, err := core.NewComputeClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return Image{}, fmt.Errorf("new compute client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&cc.BaseClient, cred)
|
||||||
|
if region != "" {
|
||||||
|
cc.SetRegion(normalizeRegion(region))
|
||||||
|
}
|
||||||
|
resp, err := cc.GetImage(ctx, core.GetImageRequest{ImageId: &imageID})
|
||||||
|
if err != nil {
|
||||||
|
return Image{}, fmt.Errorf("get image: %w", err)
|
||||||
|
}
|
||||||
|
return toImage(resp.Image), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildImagesRequest 组装镜像列表请求;过滤条件仅在非空时下发。
|
||||||
|
func buildImagesRequest(cred Credentials, q ImagesQuery, page *string) core.ListImagesRequest {
|
||||||
|
req := core.ListImagesRequest{
|
||||||
|
CompartmentId: cred.EffectiveCompartment(),
|
||||||
|
Page: page,
|
||||||
|
LifecycleState: core.ImageLifecycleStateAvailable,
|
||||||
|
}
|
||||||
|
if q.OperatingSystem != "" {
|
||||||
|
req.OperatingSystem = &q.OperatingSystem
|
||||||
|
}
|
||||||
|
if q.Shape != "" {
|
||||||
|
req.Shape = &q.Shape
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func toImage(img core.Image) Image {
|
||||||
|
out := Image{
|
||||||
|
ID: deref(img.Id),
|
||||||
|
DisplayName: deref(img.DisplayName),
|
||||||
|
OperatingSystem: deref(img.OperatingSystem),
|
||||||
|
OperatingSystemVersion: deref(img.OperatingSystemVersion),
|
||||||
|
TimeCreated: sdkTime(img.TimeCreated),
|
||||||
|
}
|
||||||
|
if img.SizeInMBs != nil {
|
||||||
|
out.SizeInMBs = *img.SizeInMBs
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/core"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Instance 是一个计算实例;IP 字段来自 VNIC 补全查询。
|
||||||
|
type Instance struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
LifecycleState string `json:"lifecycleState"`
|
||||||
|
Shape string `json:"shape"`
|
||||||
|
Ocpus float32 `json:"ocpus"`
|
||||||
|
MemoryInGBs float32 `json:"memoryInGBs"`
|
||||||
|
AvailabilityDomain string `json:"availabilityDomain"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
CompartmentID string `json:"compartmentId"`
|
||||||
|
ImageID string `json:"imageId"`
|
||||||
|
SubnetID string `json:"subnetId,omitempty"`
|
||||||
|
PrivateIP string `json:"privateIp,omitempty"`
|
||||||
|
PublicIP string `json:"publicIp,omitempty"`
|
||||||
|
Ipv6Addresses []string `json:"ipv6Addresses,omitempty"`
|
||||||
|
FreeformTags map[string]string `json:"freeformTags,omitempty"`
|
||||||
|
DefinedTags map[string]map[string]any `json:"definedTags,omitempty"`
|
||||||
|
TimeCreated *time.Time `json:"timeCreated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInstanceInput 是创建实例的输入:镜像与引导卷二选一作为启动源,
|
||||||
|
// Flex shape 必须提供 ocpus 与 memoryInGBs。
|
||||||
|
type CreateInstanceInput struct {
|
||||||
|
Region string
|
||||||
|
CompartmentID string // 目标 compartment,空为租户根
|
||||||
|
AvailabilityDomain string
|
||||||
|
DisplayName string
|
||||||
|
Shape string
|
||||||
|
Ocpus float32
|
||||||
|
MemoryInGBs float32
|
||||||
|
ImageID string
|
||||||
|
BootVolumeID string
|
||||||
|
BootVolumeSizeGBs int64
|
||||||
|
BootVolumeVpusPerGB int64 // 引导卷性能,10 均衡 / 20 高性能,仅镜像启动源生效
|
||||||
|
SubnetID string // 为空时自动创建 VCN 与子网
|
||||||
|
AssignPublicIP bool
|
||||||
|
AssignIpv6 bool
|
||||||
|
SSHPublicKey string // 与 RootPassword 互斥
|
||||||
|
RootPassword string // 与 SSHPublicKey 互斥,非空时生成开启 root 密码登录的 cloud-init
|
||||||
|
GenerateRootPassword bool // 由服务端生成随机 root 密码并写入 FreeformTags["RootPassword"]
|
||||||
|
UserData string // 显式 cloud-init,base64 编码;RootPassword 非空时被其生成脚本覆盖
|
||||||
|
FreeformTags map[string]string // 实例自由标签
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateInstanceInput 是更新实例的输入;零值字段不更新。
|
||||||
|
type UpdateInstanceInput struct {
|
||||||
|
Region string
|
||||||
|
DisplayName string
|
||||||
|
Shape string // 更换 shape;运行中实例 OCI 会自动重启,换 Flex 时须同时给 ocpus
|
||||||
|
Ocpus float32
|
||||||
|
MemoryInGBs float32
|
||||||
|
}
|
||||||
|
|
||||||
|
// instanceActions 是允许的电源操作集合。
|
||||||
|
var instanceActions = map[string]core.InstanceActionActionEnum{
|
||||||
|
"START": core.InstanceActionActionStart,
|
||||||
|
"STOP": core.InstanceActionActionStop,
|
||||||
|
"RESET": core.InstanceActionActionReset,
|
||||||
|
"SOFTSTOP": core.InstanceActionActionSoftstop,
|
||||||
|
"SOFTRESET": core.InstanceActionActionSoftreset,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RealClient) computeClient(cred Credentials, region string) (core.ComputeClient, error) {
|
||||||
|
cc, err := core.NewComputeClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return cc, fmt.Errorf("new compute client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&cc.BaseClient, cred)
|
||||||
|
if region != "" {
|
||||||
|
cc.SetRegion(normalizeRegion(region))
|
||||||
|
}
|
||||||
|
return cc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAvailabilityDomains 实现 Client:列出区域内的可用域名称。
|
||||||
|
func (c *RealClient) ListAvailabilityDomains(ctx context.Context, cred Credentials, region string) ([]string, error) {
|
||||||
|
ic, err := identity.NewIdentityClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("new identity client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&ic.BaseClient, cred)
|
||||||
|
if region != "" {
|
||||||
|
ic.SetRegion(normalizeRegion(region))
|
||||||
|
}
|
||||||
|
resp, err := ic.ListAvailabilityDomains(ctx, identity.ListAvailabilityDomainsRequest{
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list availability domains: %w", err)
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(resp.Items))
|
||||||
|
for _, ad := range resp.Items {
|
||||||
|
names = append(names, deref(ad.Name))
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultAvailabilityDomain 返回区域内第一个可用域(即 AD-1)。
|
||||||
|
func (c *RealClient) defaultAvailabilityDomain(ctx context.Context, cred Credentials, region string) (string, error) {
|
||||||
|
ads, err := c.ListAvailabilityDomains(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve default availability domain: %w", err)
|
||||||
|
}
|
||||||
|
if len(ads) == 0 {
|
||||||
|
return "", fmt.Errorf("resolve default availability domain: region has no availability domain")
|
||||||
|
}
|
||||||
|
return ads[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInstances 实现 Client:列出实例并补全网络 IP 信息。
|
||||||
|
func (c *RealClient) ListInstances(ctx context.Context, cred Credentials, region string) ([]Instance, error) {
|
||||||
|
cc, err := c.computeClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var instances []Instance
|
||||||
|
var page *string
|
||||||
|
for {
|
||||||
|
resp, err := cc.ListInstances(ctx, core.ListInstancesRequest{
|
||||||
|
CompartmentId: cred.EffectiveCompartment(),
|
||||||
|
Page: page,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list instances: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
// 已终止实例的 VNIC 已不存在,保留在列表中仅供展示
|
||||||
|
instances = append(instances, toInstance(item))
|
||||||
|
}
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
c.attachInstanceIPs(ctx, cred, region, instances)
|
||||||
|
// 列表瘦身:标签体积大且列表页不消费(详情接口仍完整返回)
|
||||||
|
for i := range instances {
|
||||||
|
instances[i].FreeformTags = nil
|
||||||
|
instances[i].DefinedTags = nil
|
||||||
|
}
|
||||||
|
return orEmpty(instances), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LaunchInstance 实现 Client;availabilityDomain 为空时默认使用第一个
|
||||||
|
// 可用域(ad-1),subnetId 为空时自动准备默认网络。
|
||||||
|
func (c *RealClient) LaunchInstance(ctx context.Context, cred Credentials, in CreateInstanceInput) (Instance, error) {
|
||||||
|
cc, err := c.computeClient(cred, in.Region)
|
||||||
|
if err != nil {
|
||||||
|
return Instance{}, err
|
||||||
|
}
|
||||||
|
if in.AvailabilityDomain == "" {
|
||||||
|
ad, err := c.defaultAvailabilityDomain(ctx, cred, in.Region)
|
||||||
|
if err != nil {
|
||||||
|
return Instance{}, err
|
||||||
|
}
|
||||||
|
in.AvailabilityDomain = ad
|
||||||
|
}
|
||||||
|
if in.SubnetID == "" {
|
||||||
|
subnetID, err := c.ensureDefaultSubnet(ctx, cred, in.Region)
|
||||||
|
if err != nil {
|
||||||
|
return Instance{}, fmt.Errorf("auto provision subnet: %w", err)
|
||||||
|
}
|
||||||
|
in.SubnetID = subnetID
|
||||||
|
}
|
||||||
|
details, err := buildLaunchDetails(*cred.EffectiveCompartment(), in)
|
||||||
|
if err != nil {
|
||||||
|
return Instance{}, err
|
||||||
|
}
|
||||||
|
resp, err := cc.LaunchInstance(ctx, core.LaunchInstanceRequest{LaunchInstanceDetails: details})
|
||||||
|
if err != nil {
|
||||||
|
return Instance{}, fmt.Errorf("launch instance: %w", err)
|
||||||
|
}
|
||||||
|
return toInstance(resp.Instance), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildLaunchDetails(compartmentID string, in CreateInstanceInput) (core.LaunchInstanceDetails, error) {
|
||||||
|
details := core.LaunchInstanceDetails{
|
||||||
|
CompartmentId: &compartmentID,
|
||||||
|
AvailabilityDomain: &in.AvailabilityDomain,
|
||||||
|
Shape: &in.Shape,
|
||||||
|
DisplayName: &in.DisplayName,
|
||||||
|
SourceDetails: buildInstanceSource(in),
|
||||||
|
CreateVnicDetails: &core.CreateVnicDetails{
|
||||||
|
SubnetId: &in.SubnetID,
|
||||||
|
AssignPublicIp: &in.AssignPublicIP,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if details.SourceDetails == nil {
|
||||||
|
return details, fmt.Errorf("launch instance: imageId or bootVolumeId is required")
|
||||||
|
}
|
||||||
|
if in.AssignIpv6 {
|
||||||
|
details.CreateVnicDetails.AssignIpv6Ip = &in.AssignIpv6
|
||||||
|
}
|
||||||
|
if in.Ocpus > 0 {
|
||||||
|
details.ShapeConfig = &core.LaunchInstanceShapeConfigDetails{Ocpus: &in.Ocpus}
|
||||||
|
if in.MemoryInGBs > 0 {
|
||||||
|
details.ShapeConfig.MemoryInGBs = &in.MemoryInGBs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(in.FreeformTags) > 0 {
|
||||||
|
details.FreeformTags = in.FreeformTags
|
||||||
|
}
|
||||||
|
details.Metadata = buildInstanceMetadata(in)
|
||||||
|
return details, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildInstanceSource(in CreateInstanceInput) core.InstanceSourceDetails {
|
||||||
|
if in.BootVolumeID != "" {
|
||||||
|
return core.InstanceSourceViaBootVolumeDetails{BootVolumeId: &in.BootVolumeID}
|
||||||
|
}
|
||||||
|
if in.ImageID != "" {
|
||||||
|
src := core.InstanceSourceViaImageDetails{ImageId: &in.ImageID}
|
||||||
|
if in.BootVolumeSizeGBs > 0 {
|
||||||
|
src.BootVolumeSizeInGBs = &in.BootVolumeSizeGBs
|
||||||
|
}
|
||||||
|
if in.BootVolumeVpusPerGB > 0 {
|
||||||
|
src.BootVolumeVpusPerGB = &in.BootVolumeVpusPerGB
|
||||||
|
}
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildInstanceMetadata(in CreateInstanceInput) map[string]string {
|
||||||
|
metadata := make(map[string]string, 2)
|
||||||
|
if in.SSHPublicKey != "" {
|
||||||
|
metadata["ssh_authorized_keys"] = in.SSHPublicKey
|
||||||
|
}
|
||||||
|
// RootPassword 生成的脚本优先作为 user_data,其次才是显式 UserData
|
||||||
|
if in.RootPassword != "" {
|
||||||
|
metadata["user_data"] = rootPasswordUserData(in.RootPassword)
|
||||||
|
} else if in.UserData != "" {
|
||||||
|
metadata["user_data"] = in.UserData
|
||||||
|
}
|
||||||
|
if len(metadata) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// rootPasswordUserData 生成开启 root 密码登录的 cloud-init 脚本并 base64 编码。
|
||||||
|
// 密码用单引号安全转义,避免 shell 注入。
|
||||||
|
func rootPasswordUserData(password string) string {
|
||||||
|
script := "#!/bin/bash\n" +
|
||||||
|
"echo root:" + shellSingleQuote(password) + " | chpasswd\n" +
|
||||||
|
"sed -i 's/^#\\?PermitRootLogin.*/PermitRootLogin yes/g' /etc/ssh/sshd_config\n" +
|
||||||
|
"sed -i 's/^#\\?PasswordAuthentication.*/PasswordAuthentication yes/g' /etc/ssh/sshd_config\n" +
|
||||||
|
"rm -f /etc/ssh/sshd_config.d/* /etc/ssh/ssh_config.d/*\n" +
|
||||||
|
"systemctl restart sshd\n"
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(script))
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellSingleQuote 用单引号包裹字符串,内部单引号转义为 '\”。
|
||||||
|
func shellSingleQuote(s string) string {
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInstance 实现 Client:查询实例并补全网络 IP 信息。
|
||||||
|
func (c *RealClient) GetInstance(ctx context.Context, cred Credentials, region, instanceID string) (Instance, error) {
|
||||||
|
cc, err := c.computeClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return Instance{}, err
|
||||||
|
}
|
||||||
|
resp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID})
|
||||||
|
if err != nil {
|
||||||
|
return Instance{}, fmt.Errorf("get instance %s: %w", instanceID, err)
|
||||||
|
}
|
||||||
|
instances := []Instance{toInstance(resp.Instance)}
|
||||||
|
c.attachInstanceIPs(ctx, cred, region, instances)
|
||||||
|
return instances[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateInstance 实现 Client:改名或调整 Flex 规格。
|
||||||
|
func (c *RealClient) UpdateInstance(ctx context.Context, cred Credentials, instanceID string, in UpdateInstanceInput) (Instance, error) {
|
||||||
|
cc, err := c.computeClient(cred, in.Region)
|
||||||
|
if err != nil {
|
||||||
|
return Instance{}, err
|
||||||
|
}
|
||||||
|
details := core.UpdateInstanceDetails{}
|
||||||
|
if in.DisplayName != "" {
|
||||||
|
details.DisplayName = &in.DisplayName
|
||||||
|
}
|
||||||
|
if in.Shape != "" {
|
||||||
|
details.Shape = &in.Shape
|
||||||
|
}
|
||||||
|
if in.Ocpus > 0 {
|
||||||
|
details.ShapeConfig = &core.UpdateInstanceShapeConfigDetails{Ocpus: &in.Ocpus}
|
||||||
|
if in.MemoryInGBs > 0 {
|
||||||
|
details.ShapeConfig.MemoryInGBs = &in.MemoryInGBs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp, err := cc.UpdateInstance(ctx, core.UpdateInstanceRequest{
|
||||||
|
InstanceId: &instanceID,
|
||||||
|
UpdateInstanceDetails: details,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Instance{}, fmt.Errorf("update instance %s: %w", instanceID, err)
|
||||||
|
}
|
||||||
|
return toInstance(resp.Instance), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TerminateInstance 实现 Client;preserveBootVolume 为 true 时保留引导卷。
|
||||||
|
func (c *RealClient) TerminateInstance(ctx context.Context, cred Credentials, region, instanceID string, preserveBootVolume bool) error {
|
||||||
|
cc, err := c.computeClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = cc.TerminateInstance(ctx, core.TerminateInstanceRequest{
|
||||||
|
InstanceId: &instanceID,
|
||||||
|
PreserveBootVolume: &preserveBootVolume,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("terminate instance %s: %w", instanceID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceAction 实现 Client:执行电源操作。
|
||||||
|
func (c *RealClient) InstanceAction(ctx context.Context, cred Credentials, region, instanceID, action string) (Instance, error) {
|
||||||
|
actionEnum, ok := instanceActions[strings.ToUpper(strings.TrimSpace(action))]
|
||||||
|
if !ok {
|
||||||
|
return Instance{}, fmt.Errorf("instance action: unsupported action %q", action)
|
||||||
|
}
|
||||||
|
cc, err := c.computeClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return Instance{}, err
|
||||||
|
}
|
||||||
|
resp, err := cc.InstanceAction(ctx, core.InstanceActionRequest{
|
||||||
|
InstanceId: &instanceID,
|
||||||
|
Action: actionEnum,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Instance{}, fmt.Errorf("instance action %s on %s: %w", action, instanceID, err)
|
||||||
|
}
|
||||||
|
return toInstance(resp.Instance), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostCompartment 返回宿主资源自身所在 compartment 的请求指针:OCI 的
|
||||||
|
// 关联查询要求 compartmentId 与资源实际所在 compartment 一致,传租户根
|
||||||
|
// 会漏掉子 compartment 里的资源;id 缺失时保底用生效 compartment。
|
||||||
|
func hostCompartment(cred Credentials, id string) *string {
|
||||||
|
if id != "" {
|
||||||
|
return &id
|
||||||
|
}
|
||||||
|
return cred.EffectiveCompartment()
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachInstanceIPs 批量补全实例的子网与 IP:按实例所在 compartment 分组
|
||||||
|
// 列出 VNIC 挂载,再逐 VNIC 查询地址;直接用实例自带的 compartment,
|
||||||
|
// 避免为定位挂载再逐台调用 GetInstance。
|
||||||
|
func (c *RealClient) attachInstanceIPs(ctx context.Context, cred Credentials, region string, instances []Instance) {
|
||||||
|
active := make(map[string]*Instance, len(instances))
|
||||||
|
compartments := make(map[string]struct{})
|
||||||
|
for i := range instances {
|
||||||
|
if instances[i].LifecycleState == string(core.InstanceLifecycleStateTerminated) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
active[instances[i].ID] = &instances[i]
|
||||||
|
compartments[deref(hostCompartment(cred, instances[i].CompartmentID))] = struct{}{}
|
||||||
|
}
|
||||||
|
if len(active) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cc, err := c.computeClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for compartmentID := range compartments {
|
||||||
|
fillInstanceIPs(ctx, cc, vn, compartmentID, active)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillInstanceIPs 列出单个 compartment 内的 VNIC 挂载,为命中的实例补全地址。
|
||||||
|
// 逐 VNIC 的 GetVnic 是列表接口的主要耗时点,限并发 8 路并行拉取。
|
||||||
|
func fillInstanceIPs(ctx context.Context, cc core.ComputeClient, vn core.VirtualNetworkClient, compartmentID string, active map[string]*Instance) {
|
||||||
|
attResp, err := cc.ListVnicAttachments(ctx, core.ListVnicAttachmentsRequest{CompartmentId: &compartmentID})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
mu sync.Mutex
|
||||||
|
sem = make(chan struct{}, 8)
|
||||||
|
)
|
||||||
|
for _, att := range attResp.Items {
|
||||||
|
inst, ok := active[deref(att.InstanceId)]
|
||||||
|
if !ok || att.VnicId == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
sem <- struct{}{}
|
||||||
|
go func(vnicID *string, inst *Instance) {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-sem }()
|
||||||
|
vnicResp, err := vn.GetVnic(ctx, core.GetVnicRequest{VnicId: vnicID})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
inst.SubnetID = deref(vnicResp.SubnetId)
|
||||||
|
inst.PrivateIP = deref(vnicResp.PrivateIp)
|
||||||
|
inst.PublicIP = deref(vnicResp.PublicIp)
|
||||||
|
inst.Ipv6Addresses = vnicResp.Ipv6Addresses
|
||||||
|
mu.Unlock()
|
||||||
|
}(att.VnicId, inst)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toInstance(inst core.Instance) Instance {
|
||||||
|
out := Instance{
|
||||||
|
ID: deref(inst.Id),
|
||||||
|
DisplayName: deref(inst.DisplayName),
|
||||||
|
LifecycleState: string(inst.LifecycleState),
|
||||||
|
Shape: deref(inst.Shape),
|
||||||
|
AvailabilityDomain: deref(inst.AvailabilityDomain),
|
||||||
|
Region: deref(inst.Region),
|
||||||
|
CompartmentID: deref(inst.CompartmentId),
|
||||||
|
ImageID: deref(inst.ImageId),
|
||||||
|
FreeformTags: inst.FreeformTags,
|
||||||
|
DefinedTags: inst.DefinedTags,
|
||||||
|
TimeCreated: sdkTime(inst.TimeCreated),
|
||||||
|
}
|
||||||
|
if inst.ShapeConfig != nil {
|
||||||
|
if inst.ShapeConfig.Ocpus != nil {
|
||||||
|
out.Ocpus = *inst.ShapeConfig.Ocpus
|
||||||
|
}
|
||||||
|
if inst.ShapeConfig.MemoryInGBs != nil {
|
||||||
|
out.MemoryInGBs = *inst.ShapeConfig.MemoryInGBs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShellSingleQuote(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "普通", in: "Passw0rd", want: "'Passw0rd'"},
|
||||||
|
{name: "含单引号", in: "a'b", want: `'a'\''b'`},
|
||||||
|
{name: "含空格特殊符", in: "p @ss!", want: "'p @ss!'"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := shellSingleQuote(tt.in); got != tt.want {
|
||||||
|
t.Errorf("shellSingleQuote(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRootPasswordUserData(t *testing.T) {
|
||||||
|
encoded := rootPasswordUserData("Secret123")
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("result is not base64: %v", err)
|
||||||
|
}
|
||||||
|
script := string(raw)
|
||||||
|
for _, want := range []string{
|
||||||
|
"#!/bin/bash",
|
||||||
|
"echo root:'Secret123' | chpasswd",
|
||||||
|
"PermitRootLogin yes",
|
||||||
|
"PasswordAuthentication yes",
|
||||||
|
"systemctl restart sshd",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(script, want) {
|
||||||
|
t.Errorf("script missing %q\n---\n%s", want, script)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildInstanceMetadataRootPasswordOverridesUserData(t *testing.T) {
|
||||||
|
md := buildInstanceMetadata(CreateInstanceInput{
|
||||||
|
RootPassword: "pw",
|
||||||
|
UserData: "ZXhwbGljaXQ=", // "explicit"
|
||||||
|
})
|
||||||
|
decoded, _ := base64.StdEncoding.DecodeString(md["user_data"])
|
||||||
|
if !strings.Contains(string(decoded), "chpasswd") {
|
||||||
|
t.Errorf("user_data = %q, want root password script (not explicit userData)", md["user_data"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildInstanceSourceVpusPerGB(t *testing.T) {
|
||||||
|
src := buildInstanceSource(CreateInstanceInput{ImageID: "img", BootVolumeSizeGBs: 100, BootVolumeVpusPerGB: 20})
|
||||||
|
img, ok := src.(core.InstanceSourceViaImageDetails)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("source type = %T, want image", src)
|
||||||
|
}
|
||||||
|
if img.BootVolumeVpusPerGB == nil || *img.BootVolumeVpusPerGB != 20 {
|
||||||
|
t.Errorf("BootVolumeVpusPerGB = %v, want 20", img.BootVolumeVpusPerGB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildInstanceSource(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in CreateInstanceInput
|
||||||
|
wantType string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "从镜像启动",
|
||||||
|
in: CreateInstanceInput{ImageID: "ocid1.image..a", BootVolumeSizeGBs: 100},
|
||||||
|
wantType: "image",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "从引导卷启动",
|
||||||
|
in: CreateInstanceInput{BootVolumeID: "ocid1.bootvolume..b"},
|
||||||
|
wantType: "bootVolume",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "引导卷优先于镜像",
|
||||||
|
in: CreateInstanceInput{ImageID: "ocid1.image..a", BootVolumeID: "ocid1.bootvolume..b"},
|
||||||
|
wantType: "bootVolume",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "两者皆无返回 nil",
|
||||||
|
in: CreateInstanceInput{},
|
||||||
|
wantType: "nil",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
src := buildInstanceSource(tt.in)
|
||||||
|
switch tt.wantType {
|
||||||
|
case "image":
|
||||||
|
img, ok := src.(core.InstanceSourceViaImageDetails)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("source type = %T, want InstanceSourceViaImageDetails", src)
|
||||||
|
}
|
||||||
|
if got, want := *img.BootVolumeSizeInGBs, tt.in.BootVolumeSizeGBs; got != want {
|
||||||
|
t.Errorf("BootVolumeSizeInGBs = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
case "bootVolume":
|
||||||
|
if _, ok := src.(core.InstanceSourceViaBootVolumeDetails); !ok {
|
||||||
|
t.Fatalf("source type = %T, want InstanceSourceViaBootVolumeDetails", src)
|
||||||
|
}
|
||||||
|
case "nil":
|
||||||
|
if src != nil {
|
||||||
|
t.Fatalf("source = %v, want nil", src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildLaunchDetails(t *testing.T) {
|
||||||
|
in := CreateInstanceInput{
|
||||||
|
AvailabilityDomain: "AD-1",
|
||||||
|
DisplayName: "vm1",
|
||||||
|
Shape: "VM.Standard.A1.Flex",
|
||||||
|
Ocpus: 2,
|
||||||
|
MemoryInGBs: 12,
|
||||||
|
ImageID: "ocid1.image..a",
|
||||||
|
SubnetID: "ocid1.subnet..s",
|
||||||
|
AssignPublicIP: true,
|
||||||
|
AssignIpv6: true,
|
||||||
|
SSHPublicKey: "ssh-ed25519 AAA",
|
||||||
|
}
|
||||||
|
details, err := buildLaunchDetails("ocid1.tenancy..t", in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildLaunchDetails: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := *details.ShapeConfig.Ocpus, float32(2); got != want {
|
||||||
|
t.Errorf("Ocpus = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if got, want := *details.ShapeConfig.MemoryInGBs, float32(12); got != want {
|
||||||
|
t.Errorf("MemoryInGBs = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if got, want := *details.CreateVnicDetails.SubnetId, in.SubnetID; got != want {
|
||||||
|
t.Errorf("SubnetId = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if details.CreateVnicDetails.AssignIpv6Ip == nil || !*details.CreateVnicDetails.AssignIpv6Ip {
|
||||||
|
t.Error("AssignIpv6Ip not set, want true")
|
||||||
|
}
|
||||||
|
if got, want := details.Metadata["ssh_authorized_keys"], in.SSHPublicKey; got != want {
|
||||||
|
t.Errorf("ssh_authorized_keys = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildLaunchDetailsRequiresSource(t *testing.T) {
|
||||||
|
_, err := buildLaunchDetails("ocid1.tenancy..t", CreateInstanceInput{
|
||||||
|
AvailabilityDomain: "AD-1", DisplayName: "vm1", Shape: "shape", SubnetID: "sub",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("buildLaunchDetails without source: got nil error, want failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildLaunchDetailsNoShapeConfig(t *testing.T) {
|
||||||
|
details, err := buildLaunchDetails("t", CreateInstanceInput{
|
||||||
|
AvailabilityDomain: "AD-1", DisplayName: "vm1", Shape: "VM.Standard.E2.1.Micro",
|
||||||
|
ImageID: "img", SubnetID: "sub",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildLaunchDetails: %v", err)
|
||||||
|
}
|
||||||
|
if details.ShapeConfig != nil {
|
||||||
|
t.Errorf("ShapeConfig = %+v, want nil for fixed shape", details.ShapeConfig)
|
||||||
|
}
|
||||||
|
if details.Metadata != nil {
|
||||||
|
t.Errorf("Metadata = %v, want nil when no key and user data", details.Metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ipv6AnyCIDR 是 IPv6 默认路由与放行目标。
|
||||||
|
const ipv6AnyCIDR = "::/0"
|
||||||
|
|
||||||
|
// IPv6Step 是一键启用 IPv6 过程中的一个步骤结果。
|
||||||
|
type IPv6Step struct {
|
||||||
|
Step string `json:"step"`
|
||||||
|
Status string `json:"status"` // done / skipped / failed
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableVCNIPv6 实现 Client:为 VCN 一键启用 IPv6。依次执行:
|
||||||
|
// 1. VCN 分配 Oracle GUA /56(已有则跳过);
|
||||||
|
// 2. 每个无 IPv6 的子网从 /56 中分配一个 /64;
|
||||||
|
// 3. 默认路由表补 ::/0 → Internet Gateway(无 IGW 时自动创建);
|
||||||
|
// 4. 默认安全列表补 egress ::/0 放行。
|
||||||
|
//
|
||||||
|
// 步骤级失败记录在返回的步骤列表中,不中断后续无依赖的步骤。
|
||||||
|
func (c *RealClient) EnableVCNIPv6(ctx context.Context, cred Credentials, region, vcnID string) ([]IPv6Step, error) {
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
vcnResp, err := vn.GetVcn(ctx, core.GetVcnRequest{VcnId: &vcnID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get vcn %s: %w", vcnID, err)
|
||||||
|
}
|
||||||
|
// 子网 / IGW 的关联查询须用 VCN 实际所在 compartment,传租户根会漏查
|
||||||
|
vcnCompartment := *hostCompartment(cred, deref(vcnResp.CompartmentId))
|
||||||
|
var steps []IPv6Step
|
||||||
|
vcnCidr, step := ensureVCNIPv6Cidr(ctx, vn, vcnResp.Vcn)
|
||||||
|
steps = append(steps, step)
|
||||||
|
steps = append(steps, ensureSubnetsIPv6(ctx, vn, vcnCompartment, vcnID, vcnCidr)...)
|
||||||
|
steps = append(steps, ensureIPv6Route(ctx, vn, vcnCompartment, vcnResp.Vcn))
|
||||||
|
steps = append(steps, ensureIPv6Egress(ctx, vn, vcnResp.Vcn))
|
||||||
|
return orEmpty(steps), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureVCNIPv6Cidr 确保 VCN 拥有 IPv6 GUA,返回其 /56 前缀。
|
||||||
|
func ensureVCNIPv6Cidr(ctx context.Context, vn core.VirtualNetworkClient, vcn core.Vcn) (string, IPv6Step) {
|
||||||
|
if len(vcn.Ipv6CidrBlocks) > 0 {
|
||||||
|
return vcn.Ipv6CidrBlocks[0], IPv6Step{Step: "vcn-ipv6-cidr", Status: "skipped", Detail: vcn.Ipv6CidrBlocks[0]}
|
||||||
|
}
|
||||||
|
enabled := true
|
||||||
|
_, err := vn.AddIpv6VcnCidr(ctx, core.AddIpv6VcnCidrRequest{
|
||||||
|
VcnId: vcn.Id,
|
||||||
|
AddVcnIpv6CidrDetails: core.AddVcnIpv6CidrDetails{IsOracleGuaAllocationEnabled: &enabled},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", IPv6Step{Step: "vcn-ipv6-cidr", Status: "failed", Detail: err.Error()}
|
||||||
|
}
|
||||||
|
cidr, err := waitVCNIPv6Cidr(ctx, vn, *vcn.Id)
|
||||||
|
if err != nil {
|
||||||
|
return "", IPv6Step{Step: "vcn-ipv6-cidr", Status: "failed", Detail: err.Error()}
|
||||||
|
}
|
||||||
|
return cidr, IPv6Step{Step: "vcn-ipv6-cidr", Status: "done", Detail: cidr}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitVCNIPv6Cidr 轮询等待 Oracle GUA 分配完成(异步操作)。
|
||||||
|
func waitVCNIPv6Cidr(ctx context.Context, vn core.VirtualNetworkClient, vcnID string) (string, error) {
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
resp, err := vn.GetVcn(ctx, core.GetVcnRequest{VcnId: &vcnID})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("poll vcn ipv6 cidr: %w", err)
|
||||||
|
}
|
||||||
|
if len(resp.Ipv6CidrBlocks) > 0 {
|
||||||
|
return resp.Ipv6CidrBlocks[0], nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", fmt.Errorf("poll vcn ipv6 cidr: %w", ctx.Err())
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("poll vcn ipv6 cidr: timed out")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureSubnetsIPv6 为每个还没有 IPv6 的子网从 VCN /56 中分配 /64;
|
||||||
|
// compartmentID 须是 VCN 实际所在 compartment,否则列不到子网。
|
||||||
|
func ensureSubnetsIPv6(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID, vcnCidr string) []IPv6Step {
|
||||||
|
if vcnCidr == "" {
|
||||||
|
return []IPv6Step{{Step: "subnet-ipv6-cidr", Status: "failed", Detail: "vcn has no ipv6 cidr"}}
|
||||||
|
}
|
||||||
|
subnets, err := listSubnets(ctx, vn, compartmentID, vcnID)
|
||||||
|
if err != nil {
|
||||||
|
return []IPv6Step{{Step: "subnet-ipv6-cidr", Status: "failed", Detail: err.Error()}}
|
||||||
|
}
|
||||||
|
used := make([]string, 0, len(subnets))
|
||||||
|
for _, s := range subnets {
|
||||||
|
if s.Ipv6CidrBlock != "" {
|
||||||
|
used = append(used, s.Ipv6CidrBlock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var steps []IPv6Step
|
||||||
|
for _, s := range subnets {
|
||||||
|
if s.Ipv6CidrBlock != "" {
|
||||||
|
steps = append(steps, IPv6Step{Step: "subnet-ipv6-cidr", Status: "skipped", Detail: s.DisplayName + " " + s.Ipv6CidrBlock})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
steps = append(steps, addSubnetIPv6(ctx, vn, s, vcnCidr, &used))
|
||||||
|
}
|
||||||
|
return orEmpty(steps)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addSubnetIPv6(ctx context.Context, vn core.VirtualNetworkClient, s Subnet, vcnCidr string, used *[]string) IPv6Step {
|
||||||
|
cidr, err := nextSubnetIPv6CIDR(vcnCidr, *used)
|
||||||
|
if err != nil {
|
||||||
|
return IPv6Step{Step: "subnet-ipv6-cidr", Status: "failed", Detail: s.DisplayName + ": " + err.Error()}
|
||||||
|
}
|
||||||
|
_, err = vn.AddIpv6SubnetCidr(ctx, core.AddIpv6SubnetCidrRequest{
|
||||||
|
SubnetId: &s.ID,
|
||||||
|
AddSubnetIpv6CidrDetails: core.AddSubnetIpv6CidrDetails{Ipv6CidrBlock: &cidr},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return IPv6Step{Step: "subnet-ipv6-cidr", Status: "failed", Detail: s.DisplayName + ": " + err.Error()}
|
||||||
|
}
|
||||||
|
*used = append(*used, cidr)
|
||||||
|
return IPv6Step{Step: "subnet-ipv6-cidr", Status: "done", Detail: s.DisplayName + " " + cidr}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextSubnetIPv6CIDR 从 VCN 的 /56 前缀中选出未被占用的 /64:
|
||||||
|
// /56 的前 7 字节固定,第 8 字节 0~255 即 256 个可用 /64。
|
||||||
|
func nextSubnetIPv6CIDR(vcnCidr string, used []string) (string, error) {
|
||||||
|
prefix, err := netip.ParsePrefix(vcnCidr)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse vcn ipv6 cidr %s: %w", vcnCidr, err)
|
||||||
|
}
|
||||||
|
base := prefix.Addr().As16()
|
||||||
|
taken := make(map[byte]bool, len(used))
|
||||||
|
for _, u := range used {
|
||||||
|
p, err := netip.ParsePrefix(u)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b := p.Addr().As16()
|
||||||
|
if [7]byte(b[:7]) == [7]byte(base[:7]) {
|
||||||
|
taken[b[7]] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i <= 255; i++ {
|
||||||
|
if taken[byte(i)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidate := base
|
||||||
|
candidate[7] = base[7] | byte(i)
|
||||||
|
return netip.PrefixFrom(netip.AddrFrom16(candidate), 64).String(), nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no free /64 left in %s", vcnCidr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureIPv6Route 在默认路由表补 ::/0 → IGW;没有 IGW 时自动创建。
|
||||||
|
// compartmentID 须是 VCN 实际所在 compartment,IGW 查找与创建都在其中进行。
|
||||||
|
func ensureIPv6Route(ctx context.Context, vn core.VirtualNetworkClient, compartmentID string, vcn core.Vcn) IPv6Step {
|
||||||
|
rtResp, err := vn.GetRouteTable(ctx, core.GetRouteTableRequest{RtId: vcn.DefaultRouteTableId})
|
||||||
|
if err != nil {
|
||||||
|
return IPv6Step{Step: "route-ipv6-default", Status: "failed", Detail: err.Error()}
|
||||||
|
}
|
||||||
|
for _, rule := range rtResp.RouteRules {
|
||||||
|
if deref(rule.Destination) == ipv6AnyCIDR {
|
||||||
|
return IPv6Step{Step: "route-ipv6-default", Status: "skipped", Detail: "::/0 route exists"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
igwID, err := ensureInternetGateway(ctx, vn, compartmentID, *vcn.Id)
|
||||||
|
if err != nil {
|
||||||
|
return IPv6Step{Step: "route-ipv6-default", Status: "failed", Detail: err.Error()}
|
||||||
|
}
|
||||||
|
dest := ipv6AnyCIDR
|
||||||
|
rules := append(rtResp.RouteRules, core.RouteRule{
|
||||||
|
Destination: &dest,
|
||||||
|
DestinationType: core.RouteRuleDestinationTypeCidrBlock,
|
||||||
|
NetworkEntityId: &igwID,
|
||||||
|
})
|
||||||
|
_, err = vn.UpdateRouteTable(ctx, core.UpdateRouteTableRequest{
|
||||||
|
RtId: vcn.DefaultRouteTableId,
|
||||||
|
UpdateRouteTableDetails: core.UpdateRouteTableDetails{RouteRules: rules},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return IPv6Step{Step: "route-ipv6-default", Status: "failed", Detail: err.Error()}
|
||||||
|
}
|
||||||
|
return IPv6Step{Step: "route-ipv6-default", Status: "done", Detail: "::/0 -> " + igwID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureInternetGateway 返回 VCN 已有的 IGW,没有则创建一个;
|
||||||
|
// compartmentID 须是 VCN 实际所在 compartment,否则查不到已有 IGW。
|
||||||
|
func ensureInternetGateway(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID string) (string, error) {
|
||||||
|
listResp, err := vn.ListInternetGateways(ctx, core.ListInternetGatewaysRequest{
|
||||||
|
CompartmentId: &compartmentID,
|
||||||
|
VcnId: &vcnID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("list internet gateways: %w", err)
|
||||||
|
}
|
||||||
|
if len(listResp.Items) > 0 {
|
||||||
|
return deref(listResp.Items[0].Id), nil
|
||||||
|
}
|
||||||
|
enabled := true
|
||||||
|
name := "oci-portal-igw"
|
||||||
|
createResp, err := vn.CreateInternetGateway(ctx, core.CreateInternetGatewayRequest{
|
||||||
|
CreateInternetGatewayDetails: core.CreateInternetGatewayDetails{
|
||||||
|
CompartmentId: &compartmentID,
|
||||||
|
VcnId: &vcnID,
|
||||||
|
IsEnabled: &enabled,
|
||||||
|
DisplayName: &name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create internet gateway: %w", err)
|
||||||
|
}
|
||||||
|
return deref(createResp.InternetGateway.Id), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddInstanceIpv6 实现 Client:为实例主 VNIC 分配一个 IPv6 地址并返回。
|
||||||
|
// address 为空时由子网 /64 自动分配;子网未启用 IPv6 时 OCI 返回错误。
|
||||||
|
func (c *RealClient) AddInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) (string, error) {
|
||||||
|
vnic, err := c.primaryVnic(ctx, cred, region, instanceID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return c.AddVnicIpv6(ctx, cred, region, deref(vnic.Id), address)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddVnicIpv6 实现 Client:为指定 VNIC 分配一个 IPv6 地址并返回(多网卡场景)。
|
||||||
|
func (c *RealClient) AddVnicIpv6(ctx context.Context, cred Credentials, region, vnicID, address string) (string, error) {
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
details := core.CreateIpv6Details{VnicId: &vnicID}
|
||||||
|
if address != "" {
|
||||||
|
details.IpAddress = &address
|
||||||
|
}
|
||||||
|
resp, err := vn.CreateIpv6(ctx, core.CreateIpv6Request{CreateIpv6Details: details})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create ipv6 on vnic %s: %w", vnicID, err)
|
||||||
|
}
|
||||||
|
return deref(resp.IpAddress), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteInstanceIpv6 实现 Client:取消分配实例上的指定 IPv6 地址。
|
||||||
|
// 遍历实例 VNIC 找到地址对应的 Ipv6 对象后删除;地址不存在时报错。
|
||||||
|
func (c *RealClient) DeleteInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) error {
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vnics, _, err := c.instanceVnics(ctx, cred, region, instanceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, vnic := range vnics {
|
||||||
|
resp, err := vn.ListIpv6s(ctx, core.ListIpv6sRequest{VnicId: vnic.Id})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, ip := range resp.Items {
|
||||||
|
if !sameIPv6(deref(ip.IpAddress), address) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := vn.DeleteIpv6(ctx, core.DeleteIpv6Request{Ipv6Id: ip.Id}); err != nil {
|
||||||
|
return fmt.Errorf("delete ipv6 %s: %w", address, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("delete ipv6: address %s not found on instance %s", address, instanceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameIPv6 规范化比较两个 IPv6 地址(兼容大小写与缩写形式差异)。
|
||||||
|
func sameIPv6(a, b string) bool {
|
||||||
|
pa, errA := netip.ParseAddr(a)
|
||||||
|
pb, errB := netip.ParseAddr(b)
|
||||||
|
if errA != nil || errB != nil {
|
||||||
|
return a == b
|
||||||
|
}
|
||||||
|
return pa == pb
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureIPv6Egress 在默认安全列表补 egress ::/0 全协议放行。
|
||||||
|
func ensureIPv6Egress(ctx context.Context, vn core.VirtualNetworkClient, vcn core.Vcn) IPv6Step {
|
||||||
|
slResp, err := vn.GetSecurityList(ctx, core.GetSecurityListRequest{SecurityListId: vcn.DefaultSecurityListId})
|
||||||
|
if err != nil {
|
||||||
|
return IPv6Step{Step: "seclist-ipv6-egress", Status: "failed", Detail: err.Error()}
|
||||||
|
}
|
||||||
|
for _, rule := range slResp.EgressSecurityRules {
|
||||||
|
if deref(rule.Destination) == ipv6AnyCIDR {
|
||||||
|
return IPv6Step{Step: "seclist-ipv6-egress", Status: "skipped", Detail: "::/0 egress exists"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dest := ipv6AnyCIDR
|
||||||
|
protocol := "all"
|
||||||
|
rules := append(slResp.EgressSecurityRules, core.EgressSecurityRule{
|
||||||
|
Protocol: &protocol,
|
||||||
|
Destination: &dest,
|
||||||
|
DestinationType: core.EgressSecurityRuleDestinationTypeCidrBlock,
|
||||||
|
})
|
||||||
|
_, err = vn.UpdateSecurityList(ctx, core.UpdateSecurityListRequest{
|
||||||
|
SecurityListId: vcn.DefaultSecurityListId,
|
||||||
|
UpdateSecurityListDetails: core.UpdateSecurityListDetails{EgressSecurityRules: rules},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return IPv6Step{Step: "seclist-ipv6-egress", Status: "failed", Detail: err.Error()}
|
||||||
|
}
|
||||||
|
return IPv6Step{Step: "seclist-ipv6-egress", Status: "done", Detail: "egress ::/0 allowed"}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNextSubnetIPv6CIDR(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
vcnCidr string
|
||||||
|
used []string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "无占用取首个",
|
||||||
|
vcnCidr: "2603:c020:e:de00::/56",
|
||||||
|
want: "2603:c020:e:de00::/64",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "跳过已用索引",
|
||||||
|
vcnCidr: "2603:c020:e:de00::/56",
|
||||||
|
used: []string{"2603:c020:e:de00::/64", "2603:c020:e:de01::/64"},
|
||||||
|
want: "2603:c020:e:de02::/64",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "其他前缀的占用不影响",
|
||||||
|
vcnCidr: "2603:c020:e:de00::/56",
|
||||||
|
used: []string{"2603:c020:f:aa00::/64"},
|
||||||
|
want: "2603:c020:e:de00::/64",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "非法 VCN 前缀报错",
|
||||||
|
vcnCidr: "not-a-cidr",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := nextSubnetIPv6CIDR(tt.vcnCidr, tt.used)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("nextSubnetIPv6CIDR() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if !tt.wantErr && got != tt.want {
|
||||||
|
t.Errorf("nextSubnetIPv6CIDR() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextSubnetIPv6CIDRExhausted(t *testing.T) {
|
||||||
|
used := make([]string, 0, 256)
|
||||||
|
base := "2603:c020:e:de00::/56"
|
||||||
|
for i := 0; i <= 255; i++ {
|
||||||
|
cidr, err := nextSubnetIPv6CIDR(base, used)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("allocation %d failed: %v", i, err)
|
||||||
|
}
|
||||||
|
used = append(used, cidr)
|
||||||
|
}
|
||||||
|
if _, err := nextSubnetIPv6CIDR(base, used); err == nil {
|
||||||
|
t.Error("257th allocation: got nil error, want exhausted failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSameIPv6(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
a, b string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "完全一致", a: "2603:c020:e:de00::1", b: "2603:c020:e:de00::1", want: true},
|
||||||
|
{name: "大小写不敏感", a: "2603:C020:E:DE00::1", b: "2603:c020:e:de00::1", want: true},
|
||||||
|
{name: "缩写与全写等价", a: "2603:c020:e:de00:0:0:0:1", b: "2603:c020:e:de00::1", want: true},
|
||||||
|
{name: "不同地址", a: "2603:c020:e:de00::1", b: "2603:c020:e:de00::2", want: false},
|
||||||
|
{name: "非法输入回退字符串比较", a: "not-an-ip", b: "not-an-ip", want: true},
|
||||||
|
{name: "非法输入不相等", a: "not-an-ip", b: "2603:c020:e:de00::1", want: false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := sameIPv6(tt.a, tt.b); got != tt.want {
|
||||||
|
t.Errorf("sameIPv6(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildRuleOptions(t *testing.T) {
|
||||||
|
port := func(n int) *int { return &n }
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rule SecurityRule
|
||||||
|
wantTCP bool
|
||||||
|
wantUDP bool
|
||||||
|
wantICMP bool
|
||||||
|
}{
|
||||||
|
{name: "TCP 端口", rule: SecurityRule{Protocol: "6", PortMin: port(22)}, wantTCP: true},
|
||||||
|
{name: "UDP 端口", rule: SecurityRule{Protocol: "17", PortMin: port(53), PortMax: port(53)}, wantUDP: true},
|
||||||
|
{name: "ICMP 类型", rule: SecurityRule{Protocol: "1", IcmpType: port(8)}, wantICMP: true},
|
||||||
|
{name: "ICMPv6 类型", rule: SecurityRule{Protocol: "58", IcmpType: port(128)}, wantICMP: true},
|
||||||
|
{name: "all 无选项", rule: SecurityRule{Protocol: "all"}},
|
||||||
|
{name: "TCP 无端口无选项", rule: SecurityRule{Protocol: "6"}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tcp, udp, icmp := buildRuleOptions(tt.rule)
|
||||||
|
if (tcp != nil) != tt.wantTCP {
|
||||||
|
t.Errorf("tcp options = %v, want present=%v", tcp, tt.wantTCP)
|
||||||
|
}
|
||||||
|
if (udp != nil) != tt.wantUDP {
|
||||||
|
t.Errorf("udp options = %v, want present=%v", udp, tt.wantUDP)
|
||||||
|
}
|
||||||
|
if (icmp != nil) != tt.wantICMP {
|
||||||
|
t.Errorf("icmp options = %v, want present=%v", icmp, tt.wantICMP)
|
||||||
|
}
|
||||||
|
if tt.wantTCP && tt.rule.PortMax == nil {
|
||||||
|
if got, want := *tcp.DestinationPortRange.Max, *tt.rule.PortMin; got != want {
|
||||||
|
t.Errorf("port max = %d, want fallback to min %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/identity"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/ons"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/sch"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 日志回传链路(方案A)在租户侧的固定资源命名,按名幂等查找与创建;
|
||||||
|
// 资源建在凭据默认区域,Audit 日志组含子 compartment。
|
||||||
|
// Topic 删除后名称有保留期(同名重建长时间 409 Conflict),故 Topic 用
|
||||||
|
// 前缀+随机后缀命名、按前缀幂等查找,销毁重建不受保留期阻塞。
|
||||||
|
const (
|
||||||
|
relayTopicPrefix = "ociportal-logs"
|
||||||
|
relayPolicyName = "ociportal-logs-sch"
|
||||||
|
relayConnectorName = "ociportal-logs"
|
||||||
|
relayAuditLogGroup = "_Audit_Include_Subcompartment"
|
||||||
|
relayTopicPages = 5 // 按前缀查找 Topic 的翻页上限
|
||||||
|
)
|
||||||
|
|
||||||
|
// Connector 创建为异步 work request,轮询直至 ACTIVE。
|
||||||
|
var (
|
||||||
|
relayConnectorPollTick = 5 * time.Second
|
||||||
|
relayConnectorPollLimit = 18
|
||||||
|
)
|
||||||
|
|
||||||
|
// RelayResource 是链路单个云资源的状态;Created 标记本次调用新建,供失败回滚判据。
|
||||||
|
type RelayResource struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Created bool `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelayState 是回传链路四资源现状;已删除的资源不出现(ID 为空)。
|
||||||
|
type RelayState struct {
|
||||||
|
Topic RelayResource `json:"topic"`
|
||||||
|
Subscription RelayResource `json:"subscription"`
|
||||||
|
Connector RelayResource `json:"connector"`
|
||||||
|
Policy RelayResource `json:"policy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelayEventCondition 生成 Connector Log Filter 条件:按 eventName 白名单收窄回传。
|
||||||
|
func RelayEventCondition(events []string) string {
|
||||||
|
terms := make([]string, 0, len(events))
|
||||||
|
for _, e := range events {
|
||||||
|
terms = append(terms, fmt.Sprintf("data.eventName='%s'", e))
|
||||||
|
}
|
||||||
|
return strings.Join(terms, " or ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RealClient) onsControlClient(cred Credentials) (ons.NotificationControlPlaneClient, error) {
|
||||||
|
cp, err := ons.NewNotificationControlPlaneClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return cp, fmt.Errorf("new ons control client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&cp.BaseClient, cred)
|
||||||
|
return cp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RealClient) onsDataClient(cred Credentials) (ons.NotificationDataPlaneClient, error) {
|
||||||
|
dp, err := ons.NewNotificationDataPlaneClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return dp, fmt.Errorf("new ons data client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&dp.BaseClient, cred)
|
||||||
|
return dp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RealClient) schClient(cred Credentials) (sch.ServiceConnectorClient, error) {
|
||||||
|
sc, err := sch.NewServiceConnectorClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return sc, fmt.Errorf("new sch client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&sc.BaseClient, cred)
|
||||||
|
return sc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureRelayTopic 实现 Client:按前缀返回既有 Topic 或以随机后缀新建。
|
||||||
|
func (c *RealClient) EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error) {
|
||||||
|
cp, err := c.onsControlClient(cred)
|
||||||
|
if err != nil {
|
||||||
|
return RelayResource{}, err
|
||||||
|
}
|
||||||
|
if res, ok, err := findRelayTopic(ctx, cp, cred.TenancyOCID); err != nil || ok {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
name, err := relayTopicNewName()
|
||||||
|
if err != nil {
|
||||||
|
return RelayResource{}, err
|
||||||
|
}
|
||||||
|
created, err := cp.CreateTopic(ctx, ons.CreateTopicRequest{
|
||||||
|
CreateTopicDetails: ons.CreateTopicDetails{
|
||||||
|
Name: &name,
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
Description: common.String("oci-portal 日志回传:关键审计事件经 Connector 投递到面板"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return RelayResource{}, fmt.Errorf("create ons topic: %w", err)
|
||||||
|
}
|
||||||
|
return RelayResource{ID: deref(created.TopicId), State: string(created.LifecycleState), Created: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// relayTopicNewName 生成带随机后缀的 Topic 名,规避删除名称保留期。
|
||||||
|
func relayTopicNewName() (string, error) {
|
||||||
|
buf := make([]byte, 4)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", fmt.Errorf("topic name suffix: %w", err)
|
||||||
|
}
|
||||||
|
return relayTopicPrefix + "-" + hex.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findRelayTopic 按名称前缀查找存活 Topic;未找到时 ok 为 false。
|
||||||
|
func findRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient, tenancy string) (RelayResource, bool, error) {
|
||||||
|
req := ons.ListTopicsRequest{CompartmentId: &tenancy}
|
||||||
|
for page := 0; page < relayTopicPages; page++ {
|
||||||
|
list, err := cp.ListTopics(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return RelayResource{}, false, fmt.Errorf("list ons topics: %w", err)
|
||||||
|
}
|
||||||
|
for _, t := range list.Items {
|
||||||
|
if strings.HasPrefix(deref(t.Name), relayTopicPrefix) &&
|
||||||
|
t.LifecycleState == ons.NotificationTopicSummaryLifecycleStateActive {
|
||||||
|
return RelayResource{ID: deref(t.TopicId), State: string(t.LifecycleState)}, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if list.OpcNextPage == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
req.Page = list.OpcNextPage
|
||||||
|
}
|
||||||
|
return RelayResource{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureRelaySubscription 实现 Client:按 endpoint 返回既有 CUSTOM_HTTPS 订阅或新建;
|
||||||
|
// 新建订阅处于 PENDING,待 ONS 向 endpoint 投递确认消息、面板回访后转 ACTIVE。
|
||||||
|
func (c *RealClient) EnsureRelaySubscription(ctx context.Context, cred Credentials, topicID, endpoint string) (RelayResource, error) {
|
||||||
|
dp, err := c.onsDataClient(cred)
|
||||||
|
if err != nil {
|
||||||
|
return RelayResource{}, err
|
||||||
|
}
|
||||||
|
if res, ok, err := findRelaySubscription(ctx, dp, cred.TenancyOCID, topicID, endpoint); err != nil || ok {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
created, err := dp.CreateSubscription(ctx, ons.CreateSubscriptionRequest{
|
||||||
|
CreateSubscriptionDetails: ons.CreateSubscriptionDetails{
|
||||||
|
TopicId: &topicID,
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
Protocol: common.String("CUSTOM_HTTPS"),
|
||||||
|
Endpoint: &endpoint,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return RelayResource{}, fmt.Errorf("create ons subscription: %w", err)
|
||||||
|
}
|
||||||
|
return RelayResource{ID: deref(created.Id), State: string(created.LifecycleState), Created: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findRelaySubscription 在 Topic 下按 endpoint 查找存活订阅。
|
||||||
|
func findRelaySubscription(ctx context.Context, dp ons.NotificationDataPlaneClient, tenancy, topicID, endpoint string) (RelayResource, bool, error) {
|
||||||
|
if endpoint == "" {
|
||||||
|
return RelayResource{}, false, nil
|
||||||
|
}
|
||||||
|
list, err := dp.ListSubscriptions(ctx, ons.ListSubscriptionsRequest{
|
||||||
|
CompartmentId: &tenancy, TopicId: &topicID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return RelayResource{}, false, fmt.Errorf("list ons subscriptions: %w", err)
|
||||||
|
}
|
||||||
|
for _, s := range list.Items {
|
||||||
|
if deref(s.Endpoint) == endpoint && s.LifecycleState != ons.SubscriptionSummaryLifecycleStateDeleted {
|
||||||
|
return RelayResource{ID: deref(s.Id), State: string(s.LifecycleState)}, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RelayResource{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRelaySubscription 实现 Client:查询订阅当前状态,供确认期轮询。
|
||||||
|
func (c *RealClient) GetRelaySubscription(ctx context.Context, cred Credentials, subscriptionID string) (RelayResource, error) {
|
||||||
|
dp, err := c.onsDataClient(cred)
|
||||||
|
if err != nil {
|
||||||
|
return RelayResource{}, err
|
||||||
|
}
|
||||||
|
resp, err := dp.GetSubscription(ctx, ons.GetSubscriptionRequest{SubscriptionId: &subscriptionID})
|
||||||
|
if err != nil {
|
||||||
|
return RelayResource{}, fmt.Errorf("get ons subscription: %w", err)
|
||||||
|
}
|
||||||
|
return RelayResource{ID: deref(resp.Id), State: string(resp.LifecycleState)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureRelayPolicy 实现 Client:按名返回既有 IAM policy 或新建;
|
||||||
|
// 写操作必须发往 home region,授权 Service Connector 发布消息到 Topic。
|
||||||
|
func (c *RealClient) EnsureRelayPolicy(ctx context.Context, cred Credentials, homeRegion string) (RelayResource, error) {
|
||||||
|
ic, err := c.identityClientAt(cred, homeRegion)
|
||||||
|
if err != nil {
|
||||||
|
return RelayResource{}, err
|
||||||
|
}
|
||||||
|
list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{
|
||||||
|
CompartmentId: &cred.TenancyOCID, Name: common.String(relayPolicyName),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return RelayResource{}, fmt.Errorf("list policies: %w", err)
|
||||||
|
}
|
||||||
|
if len(list.Items) > 0 {
|
||||||
|
return RelayResource{ID: deref(list.Items[0].Id), State: string(list.Items[0].LifecycleState)}, nil
|
||||||
|
}
|
||||||
|
stmt := fmt.Sprintf("Allow any-user to use ons-topics in tenancy where all {request.principal.type='serviceconnector', request.principal.compartment.id='%s'}", cred.TenancyOCID)
|
||||||
|
created, err := ic.CreatePolicy(ctx, identity.CreatePolicyRequest{
|
||||||
|
CreatePolicyDetails: identity.CreatePolicyDetails{
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
Name: common.String(relayPolicyName),
|
||||||
|
Description: common.String("oci-portal 日志回传:允许 Service Connector 发布到 ONS Topic"),
|
||||||
|
Statements: []string{stmt},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return RelayResource{}, fmt.Errorf("create policy: %w", err)
|
||||||
|
}
|
||||||
|
return RelayResource{ID: deref(created.Id), State: string(created.LifecycleState), Created: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureRelayConnector 实现 Client:按名返回既有 Connector 或新建(_Audit 含子区间 → Topic,
|
||||||
|
// 按 condition 过滤);新建后轮询至 ACTIVE,超时返回错误但保留 Created 供上层回滚。
|
||||||
|
func (c *RealClient) EnsureRelayConnector(ctx context.Context, cred Credentials, topicID, condition string) (RelayResource, error) {
|
||||||
|
sc, err := c.schClient(cred)
|
||||||
|
if err != nil {
|
||||||
|
return RelayResource{}, err
|
||||||
|
}
|
||||||
|
if res, ok, err := findRelayConnector(ctx, sc, cred.TenancyOCID); err != nil || ok {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
details := sch.CreateServiceConnectorDetails{
|
||||||
|
DisplayName: common.String(relayConnectorName),
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
Source: sch.LoggingSourceDetails{LogSources: []sch.LogSource{{
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
LogGroupId: common.String(relayAuditLogGroup),
|
||||||
|
}}},
|
||||||
|
Target: sch.NotificationsTargetDetails{TopicId: &topicID},
|
||||||
|
}
|
||||||
|
if condition != "" {
|
||||||
|
details.Tasks = []sch.TaskDetails{sch.LogRuleTaskDetails{Condition: &condition}}
|
||||||
|
}
|
||||||
|
if _, err := sc.CreateServiceConnector(ctx, sch.CreateServiceConnectorRequest{
|
||||||
|
CreateServiceConnectorDetails: details,
|
||||||
|
}); err != nil {
|
||||||
|
return RelayResource{}, fmt.Errorf("create service connector: %w", err)
|
||||||
|
}
|
||||||
|
return waitRelayConnector(ctx, sc, cred.TenancyOCID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findRelayConnector 按名查找存活 Connector。
|
||||||
|
func findRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string) (RelayResource, bool, error) {
|
||||||
|
list, err := sc.ListServiceConnectors(ctx, sch.ListServiceConnectorsRequest{
|
||||||
|
CompartmentId: &tenancy, DisplayName: common.String(relayConnectorName),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return RelayResource{}, false, fmt.Errorf("list service connectors: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range list.Items {
|
||||||
|
if item.LifecycleState != sch.LifecycleStateDeleted && item.LifecycleState != sch.LifecycleStateDeleting {
|
||||||
|
return RelayResource{ID: deref(item.Id), State: string(item.LifecycleState)}, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RelayResource{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitRelayConnector 轮询新建 Connector 直至 ACTIVE;超时带回已建资源信息。
|
||||||
|
func waitRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string) (RelayResource, error) {
|
||||||
|
last := RelayResource{Created: true}
|
||||||
|
for i := 0; i < relayConnectorPollLimit; i++ {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return last, ctx.Err()
|
||||||
|
case <-time.After(relayConnectorPollTick):
|
||||||
|
}
|
||||||
|
res, ok, err := findRelayConnector(ctx, sc, tenancy)
|
||||||
|
if err != nil {
|
||||||
|
return last, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
last, last.Created = res, true
|
||||||
|
if res.State == string(sch.LifecycleStateActive) {
|
||||||
|
return last, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last, fmt.Errorf("service connector not active after %s", time.Duration(relayConnectorPollLimit)*relayConnectorPollTick)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelayState 实现 Client:聚合链路四资源现状;endpoint 为空时不匹配订阅。
|
||||||
|
func (c *RealClient) RelayState(ctx context.Context, cred Credentials, endpoint string) (RelayState, error) {
|
||||||
|
var st RelayState
|
||||||
|
cp, err := c.onsControlClient(cred)
|
||||||
|
if err != nil {
|
||||||
|
return st, err
|
||||||
|
}
|
||||||
|
if st.Topic, _, err = findRelayTopic(ctx, cp, cred.TenancyOCID); err != nil {
|
||||||
|
return st, err
|
||||||
|
}
|
||||||
|
if st.Topic.ID != "" {
|
||||||
|
dp, err := c.onsDataClient(cred)
|
||||||
|
if err != nil {
|
||||||
|
return st, err
|
||||||
|
}
|
||||||
|
if st.Subscription, _, err = findRelaySubscription(ctx, dp, cred.TenancyOCID, st.Topic.ID, endpoint); err != nil {
|
||||||
|
return st, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.relayControlState(ctx, cred, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// relayControlState 补齐 Connector 与 Policy 两项状态。
|
||||||
|
func (c *RealClient) relayControlState(ctx context.Context, cred Credentials, st RelayState) (RelayState, error) {
|
||||||
|
sc, err := c.schClient(cred)
|
||||||
|
if err != nil {
|
||||||
|
return st, err
|
||||||
|
}
|
||||||
|
if st.Connector, _, err = findRelayConnector(ctx, sc, cred.TenancyOCID); err != nil {
|
||||||
|
return st, err
|
||||||
|
}
|
||||||
|
ic, err := c.identityClientAt(cred, "")
|
||||||
|
if err != nil {
|
||||||
|
return st, err
|
||||||
|
}
|
||||||
|
list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{
|
||||||
|
CompartmentId: &cred.TenancyOCID, Name: common.String(relayPolicyName),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return st, fmt.Errorf("list policies: %w", err)
|
||||||
|
}
|
||||||
|
if len(list.Items) > 0 {
|
||||||
|
st.Policy = RelayResource{ID: deref(list.Items[0].Id), State: string(list.Items[0].LifecycleState)}
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRelayConnector 实现 Client。
|
||||||
|
func (c *RealClient) DeleteRelayConnector(ctx context.Context, cred Credentials, connectorID string) error {
|
||||||
|
sc, err := c.schClient(cred)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := sc.DeleteServiceConnector(ctx, sch.DeleteServiceConnectorRequest{ServiceConnectorId: &connectorID}); err != nil {
|
||||||
|
return fmt.Errorf("delete service connector: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRelayPolicy 实现 Client;IAM 写操作发往 home region。
|
||||||
|
func (c *RealClient) DeleteRelayPolicy(ctx context.Context, cred Credentials, homeRegion, policyID string) error {
|
||||||
|
ic, err := c.identityClientAt(cred, homeRegion)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := ic.DeletePolicy(ctx, identity.DeletePolicyRequest{PolicyId: &policyID}); err != nil {
|
||||||
|
return fmt.Errorf("delete policy: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRelaySubscription 实现 Client。
|
||||||
|
func (c *RealClient) DeleteRelaySubscription(ctx context.Context, cred Credentials, subscriptionID string) error {
|
||||||
|
dp, err := c.onsDataClient(cred)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := dp.DeleteSubscription(ctx, ons.DeleteSubscriptionRequest{SubscriptionId: &subscriptionID}); err != nil {
|
||||||
|
return fmt.Errorf("delete ons subscription: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRelayTopic 实现 Client。
|
||||||
|
func (c *RealClient) DeleteRelayTopic(ctx context.Context, cred Credentials, topicID string) error {
|
||||||
|
cp, err := c.onsControlClient(cred)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := cp.DeleteTopic(ctx, ons.DeleteTopicRequest{TopicId: &topicID}); err != nil {
|
||||||
|
return fmt.Errorf("delete ons topic: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRelayEventCondition(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
events []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "单事件", events: []string{"LaunchInstance"}, want: "data.eventName='LaunchInstance'"},
|
||||||
|
{
|
||||||
|
name: "多事件 or 连接",
|
||||||
|
events: []string{"CreateUser", "DeleteUser"},
|
||||||
|
want: "data.eventName='CreateUser' or data.eventName='DeleteUser'",
|
||||||
|
},
|
||||||
|
{name: "空清单", events: nil, want: ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := RelayEventCondition(tt.events); got != tt.want {
|
||||||
|
t.Errorf("condition = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelayEventConditionQuoting(t *testing.T) {
|
||||||
|
// 清单值来自代码内常量,不受外部输入;此处只固化引号格式防手滑
|
||||||
|
cond := RelayEventCondition([]string{"InstanceAction"})
|
||||||
|
if strings.Count(cond, "'") != 2 {
|
||||||
|
t.Errorf("单引号数 = %d, want 2 (%s)", strings.Count(cond, "'"), cond)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,733 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VCN 是一个虚拟云网络。
|
||||||
|
type VCN struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
LifecycleState string `json:"lifecycleState"`
|
||||||
|
CompartmentID string `json:"compartmentId"`
|
||||||
|
CidrBlocks []string `json:"cidrBlocks"`
|
||||||
|
Ipv6CidrBlocks []string `json:"ipv6CidrBlocks"`
|
||||||
|
DnsLabel string `json:"dnsLabel"`
|
||||||
|
DefaultRouteTableID string `json:"defaultRouteTableId"`
|
||||||
|
DefaultSecurityListID string `json:"defaultSecurityListId"`
|
||||||
|
TimeCreated *time.Time `json:"timeCreated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateVCNInput 是创建 VCN 的输入。
|
||||||
|
type CreateVCNInput struct {
|
||||||
|
Region string
|
||||||
|
CompartmentID string // 目标 compartment,空为租户根
|
||||||
|
DisplayName string
|
||||||
|
CidrBlock string
|
||||||
|
DnsLabel string
|
||||||
|
EnableIPv6 bool // 创建时同时分配 Oracle GUA IPv6 /56
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subnet 是 VCN 下的一个子网。
|
||||||
|
type Subnet struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
VcnID string `json:"vcnId"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
LifecycleState string `json:"lifecycleState"`
|
||||||
|
CidrBlock string `json:"cidrBlock"`
|
||||||
|
Ipv6CidrBlock string `json:"ipv6CidrBlock"`
|
||||||
|
DnsLabel string `json:"dnsLabel"`
|
||||||
|
ProhibitPublicIP bool `json:"prohibitPublicIp"`
|
||||||
|
AvailabilityDomain string `json:"availabilityDomain"`
|
||||||
|
RouteTableID string `json:"routeTableId"`
|
||||||
|
TimeCreated *time.Time `json:"timeCreated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSubnetInput 是创建子网的输入。
|
||||||
|
type CreateSubnetInput struct {
|
||||||
|
Region string
|
||||||
|
VcnID string
|
||||||
|
DisplayName string
|
||||||
|
CidrBlock string
|
||||||
|
DnsLabel string
|
||||||
|
Ipv6CidrBlock string
|
||||||
|
ProhibitPublicIP bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityRule 是安全列表中的一条规则;TCP/UDP 只支持目标端口范围。
|
||||||
|
type SecurityRule struct {
|
||||||
|
Protocol string `json:"protocol"` // all、6(TCP)、17(UDP)、1(ICMP)、58(ICMPv6)
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
Destination string `json:"destination,omitempty"`
|
||||||
|
IsStateless bool `json:"isStateless"`
|
||||||
|
PortMin *int `json:"portMin,omitempty"`
|
||||||
|
PortMax *int `json:"portMax,omitempty"`
|
||||||
|
IcmpType *int `json:"icmpType,omitempty"`
|
||||||
|
IcmpCode *int `json:"icmpCode,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityList 是一个安全列表及其规则。
|
||||||
|
type SecurityList struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
VcnID string `json:"vcnId"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
LifecycleState string `json:"lifecycleState"`
|
||||||
|
IngressRules []SecurityRule `json:"ingressRules"`
|
||||||
|
EgressRules []SecurityRule `json:"egressRules"`
|
||||||
|
TimeCreated *time.Time `json:"timeCreated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSecurityListInput 是创建安全列表的输入。
|
||||||
|
type CreateSecurityListInput struct {
|
||||||
|
Region string
|
||||||
|
VcnID string
|
||||||
|
DisplayName string
|
||||||
|
IngressRules []SecurityRule
|
||||||
|
EgressRules []SecurityRule
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSecurityListInput 是更新安全列表的输入;nil 字段保持不变,
|
||||||
|
// 规则字段一经提供即整体替换。
|
||||||
|
type UpdateSecurityListInput struct {
|
||||||
|
Region string
|
||||||
|
DisplayName string
|
||||||
|
IngressRules *[]SecurityRule
|
||||||
|
EgressRules *[]SecurityRule
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动建网使用的固定名称,便于复用而非每次新建 VCN。
|
||||||
|
const (
|
||||||
|
autoVcnName = "oci-portal-auto-vcn"
|
||||||
|
autoSubnetName = "oci-portal-auto-subnet"
|
||||||
|
autoVcnCidr = "10.0.0.0/16"
|
||||||
|
autoSubnetCidr = "10.0.0.0/24"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ensureDefaultSubnet 在 subnetId 为空时准备一个可上公网的子网:
|
||||||
|
// 复用已有的 oci-portal-auto-vcn,否则新建 VCN + IGW + 默认路由 + 子网。
|
||||||
|
func (c *RealClient) ensureDefaultSubnet(ctx context.Context, cred Credentials, region string) (string, error) {
|
||||||
|
vcns, err := c.ListVCNs(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, v := range vcns {
|
||||||
|
if v.DisplayName != autoVcnName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 列表项自带 VCN 所在 compartment,直接用它列子网,免去重复 GetVCN
|
||||||
|
subnets, err := listSubnets(ctx, vn, *hostCompartment(cred, v.CompartmentID), v.ID)
|
||||||
|
if err == nil && len(subnets) > 0 {
|
||||||
|
return subnets[0].ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.createDefaultNetwork(ctx, cred, region)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RealClient) createDefaultNetwork(ctx context.Context, cred Credentials, region string) (string, error) {
|
||||||
|
vcn, err := c.CreateVCN(ctx, cred, CreateVCNInput{
|
||||||
|
Region: region, DisplayName: autoVcnName, CidrBlock: autoVcnCidr, DnsLabel: "ociportalauto",
|
||||||
|
EnableIPv6: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// IGW 与子网都建在新 VCN 自己的 compartment,与其余关联资源保持同域
|
||||||
|
vcnCompartment := *hostCompartment(cred, vcn.CompartmentID)
|
||||||
|
igwID, err := ensureInternetGateway(ctx, vn, vcnCompartment, vcn.ID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := addInternetRoute(ctx, vn, vcn.DefaultRouteTableID, igwID, "0.0.0.0/0"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
subnet, err := c.createSubnetIn(ctx, cred, vcnCompartment, CreateSubnetInput{
|
||||||
|
Region: region, VcnID: vcn.ID, DisplayName: autoSubnetName, CidrBlock: autoSubnetCidr, DnsLabel: "ociauto",
|
||||||
|
Ipv6CidrBlock: autoSubnetIPv6Cidr(ctx, vn, vcn.ID, vcn.DefaultRouteTableID, igwID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return subnet.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoSubnetIPv6Cidr 等待自动 VCN 的 IPv6 /56 分配就绪并补 ::/0 路由,
|
||||||
|
// 返回子网可用的 /64;IPv6 尽力而为,失败返回空串降级纯 IPv4 不阻塞建网。
|
||||||
|
func autoSubnetIPv6Cidr(ctx context.Context, vn core.VirtualNetworkClient, vcnID, routeTableID, igwID string) string {
|
||||||
|
vcnCidr, err := waitVCNIPv6Cidr(ctx, vn, vcnID)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if err := addInternetRoute(ctx, vn, routeTableID, igwID, ipv6AnyCIDR); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
cidr, err := nextSubnetIPv6CIDR(vcnCidr, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return cidr
|
||||||
|
}
|
||||||
|
|
||||||
|
// addInternetRoute 在路由表补 dest → IGW,已存在则跳过。
|
||||||
|
func addInternetRoute(ctx context.Context, vn core.VirtualNetworkClient, routeTableID, igwID, dest string) error {
|
||||||
|
resp, err := vn.GetRouteTable(ctx, core.GetRouteTableRequest{RtId: &routeTableID})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get route table: %w", err)
|
||||||
|
}
|
||||||
|
for _, rule := range resp.RouteRules {
|
||||||
|
if deref(rule.Destination) == dest {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rules := append(resp.RouteRules, core.RouteRule{
|
||||||
|
Destination: &dest,
|
||||||
|
DestinationType: core.RouteRuleDestinationTypeCidrBlock,
|
||||||
|
NetworkEntityId: &igwID,
|
||||||
|
})
|
||||||
|
_, err = vn.UpdateRouteTable(ctx, core.UpdateRouteTableRequest{
|
||||||
|
RtId: &routeTableID,
|
||||||
|
UpdateRouteTableDetails: core.UpdateRouteTableDetails{RouteRules: rules},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("add internet route %s: %w", dest, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RealClient) vcnClient(cred Credentials, region string) (core.VirtualNetworkClient, error) {
|
||||||
|
vn, err := core.NewVirtualNetworkClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return vn, fmt.Errorf("new virtual network client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&vn.BaseClient, cred)
|
||||||
|
if region != "" {
|
||||||
|
vn.SetRegion(normalizeRegion(region))
|
||||||
|
}
|
||||||
|
return vn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListVCNs 实现 Client。
|
||||||
|
func (c *RealClient) ListVCNs(ctx context.Context, cred Credentials, region string) ([]VCN, error) {
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var vcns []VCN
|
||||||
|
var page *string
|
||||||
|
for {
|
||||||
|
resp, err := vn.ListVcns(ctx, core.ListVcnsRequest{CompartmentId: cred.EffectiveCompartment(), Page: page})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list vcns: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
vcns = append(vcns, toVCN(item))
|
||||||
|
}
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
return orEmpty(vcns), nil
|
||||||
|
}
|
||||||
|
page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateVCN 实现 Client;创建后将默认安全列表放开为出入方向全放行。
|
||||||
|
func (c *RealClient) CreateVCN(ctx context.Context, cred Credentials, in CreateVCNInput) (VCN, error) {
|
||||||
|
vn, err := c.vcnClient(cred, in.Region)
|
||||||
|
if err != nil {
|
||||||
|
return VCN{}, err
|
||||||
|
}
|
||||||
|
details := core.CreateVcnDetails{
|
||||||
|
CompartmentId: cred.EffectiveCompartment(),
|
||||||
|
CidrBlocks: []string{in.CidrBlock},
|
||||||
|
DisplayName: &in.DisplayName,
|
||||||
|
}
|
||||||
|
if in.DnsLabel != "" {
|
||||||
|
details.DnsLabel = &in.DnsLabel
|
||||||
|
}
|
||||||
|
if in.EnableIPv6 {
|
||||||
|
details.IsIpv6Enabled = &in.EnableIPv6
|
||||||
|
}
|
||||||
|
resp, err := vn.CreateVcn(ctx, core.CreateVcnRequest{CreateVcnDetails: details})
|
||||||
|
if err != nil {
|
||||||
|
return VCN{}, fmt.Errorf("create vcn: %w", err)
|
||||||
|
}
|
||||||
|
vcn := toVCN(resp.Vcn)
|
||||||
|
// VCN 初始化会异步写入默认安全列表的初始规则,未就绪时替换会被覆盖
|
||||||
|
if err := waitVCNAvailable(ctx, vn, vcn.ID); err != nil {
|
||||||
|
return vcn, err
|
||||||
|
}
|
||||||
|
if err := openDefaultSecurityList(ctx, vn, vcn.DefaultSecurityListID, in.EnableIPv6); err != nil {
|
||||||
|
return vcn, err
|
||||||
|
}
|
||||||
|
return vcn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitVCNAvailable 轮询等待 VCN 进入 AVAILABLE,最长约 60 秒。
|
||||||
|
func waitVCNAvailable(ctx context.Context, vn core.VirtualNetworkClient, vcnID string) error {
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
resp, err := vn.GetVcn(ctx, core.GetVcnRequest{VcnId: &vcnID})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("poll vcn state: %w", err)
|
||||||
|
}
|
||||||
|
if resp.LifecycleState == core.VcnLifecycleStateAvailable {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fmt.Errorf("poll vcn state: %w", ctx.Err())
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("poll vcn state: timed out waiting for AVAILABLE")
|
||||||
|
}
|
||||||
|
|
||||||
|
// openDefaultSecurityList 将安全列表规则替换为出入方向全放行;
|
||||||
|
// includeIPv6 时附带 ::/0 规则(OCI 拒绝在未启用 IPv6 的 VCN 上写入)。
|
||||||
|
func openDefaultSecurityList(ctx context.Context, vn core.VirtualNetworkClient, securityListID string, includeIPv6 bool) error {
|
||||||
|
protocol := "all"
|
||||||
|
v4, v6 := "0.0.0.0/0", ipv6AnyCIDR
|
||||||
|
ingress := []core.IngressSecurityRule{{Protocol: &protocol, Source: &v4}}
|
||||||
|
egress := []core.EgressSecurityRule{{Protocol: &protocol, Destination: &v4}}
|
||||||
|
if includeIPv6 {
|
||||||
|
ingress = append(ingress, core.IngressSecurityRule{Protocol: &protocol, Source: &v6})
|
||||||
|
egress = append(egress, core.EgressSecurityRule{Protocol: &protocol, Destination: &v6})
|
||||||
|
}
|
||||||
|
_, err := vn.UpdateSecurityList(ctx, core.UpdateSecurityListRequest{
|
||||||
|
SecurityListId: &securityListID,
|
||||||
|
UpdateSecurityListDetails: core.UpdateSecurityListDetails{
|
||||||
|
IngressSecurityRules: ingress,
|
||||||
|
EgressSecurityRules: egress,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open default security list %s: %w", securityListID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVCN 实现 Client。
|
||||||
|
func (c *RealClient) GetVCN(ctx context.Context, cred Credentials, region, vcnID string) (VCN, error) {
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return VCN{}, err
|
||||||
|
}
|
||||||
|
resp, err := vn.GetVcn(ctx, core.GetVcnRequest{VcnId: &vcnID})
|
||||||
|
if err != nil {
|
||||||
|
return VCN{}, fmt.Errorf("get vcn %s: %w", vcnID, err)
|
||||||
|
}
|
||||||
|
return toVCN(resp.Vcn), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateVCN 实现 Client:当前仅支持改名。
|
||||||
|
func (c *RealClient) UpdateVCN(ctx context.Context, cred Credentials, region, vcnID, displayName string) (VCN, error) {
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return VCN{}, err
|
||||||
|
}
|
||||||
|
resp, err := vn.UpdateVcn(ctx, core.UpdateVcnRequest{
|
||||||
|
VcnId: &vcnID,
|
||||||
|
UpdateVcnDetails: core.UpdateVcnDetails{DisplayName: &displayName},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return VCN{}, fmt.Errorf("update vcn %s: %w", vcnID, err)
|
||||||
|
}
|
||||||
|
return toVCN(resp.Vcn), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteVCN 实现 Client:级联清理子网、网关与非默认路由表 / 安全列表 /
|
||||||
|
// DHCP 选项后删除 VCN(见 vcndelete.go);子网被实例占用时 OCI 返回 409。
|
||||||
|
func (c *RealClient) DeleteVCN(ctx context.Context, cred Credentials, region, vcnID string) error {
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return deleteVcnCascade(ctx, vn, vcnID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// vcnScopedCompartment 返回按 VCN 定位的查询与创建应使用的 compartment:
|
||||||
|
// OCI 要求 compartmentId 与资源实际所在 compartment 一致,vcnID 非空时取
|
||||||
|
// VCN 自身所在 compartment,为空(全 compartment 列表)时用生效 compartment。
|
||||||
|
func (c *RealClient) vcnScopedCompartment(ctx context.Context, cred Credentials, region, vcnID string) (string, error) {
|
||||||
|
if vcnID == "" {
|
||||||
|
return *cred.EffectiveCompartment(), nil
|
||||||
|
}
|
||||||
|
vcn, err := c.GetVCN(ctx, cred, region, vcnID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return *hostCompartment(cred, vcn.CompartmentID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSubnets 实现 Client;vcnID 为空时列出全部子网。
|
||||||
|
func (c *RealClient) ListSubnets(ctx context.Context, cred Credentials, region, vcnID string) ([]Subnet, error) {
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
compartmentID, err := c.vcnScopedCompartment(ctx, cred, region, vcnID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return listSubnets(ctx, vn, compartmentID, vcnID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSubnets(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID string) ([]Subnet, error) {
|
||||||
|
var subnets []Subnet
|
||||||
|
var page *string
|
||||||
|
for {
|
||||||
|
req := core.ListSubnetsRequest{CompartmentId: &compartmentID, Page: page}
|
||||||
|
if vcnID != "" {
|
||||||
|
req.VcnId = &vcnID
|
||||||
|
}
|
||||||
|
resp, err := vn.ListSubnets(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list subnets: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
subnets = append(subnets, toSubnet(item))
|
||||||
|
}
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
return orEmpty(subnets), nil
|
||||||
|
}
|
||||||
|
page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSubnet 实现 Client;不传 AD 时创建 regional 子网。
|
||||||
|
// 子网跟随其 VCN 所在 compartment 创建,避免跨 compartment 悬挂资源。
|
||||||
|
func (c *RealClient) CreateSubnet(ctx context.Context, cred Credentials, in CreateSubnetInput) (Subnet, error) {
|
||||||
|
compartmentID, err := c.vcnScopedCompartment(ctx, cred, in.Region, in.VcnID)
|
||||||
|
if err != nil {
|
||||||
|
return Subnet{}, err
|
||||||
|
}
|
||||||
|
return c.createSubnetIn(ctx, cred, compartmentID, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSubnetIn 在指定 compartment 创建子网,供已知 VCN compartment
|
||||||
|
// 的调用方(如自动建网)复用,免去一次重复的 GetVCN。
|
||||||
|
func (c *RealClient) createSubnetIn(ctx context.Context, cred Credentials, compartmentID string, in CreateSubnetInput) (Subnet, error) {
|
||||||
|
vn, err := c.vcnClient(cred, in.Region)
|
||||||
|
if err != nil {
|
||||||
|
return Subnet{}, err
|
||||||
|
}
|
||||||
|
resp, err := vn.CreateSubnet(ctx, core.CreateSubnetRequest{CreateSubnetDetails: buildSubnetDetails(compartmentID, in)})
|
||||||
|
if err != nil {
|
||||||
|
return Subnet{}, fmt.Errorf("create subnet: %w", err)
|
||||||
|
}
|
||||||
|
return toSubnet(resp.Subnet), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSubnetDetails 组装创建子网请求体;可选字段仅在非零值时下发。
|
||||||
|
func buildSubnetDetails(compartmentID string, in CreateSubnetInput) core.CreateSubnetDetails {
|
||||||
|
details := core.CreateSubnetDetails{
|
||||||
|
CompartmentId: &compartmentID,
|
||||||
|
VcnId: &in.VcnID,
|
||||||
|
CidrBlock: &in.CidrBlock,
|
||||||
|
DisplayName: &in.DisplayName,
|
||||||
|
}
|
||||||
|
if in.DnsLabel != "" {
|
||||||
|
details.DnsLabel = &in.DnsLabel
|
||||||
|
}
|
||||||
|
if in.Ipv6CidrBlock != "" {
|
||||||
|
details.Ipv6CidrBlock = &in.Ipv6CidrBlock
|
||||||
|
}
|
||||||
|
if in.ProhibitPublicIP {
|
||||||
|
details.ProhibitPublicIpOnVnic = &in.ProhibitPublicIP
|
||||||
|
}
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSubnet 实现 Client。
|
||||||
|
func (c *RealClient) GetSubnet(ctx context.Context, cred Credentials, region, subnetID string) (Subnet, error) {
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return Subnet{}, err
|
||||||
|
}
|
||||||
|
resp, err := vn.GetSubnet(ctx, core.GetSubnetRequest{SubnetId: &subnetID})
|
||||||
|
if err != nil {
|
||||||
|
return Subnet{}, fmt.Errorf("get subnet %s: %w", subnetID, err)
|
||||||
|
}
|
||||||
|
return toSubnet(resp.Subnet), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSubnet 实现 Client:当前仅支持改名。
|
||||||
|
func (c *RealClient) UpdateSubnet(ctx context.Context, cred Credentials, region, subnetID, displayName string) (Subnet, error) {
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return Subnet{}, err
|
||||||
|
}
|
||||||
|
resp, err := vn.UpdateSubnet(ctx, core.UpdateSubnetRequest{
|
||||||
|
SubnetId: &subnetID,
|
||||||
|
UpdateSubnetDetails: core.UpdateSubnetDetails{DisplayName: &displayName},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Subnet{}, fmt.Errorf("update subnet %s: %w", subnetID, err)
|
||||||
|
}
|
||||||
|
return toSubnet(resp.Subnet), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSubnet 实现 Client。
|
||||||
|
func (c *RealClient) DeleteSubnet(ctx context.Context, cred Credentials, region, subnetID string) error {
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := vn.DeleteSubnet(ctx, core.DeleteSubnetRequest{SubnetId: &subnetID}); err != nil {
|
||||||
|
return fmt.Errorf("delete subnet %s: %w", subnetID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSecurityLists 实现 Client;vcnID 为空时列出全部安全列表。
|
||||||
|
func (c *RealClient) ListSecurityLists(ctx context.Context, cred Credentials, region, vcnID string) ([]SecurityList, error) {
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
compartmentID, err := c.vcnScopedCompartment(ctx, cred, region, vcnID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var lists []SecurityList
|
||||||
|
var page *string
|
||||||
|
for {
|
||||||
|
req := core.ListSecurityListsRequest{CompartmentId: &compartmentID, Page: page}
|
||||||
|
if vcnID != "" {
|
||||||
|
req.VcnId = &vcnID
|
||||||
|
}
|
||||||
|
resp, err := vn.ListSecurityLists(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list security lists: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
lists = append(lists, toSecurityList(item))
|
||||||
|
}
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
return orEmpty(lists), nil
|
||||||
|
}
|
||||||
|
page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSecurityList 实现 Client。
|
||||||
|
// 安全列表跟随其 VCN 所在 compartment 创建,避免跨 compartment 悬挂资源。
|
||||||
|
func (c *RealClient) CreateSecurityList(ctx context.Context, cred Credentials, in CreateSecurityListInput) (SecurityList, error) {
|
||||||
|
vn, err := c.vcnClient(cred, in.Region)
|
||||||
|
if err != nil {
|
||||||
|
return SecurityList{}, err
|
||||||
|
}
|
||||||
|
compartmentID, err := c.vcnScopedCompartment(ctx, cred, in.Region, in.VcnID)
|
||||||
|
if err != nil {
|
||||||
|
return SecurityList{}, err
|
||||||
|
}
|
||||||
|
resp, err := vn.CreateSecurityList(ctx, core.CreateSecurityListRequest{
|
||||||
|
CreateSecurityListDetails: core.CreateSecurityListDetails{
|
||||||
|
CompartmentId: &compartmentID,
|
||||||
|
VcnId: &in.VcnID,
|
||||||
|
DisplayName: &in.DisplayName,
|
||||||
|
IngressSecurityRules: toIngressRules(in.IngressRules),
|
||||||
|
EgressSecurityRules: toEgressRules(in.EgressRules),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return SecurityList{}, fmt.Errorf("create security list: %w", err)
|
||||||
|
}
|
||||||
|
return toSecurityList(resp.SecurityList), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSecurityList 实现 Client。
|
||||||
|
func (c *RealClient) GetSecurityList(ctx context.Context, cred Credentials, region, securityListID string) (SecurityList, error) {
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return SecurityList{}, err
|
||||||
|
}
|
||||||
|
resp, err := vn.GetSecurityList(ctx, core.GetSecurityListRequest{SecurityListId: &securityListID})
|
||||||
|
if err != nil {
|
||||||
|
return SecurityList{}, fmt.Errorf("get security list %s: %w", securityListID, err)
|
||||||
|
}
|
||||||
|
return toSecurityList(resp.SecurityList), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSecurityList 实现 Client。
|
||||||
|
func (c *RealClient) UpdateSecurityList(ctx context.Context, cred Credentials, securityListID string, in UpdateSecurityListInput) (SecurityList, error) {
|
||||||
|
vn, err := c.vcnClient(cred, in.Region)
|
||||||
|
if err != nil {
|
||||||
|
return SecurityList{}, err
|
||||||
|
}
|
||||||
|
details := core.UpdateSecurityListDetails{}
|
||||||
|
if in.DisplayName != "" {
|
||||||
|
details.DisplayName = &in.DisplayName
|
||||||
|
}
|
||||||
|
if in.IngressRules != nil {
|
||||||
|
details.IngressSecurityRules = toIngressRules(*in.IngressRules)
|
||||||
|
}
|
||||||
|
if in.EgressRules != nil {
|
||||||
|
details.EgressSecurityRules = toEgressRules(*in.EgressRules)
|
||||||
|
}
|
||||||
|
resp, err := vn.UpdateSecurityList(ctx, core.UpdateSecurityListRequest{
|
||||||
|
SecurityListId: &securityListID,
|
||||||
|
UpdateSecurityListDetails: details,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return SecurityList{}, fmt.Errorf("update security list %s: %w", securityListID, err)
|
||||||
|
}
|
||||||
|
return toSecurityList(resp.SecurityList), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSecurityList 实现 Client。
|
||||||
|
func (c *RealClient) DeleteSecurityList(ctx context.Context, cred Credentials, region, securityListID string) error {
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := vn.DeleteSecurityList(ctx, core.DeleteSecurityListRequest{SecurityListId: &securityListID}); err != nil {
|
||||||
|
return fmt.Errorf("delete security list %s: %w", securityListID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toVCN(v core.Vcn) VCN {
|
||||||
|
return VCN{
|
||||||
|
ID: deref(v.Id),
|
||||||
|
DisplayName: deref(v.DisplayName),
|
||||||
|
LifecycleState: string(v.LifecycleState),
|
||||||
|
CompartmentID: deref(v.CompartmentId),
|
||||||
|
CidrBlocks: orEmpty(v.CidrBlocks),
|
||||||
|
Ipv6CidrBlocks: orEmpty(v.Ipv6CidrBlocks),
|
||||||
|
DnsLabel: deref(v.DnsLabel),
|
||||||
|
DefaultRouteTableID: deref(v.DefaultRouteTableId),
|
||||||
|
DefaultSecurityListID: deref(v.DefaultSecurityListId),
|
||||||
|
TimeCreated: sdkTime(v.TimeCreated),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toSubnet(s core.Subnet) Subnet {
|
||||||
|
return Subnet{
|
||||||
|
ID: deref(s.Id),
|
||||||
|
VcnID: deref(s.VcnId),
|
||||||
|
DisplayName: deref(s.DisplayName),
|
||||||
|
LifecycleState: string(s.LifecycleState),
|
||||||
|
CidrBlock: deref(s.CidrBlock),
|
||||||
|
Ipv6CidrBlock: deref(s.Ipv6CidrBlock),
|
||||||
|
DnsLabel: deref(s.DnsLabel),
|
||||||
|
ProhibitPublicIP: s.ProhibitPublicIpOnVnic != nil && *s.ProhibitPublicIpOnVnic,
|
||||||
|
AvailabilityDomain: deref(s.AvailabilityDomain),
|
||||||
|
RouteTableID: deref(s.RouteTableId),
|
||||||
|
TimeCreated: sdkTime(s.TimeCreated),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toSecurityList(sl core.SecurityList) SecurityList {
|
||||||
|
out := SecurityList{
|
||||||
|
ID: deref(sl.Id),
|
||||||
|
VcnID: deref(sl.VcnId),
|
||||||
|
DisplayName: deref(sl.DisplayName),
|
||||||
|
LifecycleState: string(sl.LifecycleState),
|
||||||
|
TimeCreated: sdkTime(sl.TimeCreated),
|
||||||
|
IngressRules: make([]SecurityRule, 0, len(sl.IngressSecurityRules)),
|
||||||
|
EgressRules: make([]SecurityRule, 0, len(sl.EgressSecurityRules)),
|
||||||
|
}
|
||||||
|
for _, r := range sl.IngressSecurityRules {
|
||||||
|
rule := SecurityRule{
|
||||||
|
Protocol: deref(r.Protocol),
|
||||||
|
Source: deref(r.Source),
|
||||||
|
IsStateless: r.IsStateless != nil && *r.IsStateless,
|
||||||
|
Description: deref(r.Description),
|
||||||
|
}
|
||||||
|
fillRuleOptions(&rule, r.TcpOptions, r.UdpOptions, r.IcmpOptions)
|
||||||
|
out.IngressRules = append(out.IngressRules, rule)
|
||||||
|
}
|
||||||
|
for _, r := range sl.EgressSecurityRules {
|
||||||
|
rule := SecurityRule{
|
||||||
|
Protocol: deref(r.Protocol),
|
||||||
|
Destination: deref(r.Destination),
|
||||||
|
IsStateless: r.IsStateless != nil && *r.IsStateless,
|
||||||
|
Description: deref(r.Description),
|
||||||
|
}
|
||||||
|
fillRuleOptions(&rule, r.TcpOptions, r.UdpOptions, r.IcmpOptions)
|
||||||
|
out.EgressRules = append(out.EgressRules, rule)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillRuleOptions(rule *SecurityRule, tcp *core.TcpOptions, udp *core.UdpOptions, icmp *core.IcmpOptions) {
|
||||||
|
switch {
|
||||||
|
case tcp != nil && tcp.DestinationPortRange != nil:
|
||||||
|
rule.PortMin = tcp.DestinationPortRange.Min
|
||||||
|
rule.PortMax = tcp.DestinationPortRange.Max
|
||||||
|
case udp != nil && udp.DestinationPortRange != nil:
|
||||||
|
rule.PortMin = udp.DestinationPortRange.Min
|
||||||
|
rule.PortMax = udp.DestinationPortRange.Max
|
||||||
|
case icmp != nil:
|
||||||
|
rule.IcmpType = icmp.Type
|
||||||
|
rule.IcmpCode = icmp.Code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toIngressRules(rules []SecurityRule) []core.IngressSecurityRule {
|
||||||
|
out := make([]core.IngressSecurityRule, 0, len(rules))
|
||||||
|
for i := range rules {
|
||||||
|
r := rules[i]
|
||||||
|
sdk := core.IngressSecurityRule{
|
||||||
|
Protocol: &r.Protocol,
|
||||||
|
Source: &r.Source,
|
||||||
|
IsStateless: &rules[i].IsStateless,
|
||||||
|
}
|
||||||
|
if r.Description != "" {
|
||||||
|
sdk.Description = &rules[i].Description
|
||||||
|
}
|
||||||
|
sdk.TcpOptions, sdk.UdpOptions, sdk.IcmpOptions = buildRuleOptions(r)
|
||||||
|
out = append(out, sdk)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func toEgressRules(rules []SecurityRule) []core.EgressSecurityRule {
|
||||||
|
out := make([]core.EgressSecurityRule, 0, len(rules))
|
||||||
|
for i := range rules {
|
||||||
|
r := rules[i]
|
||||||
|
sdk := core.EgressSecurityRule{
|
||||||
|
Protocol: &r.Protocol,
|
||||||
|
Destination: &r.Destination,
|
||||||
|
IsStateless: &rules[i].IsStateless,
|
||||||
|
}
|
||||||
|
if r.Description != "" {
|
||||||
|
sdk.Description = &rules[i].Description
|
||||||
|
}
|
||||||
|
sdk.TcpOptions, sdk.UdpOptions, sdk.IcmpOptions = buildRuleOptions(r)
|
||||||
|
out = append(out, sdk)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRuleOptions 按协议号构造端口或 ICMP 选项:6=TCP、17=UDP、1/58=ICMP(v6)。
|
||||||
|
func buildRuleOptions(r SecurityRule) (*core.TcpOptions, *core.UdpOptions, *core.IcmpOptions) {
|
||||||
|
if r.PortMin != nil {
|
||||||
|
portMax := r.PortMax
|
||||||
|
if portMax == nil {
|
||||||
|
portMax = r.PortMin
|
||||||
|
}
|
||||||
|
pr := &core.PortRange{Min: r.PortMin, Max: portMax}
|
||||||
|
switch r.Protocol {
|
||||||
|
case "6":
|
||||||
|
return &core.TcpOptions{DestinationPortRange: pr}, nil, nil
|
||||||
|
case "17":
|
||||||
|
return nil, &core.UdpOptions{DestinationPortRange: pr}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.IcmpType != nil && (r.Protocol == "1" || r.Protocol == "58") {
|
||||||
|
return nil, nil, &core.IcmpOptions{Type: r.IcmpType, Code: r.IcmpCode}
|
||||||
|
}
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProxySpec 是租户关联的出站代理;密码已解密,仅在进程内传递。
|
||||||
|
type ProxySpec struct {
|
||||||
|
Type string // "socks5" / "http" / "https"
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyClientTimeout 与 SDK 默认 HTTPClient 超时保持一致。
|
||||||
|
const proxyClientTimeout = 60 * time.Second
|
||||||
|
|
||||||
|
// applyProxy 在 SDK client 构造后统一挂出站代理;未关联代理时不动默认配置。
|
||||||
|
// 所有 New*ClientWithConfigurationProvider 调用点构造成功后都必须经过这里。
|
||||||
|
func applyProxy(base *common.BaseClient, cred Credentials) {
|
||||||
|
if hc := proxyHTTPClient(cred.Proxy); hc != nil {
|
||||||
|
base.HTTPClient = hc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyHTTPClient 按代理配置构造 http.Client;nil 或非法配置返回 nil(走直连)。
|
||||||
|
func proxyHTTPClient(p *ProxySpec) *http.Client {
|
||||||
|
if p == nil || p.Host == "" || p.Port <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tr := transportFor(p)
|
||||||
|
if tr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &http.Client{Transport: tr, Timeout: proxyClientTimeout}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPClientFor 供面板自身请求(如代理出口地理探测)复用与租户 SDK
|
||||||
|
// 完全一致的代理链路;nil 或非法配置返回 nil。
|
||||||
|
func HTTPClientFor(p *ProxySpec) *http.Client {
|
||||||
|
return proxyHTTPClient(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// transportFor 构造代理 Transport:http / https 走 CONNECT,socks5 走拨号器。
|
||||||
|
func transportFor(p *ProxySpec) *http.Transport {
|
||||||
|
addr := fmt.Sprintf("%s:%d", p.Host, p.Port)
|
||||||
|
if p.Type == "http" || p.Type == "https" {
|
||||||
|
u := &url.URL{Scheme: p.Type, Host: addr}
|
||||||
|
if p.Username != "" {
|
||||||
|
u.User = url.UserPassword(p.Username, p.Password)
|
||||||
|
}
|
||||||
|
return &http.Transport{Proxy: http.ProxyURL(u)}
|
||||||
|
}
|
||||||
|
var auth *proxy.Auth
|
||||||
|
if p.Username != "" {
|
||||||
|
auth = &proxy.Auth{User: p.Username, Password: p.Password}
|
||||||
|
}
|
||||||
|
d, err := proxy.SOCKS5("tcp", addr, auth, proxy.Direct)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cd, ok := d.(proxy.ContextDialer)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &http.Transport{DialContext: cd.DialContext}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// instanceVnics 列出实例的全部 VNIC,并一并返回实例所在 compartment,
|
||||||
|
// 供调用方复用为后续按实例定位查询的 compartmentId,避免重复 GetInstance。
|
||||||
|
func (c *RealClient) instanceVnics(ctx context.Context, cred Credentials, region, instanceID string) ([]core.Vnic, string, error) {
|
||||||
|
cc, err := c.computeClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
// VNIC 挂载记录在实例所在 compartment,先取实例定位,传租户根会漏查
|
||||||
|
instResp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("get instance %s: %w", instanceID, err)
|
||||||
|
}
|
||||||
|
compartmentID := hostCompartment(cred, deref(instResp.CompartmentId))
|
||||||
|
resp, err := cc.ListVnicAttachments(ctx, core.ListVnicAttachmentsRequest{
|
||||||
|
CompartmentId: compartmentID,
|
||||||
|
InstanceId: &instanceID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("list vnic attachments: %w", err)
|
||||||
|
}
|
||||||
|
return attachedVnics(ctx, vn, resp.Items), *compartmentID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachedVnics 逐挂载记录查询 VNIC 详情,跳过查询失败的记录。
|
||||||
|
func attachedVnics(ctx context.Context, vn core.VirtualNetworkClient, atts []core.VnicAttachment) []core.Vnic {
|
||||||
|
vnics := make([]core.Vnic, 0, len(atts))
|
||||||
|
for _, att := range atts {
|
||||||
|
if att.VnicId == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vnicResp, err := vn.GetVnic(ctx, core.GetVnicRequest{VnicId: att.VnicId})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vnics = append(vnics, vnicResp.Vnic)
|
||||||
|
}
|
||||||
|
return vnics
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupPublicIP 查询私有 IP 绑定的公网 IP,未绑定返回 nil。
|
||||||
|
func lookupPublicIP(ctx context.Context, vn core.VirtualNetworkClient, privateIPID *string) *core.PublicIp {
|
||||||
|
resp, err := vn.GetPublicIpByPrivateIpId(ctx, core.GetPublicIpByPrivateIpIdRequest{
|
||||||
|
GetPublicIpByPrivateIpIdDetails: core.GetPublicIpByPrivateIpIdDetails{PrivateIpId: privateIPID},
|
||||||
|
})
|
||||||
|
if err != nil || resp.Id == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &resp.PublicIp
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangeInstancePublicIP 实现 Client:为实例主 VNIC 主私有 IP 更换临时公网 IP。
|
||||||
|
// 删除旧的临时公网 IP 再分配新的;若旧地址是保留 IP(RESERVED),拒绝操作。
|
||||||
|
func (c *RealClient) ChangeInstancePublicIP(ctx context.Context, cred Credentials, region, instanceID string) (string, error) {
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
priv, err := c.primaryPrivateIP(ctx, cred, region, instanceID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if existing := lookupPublicIP(ctx, vn, priv.Id); existing != nil {
|
||||||
|
if existing.Lifetime == core.PublicIpLifetimeReserved {
|
||||||
|
return "", fmt.Errorf("change public ip: private ip has a reserved public ip, release it manually")
|
||||||
|
}
|
||||||
|
if _, err := vn.DeletePublicIp(ctx, core.DeletePublicIpRequest{PublicIpId: existing.Id}); err != nil {
|
||||||
|
return "", fmt.Errorf("delete old public ip: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// OCI 要求临时公网 IP 与其私有 IP 同 compartment,故不能用生效 compartment
|
||||||
|
resp, err := vn.CreatePublicIp(ctx, core.CreatePublicIpRequest{
|
||||||
|
CreatePublicIpDetails: core.CreatePublicIpDetails{
|
||||||
|
CompartmentId: hostCompartment(cred, deref(priv.CompartmentId)),
|
||||||
|
Lifetime: core.CreatePublicIpDetailsLifetimeEphemeral,
|
||||||
|
PrivateIpId: priv.Id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create public ip: %w", err)
|
||||||
|
}
|
||||||
|
return deref(resp.IpAddress), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// primaryVnic 返回实例的主 VNIC。
|
||||||
|
func (c *RealClient) primaryVnic(ctx context.Context, cred Credentials, region, instanceID string) (core.Vnic, error) {
|
||||||
|
vnics, _, err := c.instanceVnics(ctx, cred, region, instanceID)
|
||||||
|
if err != nil {
|
||||||
|
return core.Vnic{}, err
|
||||||
|
}
|
||||||
|
for i := range vnics {
|
||||||
|
if vnics[i].IsPrimary != nil && *vnics[i].IsPrimary {
|
||||||
|
return vnics[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return core.Vnic{}, fmt.Errorf("instance %s has no primary vnic", instanceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// primaryPrivateIP 返回实例主 VNIC 上的主私有 IP 对象;
|
||||||
|
// 保留完整对象是为了让调用方能拿到私有 IP 所在的 compartment。
|
||||||
|
func (c *RealClient) primaryPrivateIP(ctx context.Context, cred Credentials, region, instanceID string) (core.PrivateIp, error) {
|
||||||
|
vn, err := c.vcnClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return core.PrivateIp{}, err
|
||||||
|
}
|
||||||
|
vnic, err := c.primaryVnic(ctx, cred, region, instanceID)
|
||||||
|
if err != nil {
|
||||||
|
return core.PrivateIp{}, err
|
||||||
|
}
|
||||||
|
privResp, err := vn.ListPrivateIps(ctx, core.ListPrivateIpsRequest{VnicId: vnic.Id})
|
||||||
|
if err != nil {
|
||||||
|
return core.PrivateIp{}, fmt.Errorf("list private ips: %w", err)
|
||||||
|
}
|
||||||
|
for _, priv := range privResp.Items {
|
||||||
|
if priv.IsPrimary != nil && *priv.IsPrimary {
|
||||||
|
return priv, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return core.PrivateIp{}, fmt.Errorf("change public ip: primary vnic has no primary private ip")
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/identitydomains"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRealOCIFederation 验证 Federation 全链路:下载域元数据 → 创建 IdP
|
||||||
|
// (默认 JIT)→ 激活并上登录页 → 免 MFA 规则置顶 → 逐级回滚清理。
|
||||||
|
// 只操作本测试创建的资源,现有 IdP 与规则只做顺延断言。
|
||||||
|
func TestRealOCIFederation(t *testing.T) {
|
||||||
|
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||||
|
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
cred := loadTestCredentials(t, "试用期")
|
||||||
|
client := NewClient()
|
||||||
|
region := cred.Region
|
||||||
|
|
||||||
|
metadata, err := client.DownloadDomainSamlMetadata(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DownloadDomainSamlMetadata: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(metadata), "EntityDescriptor") {
|
||||||
|
t.Fatalf("metadata is not SAML XML: %s", string(metadata[:min(len(metadata), 120)]))
|
||||||
|
}
|
||||||
|
t.Logf("domain saml metadata: %d bytes", len(metadata))
|
||||||
|
|
||||||
|
before, err := client.ListConsoleSignOnRules(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListConsoleSignOnRules: %v", err)
|
||||||
|
}
|
||||||
|
logRules(t, "before", before)
|
||||||
|
|
||||||
|
idp := createTestIdp(ctx, t, client, cred, region)
|
||||||
|
verifyIdpDefaults(ctx, t, client, cred, region, idp.ID)
|
||||||
|
|
||||||
|
activated, err := client.SetIdentityProviderEnabled(ctx, cred, region, idp.ID, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("activate idp: %v", err)
|
||||||
|
}
|
||||||
|
if !activated.Enabled {
|
||||||
|
t.Error("idp not enabled after activate")
|
||||||
|
}
|
||||||
|
assertLoginPage(ctx, t, client, cred, region, idp.ID, true)
|
||||||
|
|
||||||
|
exemptionRoundTrip(ctx, t, client, cred, region, idp.ID, len(before))
|
||||||
|
|
||||||
|
deactivated, err := client.SetIdentityProviderEnabled(ctx, cred, region, idp.ID, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deactivate idp: %v", err)
|
||||||
|
}
|
||||||
|
if deactivated.Enabled {
|
||||||
|
t.Error("idp still enabled after deactivate")
|
||||||
|
}
|
||||||
|
assertLoginPage(ctx, t, client, cred, region, idp.ID, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestIdp(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region string) IdentityProviderInfo {
|
||||||
|
t.Helper()
|
||||||
|
metadata, err := os.ReadFile("../../test-idp.xml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read test-idp.xml: %v", err)
|
||||||
|
}
|
||||||
|
name := fmt.Sprintf("oci-portal-e2e-idp-%d", time.Now().Unix())
|
||||||
|
idp, err := client.CreateSamlIdentityProvider(ctx, cred, region, CreateIdpInput{
|
||||||
|
Name: name,
|
||||||
|
Metadata: string(metadata),
|
||||||
|
Description: "oci-portal e2e temporary idp",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSamlIdentityProvider: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("idp created: %s (%s) partner=%s", idp.Name, idp.ID, idp.PartnerProviderID)
|
||||||
|
if idp.Enabled {
|
||||||
|
t.Error("new idp should be disabled")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cctx, ccancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer ccancel()
|
||||||
|
if err := client.DeleteIdentityProvider(cctx, cred, region, idp.ID); err != nil {
|
||||||
|
t.Errorf("cleanup: delete idp: %v", err)
|
||||||
|
} else {
|
||||||
|
t.Log("cleanup: idp deleted")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return idp
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyIdpDefaults 回读 IdP 断言控制台默认值与 JIT 属性映射两条。
|
||||||
|
func verifyIdpDefaults(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string) {
|
||||||
|
t.Helper()
|
||||||
|
dc, err := client.domainsClient(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("domainsClient: %v", err)
|
||||||
|
}
|
||||||
|
got, err := dc.GetIdentityProvider(ctx, identitydomains.GetIdentityProviderRequest{IdentityProviderId: &idpID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get idp: %v", err)
|
||||||
|
}
|
||||||
|
if deref(got.NameIdFormat) != "saml-none" {
|
||||||
|
t.Errorf("nameIdFormat = %q, want saml-none", deref(got.NameIdFormat))
|
||||||
|
}
|
||||||
|
if got.UserMappingMethod != identitydomains.IdentityProviderUserMappingMethodNameidtouserattribute || deref(got.UserMappingStoreAttribute) != "userName" {
|
||||||
|
t.Errorf("user mapping = %s/%s, want NameIDToUserAttribute/userName", got.UserMappingMethod, deref(got.UserMappingStoreAttribute))
|
||||||
|
}
|
||||||
|
if got.JitUserProvEnabled == nil || !*got.JitUserProvEnabled || got.JitUserProvCreateUserEnabled == nil || !*got.JitUserProvCreateUserEnabled {
|
||||||
|
t.Error("jit enable/create should be true")
|
||||||
|
}
|
||||||
|
if got.JitUserProvAttributeUpdateEnabled != nil && *got.JitUserProvAttributeUpdateEnabled {
|
||||||
|
t.Error("jit attribute update should be false")
|
||||||
|
}
|
||||||
|
if len(got.JitUserProvAssignedGroups) != 1 || deref(got.JitUserProvAssignedGroups[0].Display) != "Administrators" {
|
||||||
|
t.Errorf("jit assigned groups = %+v, want [Administrators]", got.JitUserProvAssignedGroups)
|
||||||
|
}
|
||||||
|
verifyJitMappings(ctx, t, dc, got.JitUserProvAttributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyJitMappings(ctx context.Context, t *testing.T, dc identitydomains.IdentityDomainsClient, ref *identitydomains.IdentityProviderJitUserProvAttributes) {
|
||||||
|
t.Helper()
|
||||||
|
if ref == nil || ref.Value == nil {
|
||||||
|
t.Error("idp has no jit attribute mapping resource")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ma, err := dc.GetMappedAttribute(ctx, identitydomains.GetMappedAttributeRequest{MappedAttributeId: ref.Value})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("get mapped attribute: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
want := map[string]bool{"userName": false, "name.familyName": false}
|
||||||
|
for _, m := range ma.AttributeMappings {
|
||||||
|
if deref(m.ManagedObjectAttributeName) == "$(assertion.fed.nameidvalue)" {
|
||||||
|
want[deref(m.IdcsAttributeName)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for attr, seen := range want {
|
||||||
|
if !seen {
|
||||||
|
t.Errorf("jit mapping NameID value → %s missing; got %+v", attr, ma.AttributeMappings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertLoginPage 校验 DefaultIDPRule 的 SamlIDPs 是否包含目标 IdP。
|
||||||
|
func assertLoginPage(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string, want bool) {
|
||||||
|
t.Helper()
|
||||||
|
dc, err := client.domainsClient(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("domainsClient: %v", err)
|
||||||
|
}
|
||||||
|
rule, err := dc.GetRule(ctx, identitydomains.GetRuleRequest{RuleId: common.String(defaultIdpRuleID)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get default idp rule: %v", err)
|
||||||
|
}
|
||||||
|
for _, r := range rule.Return {
|
||||||
|
if deref(r.Name) != "SamlIDPs" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
has := strings.Contains(deref(r.Value), idpID)
|
||||||
|
if has != want {
|
||||||
|
t.Errorf("login page SamlIDPs contains idp = %v, want %v (value=%s)", has, want, deref(r.Value))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Error("DefaultIDPRule has no SamlIDPs return")
|
||||||
|
}
|
||||||
|
|
||||||
|
// exemptionRoundTrip 创建免 MFA 规则断言置顶与顺延,删除后断言完全复位。
|
||||||
|
func exemptionRoundTrip(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string, beforeCount int) {
|
||||||
|
t.Helper()
|
||||||
|
rule, err := client.CreateMfaExemptionRule(ctx, cred, region, idpID, "oci-portal-e2e-skip-mfa")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateMfaExemptionRule: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("exemption rule created: %s (%s)", rule.Name, rule.ID)
|
||||||
|
deleted := false
|
||||||
|
defer func() {
|
||||||
|
if deleted {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := client.DeleteMfaExemptionRule(ctx, cred, region, rule.ID); err != nil {
|
||||||
|
t.Errorf("cleanup: delete exemption rule: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
after, err := client.ListConsoleSignOnRules(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListConsoleSignOnRules after create: %v", err)
|
||||||
|
}
|
||||||
|
logRules(t, "after-create", after)
|
||||||
|
if len(after) != beforeCount+1 {
|
||||||
|
t.Errorf("rule count = %d, want %d", len(after), beforeCount+1)
|
||||||
|
}
|
||||||
|
if after[0].ID != rule.ID || after[0].Sequence != 1 {
|
||||||
|
t.Errorf("top rule = %s seq=%d, want %s seq=1", after[0].ID, after[0].Sequence, rule.ID)
|
||||||
|
}
|
||||||
|
if after[0].AuthenticationFactor != "IDP" {
|
||||||
|
t.Errorf("authenticationFactor = %s, want IDP", after[0].AuthenticationFactor)
|
||||||
|
}
|
||||||
|
if !strings.Contains(after[0].ConditionValue, idpID) {
|
||||||
|
t.Errorf("condition value %q does not reference idp", after[0].ConditionValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.DeleteMfaExemptionRule(ctx, cred, region, rule.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteMfaExemptionRule: %v", err)
|
||||||
|
}
|
||||||
|
deleted = true
|
||||||
|
restored, err := client.ListConsoleSignOnRules(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListConsoleSignOnRules after delete: %v", err)
|
||||||
|
}
|
||||||
|
logRules(t, "after-delete", restored)
|
||||||
|
if len(restored) != beforeCount {
|
||||||
|
t.Errorf("rule count after delete = %d, want %d", len(restored), beforeCount)
|
||||||
|
}
|
||||||
|
for _, r := range restored {
|
||||||
|
if r.ID == rule.ID {
|
||||||
|
t.Error("exemption rule still present after delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logRules(t *testing.T, tag string, rules []SignOnRuleInfo) {
|
||||||
|
t.Helper()
|
||||||
|
for _, r := range rules {
|
||||||
|
t.Logf("%s: seq=%d %s (%s) factor=%s", tag, r.Sequence, r.Name, r.ID, r.AuthenticationFactor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRealOCIInstanceLifecycle 走真实实例完整生命周期:
|
||||||
|
// 建网络 → 查 AD/镜像 → 创建 A1 实例 → 等待 RUNNING → 校验 IP 补全 →
|
||||||
|
// 改名 → 引导卷查改 → STOP → 终止(不保留卷)→ 清理网络。
|
||||||
|
// A1 容量不足时跳过(试用区域常见)。
|
||||||
|
func TestRealOCIInstanceLifecycle(t *testing.T) {
|
||||||
|
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||||
|
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
cred := loadTestCredentials(t, "试用期")
|
||||||
|
client := NewClient()
|
||||||
|
prefix := fmt.Sprintf("oci-portal-e2e-%d", time.Now().Unix())
|
||||||
|
|
||||||
|
vcn, err := client.CreateVCN(ctx, cred, CreateVCNInput{
|
||||||
|
DisplayName: prefix + "-vcn", CidrBlock: "10.98.0.0/16", DnsLabel: "e2einst",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateVCN: %v", err)
|
||||||
|
}
|
||||||
|
defer cleanupVCN(t, client, cred, vcn.ID)
|
||||||
|
subnet, err := client.CreateSubnet(ctx, cred, CreateSubnetInput{
|
||||||
|
VcnID: vcn.ID, DisplayName: prefix + "-subnet", CidrBlock: "10.98.1.0/24", DnsLabel: "e2esub",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSubnet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ads, err := client.ListAvailabilityDomains(ctx, cred, "")
|
||||||
|
if err != nil || len(ads) == 0 {
|
||||||
|
t.Fatalf("ListAvailabilityDomains: %v (got %d)", err, len(ads))
|
||||||
|
}
|
||||||
|
images, err := client.ListImages(ctx, cred, ImagesQuery{
|
||||||
|
OperatingSystem: "Canonical Ubuntu", Shape: "VM.Standard.A1.Flex",
|
||||||
|
})
|
||||||
|
if err != nil || len(images) == 0 {
|
||||||
|
t.Fatalf("ListImages: %v (got %d)", err, len(images))
|
||||||
|
}
|
||||||
|
t.Logf("using ad=%s image=%s", ads[0], images[0].DisplayName)
|
||||||
|
|
||||||
|
instance, err := client.LaunchInstance(ctx, cred, CreateInstanceInput{
|
||||||
|
AvailabilityDomain: ads[0],
|
||||||
|
DisplayName: prefix + "-vm",
|
||||||
|
Shape: "VM.Standard.A1.Flex",
|
||||||
|
Ocpus: 1,
|
||||||
|
MemoryInGBs: 6,
|
||||||
|
ImageID: images[0].ID,
|
||||||
|
BootVolumeSizeGBs: 50,
|
||||||
|
SubnetID: subnet.ID,
|
||||||
|
AssignPublicIP: true,
|
||||||
|
SSHPublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEfake+e2e+key oci-portal-e2e",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "Out of host capacity") || strings.Contains(err.Error(), "OutOfCapacity") {
|
||||||
|
t.Skipf("A1 capacity unavailable in this region: %v", err)
|
||||||
|
}
|
||||||
|
t.Fatalf("LaunchInstance: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("launched instance %s (%s)", instance.DisplayName, instance.ID)
|
||||||
|
defer terminateAndWait(t, client, cred, instance.ID)
|
||||||
|
|
||||||
|
if err := waitInstanceState(ctx, client, cred, instance.ID, "RUNNING"); err != nil {
|
||||||
|
t.Fatalf("wait RUNNING: %v", err)
|
||||||
|
}
|
||||||
|
got, err := client.GetInstance(ctx, cred, "", instance.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetInstance: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("instance running: shape=%s %gocpu/%gGB privateIp=%s publicIp=%s",
|
||||||
|
got.Shape, got.Ocpus, got.MemoryInGBs, got.PrivateIP, got.PublicIP)
|
||||||
|
if got.PrivateIP == "" {
|
||||||
|
t.Error("PrivateIP empty, want enriched from VNIC")
|
||||||
|
}
|
||||||
|
if got.PublicIP == "" {
|
||||||
|
t.Error("PublicIP empty, want assigned")
|
||||||
|
}
|
||||||
|
if got.SubnetID != subnet.ID {
|
||||||
|
t.Errorf("SubnetID = %s, want %s", got.SubnetID, subnet.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.UpdateInstance(ctx, cred, instance.ID, UpdateInstanceInput{DisplayName: prefix + "-vm-renamed"}); err != nil {
|
||||||
|
t.Errorf("UpdateInstance: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyBootVolume(ctx, t, client, cred, ads[0], instance.ID, prefix)
|
||||||
|
|
||||||
|
if _, err := client.InstanceAction(ctx, cred, "", instance.ID, "STOP"); err != nil {
|
||||||
|
t.Fatalf("InstanceAction STOP: %v", err)
|
||||||
|
}
|
||||||
|
if err := waitInstanceState(ctx, client, cred, instance.ID, "STOPPED"); err != nil {
|
||||||
|
t.Fatalf("wait STOPPED: %v", err)
|
||||||
|
}
|
||||||
|
t.Log("instance stopped, terminating")
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyBootVolume(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, ad, instanceID, prefix string) {
|
||||||
|
t.Helper()
|
||||||
|
volumes, err := client.ListBootVolumes(ctx, cred, "", ad)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ListBootVolumes: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, v := range volumes {
|
||||||
|
detail, err := client.GetBootVolume(ctx, cred, "", v.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("GetBootVolume: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if detail.AttachedInstanceID != instanceID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Logf("boot volume %s size=%dGB vpus=%d attached=%s",
|
||||||
|
detail.DisplayName, detail.SizeInGBs, detail.VpusPerGB, detail.AttachedInstanceID)
|
||||||
|
if detail.SizeInGBs != 50 {
|
||||||
|
t.Errorf("boot volume size = %d, want 50", detail.SizeInGBs)
|
||||||
|
}
|
||||||
|
if _, err := client.UpdateBootVolume(ctx, cred, v.ID, UpdateBootVolumeInput{DisplayName: prefix + "-bv"}); err != nil {
|
||||||
|
t.Errorf("UpdateBootVolume: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Errorf("no boot volume attached to instance %s found in %s", instanceID, ad)
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitInstanceState 轮询实例直到到达目标状态。
|
||||||
|
func waitInstanceState(ctx context.Context, client *RealClient, cred Credentials, instanceID, want string) error {
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
inst, err := client.GetInstance(ctx, cred, "", instanceID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("poll instance: %w", err)
|
||||||
|
}
|
||||||
|
if inst.LifecycleState == want {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fmt.Errorf("poll instance state %s: %w", want, ctx.Err())
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("poll instance state %s: timed out", want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminateAndWait 终止实例(不保留引导卷)并等待 TERMINATED,
|
||||||
|
// 保证后续网络清理不被占用的 VNIC 阻塞。
|
||||||
|
func terminateAndWait(t *testing.T, client *RealClient, cred Credentials, instanceID string) {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
inst, err := client.GetInstance(ctx, cred, "", instanceID)
|
||||||
|
if err == nil && inst.LifecycleState == "TERMINATED" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := client.TerminateInstance(ctx, cred, "", instanceID, false); err != nil {
|
||||||
|
t.Errorf("cleanup: terminate instance: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := waitInstanceState(ctx, client, cred, instanceID, "TERMINATED"); err != nil {
|
||||||
|
t.Errorf("cleanup: wait TERMINATED: %v (manual cleanup may be required)", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Logf("cleanup: instance %s terminated", instanceID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createVolumeRequest(tenancyOCID, ad, name string, size int64) core.CreateVolumeRequest {
|
||||||
|
return core.CreateVolumeRequest{CreateVolumeDetails: core.CreateVolumeDetails{
|
||||||
|
CompartmentId: &tenancyOCID,
|
||||||
|
AvailabilityDomain: &ad,
|
||||||
|
DisplayName: &name,
|
||||||
|
SizeInGBs: &size,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getVolumeRequest(volID string) core.GetVolumeRequest {
|
||||||
|
return core.GetVolumeRequest{VolumeId: &volID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteVolumeRequest(volID string) core.DeleteVolumeRequest {
|
||||||
|
return core.DeleteVolumeRequest{VolumeId: &volID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// e2eConsoleSSHKey 是控制台连接 e2e 用的一次性 RSA 公钥(私钥已丢弃,
|
||||||
|
// 仅验证连接串生成);OCI 控制台连接只接受 RSA 公钥。
|
||||||
|
const e2eConsoleSSHKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDXrOBK+eL3JPB3t470k7x+au3IcGbCy3Za7HVcMqTD2XNjar+Te8IND7zjDlrU1DuGL0qa8MFGf8PX84SqK9fQ3kbIuz5KHS+Jk8RYyFSZ1iUs27n3Vkql0tbMj8raCqCiX9nWeu5GSxVu/7UTZG5Tk4mrJXcVe6s+65baQrd6D8V/WKSDBblI8Y0jQ7g2XGsHj5eBV49nCwEtwCWPRzkdNqI8Ac0heH6OcqreuH3GowNSsV9RIe/bHKWpu3Ap6DQHMexTHc5GMmcLu4F43Pij0OaoAA83QVCXLvTMZqyLNQOXxPUEsRWATRKKMKRsvitcWx7+v8WGh06iOHfeE59z oci-portal-e2e"
|
||||||
|
|
||||||
|
// TestRealOCIInstanceAutoNetworkAndPublicIP 验证默认可用域 + 自动建网(含
|
||||||
|
// IPv6 与安全列表全开放)+ 换公网 IP + 添加/取消分配 IPv6 + 控制台连接:
|
||||||
|
// 不传 availabilityDomain 与 subnetId 创建实例(默认 ad-1、自动建 VCN、分配
|
||||||
|
// IPv6)→ 等 RUNNING → 换公网 IP → 追加 IPv6 → 控制台连接创建/删除 →
|
||||||
|
// 删除全部 IPv6 → 清理。A1 容量不足时跳过。
|
||||||
|
func TestRealOCIInstanceAutoNetworkAndPublicIP(t *testing.T) {
|
||||||
|
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||||
|
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
cred := loadTestCredentials(t, "试用期")
|
||||||
|
client := NewClient()
|
||||||
|
prefix := fmt.Sprintf("oci-portal-e2e-%d", time.Now().Unix())
|
||||||
|
|
||||||
|
images, err := client.ListImages(ctx, cred, ImagesQuery{OperatingSystem: "Canonical Ubuntu", Shape: "VM.Standard.A1.Flex"})
|
||||||
|
if err != nil || len(images) == 0 {
|
||||||
|
t.Fatalf("ListImages: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不传 availabilityDomain 与 subnetId:应默认 ad-1 并自动建 oci-portal-auto-vcn 与子网
|
||||||
|
instance, err := client.LaunchInstance(ctx, cred, CreateInstanceInput{
|
||||||
|
DisplayName: prefix + "-vm",
|
||||||
|
Shape: "VM.Standard.A1.Flex",
|
||||||
|
Ocpus: 1,
|
||||||
|
MemoryInGBs: 6,
|
||||||
|
ImageID: images[0].ID,
|
||||||
|
BootVolumeSizeGBs: 50,
|
||||||
|
AssignPublicIP: true,
|
||||||
|
AssignIpv6: true,
|
||||||
|
RootPassword: "OciPortalE2E!" + fmt.Sprint(len(prefix)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "capacity") {
|
||||||
|
t.Skipf("A1 capacity unavailable: %v", err)
|
||||||
|
}
|
||||||
|
t.Fatalf("LaunchInstance (auto network): %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(instance.AvailabilityDomain, "AD-1") {
|
||||||
|
t.Errorf("availability domain = %q, want default AD-1", instance.AvailabilityDomain)
|
||||||
|
}
|
||||||
|
t.Logf("launched %s in %s with auto network", instance.ID, instance.AvailabilityDomain)
|
||||||
|
|
||||||
|
// 确认自动建网产生了 oci-portal-auto-vcn,且 IPv6 与安全列表全开放生效
|
||||||
|
vcns, err := client.ListVCNs(ctx, cred, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListVCNs: %v", err)
|
||||||
|
}
|
||||||
|
autoVcnID := ""
|
||||||
|
for _, v := range vcns {
|
||||||
|
if v.DisplayName == autoVcnName {
|
||||||
|
autoVcnID = v.ID
|
||||||
|
checkAutoVCNDefaults(ctx, t, client, cred, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if autoVcnID == "" {
|
||||||
|
t.Error("auto vcn not created")
|
||||||
|
} else {
|
||||||
|
// 先注册 VCN 清理(LIFO 后执行),确保实例先终止再删网络
|
||||||
|
defer cleanupVCN(t, client, cred, autoVcnID)
|
||||||
|
}
|
||||||
|
defer terminateAndWait(t, client, cred, instance.ID)
|
||||||
|
|
||||||
|
if err := waitInstanceState(ctx, client, cred, instance.ID, "RUNNING"); err != nil {
|
||||||
|
t.Fatalf("wait RUNNING: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := client.GetInstance(ctx, cred, "", instance.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetInstance: %v", err)
|
||||||
|
}
|
||||||
|
oldIP := got.PublicIP
|
||||||
|
if oldIP == "" {
|
||||||
|
t.Fatal("no public ip on primary vnic, want one")
|
||||||
|
}
|
||||||
|
if len(got.Ipv6Addresses) == 0 {
|
||||||
|
t.Error("no ipv6 address on instance, want auto network ipv6")
|
||||||
|
}
|
||||||
|
t.Logf("public=%s ipv6=%v", oldIP, got.Ipv6Addresses)
|
||||||
|
|
||||||
|
newIP, err := client.ChangeInstancePublicIP(ctx, cred, "", instance.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChangeInstancePublicIP: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("public ip changed: %s -> %s", oldIP, newIP)
|
||||||
|
if newIP == "" || newIP == oldIP {
|
||||||
|
t.Errorf("new public ip = %q, want a different non-empty address", newIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 追加一个自动分配的 IPv6,应与 launch 分配的共存
|
||||||
|
added, err := client.AddInstanceIpv6(ctx, cred, "", instance.ID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddInstanceIpv6: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("ipv6 added: %s", added)
|
||||||
|
withAdded, err := client.GetInstance(ctx, cred, "", instance.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetInstance after ipv6 add: %v", err)
|
||||||
|
}
|
||||||
|
if len(withAdded.Ipv6Addresses) != 2 {
|
||||||
|
t.Errorf("ipv6 addresses = %v, want 2 after add", withAdded.Ipv6Addresses)
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyConsoleConnection(ctx, t, client, cred, instance.ID)
|
||||||
|
deleteInstanceIpv6AndVerify(ctx, t, client, cred, instance.ID, withAdded.Ipv6Addresses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyConsoleConnection 创建控制台连接、校验 VNC 连接串并删除。
|
||||||
|
func verifyConsoleConnection(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, instanceID string) {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := client.CreateConsoleConnection(ctx, cred, "", instanceID, e2eConsoleSSHKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateConsoleConnection: %v", err)
|
||||||
|
}
|
||||||
|
if conn.LifecycleState != "ACTIVE" || conn.VncConnectionString == "" {
|
||||||
|
t.Errorf("console connection state=%s vnc=%q, want ACTIVE with vnc string", conn.LifecycleState, conn.VncConnectionString)
|
||||||
|
}
|
||||||
|
t.Logf("console connection %s state=%s", conn.ID, conn.LifecycleState)
|
||||||
|
list, err := client.ListConsoleConnections(ctx, cred, "", instanceID)
|
||||||
|
if err != nil || len(list) == 0 {
|
||||||
|
t.Errorf("ListConsoleConnections: %v (got %d)", err, len(list))
|
||||||
|
}
|
||||||
|
if err := client.DeleteConsoleConnection(ctx, cred, "", conn.ID); err != nil {
|
||||||
|
t.Errorf("DeleteConsoleConnection: %v", err)
|
||||||
|
} else {
|
||||||
|
t.Log("console connection deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAutoVCNDefaults 校验自动 VCN 已分配 IPv6 /56 且默认安全列表全开放。
|
||||||
|
// OCI 在异步分配 IPv6 时可能追加缺省 ICMPv6 规则,因此按语义而非数量校验。
|
||||||
|
func checkAutoVCNDefaults(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, vcn VCN) {
|
||||||
|
t.Helper()
|
||||||
|
if len(vcn.Ipv6CidrBlocks) == 0 {
|
||||||
|
t.Error("auto vcn has no ipv6 cidr, want one")
|
||||||
|
}
|
||||||
|
sl, err := client.GetSecurityList(ctx, cred, "", vcn.DefaultSecurityListID)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("GetSecurityList: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !hasAllOpenRules(sl) {
|
||||||
|
t.Errorf("default seclist not all-open: ingress=%+v egress=%+v", sl.IngressRules, sl.EgressRules)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasAllOpenRules 判断安全列表出入方向是否均放行 IPv4 与 IPv6 全部流量。
|
||||||
|
func hasAllOpenRules(sl SecurityList) bool {
|
||||||
|
in, out := map[string]bool{}, map[string]bool{}
|
||||||
|
for _, r := range sl.IngressRules {
|
||||||
|
if r.Protocol == "all" {
|
||||||
|
in[r.Source] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range sl.EgressRules {
|
||||||
|
if r.Protocol == "all" {
|
||||||
|
out[r.Destination] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return in["0.0.0.0/0"] && in["::/0"] && out["0.0.0.0/0"] && out["::/0"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteInstanceIpv6AndVerify 取消分配实例的全部 IPv6 地址并确认清空。
|
||||||
|
func deleteInstanceIpv6AndVerify(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, instanceID string, addrs []string) {
|
||||||
|
t.Helper()
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, addr := range addrs {
|
||||||
|
if err := client.DeleteInstanceIpv6(ctx, cred, "", instanceID, addr); err != nil {
|
||||||
|
t.Fatalf("DeleteInstanceIpv6 %s: %v", addr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
after, err := client.GetInstance(ctx, cred, "", instanceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetInstance after ipv6 delete: %v", err)
|
||||||
|
}
|
||||||
|
if len(after.Ipv6Addresses) != 0 {
|
||||||
|
t.Errorf("ipv6 addresses after delete = %v, want none", after.Ipv6Addresses)
|
||||||
|
}
|
||||||
|
t.Logf("ipv6 deleted: %v", addrs)
|
||||||
|
// 删除 IPv6 后立即终止实例,偶发 OCI 侧 VNIC 级联清理失败回滚
|
||||||
|
// (VNIC 残留 AVAILABLE 挡住删子网),等待片刻让状态收敛
|
||||||
|
time.Sleep(15 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealOCIVolumeAttachment 验证块卷附加/分离与引导卷挂载查询。
|
||||||
|
// 依赖已有实例:需先运行上面的测试留存实例,或有其他运行中实例;否则跳过。
|
||||||
|
// 这里独立创建一个实例做完整链路,A1 容量不足时跳过。
|
||||||
|
func TestRealOCIVolumeAttachment(t *testing.T) {
|
||||||
|
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||||
|
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
cred := loadTestCredentials(t, "试用期")
|
||||||
|
client := NewClient()
|
||||||
|
prefix := fmt.Sprintf("oci-portal-e2e-%d", time.Now().Unix())
|
||||||
|
|
||||||
|
ads, err := client.ListAvailabilityDomains(ctx, cred, "")
|
||||||
|
if err != nil || len(ads) == 0 {
|
||||||
|
t.Fatalf("ListAvailabilityDomains: %v", err)
|
||||||
|
}
|
||||||
|
images, err := client.ListImages(ctx, cred, ImagesQuery{OperatingSystem: "Canonical Ubuntu", Shape: "VM.Standard.A1.Flex"})
|
||||||
|
if err != nil || len(images) == 0 {
|
||||||
|
t.Fatalf("ListImages: %v", err)
|
||||||
|
}
|
||||||
|
vcn, err := client.CreateVCN(ctx, cred, CreateVCNInput{DisplayName: prefix + "-vcn", CidrBlock: "10.97.0.0/16", DnsLabel: "e2evol"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateVCN: %v", err)
|
||||||
|
}
|
||||||
|
defer cleanupVCN(t, client, cred, vcn.ID)
|
||||||
|
subnet, err := client.CreateSubnet(ctx, cred, CreateSubnetInput{VcnID: vcn.ID, DisplayName: prefix + "-subnet", CidrBlock: "10.97.1.0/24", DnsLabel: "e2sub"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSubnet: %v", err)
|
||||||
|
}
|
||||||
|
instance, err := client.LaunchInstance(ctx, cred, CreateInstanceInput{
|
||||||
|
AvailabilityDomain: ads[0], DisplayName: prefix + "-vm", Shape: "VM.Standard.A1.Flex",
|
||||||
|
Ocpus: 1, MemoryInGBs: 6, ImageID: images[0].ID, BootVolumeSizeGBs: 50, SubnetID: subnet.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "capacity") {
|
||||||
|
t.Skipf("A1 capacity unavailable: %v", err)
|
||||||
|
}
|
||||||
|
t.Fatalf("LaunchInstance: %v", err)
|
||||||
|
}
|
||||||
|
defer terminateAndWait(t, client, cred, instance.ID)
|
||||||
|
if err := waitInstanceState(ctx, client, cred, instance.ID, "RUNNING"); err != nil {
|
||||||
|
t.Fatalf("wait RUNNING: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 引导卷挂载查询
|
||||||
|
bvAtts, err := client.ListBootVolumeAttachments(ctx, cred, "", instance.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListBootVolumeAttachments: %v", err)
|
||||||
|
}
|
||||||
|
if len(bvAtts) == 0 {
|
||||||
|
t.Error("no boot volume attachment, want one")
|
||||||
|
} else {
|
||||||
|
t.Logf("boot volume attachment: %s state=%s", bvAtts[0].ID, bvAtts[0].LifecycleState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建块卷并附加、分离
|
||||||
|
volID := createTestBlockVolume(ctx, t, client, cred, ads[0], prefix)
|
||||||
|
if volID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer deleteTestBlockVolume(t, client, cred, volID)
|
||||||
|
|
||||||
|
att, err := client.AttachVolume(ctx, cred, "", instance.ID, volID, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AttachVolume: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("attached volume: %s device=%s", att.ID, att.Device)
|
||||||
|
if err := waitVolumeAttachmentState(ctx, client, cred, instance.ID, att.ID, "ATTACHED"); err != nil {
|
||||||
|
t.Fatalf("wait volume ATTACHED: %v", err)
|
||||||
|
}
|
||||||
|
atts, err := client.ListVolumeAttachments(ctx, cred, "", instance.ID)
|
||||||
|
if err != nil || len(atts) == 0 {
|
||||||
|
t.Fatalf("ListVolumeAttachments: %v (got %d)", err, len(atts))
|
||||||
|
}
|
||||||
|
if err := client.DetachVolume(ctx, cred, "", att.ID); err != nil {
|
||||||
|
t.Fatalf("DetachVolume: %v", err)
|
||||||
|
}
|
||||||
|
if err := waitVolumeAttachmentState(ctx, client, cred, instance.ID, att.ID, "DETACHED"); err != nil {
|
||||||
|
t.Errorf("wait volume DETACHED: %v", err)
|
||||||
|
}
|
||||||
|
t.Log("volume detached")
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestBlockVolume(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, ad, prefix string) string {
|
||||||
|
t.Helper()
|
||||||
|
bc, err := client.blockstorageClient(cred, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("blockstorage client: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
size := int64(50)
|
||||||
|
name := prefix + "-vol"
|
||||||
|
resp, err := bc.CreateVolume(ctx, createVolumeRequest(cred.TenancyOCID, ad, name, size))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateVolume: %v", err)
|
||||||
|
}
|
||||||
|
volID := deref(resp.Id)
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
got, err := bc.GetVolume(ctx, getVolumeRequest(volID))
|
||||||
|
if err == nil && string(got.LifecycleState) == "AVAILABLE" {
|
||||||
|
return volID
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return volID
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return volID
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitVolumeAttachmentState(ctx context.Context, client *RealClient, cred Credentials, instanceID, attachmentID, want string) error {
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
atts, err := client.ListVolumeAttachments(ctx, cred, "", instanceID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("poll volume attachment: %w", err)
|
||||||
|
}
|
||||||
|
for _, a := range atts {
|
||||||
|
if a.ID == attachmentID && a.LifecycleState == want {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if want == "DETACHED" {
|
||||||
|
// 分离后可能不再出现在列表中,视为完成
|
||||||
|
found := false
|
||||||
|
for _, a := range atts {
|
||||||
|
if a.ID == attachmentID {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fmt.Errorf("poll volume attachment %s: %w", want, ctx.Err())
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("poll volume attachment %s: timed out", want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteTestBlockVolume(t *testing.T, client *RealClient, cred Credentials, volID string) {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
bc, err := client.blockstorageClient(cred, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("cleanup: blockstorage client: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := bc.DeleteVolume(ctx, deleteVolumeRequest(volID)); err != nil {
|
||||||
|
t.Errorf("cleanup: delete volume: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Logf("cleanup: volume %s deleted", volID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testKeyDir 指向仓库根的真实 API Key 测试资料目录。
|
||||||
|
const testKeyDir = "../../../test-api-key"
|
||||||
|
|
||||||
|
// keyCase 把测试 key 文件名映射到期望账户类别;expect 为空表示只打印不断言。
|
||||||
|
type keyCase struct {
|
||||||
|
name string
|
||||||
|
expect string
|
||||||
|
}
|
||||||
|
|
||||||
|
func integrationKeyCases(t *testing.T) []keyCase {
|
||||||
|
t.Helper()
|
||||||
|
all := []keyCase{
|
||||||
|
{name: "试用期", expect: model.AccountTypeTrial},
|
||||||
|
{name: "付费", expect: model.AccountTypePaid},
|
||||||
|
{name: "免费01", expect: model.AccountTypeFree},
|
||||||
|
{name: "免费02", expect: model.AccountTypeFree},
|
||||||
|
{name: "未知01"},
|
||||||
|
{name: "未知02"},
|
||||||
|
}
|
||||||
|
if name := os.Getenv("OCI_TEST_KEY"); name != "" {
|
||||||
|
for _, k := range all {
|
||||||
|
if k.name == name {
|
||||||
|
return []keyCase{k}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("OCI_TEST_KEY=%q not found in test-api-key", name)
|
||||||
|
}
|
||||||
|
if os.Getenv("OCI_TEST_ALL_KEYS") == "1" {
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
// 默认只使用试用期 key,避免不必要地触碰其他真实账号
|
||||||
|
return all[:1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTestCredentials(t *testing.T, name string) Credentials {
|
||||||
|
t.Helper()
|
||||||
|
iniPath := filepath.Join(testKeyDir, name+"-api-key.ini")
|
||||||
|
pemPath := filepath.Join(testKeyDir, name+"-api-key.pem")
|
||||||
|
iniText, err := os.ReadFile(iniPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", iniPath, err)
|
||||||
|
}
|
||||||
|
pem, err := os.ReadFile(pemPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", pemPath, err)
|
||||||
|
}
|
||||||
|
cred, err := ParseConfigINI(string(iniText))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse %s: %v", iniPath, err)
|
||||||
|
}
|
||||||
|
cred.PrivateKey = string(pem)
|
||||||
|
if err := cred.Validate(); err != nil {
|
||||||
|
t.Fatalf("validate credentials of %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return cred
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealOCITenantRegionsAndLimits 用试用期 key 验证区域订阅列表和
|
||||||
|
// 配额查询(均为只读调用)。订阅新区域不可撤销,不做真实测试。
|
||||||
|
func TestRealOCITenantRegionsAndLimits(t *testing.T) {
|
||||||
|
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||||
|
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
cred := loadTestCredentials(t, "试用期")
|
||||||
|
client := NewClient()
|
||||||
|
|
||||||
|
subs, err := client.ListRegionSubscriptions(ctx, cred)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListRegionSubscriptions: %v", err)
|
||||||
|
}
|
||||||
|
if len(subs) == 0 {
|
||||||
|
t.Fatal("ListRegionSubscriptions returned empty list, want at least home region")
|
||||||
|
}
|
||||||
|
hasHome := false
|
||||||
|
for _, sub := range subs {
|
||||||
|
t.Logf("region key=%s name=%s status=%s home=%v", sub.Key, sub.Name, sub.Status, sub.IsHomeRegion)
|
||||||
|
hasHome = hasHome || sub.IsHomeRegion
|
||||||
|
}
|
||||||
|
if !hasHome {
|
||||||
|
t.Error("no home region in subscriptions")
|
||||||
|
}
|
||||||
|
|
||||||
|
values, err := client.ListLimits(ctx, cred, LimitsQuery{
|
||||||
|
Service: "compute",
|
||||||
|
Name: "standard-a1-core-count",
|
||||||
|
WithAvailability: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListLimits: %v", err)
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
t.Fatal("ListLimits returned empty list for standard-a1-core-count")
|
||||||
|
}
|
||||||
|
for _, v := range values {
|
||||||
|
used, available := int64(-1), int64(-1)
|
||||||
|
if v.Used != nil {
|
||||||
|
used = *v.Used
|
||||||
|
}
|
||||||
|
if v.Available != nil {
|
||||||
|
available = *v.Available
|
||||||
|
}
|
||||||
|
t.Logf("limit name=%s scope=%s ad=%s value=%d used=%d available=%d",
|
||||||
|
v.Name, v.ScopeType, v.AvailabilityDomain, v.Value, used, available)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模糊过滤应比精确名命中更多配额(a1 与 e2 等系列都含 core-count)
|
||||||
|
fuzzy, err := client.ListLimits(ctx, cred, LimitsQuery{Service: "compute", Name: "core-count"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListLimits fuzzy: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("fuzzy core-count matched %d entries", len(fuzzy))
|
||||||
|
if len(fuzzy) <= len(values) {
|
||||||
|
t.Errorf("fuzzy matches = %d, want more than exact matches %d", len(fuzzy), len(values))
|
||||||
|
}
|
||||||
|
|
||||||
|
services, err := client.ListLimitServices(ctx, cred, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListLimitServices: %v", err)
|
||||||
|
}
|
||||||
|
if len(services) == 0 {
|
||||||
|
t.Fatal("ListLimitServices returned empty list")
|
||||||
|
}
|
||||||
|
hasCompute := false
|
||||||
|
for _, s := range services {
|
||||||
|
hasCompute = hasCompute || s.Name == "compute"
|
||||||
|
}
|
||||||
|
if !hasCompute {
|
||||||
|
t.Errorf("services (%d) missing compute", len(services))
|
||||||
|
}
|
||||||
|
t.Logf("limit services: %d entries, first=%+v", len(services), services[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealOCISubscriptions 用试用期 key 验证订阅列表与订阅详情(只读)。
|
||||||
|
func TestRealOCISubscriptions(t *testing.T) {
|
||||||
|
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||||
|
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
cred := loadTestCredentials(t, "试用期")
|
||||||
|
client := NewClient()
|
||||||
|
|
||||||
|
subs, err := client.ListSubscriptions(ctx, cred)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListSubscriptions: %v", err)
|
||||||
|
}
|
||||||
|
if len(subs) == 0 {
|
||||||
|
t.Fatal("ListSubscriptions returned empty list")
|
||||||
|
}
|
||||||
|
cloudcmID := ""
|
||||||
|
for _, sub := range subs {
|
||||||
|
t.Logf("subscription id=%s service=%s created=%v", sub.ID, sub.ServiceName, sub.TimeCreated)
|
||||||
|
if strings.EqualFold(sub.ServiceName, cloudcmServiceName) {
|
||||||
|
cloudcmID = sub.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cloudcmID == "" {
|
||||||
|
t.Fatal("no CLOUDCM subscription in list")
|
||||||
|
}
|
||||||
|
|
||||||
|
detail, err := client.GetSubscription(ctx, cred, cloudcmID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSubscription: %v", err)
|
||||||
|
}
|
||||||
|
if detail.ID != cloudcmID {
|
||||||
|
t.Errorf("detail.ID = %q, want %q", detail.ID, cloudcmID)
|
||||||
|
}
|
||||||
|
t.Logf("detail service=%s paymentModel=%q tier=%q state=%s currency=%s region=%s promotions=%d",
|
||||||
|
detail.ServiceName, detail.PaymentModel, detail.SubscriptionTier,
|
||||||
|
detail.LifecycleState, detail.CloudAmountCurrency, detail.RegionAssignment, len(detail.Promotions))
|
||||||
|
for _, p := range detail.Promotions {
|
||||||
|
t.Logf("promotion status=%s amount=%.2f %s expires=%v intentToPay=%v",
|
||||||
|
p.Status, p.Amount, p.CurrencyUnit, p.TimeExpired, p.IsIntentToPay)
|
||||||
|
}
|
||||||
|
if len(detail.Promotions) == 0 {
|
||||||
|
t.Error("trial subscription has no promotions, want at least one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealOCIVerifyAndClassify 走真实 OCI 验证测活与账户类别判定,
|
||||||
|
// 必须显式设置 OCI_INTEGRATION_TEST=1 才会执行。
|
||||||
|
func TestRealOCIVerifyAndClassify(t *testing.T) {
|
||||||
|
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||||
|
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||||
|
}
|
||||||
|
client := NewClient()
|
||||||
|
for _, tc := range integrationKeyCases(t) {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
cred := loadTestCredentials(t, tc.name)
|
||||||
|
|
||||||
|
info, err := client.ValidateKey(ctx, cred)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateKey: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("tenancy=%s homeRegion=%s", info.Name, info.HomeRegionKey)
|
||||||
|
|
||||||
|
profile, err := client.FetchAccountProfile(ctx, cred)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchAccountProfile: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("type=%s paymentModel=%q tier=%q promo=%q amount=%.2f service=%s",
|
||||||
|
profile.AccountType, profile.PaymentModel, profile.SubscriptionTier,
|
||||||
|
profile.PromotionStatus, profile.PromotionAmount, profile.ServiceName)
|
||||||
|
if !strings.EqualFold(profile.ServiceName, cloudcmServiceName) {
|
||||||
|
t.Errorf("ServiceName = %q, want %q", profile.ServiceName, cloudcmServiceName)
|
||||||
|
}
|
||||||
|
if tc.expect != "" && profile.AccountType != tc.expect {
|
||||||
|
t.Errorf("AccountType = %q, want %q", profile.AccountType, tc.expect)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRealOCIImages 用试用期 key 验证镜像列表查询(只读)。
|
||||||
|
func TestRealOCIImages(t *testing.T) {
|
||||||
|
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||||
|
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
cred := loadTestCredentials(t, "试用期")
|
||||||
|
client := NewClient()
|
||||||
|
|
||||||
|
images, err := client.ListImages(ctx, cred, ImagesQuery{
|
||||||
|
OperatingSystem: "Canonical Ubuntu",
|
||||||
|
Shape: "VM.Standard.A1.Flex",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListImages: %v", err)
|
||||||
|
}
|
||||||
|
if len(images) == 0 {
|
||||||
|
t.Fatal("ListImages returned empty list for Ubuntu on A1")
|
||||||
|
}
|
||||||
|
for _, img := range images[:min(3, len(images))] {
|
||||||
|
t.Logf("image %s os=%s %s size=%dMB", img.DisplayName, img.OperatingSystem, img.OperatingSystemVersion, img.SizeInMBs)
|
||||||
|
if img.ID == "" || img.OperatingSystem == "" {
|
||||||
|
t.Errorf("image %+v has empty mandatory field", img)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealOCINetworkCRUDAndIPv6 走全链路:建 VCN → 建子网 → 建安全列表 →
|
||||||
|
// 一键 IPv6 → 校验,结束后尽力清理全部资源。
|
||||||
|
// 会创建真实云资源,资源名前缀 oci-portal-e2e-<timestamp>。
|
||||||
|
func TestRealOCINetworkCRUDAndIPv6(t *testing.T) {
|
||||||
|
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||||
|
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
cred := loadTestCredentials(t, "试用期")
|
||||||
|
client := NewClient()
|
||||||
|
prefix := fmt.Sprintf("oci-portal-e2e-%d", time.Now().Unix())
|
||||||
|
|
||||||
|
vcn, err := client.CreateVCN(ctx, cred, CreateVCNInput{
|
||||||
|
DisplayName: prefix + "-vcn",
|
||||||
|
CidrBlock: "10.99.0.0/16",
|
||||||
|
DnsLabel: "ociportale2e",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateVCN: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("created vcn %s (%s)", vcn.DisplayName, vcn.ID)
|
||||||
|
defer cleanupVCN(t, client, cred, vcn.ID)
|
||||||
|
|
||||||
|
if _, err := client.UpdateVCN(ctx, cred, "", vcn.ID, prefix+"-vcn-renamed"); err != nil {
|
||||||
|
t.Errorf("UpdateVCN: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subnet, err := client.CreateSubnet(ctx, cred, CreateSubnetInput{
|
||||||
|
VcnID: vcn.ID,
|
||||||
|
DisplayName: prefix + "-subnet",
|
||||||
|
CidrBlock: "10.99.1.0/24",
|
||||||
|
DnsLabel: "e2esub",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSubnet: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("created subnet %s (%s)", subnet.DisplayName, subnet.ID)
|
||||||
|
|
||||||
|
secList, err := client.CreateSecurityList(ctx, cred, CreateSecurityListInput{
|
||||||
|
VcnID: vcn.ID,
|
||||||
|
DisplayName: prefix + "-seclist",
|
||||||
|
IngressRules: []SecurityRule{
|
||||||
|
{Protocol: "6", Source: "0.0.0.0/0", PortMin: intPtr(22), PortMax: intPtr(22), Description: "ssh"},
|
||||||
|
},
|
||||||
|
EgressRules: []SecurityRule{{Protocol: "all", Destination: "0.0.0.0/0"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSecurityList: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("created security list %s (%s)", secList.DisplayName, secList.ID)
|
||||||
|
if len(secList.IngressRules) != 1 || secList.IngressRules[0].PortMin == nil || *secList.IngressRules[0].PortMin != 22 {
|
||||||
|
t.Errorf("ingress rules = %+v, want one ssh rule with port 22", secList.IngressRules)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := client.UpdateSecurityList(ctx, cred, secList.ID, UpdateSecurityListInput{
|
||||||
|
IngressRules: &[]SecurityRule{
|
||||||
|
{Protocol: "6", Source: "0.0.0.0/0", PortMin: intPtr(22), PortMax: intPtr(22), Description: "ssh"},
|
||||||
|
{Protocol: "6", Source: "0.0.0.0/0", PortMin: intPtr(443), PortMax: intPtr(443), Description: "https"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateSecurityList: %v", err)
|
||||||
|
}
|
||||||
|
if len(updated.IngressRules) != 2 {
|
||||||
|
t.Errorf("ingress rules after update = %d, want 2", len(updated.IngressRules))
|
||||||
|
}
|
||||||
|
|
||||||
|
steps, err := client.EnableVCNIPv6(ctx, cred, "", vcn.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnableVCNIPv6: %v", err)
|
||||||
|
}
|
||||||
|
for _, s := range steps {
|
||||||
|
t.Logf("ipv6 step=%s status=%s %s", s.Step, s.Status, s.Detail)
|
||||||
|
if s.Status == "failed" {
|
||||||
|
t.Errorf("ipv6 step %s failed: %s", s.Step, s.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vcnAfter, err := client.GetVCN(ctx, cred, "", vcn.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetVCN after ipv6: %v", err)
|
||||||
|
}
|
||||||
|
if len(vcnAfter.Ipv6CidrBlocks) == 0 {
|
||||||
|
t.Error("vcn has no ipv6 cidr after enable-ipv6")
|
||||||
|
}
|
||||||
|
subnetAfter, err := client.GetSubnet(ctx, cred, "", subnet.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSubnet after ipv6: %v", err)
|
||||||
|
}
|
||||||
|
if subnetAfter.Ipv6CidrBlock == "" {
|
||||||
|
t.Error("subnet has no ipv6 cidr after enable-ipv6")
|
||||||
|
}
|
||||||
|
t.Logf("vcn ipv6=%v subnet ipv6=%s", vcnAfter.Ipv6CidrBlocks, subnetAfter.Ipv6CidrBlock)
|
||||||
|
|
||||||
|
// 幂等:重复执行应全部 skipped
|
||||||
|
steps2, err := client.EnableVCNIPv6(ctx, cred, "", vcn.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnableVCNIPv6 twice: %v", err)
|
||||||
|
}
|
||||||
|
for _, s := range steps2 {
|
||||||
|
if s.Status != "skipped" {
|
||||||
|
t.Errorf("rerun step %s status = %s, want skipped", s.Step, s.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.DeleteSecurityList(ctx, cred, "", secList.ID); err != nil {
|
||||||
|
t.Errorf("DeleteSecurityList: %v", err)
|
||||||
|
}
|
||||||
|
if err := client.DeleteSubnet(ctx, cred, "", subnet.ID); err != nil {
|
||||||
|
t.Errorf("DeleteSubnet: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupVCN 尽力清理 VCN 及其关联资源:清空默认路由表、删 IGW、
|
||||||
|
// 删残留子网与自建安全列表,最后删 VCN。
|
||||||
|
func cleanupVCN(t *testing.T, client *RealClient, cred Credentials, vcnID string) {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
vn, err := client.vcnClient(cred, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("cleanup: vcn client: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vcnResp, err := vn.GetVcn(ctx, core.GetVcnRequest{VcnId: &vcnID})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("cleanup: get vcn: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := vn.UpdateRouteTable(ctx, core.UpdateRouteTableRequest{
|
||||||
|
RtId: vcnResp.DefaultRouteTableId,
|
||||||
|
UpdateRouteTableDetails: core.UpdateRouteTableDetails{RouteRules: []core.RouteRule{}},
|
||||||
|
}); err != nil {
|
||||||
|
t.Logf("cleanup: clear route table: %v", err)
|
||||||
|
}
|
||||||
|
igws, err := vn.ListInternetGateways(ctx, core.ListInternetGatewaysRequest{
|
||||||
|
CompartmentId: &cred.TenancyOCID, VcnId: &vcnID,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
for _, igw := range igws.Items {
|
||||||
|
if _, err := vn.DeleteInternetGateway(ctx, core.DeleteInternetGatewayRequest{IgId: igw.Id}); err != nil {
|
||||||
|
t.Logf("cleanup: delete igw: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if subnets, err := listSubnets(ctx, vn, cred.TenancyOCID, vcnID); err == nil {
|
||||||
|
for _, s := range subnets {
|
||||||
|
if err := deleteSubnetWithRetry(ctx, client, cred, s.ID); err != nil {
|
||||||
|
t.Logf("cleanup: delete subnet %s: %v", s.DisplayName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lists, err := client.ListSecurityLists(ctx, cred, "", vcnID); err == nil {
|
||||||
|
for _, sl := range lists {
|
||||||
|
// 默认安全列表的名字包含 VCN 名,须用前缀匹配排除,它随 VCN 一起删除
|
||||||
|
if strings.HasPrefix(sl.DisplayName, "oci-portal-e2e-") {
|
||||||
|
if err := client.DeleteSecurityList(ctx, cred, "", sl.ID); err != nil {
|
||||||
|
t.Logf("cleanup: delete security list %s: %v", sl.DisplayName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := client.DeleteVCN(ctx, cred, "", vcnID); err != nil {
|
||||||
|
t.Errorf("cleanup: delete vcn: %v (manual cleanup may be required)", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Logf("cleanup: vcn %s deleted", vcnID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteSubnetWithRetry 删除子网;实例终止后 VNIC 异步释放,子网短暂
|
||||||
|
// 保持被引用状态,409 Conflict 时等待重试,最长约 60 秒。
|
||||||
|
func deleteSubnetWithRetry(ctx context.Context, client *RealClient, cred Credentials, subnetID string) error {
|
||||||
|
var err error
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
if err = client.DeleteSubnet(ctx, cred, "", subnetID); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "Conflict") {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return err
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtr(n int) *int { return &n }
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRealOCITenantUsers 验证用户管理全链路:新增 → 重置密码 →
|
||||||
|
// 清 MFA → 清 API Key → 删除,全部只操作新建的测试用户。
|
||||||
|
func TestRealOCITenantUsers(t *testing.T) {
|
||||||
|
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||||
|
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
cred := loadTestCredentials(t, "试用期")
|
||||||
|
client := NewClient()
|
||||||
|
homeRegion := cred.Region
|
||||||
|
|
||||||
|
users, err := client.ListTenantUsers(ctx, cred)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListTenantUsers: %v", err)
|
||||||
|
}
|
||||||
|
if len(users) == 0 {
|
||||||
|
t.Fatal("no tenant users, want at least the current one")
|
||||||
|
}
|
||||||
|
currentSeen := false
|
||||||
|
for _, u := range users {
|
||||||
|
if u.IsCurrentUser {
|
||||||
|
currentSeen = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !currentSeen {
|
||||||
|
t.Error("current user not flagged in list")
|
||||||
|
}
|
||||||
|
t.Logf("tenant users: %d", len(users))
|
||||||
|
|
||||||
|
name := fmt.Sprintf("oci-portal-e2e-user-%d", time.Now().Unix())
|
||||||
|
user, err := client.CreateTenantUser(ctx, cred, homeRegion, CreateTenantUserInput{
|
||||||
|
Name: name, Description: "e2e temp user", Email: name + "@example.com",
|
||||||
|
GivenName: "E2E", FamilyName: "Temp",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTenantUser: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("created user %s (%s)", user.Name, user.ID)
|
||||||
|
defer func() {
|
||||||
|
if err := client.DeleteTenantUser(ctx, cred, homeRegion, user.ID); err != nil {
|
||||||
|
t.Errorf("cleanup: DeleteTenantUser: %v", err)
|
||||||
|
} else {
|
||||||
|
t.Log("cleanup: user deleted")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
newDesc, newEmail, newGiven := "e2e updated", name+"+upd@example.com", "E2E2"
|
||||||
|
updated, err := client.UpdateTenantUser(ctx, cred, homeRegion, user.ID, UpdateTenantUserInput{
|
||||||
|
Description: &newDesc, Email: &newEmail, GivenName: &newGiven,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateTenantUser: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Description != newDesc {
|
||||||
|
t.Errorf("updated description = %q, want %q", updated.Description, newDesc)
|
||||||
|
}
|
||||||
|
t.Logf("updated user desc=%q email=%q", updated.Description, updated.Email)
|
||||||
|
|
||||||
|
password, err := client.ResetTenantUserPassword(ctx, cred, homeRegion, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResetTenantUserPassword: %v", err)
|
||||||
|
}
|
||||||
|
if password == "" {
|
||||||
|
t.Error("reset password returned empty password")
|
||||||
|
}
|
||||||
|
t.Logf("password reset ok (len=%d)", len(password))
|
||||||
|
|
||||||
|
mfaDeleted, err := client.DeleteTenantUserMfaDevices(ctx, cred, homeRegion, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeleteTenantUserMfaDevices: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("mfa devices deleted: %d", mfaDeleted)
|
||||||
|
|
||||||
|
keysDeleted, err := client.DeleteTenantUserApiKeys(ctx, cred, homeRegion, user.ID, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeleteTenantUserApiKeys: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("api keys deleted: %d", keysDeleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealOCIDomainSettings 验证通知收件人与密码策略的改后恢复。
|
||||||
|
func TestRealOCIDomainSettings(t *testing.T) {
|
||||||
|
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||||
|
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
cred := loadTestCredentials(t, "试用期")
|
||||||
|
client := NewClient()
|
||||||
|
region := cred.Region
|
||||||
|
|
||||||
|
original, err := client.GetNotificationRecipients(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetNotificationRecipients: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("original recipients=%v testMode=%v", original.Recipients, original.TestModeEnabled)
|
||||||
|
updated, err := client.UpdateNotificationRecipients(ctx, cred, region, []string{"e2e@example.com"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateNotificationRecipients: %v", err)
|
||||||
|
}
|
||||||
|
if !updated.TestModeEnabled || len(updated.Recipients) != 1 {
|
||||||
|
t.Errorf("updated = %+v, want test mode with 1 recipient", updated)
|
||||||
|
}
|
||||||
|
restoreRecipients := original.Recipients
|
||||||
|
if !original.TestModeEnabled {
|
||||||
|
restoreRecipients = nil
|
||||||
|
}
|
||||||
|
if _, err := client.UpdateNotificationRecipients(ctx, cred, region, restoreRecipients); err != nil {
|
||||||
|
t.Errorf("restore recipients: %v", err)
|
||||||
|
} else {
|
||||||
|
t.Log("recipients restored")
|
||||||
|
}
|
||||||
|
|
||||||
|
policies, err := client.ListPasswordPolicies(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPasswordPolicies: %v", err)
|
||||||
|
}
|
||||||
|
target, ok := pickCustomPolicy(policies)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("no Custom password policy among %d policies (built-ins are read-only)", len(policies))
|
||||||
|
}
|
||||||
|
t.Logf("policies: %d, patch target %s strength=%s expires=%v", len(policies), target.Name, target.PasswordStrength, target.PasswordExpiresAfter)
|
||||||
|
days := 350
|
||||||
|
patched, err := client.UpdatePasswordPolicy(ctx, cred, region, target.ID, UpdatePasswordPolicyInput{PasswordExpiresAfter: &days})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdatePasswordPolicy: %v", err)
|
||||||
|
}
|
||||||
|
if patched.PasswordExpiresAfter == nil || *patched.PasswordExpiresAfter != days {
|
||||||
|
t.Errorf("passwordExpiresAfter = %v, want %d", patched.PasswordExpiresAfter, days)
|
||||||
|
}
|
||||||
|
restore := 0
|
||||||
|
if target.PasswordExpiresAfter != nil {
|
||||||
|
restore = *target.PasswordExpiresAfter
|
||||||
|
}
|
||||||
|
if _, err := client.UpdatePasswordPolicy(ctx, cred, region, target.ID, UpdatePasswordPolicyInput{PasswordExpiresAfter: &restore}); err != nil {
|
||||||
|
t.Errorf("restore password policy: %v", err)
|
||||||
|
} else {
|
||||||
|
t.Logf("policy restored to %d", restore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickCustomPolicy 返回 Custom 强度的密码策略;Simple/Standard 内置策略只读不可改。
|
||||||
|
func pickCustomPolicy(policies []PasswordPolicyInfo) (PasswordPolicyInfo, bool) {
|
||||||
|
for _, p := range policies {
|
||||||
|
if strings.EqualFold(p.PasswordStrength, "custom") {
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return PasswordPolicyInfo{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRealOCIUsageAndTraffic 验证成本分析与实例流量统计:
|
||||||
|
// 成本查最近 7 天;流量需要真实 VNIC,无实例时创建一台临时 Micro 实例。
|
||||||
|
func TestRealOCIUsageAndTraffic(t *testing.T) {
|
||||||
|
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||||
|
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
cred := loadTestCredentials(t, "试用期")
|
||||||
|
client := NewClient()
|
||||||
|
|
||||||
|
end := time.Now().UTC().Truncate(24*time.Hour).AddDate(0, 0, 1)
|
||||||
|
items, err := client.SummarizeCosts(ctx, cred, CostQuery{
|
||||||
|
StartTime: end.AddDate(0, 0, -7),
|
||||||
|
EndTime: end,
|
||||||
|
Granularity: "DAILY",
|
||||||
|
QueryType: "COST",
|
||||||
|
GroupBy: "service",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SummarizeCosts: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("cost items: %d", len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
if i >= 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
t.Logf(" %s %s %.4f %s", item.TimeStart, item.GroupValue, item.ComputedAmount, item.Currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
instanceID := ensureTrafficInstance(ctx, t, client, cred)
|
||||||
|
traffic, err := client.SummarizeInstanceTraffic(ctx, cred, TrafficQuery{
|
||||||
|
InstanceID: instanceID,
|
||||||
|
StartTime: end.AddDate(0, 0, -7),
|
||||||
|
EndTime: end,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SummarizeInstanceTraffic: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("traffic vnics=%d in=%.0fB out=%.0fB", len(traffic.Vnics), traffic.InboundBytes, traffic.OutboundBytes)
|
||||||
|
if len(traffic.Vnics) == 0 {
|
||||||
|
t.Error("no vnic in traffic result, want at least one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureTrafficInstance 返回一个可查流量的实例:已有实例直接用,
|
||||||
|
// 否则创建一台临时 E2 Micro(测试结束删除)。
|
||||||
|
func ensureTrafficInstance(ctx context.Context, t *testing.T, client *RealClient, cred Credentials) string {
|
||||||
|
t.Helper()
|
||||||
|
instances, err := client.ListInstances(ctx, cred, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListInstances: %v", err)
|
||||||
|
}
|
||||||
|
for _, in := range instances {
|
||||||
|
if in.LifecycleState == "RUNNING" || in.LifecycleState == "STOPPED" {
|
||||||
|
t.Logf("using existing instance %s", in.ID)
|
||||||
|
return in.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
images, err := client.ListImages(ctx, cred, ImagesQuery{OperatingSystem: "Canonical Ubuntu", Shape: "VM.Standard.E2.1.Micro"})
|
||||||
|
if err != nil || len(images) == 0 {
|
||||||
|
t.Skipf("no micro image available: %v", err)
|
||||||
|
}
|
||||||
|
instance, err := client.LaunchInstance(ctx, cred, CreateInstanceInput{
|
||||||
|
DisplayName: fmt.Sprintf("oci-portal-e2e-traffic-%d", time.Now().Unix()),
|
||||||
|
Shape: "VM.Standard.E2.1.Micro",
|
||||||
|
ImageID: images[0].ID,
|
||||||
|
AssignPublicIP: false,
|
||||||
|
SSHPublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEfake+e2e+key oci-portal-e2e",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "capacity") || strings.Contains(err.Error(), "LimitExceeded") {
|
||||||
|
t.Skipf("micro capacity unavailable: %v", err)
|
||||||
|
}
|
||||||
|
t.Fatalf("LaunchInstance: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
terminateAndWait(t, client, cred, instance.ID)
|
||||||
|
cleanupAutoVCNIfAny(t, client, cred)
|
||||||
|
})
|
||||||
|
// VNIC attachment 在实例 RUNNING 后才就绪,否则流量查询列不到 VNIC
|
||||||
|
if err := waitInstanceState(ctx, client, cred, instance.ID, "RUNNING"); err != nil {
|
||||||
|
t.Fatalf("wait instance running: %v", err)
|
||||||
|
}
|
||||||
|
return instance.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupAutoVCNIfAny 清理临时实例触发自动建网留下的 auto-vcn。
|
||||||
|
func cleanupAutoVCNIfAny(t *testing.T, client *RealClient, cred Credentials) {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
vcns, err := client.ListVCNs(ctx, cred, "")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, v := range vcns {
|
||||||
|
if v.DisplayName == autoVcnName {
|
||||||
|
cleanupVCN(t, client, cred, v.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// regionsJSON 是本地维护的 OCI 区域表。官方 API(ListRegions、
|
||||||
|
// ListRegionSubscriptions 等)只返回 key 和 name,控制台风格的
|
||||||
|
// 友好名称(alias)官方不提供查询接口,因此在此文件旁的
|
||||||
|
// regions.json 中单独维护,新增区域时直接编辑该文件。
|
||||||
|
//
|
||||||
|
//go:embed regions.json
|
||||||
|
var regionsJSON []byte
|
||||||
|
|
||||||
|
// RegionInfo 描述一个 OCI 区域。
|
||||||
|
type RegionInfo struct {
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var loadRegions = sync.OnceValues(func() ([]RegionInfo, error) {
|
||||||
|
var regions []RegionInfo
|
||||||
|
if err := json.Unmarshal(regionsJSON, ®ions); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse embedded regions.json: %w", err)
|
||||||
|
}
|
||||||
|
// 全局统一按友好名排序,维护 regions.json 时无需关心条目顺序
|
||||||
|
sort.SliceStable(regions, func(i, j int) bool { return regions[i].Alias < regions[j].Alias })
|
||||||
|
return regions, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// AllRegions 返回本地维护的完整区域表。
|
||||||
|
func AllRegions() ([]RegionInfo, error) {
|
||||||
|
return loadRegions()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegionByKey 按三字码(如 FRA)查找区域,大小写不敏感。
|
||||||
|
func RegionByKey(key string) (RegionInfo, bool) {
|
||||||
|
regions, err := loadRegions()
|
||||||
|
if err != nil {
|
||||||
|
return RegionInfo{}, false
|
||||||
|
}
|
||||||
|
for _, r := range regions {
|
||||||
|
if strings.EqualFold(r.Key, key) {
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RegionInfo{}, false
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
[
|
||||||
|
{ "alias": "Netherlands Northwest (Amsterdam)", "key": "AMS", "name": "eu-amsterdam-1" },
|
||||||
|
{ "alias": "Sweden Central (Stockholm)", "key": "ARN", "name": "eu-stockholm-1" },
|
||||||
|
{ "alias": "UAE Central (Abu Dhabi)", "key": "AUH", "name": "me-abudhabi-1" },
|
||||||
|
{ "alias": "Colombia Central (Bogota)", "key": "BOG", "name": "sa-bogota-1" },
|
||||||
|
{ "alias": "India West (Mumbai)", "key": "BOM", "name": "ap-mumbai-1" },
|
||||||
|
{ "alias": "France Central (Paris)", "key": "CDG", "name": "eu-paris-1" },
|
||||||
|
{ "alias": "UK West (Newport)", "key": "CWL", "name": "uk-cardiff-1" },
|
||||||
|
{ "alias": "UAE East (Dubai)", "key": "DXB", "name": "me-dubai-1" },
|
||||||
|
{ "alias": "Germany Central (Frankfurt)", "key": "FRA", "name": "eu-frankfurt-1" },
|
||||||
|
{ "alias": "Brazil East (Sao Paulo)", "key": "GRU", "name": "sa-saopaulo-1" },
|
||||||
|
{ "alias": "Indonesia North (Batam)", "key": "HSG", "name": "ap-batam-1" },
|
||||||
|
{ "alias": "India South (Hyderabad)", "key": "HYD", "name": "ap-hyderabad-1" },
|
||||||
|
{ "alias": "US East (Ashburn)", "key": "IAD", "name": "us-ashburn-1" },
|
||||||
|
{ "alias": "South Korea Central (Seoul)", "key": "ICN", "name": "ap-seoul-1" },
|
||||||
|
{ "alias": "Malaysia West 2 (Kulai)", "key": "JBP", "name": "ap-kulai-2" },
|
||||||
|
{ "alias": "Saudi Arabia West (Jeddah)", "key": "JED", "name": "me-jeddah-1" },
|
||||||
|
{ "alias": "South Africa Central (Johannesburg)", "key": "JNB", "name": "af-johannesburg-1" },
|
||||||
|
{ "alias": "Japan Central (Osaka)", "key": "KIX", "name": "ap-osaka-1" },
|
||||||
|
{ "alias": "Morocco West (Casablanca)", "key": "LEJ", "name": "af-casablanca-1" },
|
||||||
|
{ "alias": "UK South (London)", "key": "LHR", "name": "uk-london-1" },
|
||||||
|
{ "alias": "Italy Northwest (Milan)", "key": "LIN", "name": "eu-milan-1" },
|
||||||
|
{ "alias": "Spain Central (Madrid)", "key": "MAD", "name": "eu-madrid-1" },
|
||||||
|
{ "alias": "Australia Southeast (Melbourne)", "key": "MEL", "name": "ap-melbourne-1" },
|
||||||
|
{ "alias": "France South (Marseille)", "key": "MRS", "name": "eu-marseille-1" },
|
||||||
|
{ "alias": "Mexico Northeast (Monterrey)", "key": "MTY", "name": "mx-monterrey-1" },
|
||||||
|
{ "alias": "Israel Central (Jerusalem)", "key": "MTZ", "name": "il-jerusalem-1" },
|
||||||
|
{ "alias": "Italy North (Turin)", "key": "NRQ", "name": "eu-turin-1" },
|
||||||
|
{ "alias": "Japan East (Tokyo)", "key": "NRT", "name": "ap-tokyo-1" },
|
||||||
|
{ "alias": "US Midwest (Chicago)", "key": "ORD", "name": "us-chicago-1" },
|
||||||
|
{ "alias": "Spain Central (Madrid 3)", "key": "ORF", "name": "eu-madrid-3" },
|
||||||
|
{ "alias": "US West (Phoenix)", "key": "PHX", "name": "us-phoenix-1" },
|
||||||
|
{ "alias": "Mexico Central (Queretaro)", "key": "QRO", "name": "mx-queretaro-1" },
|
||||||
|
{ "alias": "Saudi Arabia Central (Riyadh)", "key": "RUH", "name": "me-riyadh-1" },
|
||||||
|
{ "alias": "Chile Central (Santiago)", "key": "SCL", "name": "sa-santiago-1" },
|
||||||
|
{ "alias": "Singapore (Singapore)", "key": "SIN", "name": "ap-singapore-1" },
|
||||||
|
{ "alias": "US West (San Jose)", "key": "SJC", "name": "us-sanjose-1" },
|
||||||
|
{ "alias": "Australia East (Sydney)", "key": "SYD", "name": "ap-sydney-1" },
|
||||||
|
{ "alias": "Chile West (Valparaiso)", "key": "VAP", "name": "sa-valparaiso-1" },
|
||||||
|
{ "alias": "Brazil Southeast (Vinhedo)", "key": "VCP", "name": "sa-vinhedo-1" },
|
||||||
|
{ "alias": "Singapore West (Singapore)", "key": "XSP", "name": "ap-singapore-2" },
|
||||||
|
{ "alias": "South Korea North (Chuncheon)", "key": "YNY", "name": "ap-chuncheon-1" },
|
||||||
|
{ "alias": "Canada Southeast (Montreal)", "key": "YUL", "name": "ca-montreal-1" },
|
||||||
|
{ "alias": "Canada Southeast (Toronto)", "key": "YYZ", "name": "ca-toronto-1" },
|
||||||
|
{ "alias": "Switzerland North (Zurich)", "key": "ZRH", "name": "eu-zurich-1" }
|
||||||
|
]
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestAllRegionsParsesAndComplete(t *testing.T) {
|
||||||
|
regions, err := AllRegions()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AllRegions: %v", err)
|
||||||
|
}
|
||||||
|
if len(regions) == 0 {
|
||||||
|
t.Fatal("AllRegions returned empty table")
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool, len(regions))
|
||||||
|
for _, r := range regions {
|
||||||
|
if r.Alias == "" || r.Key == "" || r.Name == "" {
|
||||||
|
t.Errorf("region %+v has empty field", r)
|
||||||
|
}
|
||||||
|
if seen[r.Key] {
|
||||||
|
t.Errorf("duplicate region key %s", r.Key)
|
||||||
|
}
|
||||||
|
seen[r.Key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegionByKey(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
wantName string
|
||||||
|
wantFound bool
|
||||||
|
}{
|
||||||
|
{name: "精确命中", key: "FRA", wantName: "eu-frankfurt-1", wantFound: true},
|
||||||
|
{name: "小写命中", key: "fra", wantName: "eu-frankfurt-1", wantFound: true},
|
||||||
|
{name: "不存在", key: "XXX", wantFound: false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
info, found := RegionByKey(tt.key)
|
||||||
|
if found != tt.wantFound {
|
||||||
|
t.Fatalf("RegionByKey(%q) found = %v, want %v", tt.key, found, tt.wantFound)
|
||||||
|
}
|
||||||
|
if found && info.Name != tt.wantName {
|
||||||
|
t.Errorf("RegionByKey(%q).Name = %q, want %q", tt.key, info.Name, tt.wantName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scimErrorDispatcher 包装 Identity Domains 客户端的 HTTP 派发器。
|
||||||
|
// IDCS 的错误体是 SCIM 格式(detail 字段),而 SDK 只解析 code/message,
|
||||||
|
// 直接透传会得到消息为空的 BadErrorResponse;这里把 4xx/5xx 的 SCIM
|
||||||
|
// 错误体改写成 SDK 可解析的标准形状,让真实原因透出到面板。
|
||||||
|
type scimErrorDispatcher struct {
|
||||||
|
base common.HTTPRequestDispatcher
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d scimErrorDispatcher) Do(req *http.Request) (*http.Response, error) {
|
||||||
|
resp, err := d.base.Do(req)
|
||||||
|
if err != nil || resp == nil || resp.StatusCode < 400 || resp.Body == nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
resp.Body = io.NopCloser(bytes.NewReader(nil))
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
rewritten := rewriteScimError(body)
|
||||||
|
resp.Body = io.NopCloser(bytes.NewReader(rewritten))
|
||||||
|
resp.ContentLength = int64(len(rewritten))
|
||||||
|
resp.Header.Del("Content-Length")
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewriteScimError 提取 SCIM 错误的 detail 与 messageId 转成
|
||||||
|
// {code,message};不是 SCIM 错误体时原样返回。
|
||||||
|
func rewriteScimError(body []byte) []byte {
|
||||||
|
var scim struct {
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Ext struct {
|
||||||
|
MessageID string `json:"messageId"`
|
||||||
|
} `json:"urn:ietf:params:scim:api:oracle:idcs:extension:messages:Error"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(body, &scim) != nil || scim.Detail == "" {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
code := scim.Ext.MessageID
|
||||||
|
if code == "" {
|
||||||
|
code = "IdcsError"
|
||||||
|
}
|
||||||
|
std, err := json.Marshal(map[string]string{"code": code, "message": scim.Detail})
|
||||||
|
if err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return std
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRewriteScimError(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
wantCode string
|
||||||
|
wantMsg string
|
||||||
|
passThru bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "scim error with messageId",
|
||||||
|
body: `{"schemas":["urn:ietf:params:scim:api:messages:2.0:Error"],` +
|
||||||
|
`"detail":"Icon URL is too long.","status":"400",` +
|
||||||
|
`"urn:ietf:params:scim:api:oracle:idcs:extension:messages:Error":{"messageId":"error.common.validation"}}`,
|
||||||
|
wantCode: "error.common.validation",
|
||||||
|
wantMsg: "Icon URL is too long.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scim error without messageId",
|
||||||
|
body: `{"detail":"Missing required attribute.","status":"400"}`,
|
||||||
|
wantCode: "IdcsError",
|
||||||
|
wantMsg: "Missing required attribute.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "standard oci error passes through",
|
||||||
|
body: `{"code":"NotAuthorizedOrNotFound","message":"resource not found"}`,
|
||||||
|
passThru: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-json passes through",
|
||||||
|
body: `<html>bad gateway</html>`,
|
||||||
|
passThru: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
out := rewriteScimError([]byte(tt.body))
|
||||||
|
if tt.passThru {
|
||||||
|
if string(out) != tt.body {
|
||||||
|
t.Fatalf("body rewritten unexpectedly: %s", out)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &got); err != nil {
|
||||||
|
t.Fatalf("unmarshal rewritten body: %v", err)
|
||||||
|
}
|
||||||
|
if got.Code != tt.wantCode || got.Message != tt.wantMsg {
|
||||||
|
t.Errorf("got code=%q msg=%q, want code=%q msg=%q", got.Code, got.Message, tt.wantCode, tt.wantMsg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// shapeCacheTTL 是 shape 清单缓存时长:区域提供的 shape 极少变化。
|
||||||
|
const shapeCacheTTL = 6 * time.Hour
|
||||||
|
|
||||||
|
// ComputeShape 是租户在目标区域实际可用的一种实例规格。
|
||||||
|
// Flex 规格带 Ocpus/Memory 的可选范围;固定规格带其固定值。
|
||||||
|
type ComputeShape struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
BillingType string `json:"billingType"` // ALWAYS_FREE / LIMITED_FREE / PAID
|
||||||
|
IsFlexible bool `json:"isFlexible"`
|
||||||
|
Ocpus float32 `json:"ocpus,omitempty"`
|
||||||
|
MemoryInGBs float32 `json:"memoryInGBs,omitempty"`
|
||||||
|
OcpusMin float32 `json:"ocpusMin,omitempty"`
|
||||||
|
OcpusMax float32 `json:"ocpusMax,omitempty"`
|
||||||
|
MemoryMinGBs float32 `json:"memoryMinGBs,omitempty"`
|
||||||
|
MemoryMaxGBs float32 `json:"memoryMaxGBs,omitempty"`
|
||||||
|
Gpus int `json:"gpus,omitempty"`
|
||||||
|
ProcessorDescription string `json:"processorDescription,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// shapeCacheEntry 是一份 shape 清单的缓存条目。
|
||||||
|
type shapeCacheEntry struct {
|
||||||
|
expires time.Time
|
||||||
|
shapes []ComputeShape
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListShapes 实现 Client:列出租户在目标区域可用的实例规格(带 TTL 缓存)。
|
||||||
|
// availabilityDomain 为空时返回区域级去重清单。
|
||||||
|
func (c *RealClient) ListShapes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]ComputeShape, error) {
|
||||||
|
key := cred.TenancyOCID + "|" + region + "|" + availabilityDomain
|
||||||
|
if v, ok := c.shapes.Load(key); ok {
|
||||||
|
if e := v.(shapeCacheEntry); time.Now().Before(e.expires) {
|
||||||
|
return e.shapes, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shapes, err := c.fetchShapes(ctx, cred, region, availabilityDomain)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.shapes.Store(key, shapeCacheEntry{expires: time.Now().Add(shapeCacheTTL), shapes: shapes})
|
||||||
|
return shapes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchShapes 分页拉取 shape 清单并按名称去重(不带 AD 时 OCI 每个可用域各返回一份)。
|
||||||
|
func (c *RealClient) fetchShapes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]ComputeShape, error) {
|
||||||
|
cc, err := c.computeClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
shapes := []ComputeShape{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var page *string
|
||||||
|
for {
|
||||||
|
req := core.ListShapesRequest{
|
||||||
|
CompartmentId: cred.EffectiveCompartment(),
|
||||||
|
Limit: common.Int(500),
|
||||||
|
Page: page,
|
||||||
|
}
|
||||||
|
if availabilityDomain != "" {
|
||||||
|
req.AvailabilityDomain = &availabilityDomain
|
||||||
|
}
|
||||||
|
resp, err := cc.ListShapes(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list shapes: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
if name := deref(item.Shape); !seen[name] {
|
||||||
|
seen[name] = true
|
||||||
|
shapes = append(shapes, toComputeShape(item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
return shapes, nil
|
||||||
|
}
|
||||||
|
page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toComputeShape(s core.Shape) ComputeShape {
|
||||||
|
out := ComputeShape{
|
||||||
|
Name: deref(s.Shape),
|
||||||
|
BillingType: string(s.BillingType),
|
||||||
|
IsFlexible: s.OcpuOptions != nil,
|
||||||
|
ProcessorDescription: deref(s.ProcessorDescription),
|
||||||
|
}
|
||||||
|
out.Ocpus, out.MemoryInGBs = deref32(s.Ocpus), deref32(s.MemoryInGBs)
|
||||||
|
if s.Gpus != nil {
|
||||||
|
out.Gpus = *s.Gpus
|
||||||
|
}
|
||||||
|
if o := s.OcpuOptions; o != nil {
|
||||||
|
out.OcpusMin, out.OcpusMax = deref32(o.Min), deref32(o.Max)
|
||||||
|
}
|
||||||
|
if m := s.MemoryOptions; m != nil {
|
||||||
|
out.MemoryMinGBs, out.MemoryMaxGBs = deref32(m.MinInGBs), deref32(m.MaxInGBs)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// deref32 解引用 *float32,nil 时返回零值。
|
||||||
|
func deref32(p *float32) float32 {
|
||||||
|
if p == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/identitydomains"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ociConsolePolicyID 是预置 sign-on 策略「Security Policy for OCI Console」的固定 ID。
|
||||||
|
const ociConsolePolicyID = "OciConsolePolicy"
|
||||||
|
|
||||||
|
// scimPatchSchema 是 SCIM PatchOp 的 schema。
|
||||||
|
const scimPatchSchema = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
|
||||||
|
|
||||||
|
// SignOnRuleInfo 是 sign-on 策略中一条规则的关键字段。
|
||||||
|
type SignOnRuleInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Sequence int `json:"sequence"`
|
||||||
|
AuthenticationFactor string `json:"authenticationFactor"`
|
||||||
|
ConditionAttribute string `json:"conditionAttribute,omitempty"`
|
||||||
|
ConditionValue string `json:"conditionValue,omitempty"`
|
||||||
|
BuiltIn bool `json:"builtIn"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListConsoleSignOnRules 实现 Client:按优先级列出 OCI Console sign-on 策略的规则。
|
||||||
|
func (c *RealClient) ListConsoleSignOnRules(ctx context.Context, cred Credentials, region string) ([]SignOnRuleInfo, error) {
|
||||||
|
dc, err := c.domainsClient(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
refs, err := consolePolicyRules(ctx, dc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rules := make([]SignOnRuleInfo, 0, len(refs))
|
||||||
|
for _, ref := range refs {
|
||||||
|
info, err := loadSignOnRule(ctx, dc, deref(ref.Value), ref.Sequence)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rules = append(rules, info)
|
||||||
|
}
|
||||||
|
sort.Slice(rules, func(i, j int) bool { return rules[i].Sequence < rules[j].Sequence })
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func consolePolicyRules(ctx context.Context, dc identitydomains.IdentityDomainsClient) ([]identitydomains.PolicyRules, error) {
|
||||||
|
resp, err := dc.GetPolicy(ctx, identitydomains.GetPolicyRequest{
|
||||||
|
PolicyId: common.String(ociConsolePolicyID),
|
||||||
|
Attributes: common.String("id,name,rules"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get console sign-on policy: %w", err)
|
||||||
|
}
|
||||||
|
return resp.Rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSignOnRule(ctx context.Context, dc identitydomains.IdentityDomainsClient, ruleID string, seq *int) (SignOnRuleInfo, error) {
|
||||||
|
resp, err := dc.GetRule(ctx, identitydomains.GetRuleRequest{RuleId: &ruleID})
|
||||||
|
if err != nil {
|
||||||
|
return SignOnRuleInfo{}, fmt.Errorf("get rule %s: %w", ruleID, err)
|
||||||
|
}
|
||||||
|
info := SignOnRuleInfo{
|
||||||
|
ID: ruleID,
|
||||||
|
Name: deref(resp.Name),
|
||||||
|
BuiltIn: strings.HasPrefix(ruleID, "OciConsole"),
|
||||||
|
}
|
||||||
|
if seq != nil {
|
||||||
|
info.Sequence = *seq
|
||||||
|
}
|
||||||
|
for _, r := range resp.Return {
|
||||||
|
if deref(r.Name) == "authenticationFactor" {
|
||||||
|
info.AuthenticationFactor = deref(r.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fillRuleCondition(ctx, dc, resp.Rule, &info)
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillRuleCondition 尽力回填规则条件(仅解析直接引用 Condition 的规则)。
|
||||||
|
func fillRuleCondition(ctx context.Context, dc identitydomains.IdentityDomainsClient, rule identitydomains.Rule, info *SignOnRuleInfo) {
|
||||||
|
cg := rule.ConditionGroup
|
||||||
|
if cg == nil || cg.Type != identitydomains.RuleConditionGroupTypeCondition || cg.Value == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := dc.GetCondition(ctx, identitydomains.GetConditionRequest{ConditionId: cg.Value})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info.ConditionAttribute = deref(resp.AttributeName)
|
||||||
|
info.ConditionValue = deref(resp.AttributeValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMfaExemptionRule 实现 Client:为指定 IdP 创建免 MFA sign-on 规则并置顶。
|
||||||
|
// 规则语义与控制台一致:subject.authenticatedBy in [idpID] → authenticationFactor=IDP。
|
||||||
|
func (c *RealClient) CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, idpID, ruleName string) (SignOnRuleInfo, error) {
|
||||||
|
dc, err := c.domainsClient(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return SignOnRuleInfo{}, err
|
||||||
|
}
|
||||||
|
condID, err := createIdpCondition(ctx, dc, idpID, ruleName)
|
||||||
|
if err != nil {
|
||||||
|
return SignOnRuleInfo{}, err
|
||||||
|
}
|
||||||
|
ruleID, err := createExemptionRule(ctx, dc, condID, ruleName)
|
||||||
|
if err != nil {
|
||||||
|
deleteConditionQuiet(ctx, dc, condID)
|
||||||
|
return SignOnRuleInfo{}, err
|
||||||
|
}
|
||||||
|
if err := prependRuleToPolicy(ctx, dc, ruleID); err != nil {
|
||||||
|
deleteRuleQuiet(ctx, dc, ruleID)
|
||||||
|
deleteConditionQuiet(ctx, dc, condID)
|
||||||
|
return SignOnRuleInfo{}, err
|
||||||
|
}
|
||||||
|
return SignOnRuleInfo{
|
||||||
|
ID: ruleID, Name: ruleName, Sequence: 1,
|
||||||
|
AuthenticationFactor: "IDP",
|
||||||
|
ConditionAttribute: "subject.authenticatedBy",
|
||||||
|
ConditionValue: jsonList(idpID),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonList(items ...string) string {
|
||||||
|
b, _ := json.Marshal(items)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createIdpCondition(ctx context.Context, dc identitydomains.IdentityDomainsClient, idpID, name string) (string, error) {
|
||||||
|
value := jsonList(idpID)
|
||||||
|
resp, err := dc.CreateCondition(ctx, identitydomains.CreateConditionRequest{
|
||||||
|
Condition: identitydomains.Condition{
|
||||||
|
Schemas: []string{"urn:ietf:params:scim:schemas:oracle:idcs:Condition"},
|
||||||
|
Name: common.String(name + "-condition"),
|
||||||
|
AttributeName: common.String("subject.authenticatedBy"),
|
||||||
|
Operator: identitydomains.ConditionOperatorIn,
|
||||||
|
AttributeValue: &value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create idp condition: %w", err)
|
||||||
|
}
|
||||||
|
return deref(resp.Id), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createExemptionRule(ctx context.Context, dc identitydomains.IdentityDomainsClient, condID, name string) (string, error) {
|
||||||
|
resp, err := dc.CreateRule(ctx, identitydomains.CreateRuleRequest{
|
||||||
|
Rule: identitydomains.Rule{
|
||||||
|
Schemas: []string{"urn:ietf:params:scim:schemas:oracle:idcs:Rule"},
|
||||||
|
Name: &name,
|
||||||
|
PolicyType: &identitydomains.RulePolicyType{Value: common.String("SignOn")},
|
||||||
|
ConditionGroup: &identitydomains.RuleConditionGroup{
|
||||||
|
Type: identitydomains.RuleConditionGroupTypeCondition,
|
||||||
|
Value: &condID,
|
||||||
|
},
|
||||||
|
Return: []identitydomains.RuleReturn{
|
||||||
|
{Name: common.String("effect"), Value: common.String("ALLOW")},
|
||||||
|
{Name: common.String("reAuthenticate"), Value: common.String("false")},
|
||||||
|
{Name: common.String("authenticationFactor"), Value: common.String("IDP")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create exemption rule: %w", err)
|
||||||
|
}
|
||||||
|
return deref(resp.Id), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// prependRuleToPolicy 把规则插到策略首位,其余规则优先级顺延。
|
||||||
|
func prependRuleToPolicy(ctx context.Context, dc identitydomains.IdentityDomainsClient, ruleID string) error {
|
||||||
|
existing, err := consolePolicyRules(ctx, dc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rules := []interface{}{map[string]interface{}{"value": ruleID, "sequence": 1}}
|
||||||
|
for i, r := range sortedRules(existing) {
|
||||||
|
rules = append(rules, map[string]interface{}{"value": deref(r.Value), "sequence": i + 2})
|
||||||
|
}
|
||||||
|
return patchPolicyRules(ctx, dc, rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeRuleFromPolicy 从策略移除规则,其余规则优先级从 1 重排。
|
||||||
|
func removeRuleFromPolicy(ctx context.Context, dc identitydomains.IdentityDomainsClient, ruleID string) error {
|
||||||
|
existing, err := consolePolicyRules(ctx, dc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rules := make([]interface{}, 0, len(existing))
|
||||||
|
for _, r := range sortedRules(existing) {
|
||||||
|
if deref(r.Value) == ruleID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rules = append(rules, map[string]interface{}{"value": deref(r.Value), "sequence": len(rules) + 1})
|
||||||
|
}
|
||||||
|
if len(rules) == len(existing) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return patchPolicyRules(ctx, dc, rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedRules(rules []identitydomains.PolicyRules) []identitydomains.PolicyRules {
|
||||||
|
out := append([]identitydomains.PolicyRules(nil), rules...)
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
si, sj := 0, 0
|
||||||
|
if out[i].Sequence != nil {
|
||||||
|
si = *out[i].Sequence
|
||||||
|
}
|
||||||
|
if out[j].Sequence != nil {
|
||||||
|
sj = *out[j].Sequence
|
||||||
|
}
|
||||||
|
return si < sj
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func patchPolicyRules(ctx context.Context, dc identitydomains.IdentityDomainsClient, rules []interface{}) error {
|
||||||
|
var value interface{} = rules
|
||||||
|
_, err := dc.PatchPolicy(ctx, identitydomains.PatchPolicyRequest{
|
||||||
|
PolicyId: common.String(ociConsolePolicyID),
|
||||||
|
PatchOp: identitydomains.PatchOp{
|
||||||
|
Schemas: []string{scimPatchSchema},
|
||||||
|
Operations: []identitydomains.Operations{{
|
||||||
|
Op: identitydomains.OperationsOpReplace,
|
||||||
|
Path: common.String("rules"),
|
||||||
|
Value: &value,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("patch console sign-on policy rules: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMfaExemptionRule 实现 Client:删除免 MFA 规则并连带清理其条件。
|
||||||
|
// 拒绝删除 Oracle 预置规则。
|
||||||
|
func (c *RealClient) DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, ruleID string) error {
|
||||||
|
if strings.HasPrefix(ruleID, "OciConsole") {
|
||||||
|
return fmt.Errorf("delete sign-on rule: %s is a built-in rule and cannot be deleted", ruleID)
|
||||||
|
}
|
||||||
|
dc, err := c.domainsClient(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rule, err := dc.GetRule(ctx, identitydomains.GetRuleRequest{RuleId: &ruleID})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get rule %s: %w", ruleID, err)
|
||||||
|
}
|
||||||
|
if err := removeRuleFromPolicy(ctx, dc, ruleID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := dc.DeleteRule(ctx, identitydomains.DeleteRuleRequest{RuleId: &ruleID}); err != nil {
|
||||||
|
return fmt.Errorf("delete rule %s: %w", ruleID, err)
|
||||||
|
}
|
||||||
|
cg := rule.ConditionGroup
|
||||||
|
if cg != nil && cg.Type == identitydomains.RuleConditionGroupTypeCondition && cg.Value != nil {
|
||||||
|
deleteConditionQuiet(ctx, dc, *cg.Value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteRuleQuiet(ctx context.Context, dc identitydomains.IdentityDomainsClient, ruleID string) {
|
||||||
|
_, _ = dc.DeleteRule(ctx, identitydomains.DeleteRuleRequest{RuleId: &ruleID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteConditionQuiet(ctx context.Context, dc identitydomains.IdentityDomainsClient, condID string) {
|
||||||
|
_, _ = dc.DeleteCondition(ctx, identitydomains.DeleteConditionRequest{ConditionId: &condID})
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/tenantmanagercontrolplane"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SubscriptionInfo 是租户订阅的摘要信息。
|
||||||
|
type SubscriptionInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ServiceName string `json:"serviceName"`
|
||||||
|
TimeCreated *time.Time `json:"timeCreated"`
|
||||||
|
TimeUpdated *time.Time `json:"timeUpdated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromotionInfo 是订阅下的一条促销记录。
|
||||||
|
type PromotionInfo struct {
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
DurationUnit string `json:"durationUnit"`
|
||||||
|
Amount float32 `json:"amount"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
IsIntentToPay bool `json:"isIntentToPay"`
|
||||||
|
CurrencyUnit string `json:"currencyUnit"`
|
||||||
|
TimeStarted *time.Time `json:"timeStarted"`
|
||||||
|
TimeExpired *time.Time `json:"timeExpired"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscriptionDetail 是单个订阅的完整信息(Classic v1 订阅)。
|
||||||
|
type SubscriptionDetail struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ServiceName string `json:"serviceName"`
|
||||||
|
ClassicSubscriptionID string `json:"classicSubscriptionId"`
|
||||||
|
PaymentModel string `json:"paymentModel"`
|
||||||
|
SubscriptionTier string `json:"subscriptionTier"`
|
||||||
|
LifecycleState string `json:"lifecycleState"`
|
||||||
|
ProgramType string `json:"programType"`
|
||||||
|
CustomerCountryCode string `json:"customerCountryCode"`
|
||||||
|
CloudAmountCurrency string `json:"cloudAmountCurrency"`
|
||||||
|
CsiNumber string `json:"csiNumber"`
|
||||||
|
RegionAssignment string `json:"regionAssignment"`
|
||||||
|
IsGovernmentSubscription bool `json:"isGovernmentSubscription"`
|
||||||
|
Promotions []PromotionInfo `json:"promotions"`
|
||||||
|
StartDate *time.Time `json:"startDate"`
|
||||||
|
EndDate *time.Time `json:"endDate"`
|
||||||
|
TimeCreated *time.Time `json:"timeCreated"`
|
||||||
|
TimeUpdated *time.Time `json:"timeUpdated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSubscriptions 实现 Client:分页列出租户全部订阅摘要。
|
||||||
|
func (c *RealClient) ListSubscriptions(ctx context.Context, cred Credentials) ([]SubscriptionInfo, error) {
|
||||||
|
sc, err := tenantmanagercontrolplane.NewSubscriptionClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("new subscription client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&sc.BaseClient, cred)
|
||||||
|
var subs []SubscriptionInfo
|
||||||
|
var page *string
|
||||||
|
for {
|
||||||
|
resp, err := sc.ListSubscriptions(ctx, tenantmanagercontrolplane.ListSubscriptionsRequest{
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
Page: page,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list subscriptions: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
subs = append(subs, SubscriptionInfo{
|
||||||
|
ID: deref(item.GetId()),
|
||||||
|
ServiceName: deref(item.GetServiceName()),
|
||||||
|
TimeCreated: sdkTime(item.GetTimeCreated()),
|
||||||
|
TimeUpdated: sdkTime(item.GetTimeUpdated()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
return orEmpty(subs), nil
|
||||||
|
}
|
||||||
|
page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSubscription 实现 Client:查询单个订阅的完整信息。
|
||||||
|
func (c *RealClient) GetSubscription(ctx context.Context, cred Credentials, subscriptionID string) (SubscriptionDetail, error) {
|
||||||
|
sc, err := tenantmanagercontrolplane.NewSubscriptionClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return SubscriptionDetail{}, fmt.Errorf("new subscription client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&sc.BaseClient, cred)
|
||||||
|
resp, err := sc.GetSubscription(ctx, tenantmanagercontrolplane.GetSubscriptionRequest{
|
||||||
|
SubscriptionId: &subscriptionID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return SubscriptionDetail{}, fmt.Errorf("get subscription %s: %w", subscriptionID, err)
|
||||||
|
}
|
||||||
|
switch sub := resp.Subscription.(type) {
|
||||||
|
case tenantmanagercontrolplane.ClassicSubscription:
|
||||||
|
return toSubscriptionDetail(sub), nil
|
||||||
|
case *tenantmanagercontrolplane.ClassicSubscription:
|
||||||
|
return toSubscriptionDetail(*sub), nil
|
||||||
|
default:
|
||||||
|
return SubscriptionDetail{}, fmt.Errorf("get subscription %s: unexpected type %T", subscriptionID, resp.Subscription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toSubscriptionDetail(sub tenantmanagercontrolplane.ClassicSubscription) SubscriptionDetail {
|
||||||
|
detail := SubscriptionDetail{
|
||||||
|
ID: deref(sub.Id),
|
||||||
|
ServiceName: deref(sub.ServiceName),
|
||||||
|
ClassicSubscriptionID: deref(sub.ClassicSubscriptionId),
|
||||||
|
PaymentModel: deref(sub.PaymentModel),
|
||||||
|
SubscriptionTier: deref(sub.SubscriptionTier),
|
||||||
|
LifecycleState: string(sub.LifecycleState),
|
||||||
|
ProgramType: deref(sub.ProgramType),
|
||||||
|
CustomerCountryCode: deref(sub.CustomerCountryCode),
|
||||||
|
CloudAmountCurrency: deref(sub.CloudAmountCurrency),
|
||||||
|
CsiNumber: deref(sub.CsiNumber),
|
||||||
|
RegionAssignment: deref(sub.RegionAssignment),
|
||||||
|
IsGovernmentSubscription: sub.IsGovernmentSubscription != nil && *sub.IsGovernmentSubscription,
|
||||||
|
StartDate: sdkTime(sub.StartDate),
|
||||||
|
EndDate: sdkTime(sub.EndDate),
|
||||||
|
TimeCreated: sdkTime(sub.TimeCreated),
|
||||||
|
TimeUpdated: sdkTime(sub.TimeUpdated),
|
||||||
|
}
|
||||||
|
detail.Promotions = make([]PromotionInfo, 0, len(sub.Promotion))
|
||||||
|
for _, p := range sub.Promotion {
|
||||||
|
detail.Promotions = append(detail.Promotions, toPromotionInfo(p))
|
||||||
|
}
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPromotionInfo(p tenantmanagercontrolplane.Promotion) PromotionInfo {
|
||||||
|
info := PromotionInfo{
|
||||||
|
Status: string(p.Status),
|
||||||
|
DurationUnit: deref(p.DurationUnit),
|
||||||
|
CurrencyUnit: deref(p.CurrencyUnit),
|
||||||
|
TimeStarted: sdkTime(p.TimeStarted),
|
||||||
|
TimeExpired: sdkTime(p.TimeExpired),
|
||||||
|
}
|
||||||
|
if p.Duration != nil {
|
||||||
|
info.Duration = *p.Duration
|
||||||
|
}
|
||||||
|
if p.Amount != nil {
|
||||||
|
info.Amount = *p.Amount
|
||||||
|
}
|
||||||
|
if p.IsIntentToPay != nil {
|
||||||
|
info.IsIntentToPay = *p.IsIntentToPay
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
func sdkTime(t *common.SDKTime) *time.Time {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &t.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/identity"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/limits"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxAvailabilityLookups 限制附带用量查询的条数:
|
||||||
|
// GetResourceAvailability 每条配额一次调用(OCI 无批量接口),
|
||||||
|
// 定义预筛 + 并发查询后 100 条约需 1~3 秒。
|
||||||
|
const maxAvailabilityLookups = 100
|
||||||
|
|
||||||
|
// limitsPageSize 是 limits 系列 List 接口的显式分页大小(OCI 通用上限 1000);
|
||||||
|
// 不传时服务端默认约百条一页,全量拉取要反复翻页。
|
||||||
|
const limitsPageSize = 1000
|
||||||
|
|
||||||
|
// availabilityConcurrency 是用量查询并发度;Limits 端点 TPS 不高,并发过高易撞限流。
|
||||||
|
const availabilityConcurrency = 8
|
||||||
|
|
||||||
|
// limitDefTTL 是配额定义缓存时长:定义(是否支持用量查询)几乎不随时间变化。
|
||||||
|
const limitDefTTL = 6 * time.Hour
|
||||||
|
|
||||||
|
// RegionSubscription 是租户对一个区域的订阅状态。
|
||||||
|
type RegionSubscription struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
IsHomeRegion bool `json:"isHomeRegion"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitValue 是一条服务配额;Used/Available 仅在请求用量时填充,
|
||||||
|
// 且 OCI 对部分配额不提供用量数据,此时保持为 nil。
|
||||||
|
type LimitValue struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ScopeType string `json:"scopeType"`
|
||||||
|
AvailabilityDomain string `json:"availabilityDomain,omitempty"`
|
||||||
|
Value int64 `json:"value"`
|
||||||
|
Used *int64 `json:"used,omitempty"`
|
||||||
|
Available *int64 `json:"available,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitsQuery 是一次配额查询的条件。
|
||||||
|
type LimitsQuery struct {
|
||||||
|
Region string // 目标区域,空则用凭据默认区域
|
||||||
|
Service string // 服务名,如 compute
|
||||||
|
Name string // 可选,配额名模糊过滤(大小写不敏感子串,本地过滤)
|
||||||
|
ScopeType string // 可选 GLOBAL / REGION / AD,下推给 OCI 过滤
|
||||||
|
AvailabilityDomain string // 可选,AD 全名,下推给 OCI 过滤
|
||||||
|
NonZero bool // 过滤掉上限为 0 的配额项(在用量查询前生效,不占并发名额)
|
||||||
|
WithAvailability bool // 并发附带 used/available
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitService 是配额体系支持的一个服务。
|
||||||
|
type LimitService struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRegionSubscriptions 实现 Client。
|
||||||
|
func (c *RealClient) ListRegionSubscriptions(ctx context.Context, cred Credentials) ([]RegionSubscription, error) {
|
||||||
|
ic, err := identity.NewIdentityClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("new identity client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&ic.BaseClient, cred)
|
||||||
|
resp, err := ic.ListRegionSubscriptions(ctx, identity.ListRegionSubscriptionsRequest{
|
||||||
|
TenancyId: &cred.TenancyOCID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list region subscriptions: %w", err)
|
||||||
|
}
|
||||||
|
subs := make([]RegionSubscription, 0, len(resp.Items))
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
subs = append(subs, RegionSubscription{
|
||||||
|
Key: deref(item.RegionKey),
|
||||||
|
Name: deref(item.RegionName),
|
||||||
|
Status: string(item.Status),
|
||||||
|
IsHomeRegion: item.IsHomeRegion != nil && *item.IsHomeRegion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
SortRegionSubscriptions(subs)
|
||||||
|
return subs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortRegionSubscriptions 主区域置顶,其余按友好名(alias)字典序,
|
||||||
|
// 本地表查不到 alias 时回退区域名;实时接口与缓存读取共用,全局统一展示顺序。
|
||||||
|
func SortRegionSubscriptions(subs []RegionSubscription) {
|
||||||
|
sortKey := func(sub RegionSubscription) string {
|
||||||
|
if info, ok := RegionByKey(sub.Key); ok {
|
||||||
|
return info.Alias
|
||||||
|
}
|
||||||
|
return sub.Name
|
||||||
|
}
|
||||||
|
sort.SliceStable(subs, func(i, j int) bool {
|
||||||
|
if subs[i].IsHomeRegion != subs[j].IsHomeRegion {
|
||||||
|
return subs[i].IsHomeRegion
|
||||||
|
}
|
||||||
|
return sortKey(subs[i]) < sortKey(subs[j])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscribeRegion 实现 Client:订阅新区域。OCI 要求该请求发往
|
||||||
|
// home region,且区域订阅一经创建不可取消。
|
||||||
|
func (c *RealClient) SubscribeRegion(ctx context.Context, cred Credentials, homeRegion, regionKey string) error {
|
||||||
|
ic, err := identity.NewIdentityClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("new identity client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&ic.BaseClient, cred)
|
||||||
|
if homeRegion != "" {
|
||||||
|
ic.SetRegion(normalizeRegion(homeRegion))
|
||||||
|
}
|
||||||
|
_, err = ic.CreateRegionSubscription(ctx, identity.CreateRegionSubscriptionRequest{
|
||||||
|
TenancyId: &cred.TenancyOCID,
|
||||||
|
CreateRegionSubscriptionDetails: identity.CreateRegionSubscriptionDetails{
|
||||||
|
RegionKey: ®ionKey,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create region subscription %s: %w", regionKey, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// limitsClient 构造 limits 服务客户端;region 为空时用凭据默认区域。
|
||||||
|
func limitsClient(cred Credentials, region string) (limits.LimitsClient, error) {
|
||||||
|
lc, err := limits.NewLimitsClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return lc, fmt.Errorf("new limits client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&lc.BaseClient, cred)
|
||||||
|
if region != "" {
|
||||||
|
lc.SetRegion(normalizeRegion(region))
|
||||||
|
}
|
||||||
|
return lc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLimits 实现 Client:全量拉取配额(scopeType / AD 条件下推),
|
||||||
|
// 名称本地子串过滤,按需并发附带用量。
|
||||||
|
func (c *RealClient) ListLimits(ctx context.Context, cred Credentials, q LimitsQuery) ([]LimitValue, error) {
|
||||||
|
lc, err := limitsClient(cred, q.Region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values, err := listLimitValues(ctx, lc, cred.TenancyOCID, q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values = filterLimitsByName(values, q.Name)
|
||||||
|
if q.NonZero {
|
||||||
|
values = filterNonZero(values)
|
||||||
|
}
|
||||||
|
if !q.WithAvailability {
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
if len(values) > maxAvailabilityLookups {
|
||||||
|
return nil, fmt.Errorf("list limits: %d entries exceed availability lookup cap %d, narrow with name filter",
|
||||||
|
len(values), maxAvailabilityLookups)
|
||||||
|
}
|
||||||
|
supported := c.availabilitySupport(ctx, lc, cred.TenancyOCID, q.Region, q.Service)
|
||||||
|
attachAvailability(ctx, lc, cred.TenancyOCID, q.Service, values, supported)
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listLimitValues 分页取回一个服务的配额;scopeType / availabilityDomain 下推给 OCI。
|
||||||
|
// OCI 的 name 参数只支持精确匹配,子串过滤由调用方本地完成。
|
||||||
|
func listLimitValues(ctx context.Context, lc limits.LimitsClient, tenancyOCID string, q LimitsQuery) ([]LimitValue, error) {
|
||||||
|
var values []LimitValue
|
||||||
|
var page *string
|
||||||
|
for {
|
||||||
|
req := limits.ListLimitValuesRequest{
|
||||||
|
CompartmentId: &tenancyOCID,
|
||||||
|
ServiceName: &q.Service,
|
||||||
|
Limit: common.Int(limitsPageSize),
|
||||||
|
Page: page,
|
||||||
|
}
|
||||||
|
if q.ScopeType != "" {
|
||||||
|
req.ScopeType = limits.ListLimitValuesScopeTypeEnum(q.ScopeType)
|
||||||
|
}
|
||||||
|
if q.AvailabilityDomain != "" {
|
||||||
|
req.AvailabilityDomain = &q.AvailabilityDomain
|
||||||
|
}
|
||||||
|
resp, err := lc.ListLimitValues(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list limit values of %s: %w", q.Service, err)
|
||||||
|
}
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
values = append(values, toLimitValue(item))
|
||||||
|
}
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
return orEmpty(values), nil
|
||||||
|
}
|
||||||
|
page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toLimitValue(item limits.LimitValueSummary) LimitValue {
|
||||||
|
var value int64
|
||||||
|
if item.Value != nil {
|
||||||
|
value = *item.Value
|
||||||
|
}
|
||||||
|
return LimitValue{
|
||||||
|
Name: deref(item.Name),
|
||||||
|
ScopeType: string(item.ScopeType),
|
||||||
|
AvailabilityDomain: deref(item.AvailabilityDomain),
|
||||||
|
Value: value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterLimitsByName 按大小写不敏感的子串匹配过滤配额名。
|
||||||
|
func filterLimitsByName(values []LimitValue, name string) []LimitValue {
|
||||||
|
if name == "" {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
needle := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
filtered := make([]LimitValue, 0, len(values))
|
||||||
|
for _, v := range values {
|
||||||
|
if strings.Contains(strings.ToLower(v.Name), needle) {
|
||||||
|
filtered = append(filtered, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterNonZero 过滤掉上限为 0 的配额项。
|
||||||
|
func filterNonZero(values []LimitValue) []LimitValue {
|
||||||
|
filtered := make([]LimitValue, 0, len(values))
|
||||||
|
for _, v := range values {
|
||||||
|
if v.Value > 0 {
|
||||||
|
filtered = append(filtered, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLimitServices 实现 Client:分页列出配额体系支持的全部服务。
|
||||||
|
func (c *RealClient) ListLimitServices(ctx context.Context, cred Credentials, region string) ([]LimitService, error) {
|
||||||
|
lc, err := limitsClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var services []LimitService
|
||||||
|
var page *string
|
||||||
|
for {
|
||||||
|
resp, err := lc.ListServices(ctx, limits.ListServicesRequest{
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
Limit: common.Int(limitsPageSize),
|
||||||
|
Page: page,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list limit services: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
services = append(services, LimitService{
|
||||||
|
Name: deref(item.Name),
|
||||||
|
Description: deref(item.Description),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
return orEmpty(services), nil
|
||||||
|
}
|
||||||
|
page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// limitDefEntry 是一份配额定义预筛结果的缓存条目。
|
||||||
|
type limitDefEntry struct {
|
||||||
|
expires time.Time
|
||||||
|
supported map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// availabilitySupport 返回配额名 → 是否支持用量查询的映射(带 TTL 缓存);
|
||||||
|
// 拉取失败时返回 nil,调用方视为全部照查(保守降级)。
|
||||||
|
func (c *RealClient) availabilitySupport(ctx context.Context, lc limits.LimitsClient, tenancyOCID, region, service string) map[string]bool {
|
||||||
|
key := tenancyOCID + "|" + region + "|" + service
|
||||||
|
if v, ok := c.limitDefs.Load(key); ok {
|
||||||
|
if e := v.(limitDefEntry); time.Now().Before(e.expires) {
|
||||||
|
return e.supported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
supported, err := listLimitDefinitions(ctx, lc, tenancyOCID, service)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.limitDefs.Store(key, limitDefEntry{expires: time.Now().Add(limitDefTTL), supported: supported})
|
||||||
|
return supported
|
||||||
|
}
|
||||||
|
|
||||||
|
// listLimitDefinitions 分页取回一个服务的配额定义,产出配额名 → 是否支持用量查询的映射;
|
||||||
|
// 字段缺失(nil)视为支持,仅明确 false 的会被预筛跳过。
|
||||||
|
func listLimitDefinitions(ctx context.Context, lc limits.LimitsClient, tenancyOCID, service string) (map[string]bool, error) {
|
||||||
|
supported := map[string]bool{}
|
||||||
|
var page *string
|
||||||
|
for {
|
||||||
|
resp, err := lc.ListLimitDefinitions(ctx, limits.ListLimitDefinitionsRequest{
|
||||||
|
CompartmentId: &tenancyOCID,
|
||||||
|
ServiceName: &service,
|
||||||
|
Limit: common.Int(limitsPageSize),
|
||||||
|
Page: page,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list limit definitions of %s: %w", service, err)
|
||||||
|
}
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
if item.Name == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
supported[*item.Name] = item.IsResourceAvailabilitySupported == nil || *item.IsResourceAvailabilitySupported
|
||||||
|
}
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
return supported, nil
|
||||||
|
}
|
||||||
|
page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachAvailability 并发查询用量:预筛掉明确不支持用量查询的配额,
|
||||||
|
// 单条失败不影响整体,字段保持 nil。
|
||||||
|
func attachAvailability(ctx context.Context, lc limits.LimitsClient, tenancyOCID, service string, values []LimitValue, supported map[string]bool) {
|
||||||
|
sem := make(chan struct{}, availabilityConcurrency)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range values {
|
||||||
|
if s, ok := supported[values[i].Name]; ok && !s {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
sem <- struct{}{}
|
||||||
|
go func(v *LimitValue) {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-sem }()
|
||||||
|
fetchAvailability(ctx, lc, tenancyOCID, service, v)
|
||||||
|
}(&values[i])
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchAvailability 查询单条配额的用量;AD 级配额必须带 availabilityDomain。
|
||||||
|
func fetchAvailability(ctx context.Context, lc limits.LimitsClient, tenancyOCID, service string, v *LimitValue) {
|
||||||
|
req := limits.GetResourceAvailabilityRequest{
|
||||||
|
ServiceName: &service,
|
||||||
|
LimitName: &v.Name,
|
||||||
|
CompartmentId: &tenancyOCID,
|
||||||
|
}
|
||||||
|
if strings.EqualFold(v.ScopeType, "AD") {
|
||||||
|
req.AvailabilityDomain = &v.AvailabilityDomain
|
||||||
|
}
|
||||||
|
resp, err := lc.GetResourceAvailability(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.Used = resp.Used
|
||||||
|
v.Available = resp.Available
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestFilterLimitsByName(t *testing.T) {
|
||||||
|
values := []LimitValue{
|
||||||
|
{Name: "standard-a1-core-count"},
|
||||||
|
{Name: "standard-e2-core-count"},
|
||||||
|
{Name: "standard-a1-memory-count"},
|
||||||
|
{Name: "vm-standard2-1-count"},
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filter string
|
||||||
|
wantNames []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "空过滤返回全部",
|
||||||
|
filter: "",
|
||||||
|
wantNames: []string{"standard-a1-core-count", "standard-e2-core-count", "standard-a1-memory-count", "vm-standard2-1-count"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "模糊匹配 core-count",
|
||||||
|
filter: "core-count",
|
||||||
|
wantNames: []string{"standard-a1-core-count", "standard-e2-core-count"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "大小写不敏感",
|
||||||
|
filter: "CORE-Count",
|
||||||
|
wantNames: []string{"standard-a1-core-count", "standard-e2-core-count"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "前后空白被忽略",
|
||||||
|
filter: " a1 ",
|
||||||
|
wantNames: []string{"standard-a1-core-count", "standard-a1-memory-count"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "精确名仍可命中",
|
||||||
|
filter: "standard-a1-core-count",
|
||||||
|
wantNames: []string{"standard-a1-core-count"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "无匹配返回空",
|
||||||
|
filter: "gpu",
|
||||||
|
wantNames: []string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := filterLimitsByName(values, tt.filter)
|
||||||
|
gotNames := make([]string, 0, len(got))
|
||||||
|
for _, v := range got {
|
||||||
|
gotNames = append(gotNames, v.Name)
|
||||||
|
}
|
||||||
|
if len(gotNames) != len(tt.wantNames) {
|
||||||
|
t.Fatalf("filterLimitsByName(%q) = %v, want %v", tt.filter, gotNames, tt.wantNames)
|
||||||
|
}
|
||||||
|
for i := range gotNames {
|
||||||
|
if gotNames[i] != tt.wantNames[i] {
|
||||||
|
t.Errorf("filterLimitsByName(%q)[%d] = %q, want %q", tt.filter, i, gotNames[i], tt.wantNames[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,736 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/identity"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/identitydomains"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenantUser 是租户下的一个 IAM 用户。
|
||||||
|
type TenantUser struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
EmailVerified bool `json:"emailVerified"`
|
||||||
|
MfaActivated bool `json:"mfaActivated"`
|
||||||
|
LifecycleState string `json:"lifecycleState"`
|
||||||
|
IsCurrentUser bool `json:"isCurrentUser"`
|
||||||
|
TimeCreated *time.Time `json:"timeCreated"`
|
||||||
|
LastLoginTime *time.Time `json:"lastLoginTime,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oracle 预置的管理员角色与管理员组显示名。
|
||||||
|
const (
|
||||||
|
domainAdminRoleName = "Identity Domain Administrator"
|
||||||
|
administratorsGroupName = "Administrators"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scimAppRolesAttr 是 SCIM 用户 appRoles 扩展属性的完整路径。
|
||||||
|
const scimAppRolesAttr = "urn:ietf:params:scim:schemas:oracle:idcs:extension:user:User:appRoles"
|
||||||
|
|
||||||
|
// TenantUserDetail 是编辑表单需要的域档案与管理员状态;
|
||||||
|
// InDomain 为 false 表示经典 IAM 用户,其余字段无意义。
|
||||||
|
type TenantUserDetail struct {
|
||||||
|
InDomain bool `json:"inDomain"`
|
||||||
|
GivenName string `json:"givenName"`
|
||||||
|
FamilyName string `json:"familyName"`
|
||||||
|
IsDomainAdmin bool `json:"isDomainAdmin"`
|
||||||
|
InAdminGroup bool `json:"inAdminGroup"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTenantUserDetail 实现 Client:查用户域档案与管理员状态(编辑表单预填充用)。
|
||||||
|
func (c *RealClient) GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, userID string) (TenantUserDetail, error) {
|
||||||
|
dc, err := c.domainsClient(ctx, cred, homeRegion)
|
||||||
|
if err != nil {
|
||||||
|
return TenantUserDetail{}, err
|
||||||
|
}
|
||||||
|
u, found, err := domainUserByOcid(ctx, dc, userID, "groups", scimAppRolesAttr)
|
||||||
|
if err != nil || !found {
|
||||||
|
return TenantUserDetail{}, err
|
||||||
|
}
|
||||||
|
return toTenantUserDetail(u), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toTenantUserDetail 提取姓名、管理员角色与管理员组成员状态。
|
||||||
|
func toTenantUserDetail(u identitydomains.User) TenantUserDetail {
|
||||||
|
d := TenantUserDetail{InDomain: true}
|
||||||
|
if u.Name != nil {
|
||||||
|
d.GivenName = deref(u.Name.GivenName)
|
||||||
|
d.FamilyName = deref(u.Name.FamilyName)
|
||||||
|
}
|
||||||
|
for _, g := range u.Groups {
|
||||||
|
if deref(g.Display) == administratorsGroupName {
|
||||||
|
d.InAdminGroup = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ext := u.UrnIetfParamsScimSchemasOracleIdcsExtensionUserUser; ext != nil {
|
||||||
|
for _, r := range ext.AppRoles {
|
||||||
|
if deref(r.Display) == domainAdminRoleName {
|
||||||
|
d.IsDomainAdmin = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RealClient) identityClientAt(cred Credentials, region string) (identity.IdentityClient, error) {
|
||||||
|
ic, err := identity.NewIdentityClientWithConfigurationProvider(provider(cred))
|
||||||
|
if err != nil {
|
||||||
|
return ic, fmt.Errorf("new identity client: %w", err)
|
||||||
|
}
|
||||||
|
applyProxy(&ic.BaseClient, cred)
|
||||||
|
if region != "" {
|
||||||
|
ic.SetRegion(normalizeRegion(region))
|
||||||
|
}
|
||||||
|
return ic, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTenantUsers 实现 Client:列出租户全部 IAM 用户。
|
||||||
|
func (c *RealClient) ListTenantUsers(ctx context.Context, cred Credentials) ([]TenantUser, error) {
|
||||||
|
ic, err := c.identityClientAt(cred, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var users []TenantUser
|
||||||
|
var page *string
|
||||||
|
for {
|
||||||
|
resp, err := ic.ListUsers(ctx, identity.ListUsersRequest{CompartmentId: &cred.TenancyOCID, Page: page})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list users: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
users = append(users, toTenantUser(item, cred.UserOCID))
|
||||||
|
}
|
||||||
|
if resp.OpcNextPage == nil {
|
||||||
|
return orEmpty(users), nil
|
||||||
|
}
|
||||||
|
page = resp.OpcNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTenantUserInput 是创建用户的输入;GivenName/FamilyName 走
|
||||||
|
// Identity Domains SCIM(经典 IAM 无对应字段)。两个管理员选项同时勾选时
|
||||||
|
// 先授身份域管理员角色,再加入 Administrators 组。
|
||||||
|
type CreateTenantUserInput struct {
|
||||||
|
Name string // 登录用户名
|
||||||
|
Description string
|
||||||
|
Email string
|
||||||
|
GivenName string
|
||||||
|
FamilyName string
|
||||||
|
GrantDomainAdmin bool // 授予「身份域管理员」应用角色
|
||||||
|
AddToAdminGroup bool // 加入 Administrators 组
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTenantUser 实现 Client:创建用户(须发往 home region)。
|
||||||
|
// 优先 Identity Domains SCIM(支持姓名与管理员授权);SCIM 不可用时回退
|
||||||
|
// 经典 IAM API,此时管理员选项无法执行、姓名并入描述。
|
||||||
|
func (c *RealClient) CreateTenantUser(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error) {
|
||||||
|
user, err := c.createUserViaDomain(ctx, cred, homeRegion, in)
|
||||||
|
if err == nil {
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
if in.GrantDomainAdmin || in.AddToAdminGroup {
|
||||||
|
return TenantUser{}, fmt.Errorf("create user via identity domain: %w", err)
|
||||||
|
}
|
||||||
|
return c.createUserClassic(ctx, cred, homeRegion, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createUserClassic 走经典 IAM API 创建用户(姓名并入描述)。
|
||||||
|
func (c *RealClient) createUserClassic(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error) {
|
||||||
|
ic, err := c.identityClientAt(cred, homeRegion)
|
||||||
|
if err != nil {
|
||||||
|
return TenantUser{}, err
|
||||||
|
}
|
||||||
|
description := in.Description
|
||||||
|
if description == "" {
|
||||||
|
description = strings.TrimSpace(in.GivenName + " " + in.FamilyName)
|
||||||
|
}
|
||||||
|
details := identity.CreateUserDetails{
|
||||||
|
CompartmentId: &cred.TenancyOCID,
|
||||||
|
Name: &in.Name,
|
||||||
|
Description: &description,
|
||||||
|
}
|
||||||
|
if in.Email != "" {
|
||||||
|
details.Email = &in.Email
|
||||||
|
}
|
||||||
|
resp, err := ic.CreateUser(ctx, identity.CreateUserRequest{CreateUserDetails: details})
|
||||||
|
if err != nil {
|
||||||
|
return TenantUser{}, fmt.Errorf("create user %s: %w", in.Name, err)
|
||||||
|
}
|
||||||
|
return toTenantUser(resp.User, cred.UserOCID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createUserViaDomain 通过 Identity Domains SCIM 创建用户并按选项授权。
|
||||||
|
func (c *RealClient) createUserViaDomain(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error) {
|
||||||
|
dc, err := c.domainsClient(ctx, cred, homeRegion)
|
||||||
|
if err != nil {
|
||||||
|
return TenantUser{}, err
|
||||||
|
}
|
||||||
|
resp, err := dc.CreateUser(ctx, identitydomains.CreateUserRequest{User: buildScimUser(in)})
|
||||||
|
if err != nil {
|
||||||
|
return TenantUser{}, fmt.Errorf("create domain user %s: %w", in.Name, err)
|
||||||
|
}
|
||||||
|
scimID := deref(resp.User.Id)
|
||||||
|
// 同时勾选时约定先授身份域管理员,再加管理员组
|
||||||
|
if in.GrantDomainAdmin {
|
||||||
|
if err := grantDomainAdminRole(ctx, dc, scimID); err != nil {
|
||||||
|
return toDomainTenantUser(resp.User), err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.AddToAdminGroup {
|
||||||
|
if err := addUserToAdminGroup(ctx, dc, scimID); err != nil {
|
||||||
|
return toDomainTenantUser(resp.User), err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return toDomainTenantUser(resp.User), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildScimUser(in CreateTenantUserInput) identitydomains.User {
|
||||||
|
u := identitydomains.User{
|
||||||
|
Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
|
||||||
|
UserName: &in.Name,
|
||||||
|
Name: &identitydomains.UserName{
|
||||||
|
GivenName: &in.GivenName,
|
||||||
|
FamilyName: &in.FamilyName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if in.Description != "" {
|
||||||
|
u.Description = &in.Description
|
||||||
|
}
|
||||||
|
if in.Email != "" {
|
||||||
|
primary := true
|
||||||
|
u.Emails = []identitydomains.UserEmails{{
|
||||||
|
Value: &in.Email,
|
||||||
|
Type: identitydomains.UserEmailsTypeWork,
|
||||||
|
Primary: &primary,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// findDomainAdminRole 查找「Identity Domain Administrator」应用角色。
|
||||||
|
func findDomainAdminRole(ctx context.Context, dc identitydomains.IdentityDomainsClient) (identitydomains.AppRole, error) {
|
||||||
|
filter := fmt.Sprintf("displayName eq %q", domainAdminRoleName)
|
||||||
|
count := 1
|
||||||
|
roles, err := dc.ListAppRoles(ctx, identitydomains.ListAppRolesRequest{
|
||||||
|
Filter: &filter,
|
||||||
|
Attributes: common.String("id,displayName,app"),
|
||||||
|
Count: &count,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return identitydomains.AppRole{}, fmt.Errorf("find domain administrator role: %w", err)
|
||||||
|
}
|
||||||
|
if len(roles.Resources) == 0 || roles.Resources[0].Id == nil || roles.Resources[0].App == nil {
|
||||||
|
return identitydomains.AppRole{}, fmt.Errorf("identity domain administrator role not found")
|
||||||
|
}
|
||||||
|
return roles.Resources[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantDomainAdminRole 授予「Identity Domain Administrator」应用角色。
|
||||||
|
func grantDomainAdminRole(ctx context.Context, dc identitydomains.IdentityDomainsClient, userSCIMID string) error {
|
||||||
|
role, err := findDomainAdminRole(ctx, dc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = dc.CreateGrant(ctx, identitydomains.CreateGrantRequest{
|
||||||
|
Grant: identitydomains.Grant{
|
||||||
|
Schemas: []string{"urn:ietf:params:scim:schemas:oracle:idcs:Grant"},
|
||||||
|
GrantMechanism: identitydomains.GrantGrantMechanismAdministratorToUser,
|
||||||
|
Grantee: &identitydomains.GrantGrantee{
|
||||||
|
Type: identitydomains.GrantGranteeTypeUser,
|
||||||
|
Value: &userSCIMID,
|
||||||
|
},
|
||||||
|
App: &identitydomains.GrantApp{Value: role.App.Value},
|
||||||
|
Entitlement: &identitydomains.GrantEntitlement{
|
||||||
|
AttributeName: common.String("appRoles"),
|
||||||
|
AttributeValue: role.Id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("grant domain administrator role: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// revokeDomainAdminRole 撤销直接授予用户的「身份域管理员」角色;
|
||||||
|
// 只删 ADMINISTRATOR_TO_USER 机制的授权,经组继承的不动。
|
||||||
|
func revokeDomainAdminRole(ctx context.Context, dc identitydomains.IdentityDomainsClient, userSCIMID string) error {
|
||||||
|
role, err := findDomainAdminRole(ctx, dc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
filter := fmt.Sprintf("grantee.value eq %q and entitlement.attributeValue eq %q", userSCIMID, deref(role.Id))
|
||||||
|
grants, err := dc.ListGrants(ctx, identitydomains.ListGrantsRequest{
|
||||||
|
Filter: &filter,
|
||||||
|
Attributes: common.String("id,grantMechanism"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("find domain administrator grants: %w", err)
|
||||||
|
}
|
||||||
|
for _, g := range grants.Resources {
|
||||||
|
if g.GrantMechanism != identitydomains.GrantGrantMechanismAdministratorToUser {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := dc.DeleteGrant(ctx, identitydomains.DeleteGrantRequest{GrantId: g.Id}); err != nil {
|
||||||
|
return fmt.Errorf("revoke domain administrator role: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addUserToAdminGroup 把用户加入 Administrators 组。
|
||||||
|
func addUserToAdminGroup(ctx context.Context, dc identitydomains.IdentityDomainsClient, userSCIMID string) error {
|
||||||
|
group, err := adminGroupRef(ctx, dc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if group == nil {
|
||||||
|
return fmt.Errorf("administrators group not found")
|
||||||
|
}
|
||||||
|
var members interface{} = []map[string]string{{"value": userSCIMID, "type": "User"}}
|
||||||
|
_, err = dc.PatchGroup(ctx, identitydomains.PatchGroupRequest{
|
||||||
|
GroupId: group.Value,
|
||||||
|
PatchOp: identitydomains.PatchOp{
|
||||||
|
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
||||||
|
Operations: []identitydomains.Operations{{
|
||||||
|
Op: identitydomains.OperationsOpAdd,
|
||||||
|
Path: common.String("members"),
|
||||||
|
Value: &members,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("add user to administrators group: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeUserFromAdminGroup 把用户移出 Administrators 组。
|
||||||
|
func removeUserFromAdminGroup(ctx context.Context, dc identitydomains.IdentityDomainsClient, userSCIMID string) error {
|
||||||
|
group, err := adminGroupRef(ctx, dc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if group == nil {
|
||||||
|
return fmt.Errorf("administrators group not found")
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("members[value eq %q]", userSCIMID)
|
||||||
|
_, err = dc.PatchGroup(ctx, identitydomains.PatchGroupRequest{
|
||||||
|
GroupId: group.Value,
|
||||||
|
PatchOp: identitydomains.PatchOp{
|
||||||
|
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
||||||
|
Operations: []identitydomains.Operations{{
|
||||||
|
Op: identitydomains.OperationsOpRemove,
|
||||||
|
Path: &path,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("remove user from administrators group: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toDomainTenantUser 把 SCIM 用户转为列表通用形状(ID 用对应的 IAM OCID)。
|
||||||
|
func toDomainTenantUser(u identitydomains.User) TenantUser {
|
||||||
|
out := TenantUser{
|
||||||
|
ID: deref(u.Ocid),
|
||||||
|
Name: deref(u.UserName),
|
||||||
|
Description: deref(u.Description),
|
||||||
|
LifecycleState: "ACTIVE",
|
||||||
|
}
|
||||||
|
if out.ID == "" {
|
||||||
|
out.ID = deref(u.Id)
|
||||||
|
}
|
||||||
|
for _, e := range u.Emails {
|
||||||
|
if e.Primary != nil && *e.Primary {
|
||||||
|
out.Email = deref(e.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTenantUserInput 是编辑用户资料的输入;nil 字段不修改。
|
||||||
|
// 管理员字段为三态:nil 不动,true 授予/加入,false 撤销/移出。
|
||||||
|
type UpdateTenantUserInput struct {
|
||||||
|
Description *string
|
||||||
|
Email *string
|
||||||
|
GivenName *string
|
||||||
|
FamilyName *string
|
||||||
|
GrantDomainAdmin *bool // 「身份域管理员」应用角色
|
||||||
|
AddToAdminGroup *bool // Administrators 组成员
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTenantUser 实现 Client:编辑用户资料(须发往 home region)。
|
||||||
|
// 优先 Identity Domains SCIM(支持姓名);用户不在域中时回退经典
|
||||||
|
// IAM API(仅备注与邮箱,姓名变更报错)。
|
||||||
|
func (c *RealClient) UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string, in UpdateTenantUserInput) (TenantUser, error) {
|
||||||
|
dc, err := c.domainsClient(ctx, cred, homeRegion)
|
||||||
|
if err != nil {
|
||||||
|
return TenantUser{}, err
|
||||||
|
}
|
||||||
|
domainUser, found, err := domainUserByOcid(ctx, dc, userID)
|
||||||
|
if err != nil {
|
||||||
|
return TenantUser{}, err
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
user, err := patchDomainUser(ctx, dc, domainUser, in)
|
||||||
|
if err != nil {
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
scimID := deref(domainUser.Id)
|
||||||
|
if err := applyAdminGrants(ctx, dc, scimID, in); err != nil {
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
if in.GivenName != nil || in.FamilyName != nil {
|
||||||
|
return TenantUser{}, fmt.Errorf("update user %s: 姓名仅身份域用户可修改(该用户不在身份域中)", userID)
|
||||||
|
}
|
||||||
|
if in.GrantDomainAdmin != nil || in.AddToAdminGroup != nil {
|
||||||
|
return TenantUser{}, fmt.Errorf("update user %s: 管理员授权仅身份域用户可操作(该用户不在身份域中)", userID)
|
||||||
|
}
|
||||||
|
return c.updateUserClassic(ctx, cred, homeRegion, userID, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateUserClassic 走经典 IAM API 更新备注与邮箱。
|
||||||
|
func (c *RealClient) updateUserClassic(ctx context.Context, cred Credentials, homeRegion, userID string, in UpdateTenantUserInput) (TenantUser, error) {
|
||||||
|
ic, err := c.identityClientAt(cred, homeRegion)
|
||||||
|
if err != nil {
|
||||||
|
return TenantUser{}, err
|
||||||
|
}
|
||||||
|
resp, err := ic.UpdateUser(ctx, identity.UpdateUserRequest{
|
||||||
|
UserId: &userID,
|
||||||
|
UpdateUserDetails: identity.UpdateUserDetails{Description: in.Description, Email: in.Email},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return TenantUser{}, fmt.Errorf("update user %s: %w", userID, err)
|
||||||
|
}
|
||||||
|
return toTenantUser(resp.User, cred.UserOCID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchDomainUser 以 SCIM Patch 修改域用户的指定字段。
|
||||||
|
func patchDomainUser(ctx context.Context, dc identitydomains.IdentityDomainsClient, u identitydomains.User, in UpdateTenantUserInput) (TenantUser, error) {
|
||||||
|
ops := buildUserPatchOps(u, in)
|
||||||
|
if len(ops) == 0 {
|
||||||
|
return toDomainTenantUser(u), nil
|
||||||
|
}
|
||||||
|
resp, err := dc.PatchUser(ctx, identitydomains.PatchUserRequest{
|
||||||
|
UserId: u.Id,
|
||||||
|
PatchOp: identitydomains.PatchOp{
|
||||||
|
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
||||||
|
Operations: ops,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return TenantUser{}, fmt.Errorf("patch domain user %s: %w", deref(u.UserName), err)
|
||||||
|
}
|
||||||
|
return toDomainTenantUser(resp.User), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyAdminGrants 同步管理员状态:nil 不动,true 授予/加入,false 撤销/移出。
|
||||||
|
func applyAdminGrants(ctx context.Context, dc identitydomains.IdentityDomainsClient, scimID string, in UpdateTenantUserInput) error {
|
||||||
|
if in.GrantDomainAdmin != nil {
|
||||||
|
apply := revokeDomainAdminRole
|
||||||
|
if *in.GrantDomainAdmin {
|
||||||
|
apply = grantDomainAdminRole
|
||||||
|
}
|
||||||
|
if err := apply(ctx, dc, scimID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.AddToAdminGroup != nil {
|
||||||
|
apply := removeUserFromAdminGroup
|
||||||
|
if *in.AddToAdminGroup {
|
||||||
|
apply = addUserToAdminGroup
|
||||||
|
}
|
||||||
|
return apply(ctx, dc, scimID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildUserPatchOps(u identitydomains.User, in UpdateTenantUserInput) []identitydomains.Operations {
|
||||||
|
var ops []identitydomains.Operations
|
||||||
|
if in.GivenName != nil {
|
||||||
|
ops = append(ops, replaceOp("name.givenName", *in.GivenName))
|
||||||
|
}
|
||||||
|
if in.FamilyName != nil {
|
||||||
|
ops = append(ops, replaceOp("name.familyName", *in.FamilyName))
|
||||||
|
}
|
||||||
|
if in.Description != nil {
|
||||||
|
ops = append(ops, replaceOp("description", *in.Description))
|
||||||
|
}
|
||||||
|
if in.Email != nil {
|
||||||
|
ops = append(ops, emailPatchOp(u, *in.Email))
|
||||||
|
}
|
||||||
|
return ops
|
||||||
|
}
|
||||||
|
|
||||||
|
// emailPatchOp 生成主邮箱修改操作:已有 work 邮箱按 filter 路径替换,
|
||||||
|
// 没有则整体添加一条 primary work 邮箱(避免整组 replace 冲掉 recovery 邮箱)。
|
||||||
|
func emailPatchOp(u identitydomains.User, email string) identitydomains.Operations {
|
||||||
|
for _, e := range u.Emails {
|
||||||
|
if e.Type == identitydomains.UserEmailsTypeWork {
|
||||||
|
return replaceOp(`emails[type eq "work"].value`, email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var value interface{} = []map[string]interface{}{
|
||||||
|
{"value": email, "type": "work", "primary": true},
|
||||||
|
}
|
||||||
|
return identitydomains.Operations{
|
||||||
|
Op: identitydomains.OperationsOpAdd,
|
||||||
|
Path: common.String("emails"),
|
||||||
|
Value: &value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTenantUser 实现 Client:删除 IAM 用户(须发往 home region)。
|
||||||
|
func (c *RealClient) DeleteTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string) error {
|
||||||
|
ic, err := c.identityClientAt(cred, homeRegion)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := ic.DeleteUser(ctx, identity.DeleteUserRequest{UserId: &userID}); err != nil {
|
||||||
|
return fmt.Errorf("delete user %s: %w", userID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetTenantUserPassword 实现 Client:重置用户控制台密码并返回一次性新密码。
|
||||||
|
// 优先经典 IAM API;新式租户(用户只存在于 Identity Domain)自动回退 SCIM。
|
||||||
|
func (c *RealClient) ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, userID string) (string, error) {
|
||||||
|
ic, err := c.identityClientAt(cred, homeRegion)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
resp, err := ic.CreateOrResetUIPassword(ctx, identity.CreateOrResetUIPasswordRequest{UserId: &userID})
|
||||||
|
if err == nil {
|
||||||
|
return deref(resp.Password), nil
|
||||||
|
}
|
||||||
|
if !isClassicUnsupported(err) {
|
||||||
|
return "", fmt.Errorf("reset ui password of %s: %w", userID, err)
|
||||||
|
}
|
||||||
|
return c.resetPasswordViaDomain(ctx, cred, homeRegion, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetPasswordViaDomain 通过 Identity Domains SCIM API 强设随机新密码。
|
||||||
|
func (c *RealClient) resetPasswordViaDomain(ctx context.Context, cred Credentials, homeRegion, userID string) (string, error) {
|
||||||
|
dc, err := c.domainsClient(ctx, cred, homeRegion)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
scimID, err := scimUserID(ctx, dc, userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
password, err := generatePassword()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
bypass := true
|
||||||
|
_, err = dc.PutUserPasswordChanger(ctx, identitydomains.PutUserPasswordChangerRequest{
|
||||||
|
UserPasswordChangerId: &scimID,
|
||||||
|
UserPasswordChanger: identitydomains.UserPasswordChanger{
|
||||||
|
Schemas: []string{"urn:ietf:params:scim:schemas:oracle:idcs:UserPasswordChanger"},
|
||||||
|
Password: &password,
|
||||||
|
BypassNotification: &bypass,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// 身份域通常不允许凭据本人经管理通道改自己的密码
|
||||||
|
if userID == cred.UserOCID {
|
||||||
|
return "", fmt.Errorf("change password via identity domain(当前签名用户请在 OCI 控制台修改密码): %w", err)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("change password via identity domain: %w", err)
|
||||||
|
}
|
||||||
|
return password, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTenantUserMfaDevices 实现 Client:清除用户全部 MFA。
|
||||||
|
// 先删经典 TOTP 设备,再通过 Identity Domains 移除全部认证因子(尽力)。
|
||||||
|
func (c *RealClient) DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, userID string) (int, error) {
|
||||||
|
ic, err := c.identityClientAt(cred, homeRegion)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
deleted, err := deleteClassicTotpDevices(ctx, ic, userID)
|
||||||
|
if err != nil {
|
||||||
|
return deleted, err
|
||||||
|
}
|
||||||
|
if err := c.removeDomainFactors(ctx, cred, homeRegion, userID); err != nil {
|
||||||
|
return deleted, err
|
||||||
|
}
|
||||||
|
return deleted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteClassicTotpDevices(ctx context.Context, ic identity.IdentityClient, userID string) (int, error) {
|
||||||
|
resp, err := ic.ListMfaTotpDevices(ctx, identity.ListMfaTotpDevicesRequest{UserId: &userID})
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("list mfa totp devices: %w", err)
|
||||||
|
}
|
||||||
|
deleted := 0
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
_, err := ic.DeleteMfaTotpDevice(ctx, identity.DeleteMfaTotpDeviceRequest{
|
||||||
|
UserId: &userID,
|
||||||
|
MfaTotpDeviceId: item.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return deleted, fmt.Errorf("delete mfa totp device: %w", err)
|
||||||
|
}
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
return deleted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeDomainFactors 通过 SCIM AuthenticationFactorsRemover 移除用户
|
||||||
|
// 在 Identity Domain 注册的全部认证因子;用户不在域中时视为无事可做。
|
||||||
|
func (c *RealClient) removeDomainFactors(ctx context.Context, cred Credentials, homeRegion, userID string) error {
|
||||||
|
dc, err := c.domainsClient(ctx, cred, homeRegion)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
scimID, err := scimUserID(ctx, dc, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err = dc.CreateAuthenticationFactorsRemover(ctx, identitydomains.CreateAuthenticationFactorsRemoverRequest{
|
||||||
|
AuthenticationFactorsRemover: identitydomains.AuthenticationFactorsRemover{
|
||||||
|
Schemas: []string{"urn:ietf:params:scim:schemas:oracle:idcs:AuthenticationFactorsRemover"},
|
||||||
|
User: &identitydomains.AuthenticationFactorsRemoverUser{Value: &scimID},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("remove authentication factors: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTenantUserApiKeys 实现 Client:删除用户的 API Key。
|
||||||
|
// 默认跳过当前配置正在使用的指纹以免面板失联,includeCurrent 为 true 时一并删除。
|
||||||
|
func (c *RealClient) DeleteTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string, includeCurrent bool) (int, error) {
|
||||||
|
ic, err := c.identityClientAt(cred, homeRegion)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
resp, err := ic.ListApiKeys(ctx, identity.ListApiKeysRequest{UserId: &userID})
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("list api keys: %w", err)
|
||||||
|
}
|
||||||
|
deleted := 0
|
||||||
|
for _, key := range resp.Items {
|
||||||
|
fingerprint := deref(key.Fingerprint)
|
||||||
|
if !includeCurrent && userID == cred.UserOCID && fingerprint == cred.Fingerprint {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, err := ic.DeleteApiKey(ctx, identity.DeleteApiKeyRequest{UserId: &userID, Fingerprint: &fingerprint})
|
||||||
|
if err != nil {
|
||||||
|
return deleted, fmt.Errorf("delete api key %s: %w", fingerprint, err)
|
||||||
|
}
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
return deleted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scimUserID 用经典 OCID 在 Identity Domain 中解析 SCIM 用户 ID。
|
||||||
|
func scimUserID(ctx context.Context, dc identitydomains.IdentityDomainsClient, classicOCID string) (string, error) {
|
||||||
|
u, found, err := domainUserByOcid(ctx, dc, classicOCID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return "", fmt.Errorf("user %s not found in identity domain", classicOCID)
|
||||||
|
}
|
||||||
|
return *u.Id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// domainUserByOcid 用经典 OCID 查域用户;不存在时 found 为 false 而非报错。
|
||||||
|
// extraAttrs 用于按需追加 groups、appRoles 等重量级属性。
|
||||||
|
func domainUserByOcid(ctx context.Context, dc identitydomains.IdentityDomainsClient, classicOCID string, extraAttrs ...string) (identitydomains.User, bool, error) {
|
||||||
|
filter := fmt.Sprintf("ocid eq %q", classicOCID)
|
||||||
|
attrs := strings.Join(append([]string{"id,ocid,userName,description,name,emails"}, extraAttrs...), ",")
|
||||||
|
count := 1
|
||||||
|
resp, err := dc.ListUsers(ctx, identitydomains.ListUsersRequest{
|
||||||
|
Filter: &filter,
|
||||||
|
Attributes: &attrs,
|
||||||
|
Count: &count,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return identitydomains.User{}, false, fmt.Errorf("find domain user by ocid: %w", err)
|
||||||
|
}
|
||||||
|
if len(resp.Resources) == 0 || resp.Resources[0].Id == nil {
|
||||||
|
return identitydomains.User{}, false, nil
|
||||||
|
}
|
||||||
|
return resp.Resources[0], true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isClassicUnsupported 判断经典 IAM API 对该用户不适用(仅存在于 Identity Domain)。
|
||||||
|
func isClassicUnsupported(err error) bool {
|
||||||
|
var svcErr common.ServiceError
|
||||||
|
if errors.As(err, &svcErr) {
|
||||||
|
return svcErr.GetHTTPStatusCode() == 404 || svcErr.GetHTTPStatusCode() == 400
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// passwordCharsets 保证生成的密码覆盖四类字符,满足常见复杂度策略。
|
||||||
|
var passwordCharsets = []string{
|
||||||
|
"ABCDEFGHJKLMNPQRSTUVWXYZ",
|
||||||
|
"abcdefghijkmnpqrstuvwxyz",
|
||||||
|
"23456789",
|
||||||
|
"#-_+!",
|
||||||
|
}
|
||||||
|
|
||||||
|
// generatePassword 生成 16 位随机密码,每类字符至少出现一次。
|
||||||
|
func generatePassword() (string, error) {
|
||||||
|
const length = 16
|
||||||
|
all := strings.Join(passwordCharsets, "")
|
||||||
|
out := make([]byte, 0, length)
|
||||||
|
for _, set := range passwordCharsets {
|
||||||
|
ch, err := randomChar(set)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out = append(out, ch)
|
||||||
|
}
|
||||||
|
for len(out) < length {
|
||||||
|
ch, err := randomChar(all)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out = append(out, ch)
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomChar(set string) (byte, error) {
|
||||||
|
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(set))))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("generate password: %w", err)
|
||||||
|
}
|
||||||
|
return set[n.Int64()], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toTenantUser(u identity.User, currentUserOCID string) TenantUser {
|
||||||
|
return TenantUser{
|
||||||
|
ID: deref(u.Id),
|
||||||
|
Name: deref(u.Name),
|
||||||
|
Description: deref(u.Description),
|
||||||
|
Email: deref(u.Email),
|
||||||
|
EmailVerified: u.EmailVerified != nil && *u.EmailVerified,
|
||||||
|
MfaActivated: u.IsMfaActivated != nil && *u.IsMfaActivated,
|
||||||
|
LifecycleState: string(u.LifecycleState),
|
||||||
|
IsCurrentUser: deref(u.Id) == currentUserOCID,
|
||||||
|
TimeCreated: sdkTime(u.TimeCreated),
|
||||||
|
LastLoginTime: sdkTime(u.LastSuccessfulLoginTime),
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user