初始提交:OCI 面板后端(含 GenAI 网关一期)

This commit is contained in:
Wang Defa
2026-07-09 15:31:04 +08:00
commit b9a3e97e84
168 changed files with 31794 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# 构建产物
/bin/
*.exe
*.test
*.out
/onsspike
# 本地数据与环境
*.db
.env
# IDE
.idea/
.DS_Store
BIN
View File
Binary file not shown.
+90
View File
@@ -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)
}
+63
View File
@@ -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
)
+149
View File
@@ -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=
+165
View File
@@ -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"`
}
+31
View File
@@ -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"`
}
+282
View File
@@ -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"`
}
+219
View File
@@ -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"`
}
+25
View File
@@ -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,
})
}
+225
View File
@@ -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
}
+461
View File
@@ -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()
}
+263
View File
@@ -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)
}
+93
View File
@@ -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,
})
}
+287
View File
@@ -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)
}
+57
View File
@@ -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)
}
+2
View File
@@ -0,0 +1,2 @@
// Package api 承载 HTTP 路由、参数绑定、响应和中间件。
package api
+173
View File
@@ -0,0 +1,173 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"oci-portal/internal/oci"
)
// ---- FederationSAML 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)
}
+263
View File
@@ -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)
}
+128
View File
@@ -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)
}
+125
View File
@@ -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
}
+367
View File
@@ -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)
}
+238
View File
@@ -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)
}
+17
View File
@@ -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)
}
+115
View File
@@ -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)
}
}
+82
View File
@@ -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()
}
}
+62
View File
@@ -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
}
+36
View File
@@ -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)
}
})
}
}
+82
View File
@@ -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)
}
+217
View File
@@ -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
}
+185
View File
@@ -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)
}
})
}
}
+186
View File
@@ -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)
}
+46
View File
@@ -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)
}
+31
View File
@@ -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})
}
+133
View File
@@ -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)
}
+364
View File
@@ -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)
}
+193
View File
@@ -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
}
}
}
+148
View File
@@ -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
}
+252
View File
@@ -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)
}
})
}
}
+129
View File
@@ -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
}
+95
View File
@@ -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)
}
}
+45
View File
@@ -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
}
+2
View File
@@ -0,0 +1,2 @@
// Package config 负责读取环境变量并生成运行配置。
package config
+60
View File
@@ -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
}
+69
View File
@@ -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")
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package crypto 提供敏感字段的 AES-GCM 加解密。
package crypto
+32
View File
@@ -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
}
+2
View File
@@ -0,0 +1,2 @@
// Package database 管理 SQLite 连接和 AutoMigrate。
package database
+2
View File
@@ -0,0 +1,2 @@
// Package model 定义 GORM 数据模型。
package model
+323
View File
@@ -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"`
}
+43
View File
@@ -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
}
+38
View File
@@ -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)
}
})
}
}
+328
View File
@@ -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 实现 ClientavailabilityDomain 为空时列出全区域块卷。
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
}
+179
View File
@@ -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)
}
})
}
+144
View File
@@ -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 比较两个 DTOEventTime 按值比较,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)
}
})
}
}
+292
View File
@@ -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 实现 ClientavailabilityDomain 为空时列出全区域引导卷。
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
}
+285
View File
@@ -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
}
+88
View File
@@ -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)
}
}
+288
View File
@@ -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)
// FederationSAML 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 → shapeCacheEntryshape 清单缓存
}
// 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
}
+64
View File
@@ -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
}
+111
View File
@@ -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),
}
}
+108
View File
@@ -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
}
+98
View File
@@ -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 标准 configini)文本的 [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
}
}
+79
View File
@@ -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)
}
})
}
}
+304
View File
@@ -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-500replace 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: &current.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
}
+78
View File
@@ -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 匹配消息中的完整 OCIDunique 段 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
}
+111
View File
@@ -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)
}
})
}
}
+438
View File
@@ -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) 外链 URLIDCS 拒绝 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
}
+276
View File
@@ -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
}
+266
View File
@@ -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), &params) != nil || params == nil {
params = map[string]any{}
}
call := generativeaiinference.CohereToolCall{Name: &name, Parameters: &params}
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: &params}
}
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},
}
}
+367
View File
@@ -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, &params) == nil {
fd.Parameters = &params
}
}
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
}
+40
View File
@@ -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("正常模型应入池")
}
}
+193
View File
@@ -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)
}
}
+100
View File
@@ -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
}
+452
View File
@@ -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-initbase64 编码;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 实现 ClientavailabilityDomain 为空时默认使用第一个
// 可用域(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 实现 ClientpreserveBootVolume 为 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
}
+181
View File
@@ -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)
}
}
+314
View File
@@ -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"}
}
+121
View File
@@ -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)
}
}
})
}
}
+392
View File
@@ -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
}
+37
View File
@@ -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)
}
}
+733
View File
@@ -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 实现 ClientvcnID 为空时列出全部子网。
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 实现 ClientvcnID 为空时列出全部安全列表。
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
}
+75
View File
@@ -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}
}
+132
View File
@@ -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)
}
+229
View File
@@ -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)
}
}
}
+54
View File
@@ -0,0 +1,54 @@
package oci
import (
_ "embed"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
)
// regionsJSON 是本地维护的 OCI 区域表。官方 APIListRegions、
// 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, &regions); 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
}
+46
View File
@@ -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" }
]
+47
View File
@@ -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)
}
})
}
}
+59
View File
@@ -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
}
+62
View File
@@ -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)
}
})
}
}
+115
View File
@@ -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 解引用 *float32nil 时返回零值。
func deref32(p *float32) float32 {
if p == nil {
return 0
}
return *p
}
+273
View File
@@ -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})
}
+159
View File
@@ -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
}
+361
View File
@@ -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: &regionKey,
},
})
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
}
+65
View File
@@ -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])
}
}
})
}
}
+736
View File
@@ -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