commit b9a3e97e84465d0749ceb9f46417ad35dc88b5f8 Author: Wang Defa <61809431+wangdefaa@users.noreply.github.com> Date: Thu Jul 9 15:31:04 2026 +0800 初始提交:OCI 面板后端(含 GenAI 网关一期) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66d66fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# 构建产物 +/bin/ +*.exe +*.test +*.out +/onsspike + +# 本地数据与环境 +*.db +.env + +# IDE +.idea/ +.DS_Store diff --git a/cmd/server/__debug_bin3590843752 b/cmd/server/__debug_bin3590843752 new file mode 100755 index 0000000..7c23b29 Binary files /dev/null and b/cmd/server/__debug_bin3590843752 differ diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..8c610e8 --- /dev/null +++ b/cmd/server/main.go @@ -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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6df3599 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..05a480d --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/aiwire/anthropic.go b/internal/aiwire/anthropic.go new file mode 100644 index 0000000..57e9823 --- /dev/null +++ b/internal/aiwire/anthropic.go @@ -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"` +} diff --git a/internal/aiwire/embeddings.go b/internal/aiwire/embeddings.go new file mode 100644 index 0000000..d1d6b06 --- /dev/null +++ b/internal/aiwire/embeddings.go @@ -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"` +} diff --git a/internal/aiwire/openai.go b/internal/aiwire/openai.go new file mode 100644 index 0000000..c986b7f --- /dev/null +++ b/internal/aiwire/openai.go @@ -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"` +} diff --git a/internal/aiwire/responses.go b/internal/aiwire/responses.go new file mode 100644 index 0000000..8d92052 --- /dev/null +++ b/internal/aiwire/responses.go @@ -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"` +} diff --git a/internal/api/about.go b/internal/api/about.go new file mode 100644 index 0000000..cde2540 --- /dev/null +++ b/internal/api/about.go @@ -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, + }) +} diff --git a/internal/api/aiadmin.go b/internal/api/aiadmin.go new file mode 100644 index 0000000..951f3d6 --- /dev/null +++ b/internal/api/aiadmin.go @@ -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 +} diff --git a/internal/api/aigateway.go b/internal/api/aigateway.go new file mode 100644 index 0000000..0628342 --- /dev/null +++ b/internal/api/aigateway.go @@ -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() +} diff --git a/internal/api/attachment.go b/internal/api/attachment.go new file mode 100644 index 0000000..717deff --- /dev/null +++ b/internal/api/attachment.go @@ -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) +} diff --git a/internal/api/auth.go b/internal/api/auth.go new file mode 100644 index 0000000..355ee1e --- /dev/null +++ b/internal/api/auth.go @@ -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, + }) +} diff --git a/internal/api/authx.go b/internal/api/authx.go new file mode 100644 index 0000000..b13e463 --- /dev/null +++ b/internal/api/authx.go @@ -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) +} diff --git a/internal/api/console.go b/internal/api/console.go new file mode 100644 index 0000000..7cfe0d9 --- /dev/null +++ b/internal/api/console.go @@ -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) +} diff --git a/internal/api/doc.go b/internal/api/doc.go new file mode 100644 index 0000000..6c0d2ce --- /dev/null +++ b/internal/api/doc.go @@ -0,0 +1,2 @@ +// Package api 承载 HTTP 路由、参数绑定、响应和中间件。 +package api diff --git a/internal/api/federation.go b/internal/api/federation.go new file mode 100644 index 0000000..9a1e470 --- /dev/null +++ b/internal/api/federation.go @@ -0,0 +1,173 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "oci-portal/internal/oci" +) + +// ---- Federation:SAML IdP 与 sign-on 免 MFA ---- + +// createIdpRequest 的映射 / JIT 字段全部可缺省:布尔用指针区分「未传」, +// 缺省即控制台默认(JIT 开启建用户不更新、分配 Administrators 组、 +// 名称 ID 格式无、NameID 映射用户名)。 +type createIdpRequest struct { + Name string `json:"name" binding:"required"` + Metadata string `json:"metadata" binding:"required"` + Description string `json:"description"` + IconURL string `json:"iconUrl"` + + NameIDFormat string `json:"nameIdFormat"` + AssertionAttribute string `json:"assertionAttribute"` + UserStoreAttribute string `json:"userStoreAttribute"` + + JitEnabled *bool `json:"jitEnabled"` + JitCreate *bool `json:"jitCreate"` + JitUpdate *bool `json:"jitUpdate"` + JitAssignAdminGroup *bool `json:"jitAssignAdminGroup"` + JitMapEmail bool `json:"jitMapEmail"` +} + +func boolOr(v *bool, def bool) bool { + if v == nil { + return def + } + return *v +} + +func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + idps, err := h.svc.IdentityProviders(c.Request.Context(), id) + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, idps) +} + +func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + var req createIdpRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + idp, err := h.svc.CreateIdentityProvider(c.Request.Context(), id, oci.CreateIdpInput{ + Name: req.Name, + Metadata: req.Metadata, + Description: req.Description, + IconURL: req.IconURL, + NameIDFormat: req.NameIDFormat, + AssertionAttribute: req.AssertionAttribute, + UserStoreAttribute: req.UserStoreAttribute, + JitEnabled: boolOr(req.JitEnabled, true), + JitCreate: boolOr(req.JitCreate, true), + JitUpdate: boolOr(req.JitUpdate, false), + JitAssignAdminGroup: boolOr(req.JitAssignAdminGroup, true), + JitMapEmail: req.JitMapEmail, + }) + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusCreated, idp) +} + +func (h *ociConfigHandler) activateIdentityProvider(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + var req struct { + Enabled *bool `json:"enabled" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + idp, err := h.svc.SetIdentityProviderEnabled(c.Request.Context(), id, c.Param("idpId"), *req.Enabled) + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, idp) +} + +func (h *ociConfigHandler) deleteIdentityProvider(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + if err := h.svc.DeleteIdentityProvider(c.Request.Context(), id, c.Param("idpId")); err != nil { + respondError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + metadata, err := h.svc.DomainSamlMetadata(c.Request.Context(), id) + if err != nil { + respondError(c, err) + return + } + c.Header("Content-Disposition", `attachment; filename="oci-domain-saml-metadata.xml"`) + c.Data(http.StatusOK, "application/xml", metadata) +} + +func (h *ociConfigHandler) listSignOnRules(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + rules, err := h.svc.ConsoleSignOnRules(c.Request.Context(), id) + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, rules) +} + +func (h *ociConfigHandler) createMfaExemption(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + var req struct { + IdentityProviderID string `json:"identityProviderId" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + rule, err := h.svc.CreateMfaExemption(c.Request.Context(), id, req.IdentityProviderID) + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusCreated, rule) +} + +func (h *ociConfigHandler) deleteMfaExemption(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + if err := h.svc.DeleteMfaExemption(c.Request.Context(), id, c.Param("ruleId")); err != nil { + respondError(c, err) + return + } + c.Status(http.StatusNoContent) +} diff --git a/internal/api/instance.go b/internal/api/instance.go new file mode 100644 index 0000000..7e9bb8c --- /dev/null +++ b/internal/api/instance.go @@ -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) +} diff --git a/internal/api/logevent.go b/internal/api/logevent.go new file mode 100644 index 0000000..9a69c85 --- /dev/null +++ b/internal/api/logevent.go @@ -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) +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..7364b31 --- /dev/null +++ b/internal/api/middleware.go @@ -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 +} diff --git a/internal/api/network.go b/internal/api/network.go new file mode 100644 index 0000000..ada086e --- /dev/null +++ b/internal/api/network.go @@ -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) +} diff --git a/internal/api/ociconfig.go b/internal/api/ociconfig.go new file mode 100644 index 0000000..dbd0fc3 --- /dev/null +++ b/internal/api/ociconfig.go @@ -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) +} diff --git a/internal/api/overview.go b/internal/api/overview.go new file mode 100644 index 0000000..00cb508 --- /dev/null +++ b/internal/api/overview.go @@ -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) +} diff --git a/internal/api/proxy.go b/internal/api/proxy.go new file mode 100644 index 0000000..ff5fbb6 --- /dev/null +++ b/internal/api/proxy.go @@ -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) + } +} diff --git a/internal/api/ratelimit.go b/internal/api/ratelimit.go new file mode 100644 index 0000000..da4c181 --- /dev/null +++ b/internal/api/ratelimit.go @@ -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() + } +} diff --git a/internal/api/realip.go b/internal/api/realip.go new file mode 100644 index 0000000..90f7a3b --- /dev/null +++ b/internal/api/realip.go @@ -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 +} diff --git a/internal/api/realip_test.go b/internal/api/realip_test.go new file mode 100644 index 0000000..d475732 --- /dev/null +++ b/internal/api/realip_test.go @@ -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) + } + }) + } +} diff --git a/internal/api/region.go b/internal/api/region.go new file mode 100644 index 0000000..1e433e3 --- /dev/null +++ b/internal/api/region.go @@ -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) +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..75cd5db --- /dev/null +++ b/internal/api/router.go @@ -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 +} diff --git a/internal/api/router_test.go b/internal/api/router_test.go new file mode 100644 index 0000000..3cab282 --- /dev/null +++ b/internal/api/router_test.go @@ -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) + } + }) + } +} diff --git a/internal/api/settings.go b/internal/api/settings.go new file mode 100644 index 0000000..f7fb84c --- /dev/null +++ b/internal/api/settings.go @@ -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) +} diff --git a/internal/api/subscription.go b/internal/api/subscription.go new file mode 100644 index 0000000..3fc5ba9 --- /dev/null +++ b/internal/api/subscription.go @@ -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) +} diff --git a/internal/api/systemlog.go b/internal/api/systemlog.go new file mode 100644 index 0000000..4f780e5 --- /dev/null +++ b/internal/api/systemlog.go @@ -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}) +} diff --git a/internal/api/task.go b/internal/api/task.go new file mode 100644 index 0000000..1e77e8b --- /dev/null +++ b/internal/api/task.go @@ -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) +} diff --git a/internal/api/tenant.go b/internal/api/tenant.go new file mode 100644 index 0000000..e8d767b --- /dev/null +++ b/internal/api/tenant.go @@ -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) +} diff --git a/internal/api/webconsole.go b/internal/api/webconsole.go new file mode 100644 index 0000000..23abd59 --- /dev/null +++ b/internal/api/webconsole.go @@ -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 + } + } +} diff --git a/internal/api/webhook.go b/internal/api/webhook.go new file mode 100644 index 0000000..eb9c0cb --- /dev/null +++ b/internal/api/webhook.go @@ -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 +} diff --git a/internal/api/webhook_test.go b/internal/api/webhook_test.go new file mode 100644 index 0000000..3029aaf --- /dev/null +++ b/internal/api/webhook_test.go @@ -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) + } + }) + } +} diff --git a/internal/cache/ttlcache.go b/internal/cache/ttlcache.go new file mode 100644 index 0000000..a431940 --- /dev/null +++ b/internal/cache/ttlcache.go @@ -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 +} diff --git a/internal/cache/ttlcache_test.go b/internal/cache/ttlcache_test.go new file mode 100644 index 0000000..471b243 --- /dev/null +++ b/internal/cache/ttlcache_test.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..73bb492 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/doc.go b/internal/config/doc.go new file mode 100644 index 0000000..3fc222c --- /dev/null +++ b/internal/config/doc.go @@ -0,0 +1,2 @@ +// Package config 负责读取环境变量并生成运行配置。 +package config diff --git a/internal/crypto/aead.go b/internal/crypto/aead.go new file mode 100644 index 0000000..2de3186 --- /dev/null +++ b/internal/crypto/aead.go @@ -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 +} diff --git a/internal/crypto/aead_test.go b/internal/crypto/aead_test.go new file mode 100644 index 0000000..fbd0892 --- /dev/null +++ b/internal/crypto/aead_test.go @@ -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") + } +} diff --git a/internal/crypto/doc.go b/internal/crypto/doc.go new file mode 100644 index 0000000..5149b88 --- /dev/null +++ b/internal/crypto/doc.go @@ -0,0 +1,2 @@ +// Package crypto 提供敏感字段的 AES-GCM 加解密。 +package crypto diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..4ad5227 --- /dev/null +++ b/internal/database/database.go @@ -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 +} diff --git a/internal/database/doc.go b/internal/database/doc.go new file mode 100644 index 0000000..e2d28a2 --- /dev/null +++ b/internal/database/doc.go @@ -0,0 +1,2 @@ +// Package database 管理 SQLite 连接和 AutoMigrate。 +package database diff --git a/internal/model/doc.go b/internal/model/doc.go new file mode 100644 index 0000000..008a210 --- /dev/null +++ b/internal/model/doc.go @@ -0,0 +1,2 @@ +// Package model 定义 GORM 数据模型。 +package model diff --git a/internal/model/models.go b/internal/model/models.go new file mode 100644 index 0000000..ce8cddb --- /dev/null +++ b/internal/model/models.go @@ -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"` +} diff --git a/internal/oci/account.go b/internal/oci/account.go new file mode 100644 index 0000000..3194e22 --- /dev/null +++ b/internal/oci/account.go @@ -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 +} diff --git a/internal/oci/account_test.go b/internal/oci/account_test.go new file mode 100644 index 0000000..98c5d54 --- /dev/null +++ b/internal/oci/account_test.go @@ -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) + } + }) + } +} diff --git a/internal/oci/attachment.go b/internal/oci/attachment.go new file mode 100644 index 0000000..4c02c26 --- /dev/null +++ b/internal/oci/attachment.go @@ -0,0 +1,328 @@ +package oci + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/core" +) + +// BootVolumeAttachment 是引导卷与实例的挂载关系。 +type BootVolumeAttachment struct { + ID string `json:"id"` + InstanceID string `json:"instanceId"` + BootVolumeID string `json:"bootVolumeId"` + LifecycleState string `json:"lifecycleState"` + DisplayName string `json:"displayName"` + AvailabilityDomain string `json:"availabilityDomain"` +} + +// VolumeAttachment 是块存储卷与实例的挂载关系。 +type VolumeAttachment struct { + ID string `json:"id"` + InstanceID string `json:"instanceId"` + VolumeID string `json:"volumeId"` + VolumeName string `json:"volumeName,omitempty"` // 卷本身的名称(attachment 名是自动生成的) + LifecycleState string `json:"lifecycleState"` + DisplayName string `json:"displayName"` + Device string `json:"device"` + IsReadOnly bool `json:"isReadOnly"` + AvailabilityDomain string `json:"availabilityDomain"` +} + +// BlockVolume 是一个块存储卷。 +type BlockVolume struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + LifecycleState string `json:"lifecycleState"` + AvailabilityDomain string `json:"availabilityDomain"` + SizeInGBs int64 `json:"sizeInGBs"` + VpusPerGB int64 `json:"vpusPerGB"` + TimeCreated *time.Time `json:"timeCreated"` +} + +// ---- 引导卷挂载 ---- + +// ListBootVolumeAttachments 实现 Client:列出实例的引导卷挂载。 +func (c *RealClient) ListBootVolumeAttachments(ctx context.Context, cred Credentials, region, instanceID string) ([]BootVolumeAttachment, error) { + cc, err := c.computeClient(cred, region) + if err != nil { + return nil, err + } + instResp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID}) + if err != nil { + return nil, fmt.Errorf("get instance %s: %w", instanceID, err) + } + // 挂载记录在实例所在 compartment,复用上面查到的实例定位,传租户根会漏查 + resp, err := cc.ListBootVolumeAttachments(ctx, core.ListBootVolumeAttachmentsRequest{ + AvailabilityDomain: instResp.AvailabilityDomain, + CompartmentId: hostCompartment(cred, deref(instResp.CompartmentId)), + InstanceId: &instanceID, + }) + if err != nil { + return nil, fmt.Errorf("list boot volume attachments: %w", err) + } + atts := make([]BootVolumeAttachment, 0, len(resp.Items)) + for _, item := range resp.Items { + atts = append(atts, toBootVolumeAttachment(item)) + } + return atts, nil +} + +// AttachBootVolume 实现 Client;实例需处于 STOPPED,引导卷与实例同可用域。 +func (c *RealClient) AttachBootVolume(ctx context.Context, cred Credentials, region, instanceID, bootVolumeID string) (BootVolumeAttachment, error) { + cc, err := c.computeClient(cred, region) + if err != nil { + return BootVolumeAttachment{}, err + } + resp, err := cc.AttachBootVolume(ctx, core.AttachBootVolumeRequest{ + AttachBootVolumeDetails: core.AttachBootVolumeDetails{ + InstanceId: &instanceID, + BootVolumeId: &bootVolumeID, + }, + }) + if err != nil { + return BootVolumeAttachment{}, fmt.Errorf("attach boot volume: %w", err) + } + return toBootVolumeAttachment(resp.BootVolumeAttachment), nil +} + +// DetachBootVolume 实现 Client;实例需处于 STOPPED。 +func (c *RealClient) DetachBootVolume(ctx context.Context, cred Credentials, region, attachmentID string) error { + cc, err := c.computeClient(cred, region) + if err != nil { + return err + } + if _, err := cc.DetachBootVolume(ctx, core.DetachBootVolumeRequest{BootVolumeAttachmentId: &attachmentID}); err != nil { + return fmt.Errorf("detach boot volume %s: %w", attachmentID, err) + } + return nil +} + +// ReplaceBootVolume 实现 Client:分离实例当前引导卷,挂载新引导卷。 +// 实例必须处于 STOPPED,全程等待分离完成后再挂载。 +func (c *RealClient) ReplaceBootVolume(ctx context.Context, cred Credentials, region, instanceID, newBootVolumeID string) (BootVolumeAttachment, error) { + cc, err := c.computeClient(cred, region) + if err != nil { + return BootVolumeAttachment{}, err + } + instResp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID}) + if err != nil { + return BootVolumeAttachment{}, fmt.Errorf("get instance %s: %w", instanceID, err) + } + if instResp.LifecycleState != core.InstanceLifecycleStateStopped { + return BootVolumeAttachment{}, fmt.Errorf("replace boot volume: instance must be STOPPED, current %s", instResp.LifecycleState) + } + atts, err := c.ListBootVolumeAttachments(ctx, cred, region, instanceID) + if err != nil { + return BootVolumeAttachment{}, err + } + for _, att := range atts { + if att.LifecycleState != string(core.BootVolumeAttachmentLifecycleStateDetached) { + if err := c.DetachBootVolume(ctx, cred, region, att.ID); err != nil { + return BootVolumeAttachment{}, err + } + if err := waitBootVolumeDetached(ctx, cc, att.ID); err != nil { + return BootVolumeAttachment{}, err + } + } + } + return c.AttachBootVolume(ctx, cred, region, instanceID, newBootVolumeID) +} + +// waitBootVolumeDetached 轮询等待引导卷挂载进入 DETACHED; +// OCI 可能在分离完成后回收挂载记录,404 同样视为已分离。 +func waitBootVolumeDetached(ctx context.Context, cc core.ComputeClient, attachmentID string) error { + for i := 0; i < 60; i++ { + resp, err := cc.GetBootVolumeAttachment(ctx, core.GetBootVolumeAttachmentRequest{BootVolumeAttachmentId: &attachmentID}) + if err != nil { + var svcErr common.ServiceError + if errors.As(err, &svcErr) && svcErr.GetHTTPStatusCode() == 404 { + return nil + } + return fmt.Errorf("poll boot volume attachment: %w", err) + } + if resp.LifecycleState == core.BootVolumeAttachmentLifecycleStateDetached { + return nil + } + select { + case <-ctx.Done(): + return fmt.Errorf("poll boot volume detached: %w", ctx.Err()) + case <-time.After(3 * time.Second): + } + } + return fmt.Errorf("poll boot volume detached: timed out") +} + +// ---- 块卷挂载 ---- + +// ListVolumeAttachments 实现 Client:列出实例的块卷挂载。 +func (c *RealClient) ListVolumeAttachments(ctx context.Context, cred Credentials, region, instanceID string) ([]VolumeAttachment, error) { + cc, err := c.computeClient(cred, region) + if err != nil { + return nil, err + } + req := core.ListVolumeAttachmentsRequest{CompartmentId: cred.EffectiveCompartment()} + if instanceID != "" { + // 挂载记录在实例所在 compartment,先取实例定位,传租户根会漏查 + instResp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID}) + if err != nil { + return nil, fmt.Errorf("get instance %s: %w", instanceID, err) + } + req.CompartmentId = hostCompartment(cred, deref(instResp.CompartmentId)) + req.InstanceId = &instanceID + } + resp, err := cc.ListVolumeAttachments(ctx, req) + if err != nil { + return nil, fmt.Errorf("list volume attachments: %w", err) + } + atts := make([]VolumeAttachment, 0, len(resp.Items)) + for _, item := range resp.Items { + atts = append(atts, toVolumeAttachment(item)) + } + c.attachVolumeNames(ctx, cred, region, atts) + return atts, nil +} + +// attachVolumeNames 补全每条挂载的卷名(attachment 名是自动生成的无意义串); +// 引导卷可作数据卷附加,按 OCID 类型分别查询,查询失败留空不影响列表。 +func (c *RealClient) attachVolumeNames(ctx context.Context, cred Credentials, region string, atts []VolumeAttachment) { + bc, err := c.blockstorageClient(cred, region) + if err != nil { + return + } + names := map[string]string{} + for i := range atts { + id := atts[i].VolumeID + if id == "" { + continue + } + name, ok := names[id] + if !ok { + name = lookupVolumeName(ctx, bc, id) + names[id] = name + } + atts[i].VolumeName = name + } +} + +func lookupVolumeName(ctx context.Context, bc core.BlockstorageClient, id string) string { + if strings.Contains(id, ".bootvolume.") { + resp, err := bc.GetBootVolume(ctx, core.GetBootVolumeRequest{BootVolumeId: &id}) + if err != nil { + return "" + } + return deref(resp.DisplayName) + } + resp, err := bc.GetVolume(ctx, core.GetVolumeRequest{VolumeId: &id}) + if err != nil { + return "" + } + return deref(resp.DisplayName) +} + +// AttachVolume 实现 Client:以半虚拟化方式挂载块卷(无需 iSCSI 配置)。 +func (c *RealClient) AttachVolume(ctx context.Context, cred Credentials, region, instanceID, volumeID string, readOnly bool) (VolumeAttachment, error) { + cc, err := c.computeClient(cred, region) + if err != nil { + return VolumeAttachment{}, err + } + resp, err := cc.AttachVolume(ctx, core.AttachVolumeRequest{ + AttachVolumeDetails: core.AttachParavirtualizedVolumeDetails{ + InstanceId: &instanceID, + VolumeId: &volumeID, + IsReadOnly: &readOnly, + }, + }) + if err != nil { + return VolumeAttachment{}, fmt.Errorf("attach volume: %w", err) + } + return toVolumeAttachment(resp.VolumeAttachment), nil +} + +// DetachVolume 实现 Client。 +func (c *RealClient) DetachVolume(ctx context.Context, cred Credentials, region, attachmentID string) error { + cc, err := c.computeClient(cred, region) + if err != nil { + return err + } + if _, err := cc.DetachVolume(ctx, core.DetachVolumeRequest{VolumeAttachmentId: &attachmentID}); err != nil { + return fmt.Errorf("detach volume %s: %w", attachmentID, err) + } + return nil +} + +// ListBlockVolumes 实现 Client;availabilityDomain 为空时列出全区域块卷。 +func (c *RealClient) ListBlockVolumes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]BlockVolume, error) { + bc, err := c.blockstorageClient(cred, region) + if err != nil { + return nil, err + } + var volumes []BlockVolume + var page *string + for { + req := core.ListVolumesRequest{CompartmentId: cred.EffectiveCompartment(), Page: page} + if availabilityDomain != "" { + req.AvailabilityDomain = &availabilityDomain + } + resp, err := bc.ListVolumes(ctx, req) + if err != nil { + return nil, fmt.Errorf("list volumes: %w", err) + } + for _, item := range resp.Items { + volumes = append(volumes, toBlockVolume(item)) + } + if resp.OpcNextPage == nil { + return orEmpty(volumes), nil + } + page = resp.OpcNextPage + } +} + +func toBootVolumeAttachment(a core.BootVolumeAttachment) BootVolumeAttachment { + return BootVolumeAttachment{ + ID: deref(a.Id), + InstanceID: deref(a.InstanceId), + BootVolumeID: deref(a.BootVolumeId), + LifecycleState: string(a.LifecycleState), + DisplayName: deref(a.DisplayName), + AvailabilityDomain: deref(a.AvailabilityDomain), + } +} + +func toVolumeAttachment(a core.VolumeAttachment) VolumeAttachment { + out := VolumeAttachment{ + ID: deref(a.GetId()), + InstanceID: deref(a.GetInstanceId()), + VolumeID: deref(a.GetVolumeId()), + LifecycleState: string(a.GetLifecycleState()), + DisplayName: deref(a.GetDisplayName()), + Device: deref(a.GetDevice()), + AvailabilityDomain: deref(a.GetAvailabilityDomain()), + } + if ro := a.GetIsReadOnly(); ro != nil { + out.IsReadOnly = *ro + } + return out +} + +func toBlockVolume(v core.Volume) BlockVolume { + out := BlockVolume{ + ID: deref(v.Id), + DisplayName: deref(v.DisplayName), + LifecycleState: string(v.LifecycleState), + AvailabilityDomain: deref(v.AvailabilityDomain), + TimeCreated: sdkTime(v.TimeCreated), + } + if v.SizeInGBs != nil { + out.SizeInGBs = *v.SizeInGBs + } + if v.VpusPerGB != nil { + out.VpusPerGB = *v.VpusPerGB + } + return out +} diff --git a/internal/oci/audit.go b/internal/oci/audit.go new file mode 100644 index 0000000..fb30f50 --- /dev/null +++ b/internal/oci/audit.go @@ -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) + } + }) +} diff --git a/internal/oci/audit_test.go b/internal/oci/audit_test.go new file mode 100644 index 0000000..d986f78 --- /dev/null +++ b/internal/oci/audit_test.go @@ -0,0 +1,144 @@ +package oci + +import ( + "reflect" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/audit" + "github.com/oracle/oci-go-sdk/v65/common" +) + +func TestToAuditEvent(t *testing.T) { + eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC) + tests := []struct { + name string + ev audit.AuditEvent + want AuditEvent + }{ + { + name: "全字段齐全", + ev: audit.AuditEvent{ + EventId: common.String("evt-abc"), + Source: common.String("ComputeApi"), + EventTime: &common.SDKTime{Time: eventTime}, + Data: &audit.Data{ + EventName: common.String("TerminateInstance"), + ResourceName: common.String("web-1"), + CompartmentName: common.String("prod"), + Identity: &audit.Identity{ + PrincipalName: common.String("api-admin"), + IpAddress: common.String("1.2.3.4"), + }, + Request: &audit.Request{ + Action: common.String("DELETE"), + Path: common.String("/20160918/instances/ocid1..."), + }, + Response: &audit.Response{Status: common.String("204")}, + }, + }, + want: AuditEvent{ + EventId: "evt-abc", + EventTime: &eventTime, + EventName: "TerminateInstance", + Source: "ComputeApi", + ResourceName: "web-1", + CompartmentName: "prod", + PrincipalName: "api-admin", + IPAddress: "1.2.3.4", + Status: "204", + RequestAction: "DELETE", + RequestPath: "/20160918/instances/ocid1...", + }, + }, + { + name: "Data 为 nil 时只保留信封字段", + ev: audit.AuditEvent{ + Source: common.String("VcnApi"), + EventTime: &common.SDKTime{Time: eventTime}, + }, + want: AuditEvent{EventTime: &eventTime, Source: "VcnApi"}, + }, + { + name: "嵌套局部 nil 各自安全跳过", + ev: audit.AuditEvent{ + Data: &audit.Data{ + EventName: common.String("GetInstance"), + Identity: nil, + Request: &audit.Request{Path: common.String("/instances")}, + Response: nil, + }, + }, + want: AuditEvent{EventName: "GetInstance", RequestPath: "/instances"}, + }, + { + name: "空事件全部零值", + ev: audit.AuditEvent{}, + want: AuditEvent{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := toAuditEvent(tt.ev); !auditEventEqual(got, tt.want) { + t.Errorf("toAuditEvent() = %+v, want %+v", got, tt.want) + } + }) + } +} + +// auditEventEqual 比较两个 DTO:EventTime 按值比较,Raw 不参与,其余反射比较。 +func auditEventEqual(a, b AuditEvent) bool { + if (a.EventTime == nil) != (b.EventTime == nil) { + return false + } + if a.EventTime != nil && !a.EventTime.Equal(*b.EventTime) { + return false + } + a.EventTime, b.EventTime = nil, nil + a.Raw, b.Raw = nil, nil + return reflect.DeepEqual(a, b) +} + +func TestSortAuditEvents(t *testing.T) { + t1 := time.Date(2026, 7, 6, 8, 0, 0, 0, time.UTC) + t2 := time.Date(2026, 7, 6, 9, 0, 0, 0, time.UTC) + items := []AuditEvent{ + {EventName: "old", EventTime: &t1}, + {EventName: "no-time", EventTime: nil}, + {EventName: "new", EventTime: &t2}, + } + sortAuditEvents(items) + got := []string{items[0].EventName, items[1].EventName, items[2].EventName} + want := []string{"new", "old", "no-time"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("sortAuditEvents() order = %v, want %v", got, want) + } + } +} + +func TestKeepAuditEvent(t *testing.T) { + cases := []struct { + name string + event AuditEvent + keep bool + }{ + {"公网IP的普通事件保留", AuditEvent{EventName: "CreateUser", IPAddress: "203.0.113.7"}, true}, + {"SummarizeMetricsData 噪声排除", AuditEvent{EventName: "SummarizeMetricsData", IPAddress: "203.0.113.7"}, false}, + {"内网10段发起排除", AuditEvent{EventName: "ListInstances", IPAddress: "10.1.2.3"}, false}, + {"内网172.16段发起排除", AuditEvent{EventName: "ListInstances", IPAddress: "172.20.0.1"}, false}, + {"172.15不属内网保留", AuditEvent{EventName: "ListInstances", IPAddress: "172.15.0.1"}, true}, + {"内网192.168段发起排除", AuditEvent{EventName: "ListInstances", IPAddress: "192.168.1.1"}, false}, + {"CGNAT 100.64段排除", AuditEvent{EventName: "ListInstances", IPAddress: "100.100.0.1"}, false}, + {"100.128不属CGNAT保留", AuditEvent{EventName: "ListInstances", IPAddress: "100.128.0.1"}, true}, + {"IP为空保留(控制面事件)", AuditEvent{EventName: "TerminateInstance", IPAddress: ""}, true}, + {"IP不可解析保留", AuditEvent{EventName: "ListInstances", IPAddress: "not-an-ip"}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := keepAuditEvent(tc.event); got != tc.keep { + t.Fatalf("keepAuditEvent(%+v) = %v, want %v", tc.event, got, tc.keep) + } + }) + } +} diff --git a/internal/oci/bootvolume.go b/internal/oci/bootvolume.go new file mode 100644 index 0000000..ea653bb --- /dev/null +++ b/internal/oci/bootvolume.go @@ -0,0 +1,292 @@ +package oci + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/oracle/oci-go-sdk/v65/core" +) + +// BootVolume 是一个引导卷;引导卷由创建实例产生,本面板不单独创建。 +// AttachedInstanceID / AttachedInstanceName 与 ImageName 在列表与详情查询时都会补全。 +type BootVolume struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + LifecycleState string `json:"lifecycleState"` + AvailabilityDomain string `json:"availabilityDomain"` + CompartmentID string `json:"compartmentId"` + SizeInGBs int64 `json:"sizeInGBs"` + VpusPerGB int64 `json:"vpusPerGB"` + ImageID string `json:"imageId"` + ImageName string `json:"imageName,omitempty"` + AttachedInstanceID string `json:"attachedInstanceId,omitempty"` + AttachedInstanceName string `json:"attachedInstanceName,omitempty"` + TimeCreated *time.Time `json:"timeCreated"` +} + +// UpdateBootVolumeInput 是更新引导卷的输入;零值字段不更新。 +// SizeInGBs 只能扩容,VpusPerGB 调整卷性能(10 均衡 / 20 高性能)。 +type UpdateBootVolumeInput struct { + Region string + DisplayName string + SizeInGBs int64 + VpusPerGB int64 +} + +func (c *RealClient) blockstorageClient(cred Credentials, region string) (core.BlockstorageClient, error) { + bc, err := core.NewBlockstorageClientWithConfigurationProvider(provider(cred)) + if err != nil { + return bc, fmt.Errorf("new blockstorage client: %w", err) + } + applyProxy(&bc.BaseClient, cred) + if region != "" { + bc.SetRegion(normalizeRegion(region)) + } + return bc, nil +} + +// ListBootVolumes 实现 Client;availabilityDomain 为空时列出全区域引导卷。 +func (c *RealClient) ListBootVolumes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]BootVolume, error) { + bc, err := c.blockstorageClient(cred, region) + if err != nil { + return nil, err + } + var volumes []BootVolume + var page *string + for { + req := core.ListBootVolumesRequest{CompartmentId: cred.EffectiveCompartment(), Page: page} + if availabilityDomain != "" { + req.AvailabilityDomain = &availabilityDomain + } + resp, err := bc.ListBootVolumes(ctx, req) + if err != nil { + return nil, fmt.Errorf("list boot volumes: %w", err) + } + for _, item := range resp.Items { + volumes = append(volumes, toBootVolume(item)) + } + if resp.OpcNextPage == nil { + c.attachBootVolumeInstances(ctx, cred, region, volumes) + c.attachBootVolumeImages(ctx, cred, region, volumes) + return orEmpty(volumes), nil + } + page = resp.OpcNextPage + } +} + +// attachBootVolumeImages 批量补全来源镜像名:按 imageId 去重后逐个查询, +// 镜像已删除或查询失败时保持为空。 +func (c *RealClient) attachBootVolumeImages(ctx context.Context, cred Credentials, region string, volumes []BootVolume) { + names := map[string]string{} + for _, v := range volumes { + if v.ImageID != "" { + names[v.ImageID] = "" + } + } + for id := range names { + img, err := c.GetImage(ctx, cred, region, id) + if err != nil { + continue + } + names[id] = img.DisplayName + } + for i := range volumes { + volumes[i].ImageName = names[volumes[i].ImageID] + } +} + +// attachBootVolumeInstances 批量补全挂载实例:按 AD + compartment 分组各拉一次 +// 全量挂载记录(免逐卷查询),构建 bootVolumeId → instanceId 映射后回填。 +func (c *RealClient) attachBootVolumeInstances(ctx context.Context, cred Credentials, region string, volumes []BootVolume) { + cc, err := c.computeClient(cred, region) + if err != nil { + return + } + type scope struct{ ad, comp string } + attached := map[string]string{} + seen := map[scope]bool{} + for _, v := range volumes { + s := scope{v.AvailabilityDomain, v.CompartmentID} + if seen[s] { + continue + } + seen[s] = true + collectBootVolumeAttachments(ctx, cc, s.ad, hostCompartment(cred, s.comp), attached) + collectVolumeAttachedBootVolumes(ctx, cc, s.ad, hostCompartment(cred, s.comp), attached) + } + names := instanceNames(ctx, cc, attached) + for i := range volumes { + id := attached[volumes[i].ID] + volumes[i].AttachedInstanceID = id + volumes[i].AttachedInstanceName = names[id] + } +} + +// instanceNames 按去重后的实例 ID 批量查询显示名;查询失败的留空。 +func instanceNames(ctx context.Context, cc core.ComputeClient, attached map[string]string) map[string]string { + names := map[string]string{} + for _, instID := range attached { + if instID == "" || names[instID] != "" { + continue + } + resp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instID}) + if err != nil { + continue + } + names[instID] = deref(resp.DisplayName) + } + return names +} + +// collectVolumeAttachedBootVolumes 补齐「引导卷作数据卷附加」的挂载:这类挂载 +// 记录在块卷挂载列表(volumeId 为引导卷 OCID),引导卷挂载列表查不到。 +func collectVolumeAttachedBootVolumes(ctx context.Context, cc core.ComputeClient, ad string, compartmentID *string, out map[string]string) { + var page *string + for { + resp, err := cc.ListVolumeAttachments(ctx, core.ListVolumeAttachmentsRequest{ + AvailabilityDomain: &ad, + CompartmentId: compartmentID, + Page: page, + }) + if err != nil { + return + } + for _, att := range resp.Items { + id := deref(att.GetVolumeId()) + if att.GetLifecycleState() == core.VolumeAttachmentLifecycleStateAttached && + strings.Contains(id, ".bootvolume.") { + out[id] = deref(att.GetInstanceId()) + } + } + if resp.OpcNextPage == nil { + return + } + page = resp.OpcNextPage + } +} + +// collectBootVolumeAttachments 把一个 AD + compartment 下 ATTACHED 状态的挂载写入映射。 +func collectBootVolumeAttachments(ctx context.Context, cc core.ComputeClient, ad string, compartmentID *string, out map[string]string) { + var page *string + for { + resp, err := cc.ListBootVolumeAttachments(ctx, core.ListBootVolumeAttachmentsRequest{ + AvailabilityDomain: &ad, + CompartmentId: compartmentID, + Page: page, + }) + if err != nil { + return + } + for _, att := range resp.Items { + if att.LifecycleState == core.BootVolumeAttachmentLifecycleStateAttached { + out[deref(att.BootVolumeId)] = deref(att.InstanceId) + } + } + if resp.OpcNextPage == nil { + return + } + page = resp.OpcNextPage + } +} + +// GetBootVolume 实现 Client:查询引导卷并补全挂载的实例。 +func (c *RealClient) GetBootVolume(ctx context.Context, cred Credentials, region, bootVolumeID string) (BootVolume, error) { + bc, err := c.blockstorageClient(cred, region) + if err != nil { + return BootVolume{}, err + } + resp, err := bc.GetBootVolume(ctx, core.GetBootVolumeRequest{BootVolumeId: &bootVolumeID}) + if err != nil { + return BootVolume{}, fmt.Errorf("get boot volume %s: %w", bootVolumeID, err) + } + volume := toBootVolume(resp.BootVolume) + volume.AttachedInstanceID = c.bootVolumeAttachedInstance(ctx, cred, region, volume) + if volume.ImageID != "" { + if img, err := c.GetImage(ctx, cred, region, volume.ImageID); err == nil { + volume.ImageName = img.DisplayName + } + } + return volume, nil +} + +// bootVolumeAttachedInstance 查询引导卷当前挂载的实例;查询失败视为未挂载。 +func (c *RealClient) bootVolumeAttachedInstance(ctx context.Context, cred Credentials, region string, volume BootVolume) string { + cc, err := c.computeClient(cred, region) + if err != nil { + return "" + } + // 挂载记录在引导卷所在 compartment,传租户根会漏掉子 compartment 的挂载 + resp, err := cc.ListBootVolumeAttachments(ctx, core.ListBootVolumeAttachmentsRequest{ + AvailabilityDomain: &volume.AvailabilityDomain, + CompartmentId: hostCompartment(cred, volume.CompartmentID), + BootVolumeId: &volume.ID, + }) + if err != nil { + return "" + } + for _, att := range resp.Items { + if att.LifecycleState == core.BootVolumeAttachmentLifecycleStateAttached { + return deref(att.InstanceId) + } + } + return "" +} + +// UpdateBootVolume 实现 Client。 +func (c *RealClient) UpdateBootVolume(ctx context.Context, cred Credentials, bootVolumeID string, in UpdateBootVolumeInput) (BootVolume, error) { + bc, err := c.blockstorageClient(cred, in.Region) + if err != nil { + return BootVolume{}, err + } + details := core.UpdateBootVolumeDetails{} + if in.DisplayName != "" { + details.DisplayName = &in.DisplayName + } + if in.SizeInGBs > 0 { + details.SizeInGBs = &in.SizeInGBs + } + if in.VpusPerGB > 0 { + details.VpusPerGB = &in.VpusPerGB + } + resp, err := bc.UpdateBootVolume(ctx, core.UpdateBootVolumeRequest{ + BootVolumeId: &bootVolumeID, + UpdateBootVolumeDetails: details, + }) + if err != nil { + return BootVolume{}, fmt.Errorf("update boot volume %s: %w", bootVolumeID, err) + } + return toBootVolume(resp.BootVolume), nil +} + +// DeleteBootVolume 实现 Client;仅未挂载(detached)的引导卷可删除。 +func (c *RealClient) DeleteBootVolume(ctx context.Context, cred Credentials, region, bootVolumeID string) error { + bc, err := c.blockstorageClient(cred, region) + if err != nil { + return err + } + if _, err := bc.DeleteBootVolume(ctx, core.DeleteBootVolumeRequest{BootVolumeId: &bootVolumeID}); err != nil { + return fmt.Errorf("delete boot volume %s: %w", bootVolumeID, err) + } + return nil +} + +func toBootVolume(v core.BootVolume) BootVolume { + out := BootVolume{ + ID: deref(v.Id), + DisplayName: deref(v.DisplayName), + LifecycleState: string(v.LifecycleState), + AvailabilityDomain: deref(v.AvailabilityDomain), + CompartmentID: deref(v.CompartmentId), + ImageID: deref(v.ImageId), + TimeCreated: sdkTime(v.TimeCreated), + } + if v.SizeInGBs != nil { + out.SizeInGBs = *v.SizeInGBs + } + if v.VpusPerGB != nil { + out.VpusPerGB = *v.VpusPerGB + } + return out +} diff --git a/internal/oci/cached.go b/internal/oci/cached.go new file mode 100644 index 0000000..1d21e91 --- /dev/null +++ b/internal/oci/cached.go @@ -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 +} diff --git a/internal/oci/cached_test.go b/internal/oci/cached_test.go new file mode 100644 index 0000000..1d0bf6c --- /dev/null +++ b/internal/oci/cached_test.go @@ -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) + } +} diff --git a/internal/oci/client.go b/internal/oci/client.go new file mode 100644 index 0000000..de1070d --- /dev/null +++ b/internal/oci/client.go @@ -0,0 +1,288 @@ +// Package oci 封装 Oracle Cloud Infrastructure 官方 Go SDK, +// 对上层提供统一的客户端抽象;真实云请求只允许出现在本包。 +package oci + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/identity" + + "oci-portal/internal/aiwire" +) + +// cloudcmServiceName 是 OCI 云消费(Cloud Consumption Model)订阅的服务名, +// 账户类别信息挂在该订阅之下。 +const cloudcmServiceName = "CLOUDCM" + +// ErrNoCloudcmSubscription 表示租户下没有 serviceName=CLOUDCM 的订阅。 +var ErrNoCloudcmSubscription = errors.New("no CLOUDCM subscription found") + +// TenancyInfo 是测活成功后取得的租户基本信息。 +type TenancyInfo struct { + Name string + HomeRegionKey string +} + +// Client 是真实 OCI 调用的统一入口,service 层只依赖此接口。 +type Client interface { + // ValidateKey 用 Identity GetTenancy 验证凭据有效性(测活)。 + ValidateKey(ctx context.Context, cred Credentials) (TenancyInfo, error) + // FetchAccountProfile 查询 CLOUDCM 订阅并判定账户类别。 + FetchAccountProfile(ctx context.Context, cred Credentials) (AccountProfile, error) + // ListCompartments 递归列出租户下全部 ACTIVE compartment(不含租户根)。 + ListCompartments(ctx context.Context, cred Credentials) ([]Compartment, error) + // ListRegionSubscriptions 列出租户已订阅的区域。 + ListRegionSubscriptions(ctx context.Context, cred Credentials) ([]RegionSubscription, error) + // SubscribeRegion 为租户订阅新区域;请求发往 homeRegion,操作不可撤销。 + SubscribeRegion(ctx context.Context, cred Credentials, homeRegion, regionKey string) error + // ListLimits 查询服务配额,name 为大小写不敏感的模糊过滤,按需附带用量。 + ListLimits(ctx context.Context, cred Credentials, q LimitsQuery) ([]LimitValue, error) + // ListLimitServices 列出配额体系支持的全部服务。 + ListLimitServices(ctx context.Context, cred Credentials, region string) ([]LimitService, error) + // ListSubscriptions 列出租户全部订阅摘要。 + ListSubscriptions(ctx context.Context, cred Credentials) ([]SubscriptionInfo, error) + // GetSubscription 查询单个订阅的完整信息。 + GetSubscription(ctx context.Context, cred Credentials, subscriptionID string) (SubscriptionDetail, error) + // ListShapes 列出租户在目标区域可用的实例规格(带缓存)。 + ListShapes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]ComputeShape, error) + // ListImages 列出可用的实例镜像。 + ListImages(ctx context.Context, cred Credentials, q ImagesQuery) ([]Image, error) + // GetImage 查询单个镜像。 + GetImage(ctx context.Context, cred Credentials, region, imageID string) (Image, error) + // VCN 增删改查与一键 IPv6。 + ListVCNs(ctx context.Context, cred Credentials, region string) ([]VCN, error) + CreateVCN(ctx context.Context, cred Credentials, in CreateVCNInput) (VCN, error) + GetVCN(ctx context.Context, cred Credentials, region, vcnID string) (VCN, error) + UpdateVCN(ctx context.Context, cred Credentials, region, vcnID, displayName string) (VCN, error) + DeleteVCN(ctx context.Context, cred Credentials, region, vcnID string) error + EnableVCNIPv6(ctx context.Context, cred Credentials, region, vcnID string) ([]IPv6Step, error) + // 子网增删改查。 + ListSubnets(ctx context.Context, cred Credentials, region, vcnID string) ([]Subnet, error) + CreateSubnet(ctx context.Context, cred Credentials, in CreateSubnetInput) (Subnet, error) + GetSubnet(ctx context.Context, cred Credentials, region, subnetID string) (Subnet, error) + UpdateSubnet(ctx context.Context, cred Credentials, region, subnetID, displayName string) (Subnet, error) + DeleteSubnet(ctx context.Context, cred Credentials, region, subnetID string) error + // 安全列表增删改查。 + ListSecurityLists(ctx context.Context, cred Credentials, region, vcnID string) ([]SecurityList, error) + CreateSecurityList(ctx context.Context, cred Credentials, in CreateSecurityListInput) (SecurityList, error) + GetSecurityList(ctx context.Context, cred Credentials, region, securityListID string) (SecurityList, error) + UpdateSecurityList(ctx context.Context, cred Credentials, securityListID string, in UpdateSecurityListInput) (SecurityList, error) + DeleteSecurityList(ctx context.Context, cred Credentials, region, securityListID string) error + // ListAvailabilityDomains 列出区域内可用域,创建实例的前置查询。 + ListAvailabilityDomains(ctx context.Context, cred Credentials, region string) ([]string, error) + // 实例增删改查与电源操作。 + ListInstances(ctx context.Context, cred Credentials, region string) ([]Instance, error) + LaunchInstance(ctx context.Context, cred Credentials, in CreateInstanceInput) (Instance, error) + GetInstance(ctx context.Context, cred Credentials, region, instanceID string) (Instance, error) + UpdateInstance(ctx context.Context, cred Credentials, instanceID string, in UpdateInstanceInput) (Instance, error) + TerminateInstance(ctx context.Context, cred Credentials, region, instanceID string, preserveBootVolume bool) error + InstanceAction(ctx context.Context, cred Credentials, region, instanceID, action string) (Instance, error) + // 引导卷查删改;引导卷由创建实例产生,不单独创建。 + ListBootVolumes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]BootVolume, error) + GetBootVolume(ctx context.Context, cred Credentials, region, bootVolumeID string) (BootVolume, error) + UpdateBootVolume(ctx context.Context, cred Credentials, bootVolumeID string, in UpdateBootVolumeInput) (BootVolume, error) + DeleteBootVolume(ctx context.Context, cred Credentials, region, bootVolumeID string) error + // 实例 IP:更换主 VNIC 临时公网 IP、添加与取消分配 IPv6 地址。 + ChangeInstancePublicIP(ctx context.Context, cred Credentials, region, instanceID string) (string, error) + AddInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) (string, error) + DeleteInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) error + // VNIC 管理:列出实例网卡、附加次要网卡、分离(主网卡由 OCI 拒绝)、按网卡加 IPv6。 + ListInstanceVnics(ctx context.Context, cred Credentials, region, instanceID string) ([]Vnic, error) + AttachVnic(ctx context.Context, cred Credentials, region, instanceID string, in AttachVnicInput) (Vnic, error) + DetachVnic(ctx context.Context, cred Credentials, region, attachmentID string) error + AddVnicIpv6(ctx context.Context, cred Credentials, region, vnicID, address string) (string, error) + // GenAI 网关:区域模型列表、聊天(非流式/流式)、配额探测、文本向量化。 + ListGenAiModels(ctx context.Context, cred Credentials, region string) ([]GenAiModel, error) + GenAiChat(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error) + GenAiChatStream(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (GenAiStream, error) + GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error) + GenAiEmbed(ctx context.Context, cred Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error) + // 控制台连接:创建(VNC/串口连接串)、列出、删除。 + CreateConsoleConnection(ctx context.Context, cred Credentials, region, instanceID, sshPublicKey string) (ConsoleConnection, error) + ListConsoleConnections(ctx context.Context, cred Credentials, region, instanceID string) ([]ConsoleConnection, error) + DeleteConsoleConnection(ctx context.Context, cred Credentials, region, connectionID string) error + // 引导卷挂载:列出、挂载、分离、替换。 + ListBootVolumeAttachments(ctx context.Context, cred Credentials, region, instanceID string) ([]BootVolumeAttachment, error) + AttachBootVolume(ctx context.Context, cred Credentials, region, instanceID, bootVolumeID string) (BootVolumeAttachment, error) + DetachBootVolume(ctx context.Context, cred Credentials, region, attachmentID string) error + ReplaceBootVolume(ctx context.Context, cred Credentials, region, instanceID, newBootVolumeID string) (BootVolumeAttachment, error) + // 块卷挂载:列出块卷、列出挂载、附加、分离。 + ListBlockVolumes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]BlockVolume, error) + ListVolumeAttachments(ctx context.Context, cred Credentials, region, instanceID string) ([]VolumeAttachment, error) + AttachVolume(ctx context.Context, cred Credentials, region, instanceID, volumeID string, readOnly bool) (VolumeAttachment, error) + DetachVolume(ctx context.Context, cred Credentials, region, attachmentID string) error + // 租户观测:实例流量统计、成本分析。 + SummarizeInstanceTraffic(ctx context.Context, cred Credentials, q TrafficQuery) (InstanceTraffic, error) + SummarizeCosts(ctx context.Context, cred Credentials, q CostQuery) ([]CostItem, error) + // ListAuditEvents 实时查询租户根 compartment 的审计事件(最多翻 maxAuditPages 页, + // 到限截断并回传续查游标);page 传上次结果的 NextPage 可从断点续翻。 + ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error) + // 租户用户管理:经典 IAM 为主,新式租户自动回退 Identity Domains。 + ListTenantUsers(ctx context.Context, cred Credentials) ([]TenantUser, error) + GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, userID string) (TenantUserDetail, error) + CreateTenantUser(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error) + UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string, in UpdateTenantUserInput) (TenantUser, error) + DeleteTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string) error + ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, userID string) (string, error) + DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, userID string) (int, error) + DeleteTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string, includeCurrent bool) (int, error) + // 域设置:通知收件人、密码策略、身份设置。 + GetNotificationRecipients(ctx context.Context, cred Credentials, region string) (NotificationRecipients, error) + UpdateNotificationRecipients(ctx context.Context, cred Credentials, region string, emails []string) (NotificationRecipients, error) + ListPasswordPolicies(ctx context.Context, cred Credentials, region string) ([]PasswordPolicyInfo, error) + UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error) + GetIdentitySetting(ctx context.Context, cred Credentials, region string) (IdentitySettingInfo, error) + UpdateIdentitySetting(ctx context.Context, cred Credentials, region string, primaryEmailRequired bool) (IdentitySettingInfo, error) + // Federation:SAML IdP 管理、域 SP 元数据、sign-on 免 MFA 规则。 + ListIdentityProviders(ctx context.Context, cred Credentials, region string) ([]IdentityProviderInfo, error) + CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region string, in CreateIdpInput) (IdentityProviderInfo, error) + SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, idpID string, enabled bool) (IdentityProviderInfo, error) + DeleteIdentityProvider(ctx context.Context, cred Credentials, region, idpID string) error + DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region string) ([]byte, error) + ListConsoleSignOnRules(ctx context.Context, cred Credentials, region string) ([]SignOnRuleInfo, error) + CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, idpID, ruleName string) (SignOnRuleInfo, error) + DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, ruleID string) error + // 日志回传链路(方案A):Topic/CUSTOM_HTTPS 订阅/IAM Policy/Service Connector + // 的幂等创建、状态聚合与销毁;IAM 写操作发往 homeRegion。 + EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error) + EnsureRelaySubscription(ctx context.Context, cred Credentials, topicID, endpoint string) (RelayResource, error) + GetRelaySubscription(ctx context.Context, cred Credentials, subscriptionID string) (RelayResource, error) + EnsureRelayPolicy(ctx context.Context, cred Credentials, homeRegion string) (RelayResource, error) + EnsureRelayConnector(ctx context.Context, cred Credentials, topicID, condition string) (RelayResource, error) + RelayState(ctx context.Context, cred Credentials, endpoint string) (RelayState, error) + DeleteRelayConnector(ctx context.Context, cred Credentials, connectorID string) error + DeleteRelayPolicy(ctx context.Context, cred Credentials, homeRegion, policyID string) error + DeleteRelaySubscription(ctx context.Context, cred Credentials, subscriptionID string) error + DeleteRelayTopic(ctx context.Context, cred Credentials, topicID string) error +} + +// RealClient 基于官方 SDK 实现 Client。 +type RealClient struct { + limitDefs sync.Map // tenancy|region|service → limitDefEntry,配额定义预筛缓存 + shapes sync.Map // tenancy|region|ad → shapeCacheEntry,shape 清单缓存 +} + +// NewClient 返回生产使用的真实客户端。 +func NewClient() *RealClient { return &RealClient{} } + +func provider(cred Credentials) common.ConfigurationProvider { + var pass *string + if cred.Passphrase != "" { + pass = &cred.Passphrase + } + return common.NewRawConfigurationProvider( + cred.TenancyOCID, cred.UserOCID, cred.Region, + cred.Fingerprint, cred.PrivateKey, pass, + ) +} + +// normalizeRegion 兼容区域短名:iad → us-ashburn-1;空值与全名原样返回。 +// 实例等资源对象上的 region 字段是短名,直接透传时借此归一。 +func normalizeRegion(region string) string { + if region == "" { + return "" + } + return string(common.StringToRegion(region)) +} + +// ValidateKey 实现 Client。 +func (c *RealClient) ValidateKey(ctx context.Context, cred Credentials) (TenancyInfo, error) { + ic, err := identity.NewIdentityClientWithConfigurationProvider(provider(cred)) + if err != nil { + return TenancyInfo{}, fmt.Errorf("new identity client: %w", err) + } + applyProxy(&ic.BaseClient, cred) + resp, err := ic.GetTenancy(ctx, identity.GetTenancyRequest{TenancyId: &cred.TenancyOCID}) + if err != nil { + return TenancyInfo{}, fmt.Errorf("get tenancy: %w", err) + } + return TenancyInfo{ + Name: deref(resp.Name), + HomeRegionKey: deref(resp.HomeRegionKey), + }, nil +} + +// FetchAccountProfile 实现 Client:从订阅列表选中 CLOUDCM, +// 再查询完整订阅并按付费模式、促销状态判定账户类别。 +func (c *RealClient) FetchAccountProfile(ctx context.Context, cred Credentials) (AccountProfile, error) { + subs, err := c.ListSubscriptions(ctx, cred) + if err != nil { + return AccountProfile{}, err + } + subID := "" + for _, sub := range subs { + if strings.EqualFold(sub.ServiceName, cloudcmServiceName) { + subID = sub.ID + break + } + } + if subID == "" { + return AccountProfile{}, ErrNoCloudcmSubscription + } + detail, err := c.GetSubscription(ctx, cred, subID) + if err != nil { + return AccountProfile{}, err + } + return buildAccountProfile(detail), nil +} + +func buildAccountProfile(detail SubscriptionDetail) AccountProfile { + p := AccountProfile{ + SubscriptionID: detail.ID, + ServiceName: detail.ServiceName, + PaymentModel: detail.PaymentModel, + SubscriptionTier: detail.SubscriptionTier, + } + p.PromotionStatus, p.PromotionAmount, p.PromotionExpires = pickPromotion(detail.Promotions) + p.AccountType = ClassifyAccount(p.PaymentModel, p.SubscriptionTier, p.PromotionStatus) + return p +} + +// pickPromotion 从多条促销记录中选出最能代表当前状态的一条: +// 优先 ACTIVE,其次 EXPIRED,否则回退第一条。 +func pickPromotion(promos []PromotionInfo) (string, float32, *time.Time) { + best, bestRank := -1, -1 + for i, p := range promos { + if rank := promotionRank(p.Status); rank > bestRank { + best, bestRank = i, rank + } + } + if best < 0 { + return "", 0, nil + } + p := promos[best] + return p.Status, p.Amount, p.TimeExpired +} + +func promotionRank(status string) int { + switch strings.ToUpper(status) { + case "ACTIVE": + return 2 + case "EXPIRED": + return 1 + default: + return 0 + } +} + +func deref(s *string) string { + if s != nil { + return *s + } + return "" +} + +// orEmpty 把 nil 切片归一为空切片:JSON 序列化输出 [] 而非 null, +// 避免前端对 null 调用 .length / .map / .join 崩溃。 +func orEmpty[T any](s []T) []T { + if s == nil { + return []T{} + } + return s +} diff --git a/internal/oci/compartment.go b/internal/oci/compartment.go new file mode 100644 index 0000000..479912c --- /dev/null +++ b/internal/oci/compartment.go @@ -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 +} diff --git a/internal/oci/consoleconnection.go b/internal/oci/consoleconnection.go new file mode 100644 index 0000000..808bcf1 --- /dev/null +++ b/internal/oci/consoleconnection.go @@ -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), + } +} diff --git a/internal/oci/cost.go b/internal/oci/cost.go new file mode 100644 index 0000000..677f5d1 --- /dev/null +++ b/internal/oci/cost.go @@ -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 +} diff --git a/internal/oci/credentials.go b/internal/oci/credentials.go new file mode 100644 index 0000000..309bee5 --- /dev/null +++ b/internal/oci/credentials.go @@ -0,0 +1,98 @@ +package oci + +import ( + "fmt" + "strings" +) + +// Credentials 是调用 OCI API 所需的完整凭据,私钥为 PEM 明文。 +// CompartmentID 限定资源操作的目标 compartment,空表示租户根; +// 只影响 compute / 网络 / 块存储等资源查询,身份与订阅类调用始终走租户根。 +type Credentials struct { + TenancyOCID string + UserOCID string + Region string + Fingerprint string + PrivateKey string + Passphrase string + CompartmentID string + // Proxy 非 nil 时该租户全部 SDK 出站请求经此代理 + Proxy *ProxySpec +} + +// EffectiveCompartment 返回资源操作的目标 compartment,未指定时为租户根。 +func (c Credentials) EffectiveCompartment() *string { + if c.CompartmentID != "" { + id := c.CompartmentID + return &id + } + id := c.TenancyOCID + return &id +} + +// Validate 检查凭据必填字段。 +func (c Credentials) Validate() error { + switch { + case c.TenancyOCID == "": + return fmt.Errorf("credentials: tenancy is empty") + case c.UserOCID == "": + return fmt.Errorf("credentials: user is empty") + case c.Region == "": + return fmt.Errorf("credentials: region is empty") + case c.Fingerprint == "": + return fmt.Errorf("credentials: fingerprint is empty") + case !strings.Contains(c.PrivateKey, "PRIVATE KEY"): + return fmt.Errorf("credentials: private key is not a PEM") + } + return nil +} + +// ParseConfigINI 解析 OCI 标准 config(ini)文本的 [DEFAULT] 段。 +// key_file 只在本机指向文件,导入场景私钥内容必须单独提供,因此忽略该行。 +func ParseConfigINI(text string) (Credentials, error) { + var cred Credentials + section := "DEFAULT" + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.TrimSpace(line[1 : len(line)-1]) + continue + } + if !strings.EqualFold(section, "DEFAULT") { + continue + } + applyConfigLine(&cred, line) + } + if cred.TenancyOCID == "" && cred.UserOCID == "" { + return cred, fmt.Errorf("parse oci config: no recognizable fields") + } + return cred, nil +} + +func applyConfigLine(cred *Credentials, line string) { + key, value, ok := strings.Cut(line, "=") + if !ok { + return + } + // OCID、指纹、region 值里不含 #,可安全去掉行尾注释 + if i := strings.Index(value, "#"); i >= 0 { + value = value[:i] + } + key = strings.TrimSpace(strings.ToLower(key)) + value = strings.TrimSpace(value) + switch key { + case "user": + cred.UserOCID = value + case "tenancy": + cred.TenancyOCID = value + case "fingerprint": + cred.Fingerprint = value + case "region": + cred.Region = value + case "pass_phrase": + cred.Passphrase = value + } +} diff --git a/internal/oci/credentials_test.go b/internal/oci/credentials_test.go new file mode 100644 index 0000000..b8d2d82 --- /dev/null +++ b/internal/oci/credentials_test.go @@ -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= # 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) + } + }) + } +} diff --git a/internal/oci/domainsettings.go b/internal/oci/domainsettings.go new file mode 100644 index 0000000..64b40ac --- /dev/null +++ b/internal/oci/domainsettings.go @@ -0,0 +1,304 @@ +package oci + +import ( + "context" + "errors" + "fmt" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/identity" + "github.com/oracle/oci-go-sdk/v65/identitydomains" +) + +// notificationSettingID 是 Identity Domain 通知设置的固定资源 ID。 +const notificationSettingID = "NotificationSettings" + +// NotificationRecipients 是域通知收件人设置:test mode 开启后 +// 域内全部通知只发给 recipients 列表。 +type NotificationRecipients struct { + Recipients []string `json:"recipients"` + TestModeEnabled bool `json:"testModeEnabled"` +} + +// PasswordPolicyInfo 是一条域密码策略的关键字段。 +type PasswordPolicyInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Priority int `json:"priority"` + PasswordStrength string `json:"passwordStrength"` + PasswordExpiresAfter *int `json:"passwordExpiresAfter,omitempty"` + MinLength *int `json:"minLength,omitempty"` + NumPasswordsInHistory *int `json:"numPasswordsInHistory,omitempty"` +} + +// UpdatePasswordPolicyInput 是密码策略修改项;nil 字段不修改。 +// PasswordExpiresAfter 为 0 表示密码永不过期; +// NumPasswordsInHistory 为 0 表示可复用以前的密码(不记历史)。 +type UpdatePasswordPolicyInput struct { + PasswordExpiresAfter *int + MinLength *int + NumPasswordsInHistory *int +} + +// IdentitySettingInfo 是域身份设置里本面板关心的开关。 +type IdentitySettingInfo struct { + ID string `json:"id"` + // 用户需要提供主电子邮件地址:开启后创建用户 / JIT 预配必须带邮箱 + PrimaryEmailRequired bool `json:"primaryEmailRequired"` +} + +// domainURL 解析租户 Default Identity Domain 的 endpoint URL。 +func (c *RealClient) domainURL(ctx context.Context, cred Credentials, region string) (string, error) { + ic, err := c.identityClientAt(cred, region) + if err != nil { + return "", err + } + resp, err := ic.ListDomains(ctx, identity.ListDomainsRequest{CompartmentId: &cred.TenancyOCID}) + if err != nil { + return "", fmt.Errorf("list identity domains: %w", err) + } + url := pickDomainURL(resp.Items) + if url == "" { + return "", fmt.Errorf("no active identity domain found") + } + return url, nil +} + +// domainsClient 解析租户的 Default Identity Domain 并构造 SCIM 客户端。 +func (c *RealClient) domainsClient(ctx context.Context, cred Credentials, region string) (identitydomains.IdentityDomainsClient, error) { + url, err := c.domainURL(ctx, cred, region) + if err != nil { + return identitydomains.IdentityDomainsClient{}, err + } + dc, err := identitydomains.NewIdentityDomainsClientWithConfigurationProvider(provider(cred), url) + if err != nil { + return dc, fmt.Errorf("new identity domains client: %w", err) + } + applyProxy(&dc.BaseClient, cred) + // IDCS 错误体是 SCIM 格式,改写后 SDK 才能解析出真实错误消息 + dc.HTTPClient = scimErrorDispatcher{base: dc.HTTPClient} + return dc, nil +} + +// pickDomainURL 优先返回名为 Default 的域,否则取第一个 ACTIVE 的域。 +func pickDomainURL(domains []identity.DomainSummary) string { + fallback := "" + for _, d := range domains { + if d.LifecycleState != identity.DomainLifecycleStateActive { + continue + } + if deref(d.DisplayName) == "Default" { + return deref(d.Url) + } + if fallback == "" { + fallback = deref(d.Url) + } + } + return fallback +} + +// GetNotificationRecipients 实现 Client:查询域通知收件人设置。 +func (c *RealClient) GetNotificationRecipients(ctx context.Context, cred Credentials, region string) (NotificationRecipients, error) { + dc, err := c.domainsClient(ctx, cred, region) + if err != nil { + return NotificationRecipients{}, err + } + setting, err := getNotificationSetting(ctx, dc) + if err != nil { + return NotificationRecipients{}, err + } + return NotificationRecipients{ + Recipients: orEmpty(setting.TestRecipients), + TestModeEnabled: setting.TestModeEnabled != nil && *setting.TestModeEnabled, + }, nil +} + +// UpdateNotificationRecipients 实现 Client:把域通知改为只发给指定收件人; +// emails 为空时关闭 test mode 恢复默认发送。其余字段原样保留。 +func (c *RealClient) UpdateNotificationRecipients(ctx context.Context, cred Credentials, region string, emails []string) (NotificationRecipients, error) { + dc, err := c.domainsClient(ctx, cred, region) + if err != nil { + return NotificationRecipients{}, err + } + setting, err := getNotificationSetting(ctx, dc) + if err != nil { + return NotificationRecipients{}, err + } + enabled := len(emails) > 0 + setting.TestRecipients = emails + setting.TestModeEnabled = &enabled + id := notificationSettingID + resp, err := dc.PutNotificationSetting(ctx, identitydomains.PutNotificationSettingRequest{ + NotificationSettingId: &id, + NotificationSetting: setting, + }) + if err != nil { + return NotificationRecipients{}, fmt.Errorf("put notification setting: %w", err) + } + return NotificationRecipients{ + Recipients: orEmpty(resp.TestRecipients), + TestModeEnabled: resp.TestModeEnabled != nil && *resp.TestModeEnabled, + }, nil +} + +func getNotificationSetting(ctx context.Context, dc identitydomains.IdentityDomainsClient) (identitydomains.NotificationSetting, error) { + id := notificationSettingID + resp, err := dc.GetNotificationSetting(ctx, identitydomains.GetNotificationSettingRequest{ + NotificationSettingId: &id, + }) + if err != nil { + return identitydomains.NotificationSetting{}, fmt.Errorf("get notification setting: %w", err) + } + return resp.NotificationSetting, nil +} + +// ListPasswordPolicies 实现 Client:列出域内全部密码策略。 +func (c *RealClient) ListPasswordPolicies(ctx context.Context, cred Credentials, region string) ([]PasswordPolicyInfo, error) { + dc, err := c.domainsClient(ctx, cred, region) + if err != nil { + return nil, err + } + resp, err := dc.ListPasswordPolicies(ctx, identitydomains.ListPasswordPoliciesRequest{}) + if err != nil { + return nil, fmt.Errorf("list password policies: %w", err) + } + policies := make([]PasswordPolicyInfo, 0, len(resp.Resources)) + for _, p := range resp.Resources { + policies = append(policies, toPasswordPolicyInfo(p)) + } + return policies, nil +} + +// UpdatePasswordPolicy 实现 Client:以 SCIM Patch 修改密码策略的指定字段。 +// 仅 Custom 强度的策略可修改,Simple/Standard 内置策略 OCI 返回 403。 +func (c *RealClient) UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error) { + ops := buildPolicyPatchOps(in) + if len(ops) == 0 { + return PasswordPolicyInfo{}, fmt.Errorf("update password policy: nothing to update") + } + dc, err := c.domainsClient(ctx, cred, region) + if err != nil { + return PasswordPolicyInfo{}, err + } + resp, err := dc.PatchPasswordPolicy(ctx, identitydomains.PatchPasswordPolicyRequest{ + PasswordPolicyId: &policyID, + PatchOp: identitydomains.PatchOp{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + Operations: ops, + }, + }) + if err != nil { + if isForbidden(err) { + return PasswordPolicyInfo{}, fmt.Errorf("patch password policy %s: only Custom-strength policies are editable: %w", policyID, err) + } + return PasswordPolicyInfo{}, fmt.Errorf("patch password policy %s: %w", policyID, err) + } + return toPasswordPolicyInfo(resp.PasswordPolicy), nil +} + +// isForbidden 判断服务端返回 403(内置只读资源等场景)。 +func isForbidden(err error) bool { + var svcErr common.ServiceError + return errors.As(err, &svcErr) && svcErr.GetHTTPStatusCode() == 403 +} + +func buildPolicyPatchOps(in UpdatePasswordPolicyInput) []identitydomains.Operations { + var ops []identitydomains.Operations + if in.PasswordExpiresAfter != nil { + ops = append(ops, intOrRemoveOp("passwordExpiresAfter", *in.PasswordExpiresAfter)) + } + if in.MinLength != nil { + ops = append(ops, replaceOp("minLength", *in.MinLength)) + } + if in.NumPasswordsInHistory != nil { + ops = append(ops, intOrRemoveOp("numPasswordsInHistory", *in.NumPasswordsInHistory)) + } + return ops +} + +// intOrRemoveOp 值为 0 时移除属性(IDCS 语义 null = 永不过期 / 可复用密码; +// numPasswordsInHistory 合法范围 1-500,replace 0 会被拒),否则 replace。 +func intOrRemoveOp(path string, v int) identitydomains.Operations { + if v == 0 { + return removeOp(path) + } + return replaceOp(path, v) +} + +func removeOp(path string) identitydomains.Operations { + return identitydomains.Operations{ + Op: identitydomains.OperationsOpRemove, + Path: &path, + } +} + +// GetIdentitySetting 实现 Client:读取域身份设置(IdentitySettings 单例资源)。 +func (c *RealClient) GetIdentitySetting(ctx context.Context, cred Credentials, region string) (IdentitySettingInfo, error) { + dc, err := c.domainsClient(ctx, cred, region) + if err != nil { + return IdentitySettingInfo{}, err + } + resp, err := dc.ListIdentitySettings(ctx, identitydomains.ListIdentitySettingsRequest{}) + if err != nil { + return IdentitySettingInfo{}, fmt.Errorf("list identity settings: %w", err) + } + if len(resp.Resources) == 0 { + return IdentitySettingInfo{}, fmt.Errorf("identity settings resource not found") + } + s := resp.Resources[0] + info := IdentitySettingInfo{ID: deref(s.Id)} + if s.PrimaryEmailRequired != nil { + info.PrimaryEmailRequired = *s.PrimaryEmailRequired + } + return info, nil +} + +// UpdateIdentitySetting 实现 Client:修改「用户需要提供主电子邮件地址」开关。 +func (c *RealClient) UpdateIdentitySetting(ctx context.Context, cred Credentials, region string, primaryEmailRequired bool) (IdentitySettingInfo, error) { + current, err := c.GetIdentitySetting(ctx, cred, region) + if err != nil { + return IdentitySettingInfo{}, err + } + dc, err := c.domainsClient(ctx, cred, region) + if err != nil { + return IdentitySettingInfo{}, err + } + resp, err := dc.PatchIdentitySetting(ctx, identitydomains.PatchIdentitySettingRequest{ + IdentitySettingId: ¤t.ID, + PatchOp: identitydomains.PatchOp{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + Operations: []identitydomains.Operations{replaceOp("primaryEmailRequired", primaryEmailRequired)}, + }, + }) + if err != nil { + return IdentitySettingInfo{}, fmt.Errorf("patch identity settings: %w", err) + } + info := IdentitySettingInfo{ID: deref(resp.IdentitySetting.Id)} + if resp.IdentitySetting.PrimaryEmailRequired != nil { + info.PrimaryEmailRequired = *resp.IdentitySetting.PrimaryEmailRequired + } + return info, nil +} + +func replaceOp(path string, value interface{}) identitydomains.Operations { + return identitydomains.Operations{ + Op: identitydomains.OperationsOpReplace, + Path: &path, + Value: &value, + } +} + +func toPasswordPolicyInfo(p identitydomains.PasswordPolicy) PasswordPolicyInfo { + info := PasswordPolicyInfo{ + ID: deref(p.Id), + Name: deref(p.Name), + PasswordStrength: string(p.PasswordStrength), + PasswordExpiresAfter: p.PasswordExpiresAfter, + MinLength: p.MinLength, + NumPasswordsInHistory: p.NumPasswordsInHistory, + } + if p.Priority != nil { + info.Priority = *p.Priority + } + return info +} diff --git a/internal/oci/errors.go b/internal/oci/errors.go new file mode 100644 index 0000000..342e9da --- /dev/null +++ b/internal/oci/errors.go @@ -0,0 +1,78 @@ +package oci + +import ( + "errors" + "regexp" + "strings" + + "github.com/oracle/oci-go-sdk/v65/common" +) + +// ociErrorHints 把常见 OCI 错误码翻译成给用户看的中文提示。 +var ociErrorHints = map[string]string{ + "IncorrectState": "资源当前状态不允许该操作,通常是仍被其他资源占用或正在变更中", + "NotAuthorizedOrNotFound": "无权限或资源不存在(可能已被删除)", + "LimitExceeded": "已达到服务配额上限", + "QuotaExceeded": "compartment 配额不足", + "OutOfHostCapacity": "该可用域容量不足,可稍后重试或改用抢机任务", + "TooManyRequests": "请求过于频繁,请稍后再试", + "InvalidParameter": "请求参数无效", + "InternalError": "OCI 服务内部错误,请稍后重试", + "Conflict": "资源正在变更中,请稍后重试", +} + +// ocidRe 匹配消息中的完整 OCID(unique 段 20 位以上才压缩,避免误伤短标识)。 +var ocidRe = regexp.MustCompile(`ocid1\.([a-z0-9]+)\.[a-z0-9]*\.[a-z0-9-]*\.{1,2}[a-z0-9]{20,}`) + +// shortenOcids 把消息里的长 OCID 压缩为「ocid1.<类型>…<尾6位>」, +// 保留资源类型与可比对的尾部,避免整条错误被 OCID 撑爆。 +func shortenOcids(s string) string { + return ocidRe.ReplaceAllStringFunc(s, func(m string) string { + i := strings.Index(m, ".") + typ := m[i+1:] + if j := strings.Index(typ, "."); j > 0 { + typ = typ[:j] + } + return "ocid1." + typ + "…" + m[len(m)-6:] + }) +} + +// CompactError 把含 OCI ServiceError 的错误链提炼成短消息: +// 操作前缀 + 服务端消息,剥掉 SDK 附加的错误码、排障链接、 +// 时间戳、SDK 版本等长文(错误码另经 API 的 ociCode 字段透出); +// 消息中的长 OCID 压缩为类型加尾位的短形式。非 OCI 错误原样返回。 +func CompactError(err error) string { + var svcErr common.ServiceError + if !errors.As(err, &svcErr) { + return shortenOcids(err.Error()) + } + prefix := err.Error() + if i := strings.Index(prefix, ": Error returned by"); i > 0 { + prefix = prefix[:i] + ": " + } else { + prefix = "" + } + return shortenOcids(prefix + svcErr.GetMessage()) +} + +// ErrorHint 返回常见 OCI 错误的中文提示;未识别返回空串。 +// 容量不足常以 code=InternalError 返回,按消息内容归到容量提示。 +func ErrorHint(err error) string { + var svcErr common.ServiceError + if !errors.As(err, &svcErr) { + return "" + } + if strings.Contains(svcErr.GetMessage(), "Out of host capacity") { + return ociErrorHints["OutOfHostCapacity"] + } + return ociErrorHints[svcErr.GetCode()] +} + +// ServiceStatus 返回错误链中 OCI ServiceError 的 HTTP 状态码;非服务端错误 ok=false。 +func ServiceStatus(err error) (int, bool) { + var svcErr common.ServiceError + if errors.As(err, &svcErr) { + return svcErr.GetHTTPStatusCode(), true + } + return 0, false +} diff --git a/internal/oci/errors_test.go b/internal/oci/errors_test.go new file mode 100644 index 0000000..c261621 --- /dev/null +++ b/internal/oci/errors_test.go @@ -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) + } + }) + } +} diff --git a/internal/oci/federation.go b/internal/oci/federation.go new file mode 100644 index 0000000..db6e86c --- /dev/null +++ b/internal/oci/federation.go @@ -0,0 +1,438 @@ +package oci + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/identitydomains" +) + +// defaultIdpRuleID 是 Default Identity Provider Policy 的预置规则固定 ID, +// 其 return 中 SamlIDPs 数组决定登录页显示哪些 SAML IdP。 +const defaultIdpRuleID = "DefaultIDPRule" + +// samlMetadataPath 是 Identity Domain 的 SP SAML 元数据端点。 +const samlMetadataPath = "/fed/v1/metadata" + +// idpSchema 是 IdentityProvider 资源的 SCIM schema。 +const idpSchema = "urn:ietf:params:scim:schemas:oracle:idcs:IdentityProvider" + +// IdentityProviderInfo 是一个外部身份提供者的关键字段。 +type IdentityProviderInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Enabled bool `json:"enabled"` + PartnerProviderID string `json:"partnerProviderId"` + JitEnabled bool `json:"jitEnabled"` + TimeCreated *time.Time `json:"timeCreated,omitempty"` +} + +// CreateIdpInput 是创建 SAML IdP 的输入;零值即控制台默认行为: +// 名称 ID 格式「无」、SAML 断言名称 ID 映射到用户名、JIT 开启建用户不更新、 +// 静态分配 Administrators 组(service 层负责把缺省字段填成这些默认值)。 +type CreateIdpInput struct { + Name string // partnerName,必填 + Metadata string // IdP metadata XML,必填 + Description string + IconURL string // 登录页 logo,仅接受 http(s) 外链 URL(IDCS 拒绝 data URI),空则不设置 + + // 映射用户身份 + NameIDFormat string // 请求的名称 ID 格式,如 saml-none / saml-emailaddress + AssertionAttribute string // 非空时改用断言属性映射,值为断言中的属性名;空用 NameID + UserStoreAttribute string // 目标域用户属性:userName 或 emails.primary(主邮箱) + + // JIT 预配 + JitEnabled bool + JitCreate bool // JIT 时创建新用户 + JitUpdate bool // JIT 时更新既有用户属性 + JitAssignAdminGroup bool // 静态分配 Administrators 组 + JitMapEmail bool // 属性映射额外映射主邮箱(域要求邮箱必填时需开启) +} + +// ListIdentityProviders 实现 Client:列出域内 SAML 身份提供者。 +func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials, region string) ([]IdentityProviderInfo, error) { + dc, err := c.domainsClient(ctx, cred, region) + if err != nil { + return nil, err + } + resp, err := dc.ListIdentityProviders(ctx, identitydomains.ListIdentityProvidersRequest{}) + if err != nil { + return nil, fmt.Errorf("list identity providers: %w", err) + } + idps := make([]IdentityProviderInfo, 0, len(resp.Resources)) + for _, idp := range resp.Resources { + if idp.Type != identitydomains.IdentityProviderTypeSaml { + continue + } + idps = append(idps, toIdpInfo(idp)) + } + return idps, nil +} + +// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT。 +func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region string, in CreateIdpInput) (IdentityProviderInfo, error) { + dc, err := c.domainsClient(ctx, cred, region) + if err != nil { + return IdentityProviderInfo{}, err + } + var adminGroup *identitydomains.IdentityProviderJitUserProvAssignedGroups + if in.JitEnabled && in.JitAssignAdminGroup { + if adminGroup, err = adminGroupRef(ctx, dc); err != nil { + return IdentityProviderInfo{}, err + } + } + resp, err := dc.CreateIdentityProvider(ctx, identitydomains.CreateIdentityProviderRequest{ + IdentityProvider: buildSamlIdp(in, adminGroup), + }) + if err != nil { + return IdentityProviderInfo{}, fmt.Errorf("create identity provider %s: %w", in.Name, err) + } + if in.JitEnabled { + if err := ensureJitAttributeMappings(ctx, dc, resp.IdentityProvider, jitMappings(in)); err != nil { + return toIdpInfo(resp.IdentityProvider), err + } + } + return toIdpInfo(resp.IdentityProvider), nil +} + +// buildSamlIdp 按输入组装 SAML IdP(创建为禁用态,启用走 activate 接口)。 +func buildSamlIdp(in CreateIdpInput, adminGroup *identitydomains.IdentityProviderJitUserProvAssignedGroups) identitydomains.IdentityProvider { + enabled := false + boolFalse := false + idp := identitydomains.IdentityProvider{ + Schemas: []string{idpSchema}, + PartnerName: &in.Name, + Enabled: &enabled, + Type: identitydomains.IdentityProviderTypeSaml, + Metadata: &in.Metadata, + NameIdFormat: &in.NameIDFormat, + SignatureHashAlgorithm: identitydomains.IdentityProviderSignatureHashAlgorithm256, + IncludeSigningCertInSignature: &boolFalse, + UserMappingMethod: identitydomains.IdentityProviderUserMappingMethodNameidtouserattribute, + UserMappingStoreAttribute: &in.UserStoreAttribute, + JitUserProvEnabled: &in.JitEnabled, + JitUserProvCreateUserEnabled: &in.JitCreate, + JitUserProvAttributeUpdateEnabled: &in.JitUpdate, + JitUserProvGroupAssertionAttributeEnabled: &boolFalse, + JitUserProvGroupStaticListEnabled: &in.JitAssignAdminGroup, + JitUserProvGroupMappingMode: identitydomains.IdentityProviderJitUserProvGroupMappingModeImplicit, + } + if in.AssertionAttribute != "" { + idp.UserMappingMethod = identitydomains.IdentityProviderUserMappingMethodAssertionattributetouserattribute + idp.AssertionAttribute = &in.AssertionAttribute + } + if in.IconURL != "" { + idp.IconUrl = &in.IconURL + } + if in.Description != "" { + idp.Description = &in.Description + } + if adminGroup != nil { + idp.JitUserProvAssignedGroups = []identitydomains.IdentityProviderJitUserProvAssignedGroups{*adminGroup} + } + return idp +} + +// adminGroupRef 查询域内 Administrators 组作为 JIT 静态分配目标;不存在时返回 nil。 +func adminGroupRef(ctx context.Context, dc identitydomains.IdentityDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) { + filter := fmt.Sprintf("displayName eq %q", administratorsGroupName) + count := 1 + resp, err := dc.ListGroups(ctx, identitydomains.ListGroupsRequest{ + Filter: &filter, + Attributes: common.String("id,displayName"), + Count: &count, + }) + if err != nil { + return nil, fmt.Errorf("find Administrators group: %w", err) + } + if len(resp.Resources) == 0 || resp.Resources[0].Id == nil { + return nil, nil + } + return &identitydomains.IdentityProviderJitUserProvAssignedGroups{Value: resp.Resources[0].Id}, nil +} + +// jitMappings 生成 JIT 属性映射:来源为 NameID(或指定断言属性), +// 固定映射 用户名 + 姓,按需追加主邮箱(emails[work].value)。 +func jitMappings(in CreateIdpInput) []interface{} { + src := "$(assertion.fed.nameidvalue)" + if in.AssertionAttribute != "" { + src = "$(assertion." + in.AssertionAttribute + ")" + } + ms := []interface{}{ + map[string]string{"managedObjectAttributeName": src, "idcsAttributeName": "userName"}, + map[string]string{"managedObjectAttributeName": src, "idcsAttributeName": "name.familyName"}, + } + if in.JitMapEmail { + ms = append(ms, map[string]string{"managedObjectAttributeName": src, "idcsAttributeName": "emails[work].value"}) + } + return ms +} + +// ensureJitAttributeMappings 把 IdP 自动生成的 JIT 属性映射替换为给定映射。 +func ensureJitAttributeMappings(ctx context.Context, dc identitydomains.IdentityDomainsClient, idp identitydomains.IdentityProvider, mappings []interface{}) error { + ref := idp.JitUserProvAttributes + if ref == nil || ref.Value == nil { + got, err := dc.GetIdentityProvider(ctx, identitydomains.GetIdentityProviderRequest{IdentityProviderId: idp.Id}) + if err != nil { + return fmt.Errorf("reload idp for jit attributes: %w", err) + } + ref = got.JitUserProvAttributes + } + if ref == nil || ref.Value == nil { + return fmt.Errorf("idp %s has no jit attribute mapping resource", deref(idp.Id)) + } + var value interface{} = mappings + _, err := dc.PatchMappedAttribute(ctx, identitydomains.PatchMappedAttributeRequest{ + MappedAttributeId: ref.Value, + PatchOp: identitydomains.PatchOp{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + Operations: []identitydomains.Operations{{ + Op: identitydomains.OperationsOpReplace, + Path: common.String("attributeMappings"), + Value: &value, + }}, + }, + }) + if err != nil { + return fmt.Errorf("patch jit attribute mappings: %w", err) + } + return nil +} + +// SetIdentityProviderEnabled 实现 Client:启用/停用 IdP 并同步登录页显示。 +// 顺序有讲究:启用后才能上登录页,停用前必须先从登录页移除。 +func (c *RealClient) SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, idpID string, enabled bool) (IdentityProviderInfo, error) { + dc, err := c.domainsClient(ctx, cred, region) + if err != nil { + return IdentityProviderInfo{}, err + } + if !enabled { + if err := updateLoginPageIdps(ctx, dc, idpID, false); err != nil { + return IdentityProviderInfo{}, err + } + } + idp, err := patchIdpEnabled(ctx, dc, idpID, enabled) + if err != nil { + return IdentityProviderInfo{}, err + } + if enabled { + if err := updateLoginPageIdps(ctx, dc, idpID, true); err != nil { + return toIdpInfo(idp), err + } + } + return toIdpInfo(idp), nil +} + +func patchIdpEnabled(ctx context.Context, dc identitydomains.IdentityDomainsClient, idpID string, enabled bool) (identitydomains.IdentityProvider, error) { + var value interface{} = enabled + resp, err := dc.PatchIdentityProvider(ctx, identitydomains.PatchIdentityProviderRequest{ + IdentityProviderId: &idpID, + PatchOp: identitydomains.PatchOp{ + Schemas: []string{scimPatchSchema}, + Operations: []identitydomains.Operations{{ + Op: identitydomains.OperationsOpReplace, + Path: common.String("enabled"), + Value: &value, + }}, + }, + }) + if err != nil { + return identitydomains.IdentityProvider{}, fmt.Errorf("patch idp %s enabled=%v: %w", idpID, enabled, err) + } + return resp.IdentityProvider, nil +} + +// DeleteIdentityProvider 实现 Client:删除 IdP;先移出登录页并停用。 +func (c *RealClient) DeleteIdentityProvider(ctx context.Context, cred Credentials, region, idpID string) error { + dc, err := c.domainsClient(ctx, cred, region) + if err != nil { + return err + } + if err := updateLoginPageIdps(ctx, dc, idpID, false); err != nil { + return err + } + if _, err := patchIdpEnabled(ctx, dc, idpID, false); err != nil { + return err + } + if _, err := dc.DeleteIdentityProvider(ctx, identitydomains.DeleteIdentityProviderRequest{ + IdentityProviderId: &idpID, + }); err != nil { + return fmt.Errorf("delete identity provider %s: %w", idpID, err) + } + return nil +} + +// updateLoginPageIdps 修改预置 DefaultIDPRule 的 SamlIDPs 数组控制登录页显示。 +func updateLoginPageIdps(ctx context.Context, dc identitydomains.IdentityDomainsClient, idpID string, show bool) error { + rule, err := dc.GetRule(ctx, identitydomains.GetRuleRequest{RuleId: common.String(defaultIdpRuleID)}) + if err != nil { + return fmt.Errorf("get default idp rule: %w", err) + } + returns, changed, err := rebuildSamlIdpsReturn(rule.Return, idpID, show) + if err != nil || !changed { + return err + } + var value interface{} = returns + _, err = dc.PatchRule(ctx, identitydomains.PatchRuleRequest{ + RuleId: common.String(defaultIdpRuleID), + PatchOp: identitydomains.PatchOp{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + Operations: []identitydomains.Operations{{ + Op: identitydomains.OperationsOpReplace, + Path: common.String("return"), + Value: &value, + }}, + }, + }) + if err != nil { + return fmt.Errorf("patch default idp rule: %w", err) + } + return nil +} + +// rebuildSamlIdpsReturn 重建规则 return 数组,增删 SamlIDPs 中的目标 IdP。 +func rebuildSamlIdpsReturn(items []identitydomains.RuleReturn, idpID string, show bool) ([]interface{}, bool, error) { + returns := make([]interface{}, 0, len(items)) + changed := false + for _, item := range items { + name, value := deref(item.Name), deref(item.Value) + if name == "SamlIDPs" { + next, ok, err := toggleJSONList(value, idpID, show) + if err != nil { + return nil, false, fmt.Errorf("parse SamlIDPs %q: %w", value, err) + } + value, changed = next, ok + } + returns = append(returns, map[string]string{"name": name, "value": value}) + } + return returns, changed, nil +} + +// toggleJSONList 在 JSON 数组字符串中添加或移除一个元素,返回是否有变化。 +func toggleJSONList(list, item string, add bool) (string, bool, error) { + var items []string + if list != "" { + if err := json.Unmarshal([]byte(list), &items); err != nil { + return "", false, err + } + } + next := make([]string, 0, len(items)+1) + found := false + for _, it := range items { + if it == item { + found = true + if !add { + continue + } + } + next = append(next, it) + } + if add && !found { + next = append(next, item) + } + if add == found { + return list, false, nil + } + b, err := json.Marshal(next) + return string(b), true, err +} + +// DownloadDomainSamlMetadata 实现 Client:下载域的 SP SAML 元数据 XML。 +// 域设置「访问签名证书」未开启时端点不可用,自动开启后重试 +// (联邦配置要求 IdP 侧能读取该元数据,开启公开访问是官方标准做法)。 +func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region string) ([]byte, error) { + url, err := c.domainURL(ctx, cred, region) + if err != nil { + return nil, err + } + body, status, err := fetchSamlMetadata(ctx, url) + if err != nil { + return nil, err + } + if status == http.StatusOK { + return body, nil + } + if err := c.enableSigningCertPublicAccess(ctx, cred, region); err != nil { + return nil, err + } + body, status, err = fetchSamlMetadata(ctx, url) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("download saml metadata: HTTP %d: %s", status, truncate(string(body), 200)) + } + return body, nil +} + +// fetchSamlMetadata 匿名请求元数据端点;该端点只支持公开访问,不接受 OCI 签名。 +func fetchSamlMetadata(ctx context.Context, url string) ([]byte, int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url+samlMetadataPath, nil) + if err != nil { + return nil, 0, fmt.Errorf("build metadata request: %w", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("download saml metadata: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, 0, fmt.Errorf("read saml metadata: %w", err) + } + return body, resp.StatusCode, nil +} + +// enableSigningCertPublicAccess 开启域设置「访问签名证书」公开访问。 +func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region string) error { + dc, err := c.domainsClient(ctx, cred, region) + if err != nil { + return err + } + var value interface{} = true + _, err = dc.PatchSetting(ctx, identitydomains.PatchSettingRequest{ + SettingId: common.String("Settings"), + PatchOp: identitydomains.PatchOp{ + Schemas: []string{scimPatchSchema}, + Operations: []identitydomains.Operations{{ + Op: identitydomains.OperationsOpReplace, + Path: common.String("signingCertPublicAccess"), + Value: &value, + }}, + }, + }) + if err != nil { + return fmt.Errorf("enable signing cert public access: %w", err) + } + return nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +func toIdpInfo(idp identitydomains.IdentityProvider) IdentityProviderInfo { + info := IdentityProviderInfo{ + ID: deref(idp.Id), + Name: deref(idp.PartnerName), + Type: string(idp.Type), + Enabled: idp.Enabled != nil && *idp.Enabled, + PartnerProviderID: deref(idp.PartnerProviderId), + JitEnabled: idp.JitUserProvEnabled != nil && *idp.JitUserProvEnabled, + } + if idp.Meta != nil && idp.Meta.Created != nil { + if t, err := time.Parse(time.RFC3339, *idp.Meta.Created); err == nil { + info.TimeCreated = &t + } + } + return info +} diff --git a/internal/oci/genai.go b/internal/oci/genai.go new file mode 100644 index 0000000..60788a6 --- /dev/null +++ b/internal/oci/genai.go @@ -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 +} diff --git a/internal/oci/genai_cohere.go b/internal/oci/genai_cohere.go new file mode 100644 index 0000000..a4bc4c5 --- /dev/null +++ b/internal/oci/genai_cohere.go @@ -0,0 +1,266 @@ +package oci + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/oracle/oci-go-sdk/v65/generativeaiinference" + + "oci-portal/internal/aiwire" +) + +// isCohereModel 依据模型名前缀判定 COHERE 系(走专属请求格式)。 +func isCohereModel(name string) bool { + return strings.HasPrefix(strings.ToLower(name), "cohere.") +} + +// irToCohereSDK 把 IR 转为 COHERE 聊天请求;工具与结构化输出做有损降级 +// (参数仅取 JSON Schema 顶层 properties,tool_choice / json_schema 的 name、strict 无对应)。 +func irToCohereSDK(ir aiwire.ChatRequest, stream bool) (generativeaiinference.CohereChatRequest, error) { + var req generativeaiinference.CohereChatRequest + if err := cohereRejectUnsupported(ir); err != nil { + return req, err + } + p, err := cohereSplitMessages(ir.Messages) + if err != nil { + return req, err + } + req = generativeaiinference.CohereChatRequest{ + Message: &p.message, + ChatHistory: p.history, + ToolResults: p.toolResults, + Tools: cohereTools(ir.Tools), + ResponseFormat: cohereResponseFormat(ir.ResponseFormat), + MaxTokens: ir.MaxTokens, + Temperature: ir.Temperature, + TopP: ir.TopP, + TopK: ir.TopK, + FrequencyPenalty: ir.FrequencyPenalty, + PresencePenalty: ir.PresencePenalty, + Seed: ir.Seed, + } + if p.preamble != "" { + req.PreambleOverride = &p.preamble + } + if len(ir.Stop) > 0 { + req.StopSequences = ir.Stop + } + if stream { + req.IsStream = &stream + includeUsage := ir.StreamOptions == nil || ir.StreamOptions.IncludeUsage + req.StreamOptions = &generativeaiinference.StreamOptions{IsIncludeUsage: &includeUsage} + } + return req, nil +} + +// cohereRejectUnsupported 拒绝 COHERE 无法承接的能力(多模态图片)。 +func cohereRejectUnsupported(ir aiwire.ChatRequest) error { + for _, m := range ir.Messages { + for _, p := range m.Content.Parts { + if p.Type == "image_url" { + return fmt.Errorf("cohere 系模型暂不支持图片输入,请改用 meta/google 等多模态模型") + } + } + } + return nil +} + +// cohereParts 是 IR 消息拆装为 COHERE 请求的中间结果。 +type cohereParts struct { + message string + history []generativeaiinference.CohereMessage + preamble string + toolResults []generativeaiinference.CohereToolResult +} + +// cohereSplitMessages 拆装消息:system 拼 preamble,末尾 user 抽为 message, +// assistant(含 toolCalls)与其余 user 进 history,tool 结果转顶层 toolResults +// (工具结果在末尾时 message 留空,COHERE 以 toolResults 续跑)。 +func cohereSplitMessages(msgs []aiwire.ChatMessage) (cohereParts, error) { + lastUser, lastTool := -1, -1 + for i, m := range msgs { + switch m.Role { + case "user": + lastUser = i + case "tool": + lastTool = i + } + } + if lastUser == -1 && lastTool == -1 { + return cohereParts{}, fmt.Errorf("cohere 系模型至少需要一条 user 消息") + } + var p cohereParts + var preamble []string + calls := map[string]generativeaiinference.CohereToolCall{} + for i, m := range msgs { + text := m.Content.JoinText() + switch m.Role { + case "system", "developer": + preamble = append(preamble, text) + case "assistant": + p.history = append(p.history, cohereBotMessage(text, m.ToolCalls, calls)) + case "tool": + p.toolResults = append(p.toolResults, cohereToolResult(m, calls)) + default: // user + if i == lastUser && lastUser > lastTool { + p.message = text + continue + } + p.history = append(p.history, generativeaiinference.CohereUserMessage{Message: &text}) + } + } + p.preamble = strings.Join(preamble, "\n") + return p, nil +} + +// cohereBotMessage 转 assistant 消息;toolCalls 同步登记进 id→call 映射供 tool 结果回查。 +func cohereBotMessage(text string, tcs []aiwire.ToolCall, calls map[string]generativeaiinference.CohereToolCall) generativeaiinference.CohereChatBotMessage { + msg := generativeaiinference.CohereChatBotMessage{} + if text != "" { + msg.Message = &text + } + for _, tc := range tcs { + name := tc.Function.Name + var params interface{} + if json.Unmarshal([]byte(tc.Function.Arguments), ¶ms) != nil || params == nil { + params = map[string]any{} + } + call := generativeaiinference.CohereToolCall{Name: &name, Parameters: ¶ms} + calls[tc.ID] = call + msg.ToolCalls = append(msg.ToolCalls, call) + } + return msg +} + +// cohereToolResult 把 IR tool 消息转顶层工具结果;COHERE 工具调用无 id, +// 靠 assistant 历史登记的映射回查,缺失时以 tool_call_id 名义调用兜底。 +func cohereToolResult(m aiwire.ChatMessage, calls map[string]generativeaiinference.CohereToolCall) generativeaiinference.CohereToolResult { + call, ok := calls[m.ToolCallID] + if !ok { + name := m.ToolCallID + var params interface{} = map[string]any{} + call = generativeaiinference.CohereToolCall{Name: &name, Parameters: ¶ms} + } + text := m.Content.JoinText() + var output interface{} + if json.Unmarshal([]byte(text), &output) != nil || output == nil { + output = map[string]any{"output": text} + } + if arr, isArr := output.([]interface{}); isArr { + return generativeaiinference.CohereToolResult{Call: &call, Outputs: arr} + } + if _, isMap := output.(map[string]interface{}); !isMap { + output = map[string]any{"output": output} + } + return generativeaiinference.CohereToolResult{Call: &call, Outputs: []interface{}{output}} +} + +// cohereTools 把 JSON Schema 工具定义降级为 COHERE 扁平参数表(嵌套结构有损:仅取顶层)。 +func cohereTools(tools []aiwire.Tool) []generativeaiinference.CohereTool { + if len(tools) == 0 { + return nil + } + out := make([]generativeaiinference.CohereTool, 0, len(tools)) + for _, t := range tools { + name, desc := t.Function.Name, t.Function.Description + if desc == "" { + desc = name // Description 为 COHERE 必填 + } + out = append(out, generativeaiinference.CohereTool{ + Name: &name, Description: &desc, + ParameterDefinitions: cohereParams(t.Function.Parameters), + }) + } + return out +} + +// cohereParams 取 JSON Schema 顶层 properties 转扁平参数定义;嵌套 schema 只保留类型名。 +func cohereParams(schema json.RawMessage) map[string]generativeaiinference.CohereParameterDefinition { + var s struct { + Properties map[string]struct { + Type string `json:"type"` + Description string `json:"description"` + } `json:"properties"` + Required []string `json:"required"` + } + if json.Unmarshal(schema, &s) != nil || len(s.Properties) == 0 { + return nil + } + req := map[string]bool{} + for _, r := range s.Required { + req[r] = true + } + out := make(map[string]generativeaiinference.CohereParameterDefinition, len(s.Properties)) + for k, v := range s.Properties { + typ, desc := v.Type, v.Description + if typ == "" { + typ = "string" + } + pd := generativeaiinference.CohereParameterDefinition{Type: &typ} + if desc != "" { + pd.Description = &desc + } + if req[k] { + t := true + pd.IsRequired = &t + } + out[k] = pd + } + return out +} + +// cohereResponseFormat 映射 json_object / json_schema(name、strict 无对应,有损降级)。 +func cohereResponseFormat(rf *aiwire.ResponseFormat) generativeaiinference.CohereResponseFormat { + if rf == nil { + return nil + } + switch rf.Type { + case "json_object", "json_schema": + out := generativeaiinference.CohereResponseJsonFormat{} + var in struct { + Schema json.RawMessage `json:"schema"` + } + if json.Unmarshal(rf.JSONSchema, &in) == nil && len(in.Schema) > 0 { + var schema interface{} + if json.Unmarshal(in.Schema, &schema) == nil { + out.Schema = &schema + } + } + return out + } + return nil +} + +// cohereSDKToIR 把 COHERE 非流式响应转回 IR;工具调用补派生 ID,存在时结束原因归 tool_calls。 +func cohereSDKToIR(resp generativeaiinference.CohereChatResponse, model string) *aiwire.ChatResponse { + msg := aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent(deref(resp.Text))} + for i, tc := range resp.ToolCalls { + msg.ToolCalls = append(msg.ToolCalls, cohereCallToIR(deref(tc.Name), tc.Parameters, i)) + } + finish := mapFinishReason(string(resp.FinishReason)) + if len(msg.ToolCalls) > 0 { + finish = "tool_calls" + } + return &aiwire.ChatResponse{ + Object: "chat.completion", + Model: model, + Choices: []aiwire.Choice{{Message: msg, FinishReason: finish}}, + Usage: sdkUsageToIR(resp.Usage), + } +} + +// cohereCallToIR 生成 IR 工具调用;COHERE 无调用 id,按名称+序号派生(回传时按此回查)。 +func cohereCallToIR(name string, params *interface{}, idx int) aiwire.ToolCall { + args := "{}" + if params != nil && *params != nil { + if b, err := json.Marshal(*params); err == nil { + args = string(b) + } + } + return aiwire.ToolCall{ + ID: fmt.Sprintf("call_%s_%d", name, idx), + Type: "function", + Function: aiwire.FunctionCall{Name: name, Arguments: args}, + } +} diff --git a/internal/oci/genai_convert.go b/internal/oci/genai_convert.go new file mode 100644 index 0000000..c1f9cdc --- /dev/null +++ b/internal/oci/genai_convert.go @@ -0,0 +1,367 @@ +package oci + +import ( + "encoding/json" + "strings" + + "github.com/oracle/oci-go-sdk/v65/generativeaiinference" + + "oci-portal/internal/aiwire" +) + +// irToSDK 把 IR(OpenAI 线格式)转为 OCI GENERIC 聊天请求;字段近一一镜像。 +func irToSDK(ir aiwire.ChatRequest, stream bool) generativeaiinference.GenericChatRequest { + req := generativeaiinference.GenericChatRequest{ + Messages: irMessages(ir.Messages), + MaxTokens: ir.MaxTokens, + MaxCompletionTokens: ir.MaxCompletionTokens, + Temperature: ir.Temperature, + TopP: ir.TopP, + TopK: ir.TopK, + FrequencyPenalty: ir.FrequencyPenalty, + PresencePenalty: ir.PresencePenalty, + Seed: ir.Seed, + NumGenerations: ir.N, + Tools: irTools(ir.Tools), + ToolChoice: irToolChoice(ir.ToolChoice), + ResponseFormat: irResponseFormat(ir.ResponseFormat), + } + if len(ir.Stop) > 0 { + req.Stop = ir.Stop + } + if ir.ReasoningEffort != "" { + req.ReasoningEffort = generativeaiinference.GenericChatRequestReasoningEffortEnum(strings.ToUpper(ir.ReasoningEffort)) + } + if stream { + req.IsStream = &stream + includeUsage := ir.StreamOptions == nil || ir.StreamOptions.IncludeUsage + req.StreamOptions = &generativeaiinference.StreamOptions{IsIncludeUsage: &includeUsage} + } + return req +} + +// irMessages 按 role 拆装消息;user 消息保留图文块,其余角色只取文本。 +func irMessages(msgs []aiwire.ChatMessage) []generativeaiinference.Message { + out := make([]generativeaiinference.Message, 0, len(msgs)) + for _, m := range msgs { + switch m.Role { + case "system", "developer": + out = append(out, generativeaiinference.SystemMessage{Content: textContents(m.Content.JoinText())}) + case "assistant": + out = append(out, generativeaiinference.AssistantMessage{ + Content: textContents(m.Content.JoinText()), + ToolCalls: irToolCalls(m.ToolCalls), + }) + case "tool": + tm := generativeaiinference.ToolMessage{Content: textContents(m.Content.JoinText())} + if m.ToolCallID != "" { + id := m.ToolCallID + tm.ToolCallId = &id + } + out = append(out, tm) + default: // user + out = append(out, generativeaiinference.UserMessage{Content: chatContents(m.Content)}) + } + } + return out +} + +// chatContents 把 IR 内容转 SDK 内容块;文本直通,image_url 转 IMAGE 块(data URI / 公网地址)。 +func chatContents(c aiwire.Content) []generativeaiinference.ChatContent { + if len(c.Parts) == 0 { + return textContents(c.Text) + } + var out []generativeaiinference.ChatContent + for _, p := range c.Parts { + switch { + case p.Type == "image_url" && p.ImageURL != nil: + img := generativeaiinference.ImageUrl{Url: &p.ImageURL.URL} + if d, ok := generativeaiinference.GetMappingImageUrlDetailEnum(p.ImageURL.Detail); ok { + img.Detail = d + } + out = append(out, generativeaiinference.ImageContent{ImageUrl: &img}) + case p.Text != "": + t := p.Text + out = append(out, generativeaiinference.TextContent{Text: &t}) + } + } + return out +} + +// textContents 把纯文本包装为单元素 TextContent 数组;空文本返回 nil。 +func textContents(text string) []generativeaiinference.ChatContent { + if text == "" { + return nil + } + return []generativeaiinference.ChatContent{generativeaiinference.TextContent{Text: &text}} +} + +func irToolCalls(calls []aiwire.ToolCall) []generativeaiinference.ToolCall { + if len(calls) == 0 { + return nil + } + out := make([]generativeaiinference.ToolCall, 0, len(calls)) + for _, c := range calls { + id, name, args := c.ID, c.Function.Name, c.Function.Arguments + out = append(out, generativeaiinference.FunctionCall{Id: &id, Name: &name, Arguments: &args}) + } + return out +} + +func irTools(tools []aiwire.Tool) []generativeaiinference.ToolDefinition { + if len(tools) == 0 { + return nil + } + out := make([]generativeaiinference.ToolDefinition, 0, len(tools)) + for _, t := range tools { + name, desc := t.Function.Name, t.Function.Description + fd := generativeaiinference.FunctionDefinition{Name: &name} + if desc != "" { + fd.Description = &desc + } + if len(t.Function.Parameters) > 0 { + var params interface{} + if json.Unmarshal(t.Function.Parameters, ¶ms) == nil { + fd.Parameters = ¶ms + } + } + out = append(out, fd) + } + return out +} + +// irToolChoice 解析 "auto"/"none"/"required" 或 {type:function,function:{name}}。 +func irToolChoice(raw json.RawMessage) generativeaiinference.ToolChoice { + if len(raw) == 0 { + return nil + } + var s string + if json.Unmarshal(raw, &s) == nil { + switch s { + case "none": + return generativeaiinference.ToolChoiceNone{} + case "required": + return generativeaiinference.ToolChoiceRequired{} + case "auto": + return generativeaiinference.ToolChoiceAuto{} + } + return nil + } + var obj struct { + Function struct { + Name string `json:"name"` + } `json:"function"` + } + if json.Unmarshal(raw, &obj) == nil && obj.Function.Name != "" { + return generativeaiinference.ToolChoiceFunction{Name: &obj.Function.Name} + } + return nil +} + +func irResponseFormat(rf *aiwire.ResponseFormat) generativeaiinference.ResponseFormat { + if rf == nil { + return nil + } + switch rf.Type { + case "json_object": + return generativeaiinference.JsonObjectResponseFormat{} + case "json_schema": + return irJSONSchemaFormat(rf.JSONSchema) + default: + return nil + } +} + +// irJSONSchemaFormat 映射 OpenAI json_schema{name,description,schema,strict}。 +func irJSONSchemaFormat(raw json.RawMessage) generativeaiinference.ResponseFormat { + var in struct { + Name string `json:"name"` + Description string `json:"description"` + Schema json.RawMessage `json:"schema"` + Strict *bool `json:"strict"` + } + if json.Unmarshal(raw, &in) != nil || in.Name == "" { + return generativeaiinference.JsonObjectResponseFormat{} + } + js := generativeaiinference.ResponseJsonSchema{Name: &in.Name, IsStrict: in.Strict} + if in.Description != "" { + js.Description = &in.Description + } + if len(in.Schema) > 0 { + var schema interface{} + if json.Unmarshal(in.Schema, &schema) == nil { + js.Schema = &schema + } + } + return generativeaiinference.JsonSchemaResponseFormat{JsonSchema: &js} +} + +// sdkToIR 把非流式 GenericChatResponse 转回 IR 响应;id/created 由调用方补。 +func sdkToIR(resp generativeaiinference.GenericChatResponse, model string) *aiwire.ChatResponse { + out := &aiwire.ChatResponse{Object: "chat.completion", Model: model} + if resp.TimeCreated != nil { + out.Created = resp.TimeCreated.Unix() + } + for i, ch := range resp.Choices { + idx := i + if ch.Index != nil { + idx = *ch.Index + } + out.Choices = append(out.Choices, aiwire.Choice{ + Index: idx, + Message: sdkMessageToIR(ch.Message), + FinishReason: mapFinishReason(deref(ch.FinishReason)), + }) + } + out.Usage = sdkUsageToIR(resp.Usage) + return out +} + +func sdkUsageToIR(u *generativeaiinference.Usage) *aiwire.Usage { + if u == nil { + return nil + } + out := &aiwire.Usage{} + if u.PromptTokens != nil { + out.PromptTokens = *u.PromptTokens + } + if u.CompletionTokens != nil { + out.CompletionTokens = *u.CompletionTokens + } + if u.TotalTokens != nil { + out.TotalTokens = *u.TotalTokens + } + if d := u.PromptTokensDetails; d != nil && d.CachedTokens != nil && *d.CachedTokens > 0 { + out.PromptTokensDetails = &aiwire.PromptTokensDetails{CachedTokens: *d.CachedTokens} + } + return out +} + +// sdkMessageToIR 提取助手消息文本与工具调用。 +func sdkMessageToIR(m generativeaiinference.Message) aiwire.ChatMessage { + out := aiwire.ChatMessage{Role: "assistant"} + am, ok := m.(generativeaiinference.AssistantMessage) + if !ok { + return out + } + var sb strings.Builder + for _, c := range am.Content { + if tc, ok := c.(generativeaiinference.TextContent); ok && tc.Text != nil { + sb.WriteString(*tc.Text) + } + } + out.Content = aiwire.NewTextContent(sb.String()) + for _, call := range am.ToolCalls { + if fc, ok := call.(generativeaiinference.FunctionCall); ok { + out.ToolCalls = append(out.ToolCalls, aiwire.ToolCall{ + ID: deref(fc.Id), + Type: "function", + Function: aiwire.FunctionCall{ + Name: deref(fc.Name), + Arguments: deref(fc.Arguments), + }, + }) + } + } + return out +} + +// mapFinishReason 归一化结束原因;OCI 与 OpenAI 取值同名,做小写与别名兜底。 +func mapFinishReason(reason string) string { + switch strings.ToLower(reason) { + case "", "null": + return "" + case "stop", "completed", "end_turn": + return "stop" + case "length", "max_tokens": + return "length" + case "tool_calls", "tool_call", "tool_use": + return "tool_calls" + case "complete": + return "stop" + case "error_toxic": + return "content_filter" + default: + return strings.ToLower(reason) + } +} + +// genAiStreamEvent 是 GENERIC 流式事件的宽容解析结构(字段按增量 choice 形状)。 +type genAiStreamEvent struct { + Index *int `json:"index"` + Message struct { + Role string `json:"role"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + ToolCalls []struct { + ID string `json:"id"` + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"toolCalls"` + } `json:"message"` + FinishReason *string `json:"finishReason"` + Usage *generativeaiinference.Usage `json:"usage"` + Choices []json.RawMessage `json:"choices"` // 兼容整包 choices 形态 + Text string `json:"text"` // COHERE 流事件的顶层增量文本 + // CohereCalls 是 COHERE 流事件顶层的工具调用(与 GENERIC 的 message.toolCalls 不同层级) + CohereCalls []struct { + Name string `json:"name"` + Parameters interface{} `json:"parameters"` + } `json:"toolCalls"` +} + +// parseGenAiEvent 把一条 SSE 事件 JSON 解析为 IR chunk;无有效负载时 ok=false。 +func parseGenAiEvent(data []byte, model string) (aiwire.ChatChunk, bool) { + var ev genAiStreamEvent + if err := json.Unmarshal(data, &ev); err != nil { + return aiwire.ChatChunk{}, false + } + if len(ev.Choices) > 0 { // choices 包裹形态:取首个展开重解析 + var inner genAiStreamEvent + if json.Unmarshal(ev.Choices[0], &inner) == nil { + inner.Usage = ev.Usage + ev = inner + } + } + chunk := aiwire.ChatChunk{Object: "chat.completion.chunk", Model: model} + choice := aiwire.ChunkChoice{} + if ev.Index != nil { + choice.Index = *ev.Index + } + choice.Delta.Role = strings.ToLower(ev.Message.Role) + var sb strings.Builder + for _, c := range ev.Message.Content { + sb.WriteString(c.Text) + } + if sb.Len() == 0 && ev.Text != "" { // COHERE 事件形态 + sb.WriteString(ev.Text) + } + choice.Delta.Content = sb.String() + for i, tc := range ev.Message.ToolCalls { + d := aiwire.ToolCallDelta{Index: i, ID: tc.ID} + if tc.ID != "" { + d.Type = "function" + } + d.Function.Name = tc.Name + d.Function.Arguments = tc.Arguments + choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, d) + } + for i, tc := range ev.CohereCalls { // COHERE 顶层工具调用:整包出现,补派生 ID + call := cohereCallToIR(tc.Name, &tc.Parameters, i) + d := aiwire.ToolCallDelta{Index: len(choice.Delta.ToolCalls) + i, ID: call.ID, Type: "function"} + d.Function.Name = call.Function.Name + d.Function.Arguments = call.Function.Arguments + choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, d) + } + if r := mapFinishReason(deref(ev.FinishReason)); r != "" { + choice.FinishReason = &r + } + hasPayload := choice.Delta.Content != "" || len(choice.Delta.ToolCalls) > 0 || choice.FinishReason != nil + if hasPayload { + chunk.Choices = []aiwire.ChunkChoice{choice} + } + chunk.Usage = sdkUsageToIR(ev.Usage) + return chunk, hasPayload || chunk.Usage != nil +} diff --git a/internal/oci/genai_retire_test.go b/internal/oci/genai_retire_test.go new file mode 100644 index 0000000..0ec4739 --- /dev/null +++ b/internal/oci/genai_retire_test.go @@ -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("正常模型应入池") + } +} diff --git a/internal/oci/genai_test.go b/internal/oci/genai_test.go new file mode 100644 index 0000000..336c474 --- /dev/null +++ b/internal/oci/genai_test.go @@ -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) + } +} diff --git a/internal/oci/image.go b/internal/oci/image.go new file mode 100644 index 0000000..55219c8 --- /dev/null +++ b/internal/oci/image.go @@ -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 +} diff --git a/internal/oci/instance.go b/internal/oci/instance.go new file mode 100644 index 0000000..e177390 --- /dev/null +++ b/internal/oci/instance.go @@ -0,0 +1,452 @@ +package oci + +import ( + "context" + "encoding/base64" + "fmt" + "strings" + "sync" + "time" + + "github.com/oracle/oci-go-sdk/v65/core" + "github.com/oracle/oci-go-sdk/v65/identity" +) + +// Instance 是一个计算实例;IP 字段来自 VNIC 补全查询。 +type Instance struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + LifecycleState string `json:"lifecycleState"` + Shape string `json:"shape"` + Ocpus float32 `json:"ocpus"` + MemoryInGBs float32 `json:"memoryInGBs"` + AvailabilityDomain string `json:"availabilityDomain"` + Region string `json:"region"` + CompartmentID string `json:"compartmentId"` + ImageID string `json:"imageId"` + SubnetID string `json:"subnetId,omitempty"` + PrivateIP string `json:"privateIp,omitempty"` + PublicIP string `json:"publicIp,omitempty"` + Ipv6Addresses []string `json:"ipv6Addresses,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags map[string]map[string]any `json:"definedTags,omitempty"` + TimeCreated *time.Time `json:"timeCreated"` +} + +// CreateInstanceInput 是创建实例的输入:镜像与引导卷二选一作为启动源, +// Flex shape 必须提供 ocpus 与 memoryInGBs。 +type CreateInstanceInput struct { + Region string + CompartmentID string // 目标 compartment,空为租户根 + AvailabilityDomain string + DisplayName string + Shape string + Ocpus float32 + MemoryInGBs float32 + ImageID string + BootVolumeID string + BootVolumeSizeGBs int64 + BootVolumeVpusPerGB int64 // 引导卷性能,10 均衡 / 20 高性能,仅镜像启动源生效 + SubnetID string // 为空时自动创建 VCN 与子网 + AssignPublicIP bool + AssignIpv6 bool + SSHPublicKey string // 与 RootPassword 互斥 + RootPassword string // 与 SSHPublicKey 互斥,非空时生成开启 root 密码登录的 cloud-init + GenerateRootPassword bool // 由服务端生成随机 root 密码并写入 FreeformTags["RootPassword"] + UserData string // 显式 cloud-init,base64 编码;RootPassword 非空时被其生成脚本覆盖 + FreeformTags map[string]string // 实例自由标签 +} + +// UpdateInstanceInput 是更新实例的输入;零值字段不更新。 +type UpdateInstanceInput struct { + Region string + DisplayName string + Shape string // 更换 shape;运行中实例 OCI 会自动重启,换 Flex 时须同时给 ocpus + Ocpus float32 + MemoryInGBs float32 +} + +// instanceActions 是允许的电源操作集合。 +var instanceActions = map[string]core.InstanceActionActionEnum{ + "START": core.InstanceActionActionStart, + "STOP": core.InstanceActionActionStop, + "RESET": core.InstanceActionActionReset, + "SOFTSTOP": core.InstanceActionActionSoftstop, + "SOFTRESET": core.InstanceActionActionSoftreset, +} + +func (c *RealClient) computeClient(cred Credentials, region string) (core.ComputeClient, error) { + cc, err := core.NewComputeClientWithConfigurationProvider(provider(cred)) + if err != nil { + return cc, fmt.Errorf("new compute client: %w", err) + } + applyProxy(&cc.BaseClient, cred) + if region != "" { + cc.SetRegion(normalizeRegion(region)) + } + return cc, nil +} + +// ListAvailabilityDomains 实现 Client:列出区域内的可用域名称。 +func (c *RealClient) ListAvailabilityDomains(ctx context.Context, cred Credentials, region string) ([]string, error) { + ic, err := identity.NewIdentityClientWithConfigurationProvider(provider(cred)) + if err != nil { + return nil, fmt.Errorf("new identity client: %w", err) + } + applyProxy(&ic.BaseClient, cred) + if region != "" { + ic.SetRegion(normalizeRegion(region)) + } + resp, err := ic.ListAvailabilityDomains(ctx, identity.ListAvailabilityDomainsRequest{ + CompartmentId: &cred.TenancyOCID, + }) + if err != nil { + return nil, fmt.Errorf("list availability domains: %w", err) + } + names := make([]string, 0, len(resp.Items)) + for _, ad := range resp.Items { + names = append(names, deref(ad.Name)) + } + return names, nil +} + +// defaultAvailabilityDomain 返回区域内第一个可用域(即 AD-1)。 +func (c *RealClient) defaultAvailabilityDomain(ctx context.Context, cred Credentials, region string) (string, error) { + ads, err := c.ListAvailabilityDomains(ctx, cred, region) + if err != nil { + return "", fmt.Errorf("resolve default availability domain: %w", err) + } + if len(ads) == 0 { + return "", fmt.Errorf("resolve default availability domain: region has no availability domain") + } + return ads[0], nil +} + +// ListInstances 实现 Client:列出实例并补全网络 IP 信息。 +func (c *RealClient) ListInstances(ctx context.Context, cred Credentials, region string) ([]Instance, error) { + cc, err := c.computeClient(cred, region) + if err != nil { + return nil, err + } + var instances []Instance + var page *string + for { + resp, err := cc.ListInstances(ctx, core.ListInstancesRequest{ + CompartmentId: cred.EffectiveCompartment(), + Page: page, + }) + if err != nil { + return nil, fmt.Errorf("list instances: %w", err) + } + for _, item := range resp.Items { + // 已终止实例的 VNIC 已不存在,保留在列表中仅供展示 + instances = append(instances, toInstance(item)) + } + if resp.OpcNextPage == nil { + break + } + page = resp.OpcNextPage + } + c.attachInstanceIPs(ctx, cred, region, instances) + // 列表瘦身:标签体积大且列表页不消费(详情接口仍完整返回) + for i := range instances { + instances[i].FreeformTags = nil + instances[i].DefinedTags = nil + } + return orEmpty(instances), nil +} + +// LaunchInstance 实现 Client;availabilityDomain 为空时默认使用第一个 +// 可用域(ad-1),subnetId 为空时自动准备默认网络。 +func (c *RealClient) LaunchInstance(ctx context.Context, cred Credentials, in CreateInstanceInput) (Instance, error) { + cc, err := c.computeClient(cred, in.Region) + if err != nil { + return Instance{}, err + } + if in.AvailabilityDomain == "" { + ad, err := c.defaultAvailabilityDomain(ctx, cred, in.Region) + if err != nil { + return Instance{}, err + } + in.AvailabilityDomain = ad + } + if in.SubnetID == "" { + subnetID, err := c.ensureDefaultSubnet(ctx, cred, in.Region) + if err != nil { + return Instance{}, fmt.Errorf("auto provision subnet: %w", err) + } + in.SubnetID = subnetID + } + details, err := buildLaunchDetails(*cred.EffectiveCompartment(), in) + if err != nil { + return Instance{}, err + } + resp, err := cc.LaunchInstance(ctx, core.LaunchInstanceRequest{LaunchInstanceDetails: details}) + if err != nil { + return Instance{}, fmt.Errorf("launch instance: %w", err) + } + return toInstance(resp.Instance), nil +} + +func buildLaunchDetails(compartmentID string, in CreateInstanceInput) (core.LaunchInstanceDetails, error) { + details := core.LaunchInstanceDetails{ + CompartmentId: &compartmentID, + AvailabilityDomain: &in.AvailabilityDomain, + Shape: &in.Shape, + DisplayName: &in.DisplayName, + SourceDetails: buildInstanceSource(in), + CreateVnicDetails: &core.CreateVnicDetails{ + SubnetId: &in.SubnetID, + AssignPublicIp: &in.AssignPublicIP, + }, + } + if details.SourceDetails == nil { + return details, fmt.Errorf("launch instance: imageId or bootVolumeId is required") + } + if in.AssignIpv6 { + details.CreateVnicDetails.AssignIpv6Ip = &in.AssignIpv6 + } + if in.Ocpus > 0 { + details.ShapeConfig = &core.LaunchInstanceShapeConfigDetails{Ocpus: &in.Ocpus} + if in.MemoryInGBs > 0 { + details.ShapeConfig.MemoryInGBs = &in.MemoryInGBs + } + } + if len(in.FreeformTags) > 0 { + details.FreeformTags = in.FreeformTags + } + details.Metadata = buildInstanceMetadata(in) + return details, nil +} + +func buildInstanceSource(in CreateInstanceInput) core.InstanceSourceDetails { + if in.BootVolumeID != "" { + return core.InstanceSourceViaBootVolumeDetails{BootVolumeId: &in.BootVolumeID} + } + if in.ImageID != "" { + src := core.InstanceSourceViaImageDetails{ImageId: &in.ImageID} + if in.BootVolumeSizeGBs > 0 { + src.BootVolumeSizeInGBs = &in.BootVolumeSizeGBs + } + if in.BootVolumeVpusPerGB > 0 { + src.BootVolumeVpusPerGB = &in.BootVolumeVpusPerGB + } + return src + } + return nil +} + +func buildInstanceMetadata(in CreateInstanceInput) map[string]string { + metadata := make(map[string]string, 2) + if in.SSHPublicKey != "" { + metadata["ssh_authorized_keys"] = in.SSHPublicKey + } + // RootPassword 生成的脚本优先作为 user_data,其次才是显式 UserData + if in.RootPassword != "" { + metadata["user_data"] = rootPasswordUserData(in.RootPassword) + } else if in.UserData != "" { + metadata["user_data"] = in.UserData + } + if len(metadata) == 0 { + return nil + } + return metadata +} + +// rootPasswordUserData 生成开启 root 密码登录的 cloud-init 脚本并 base64 编码。 +// 密码用单引号安全转义,避免 shell 注入。 +func rootPasswordUserData(password string) string { + script := "#!/bin/bash\n" + + "echo root:" + shellSingleQuote(password) + " | chpasswd\n" + + "sed -i 's/^#\\?PermitRootLogin.*/PermitRootLogin yes/g' /etc/ssh/sshd_config\n" + + "sed -i 's/^#\\?PasswordAuthentication.*/PasswordAuthentication yes/g' /etc/ssh/sshd_config\n" + + "rm -f /etc/ssh/sshd_config.d/* /etc/ssh/ssh_config.d/*\n" + + "systemctl restart sshd\n" + return base64.StdEncoding.EncodeToString([]byte(script)) +} + +// shellSingleQuote 用单引号包裹字符串,内部单引号转义为 '\”。 +func shellSingleQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// GetInstance 实现 Client:查询实例并补全网络 IP 信息。 +func (c *RealClient) GetInstance(ctx context.Context, cred Credentials, region, instanceID string) (Instance, error) { + cc, err := c.computeClient(cred, region) + if err != nil { + return Instance{}, err + } + resp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID}) + if err != nil { + return Instance{}, fmt.Errorf("get instance %s: %w", instanceID, err) + } + instances := []Instance{toInstance(resp.Instance)} + c.attachInstanceIPs(ctx, cred, region, instances) + return instances[0], nil +} + +// UpdateInstance 实现 Client:改名或调整 Flex 规格。 +func (c *RealClient) UpdateInstance(ctx context.Context, cred Credentials, instanceID string, in UpdateInstanceInput) (Instance, error) { + cc, err := c.computeClient(cred, in.Region) + if err != nil { + return Instance{}, err + } + details := core.UpdateInstanceDetails{} + if in.DisplayName != "" { + details.DisplayName = &in.DisplayName + } + if in.Shape != "" { + details.Shape = &in.Shape + } + if in.Ocpus > 0 { + details.ShapeConfig = &core.UpdateInstanceShapeConfigDetails{Ocpus: &in.Ocpus} + if in.MemoryInGBs > 0 { + details.ShapeConfig.MemoryInGBs = &in.MemoryInGBs + } + } + resp, err := cc.UpdateInstance(ctx, core.UpdateInstanceRequest{ + InstanceId: &instanceID, + UpdateInstanceDetails: details, + }) + if err != nil { + return Instance{}, fmt.Errorf("update instance %s: %w", instanceID, err) + } + return toInstance(resp.Instance), nil +} + +// TerminateInstance 实现 Client;preserveBootVolume 为 true 时保留引导卷。 +func (c *RealClient) TerminateInstance(ctx context.Context, cred Credentials, region, instanceID string, preserveBootVolume bool) error { + cc, err := c.computeClient(cred, region) + if err != nil { + return err + } + _, err = cc.TerminateInstance(ctx, core.TerminateInstanceRequest{ + InstanceId: &instanceID, + PreserveBootVolume: &preserveBootVolume, + }) + if err != nil { + return fmt.Errorf("terminate instance %s: %w", instanceID, err) + } + return nil +} + +// InstanceAction 实现 Client:执行电源操作。 +func (c *RealClient) InstanceAction(ctx context.Context, cred Credentials, region, instanceID, action string) (Instance, error) { + actionEnum, ok := instanceActions[strings.ToUpper(strings.TrimSpace(action))] + if !ok { + return Instance{}, fmt.Errorf("instance action: unsupported action %q", action) + } + cc, err := c.computeClient(cred, region) + if err != nil { + return Instance{}, err + } + resp, err := cc.InstanceAction(ctx, core.InstanceActionRequest{ + InstanceId: &instanceID, + Action: actionEnum, + }) + if err != nil { + return Instance{}, fmt.Errorf("instance action %s on %s: %w", action, instanceID, err) + } + return toInstance(resp.Instance), nil +} + +// hostCompartment 返回宿主资源自身所在 compartment 的请求指针:OCI 的 +// 关联查询要求 compartmentId 与资源实际所在 compartment 一致,传租户根 +// 会漏掉子 compartment 里的资源;id 缺失时保底用生效 compartment。 +func hostCompartment(cred Credentials, id string) *string { + if id != "" { + return &id + } + return cred.EffectiveCompartment() +} + +// attachInstanceIPs 批量补全实例的子网与 IP:按实例所在 compartment 分组 +// 列出 VNIC 挂载,再逐 VNIC 查询地址;直接用实例自带的 compartment, +// 避免为定位挂载再逐台调用 GetInstance。 +func (c *RealClient) attachInstanceIPs(ctx context.Context, cred Credentials, region string, instances []Instance) { + active := make(map[string]*Instance, len(instances)) + compartments := make(map[string]struct{}) + for i := range instances { + if instances[i].LifecycleState == string(core.InstanceLifecycleStateTerminated) { + continue + } + active[instances[i].ID] = &instances[i] + compartments[deref(hostCompartment(cred, instances[i].CompartmentID))] = struct{}{} + } + if len(active) == 0 { + return + } + cc, err := c.computeClient(cred, region) + if err != nil { + return + } + vn, err := c.vcnClient(cred, region) + if err != nil { + return + } + for compartmentID := range compartments { + fillInstanceIPs(ctx, cc, vn, compartmentID, active) + } +} + +// fillInstanceIPs 列出单个 compartment 内的 VNIC 挂载,为命中的实例补全地址。 +// 逐 VNIC 的 GetVnic 是列表接口的主要耗时点,限并发 8 路并行拉取。 +func fillInstanceIPs(ctx context.Context, cc core.ComputeClient, vn core.VirtualNetworkClient, compartmentID string, active map[string]*Instance) { + attResp, err := cc.ListVnicAttachments(ctx, core.ListVnicAttachmentsRequest{CompartmentId: &compartmentID}) + if err != nil { + return + } + var ( + wg sync.WaitGroup + mu sync.Mutex + sem = make(chan struct{}, 8) + ) + for _, att := range attResp.Items { + inst, ok := active[deref(att.InstanceId)] + if !ok || att.VnicId == nil { + continue + } + wg.Add(1) + sem <- struct{}{} + go func(vnicID *string, inst *Instance) { + defer wg.Done() + defer func() { <-sem }() + vnicResp, err := vn.GetVnic(ctx, core.GetVnicRequest{VnicId: vnicID}) + if err != nil { + return + } + mu.Lock() + inst.SubnetID = deref(vnicResp.SubnetId) + inst.PrivateIP = deref(vnicResp.PrivateIp) + inst.PublicIP = deref(vnicResp.PublicIp) + inst.Ipv6Addresses = vnicResp.Ipv6Addresses + mu.Unlock() + }(att.VnicId, inst) + } + wg.Wait() +} + +func toInstance(inst core.Instance) Instance { + out := Instance{ + ID: deref(inst.Id), + DisplayName: deref(inst.DisplayName), + LifecycleState: string(inst.LifecycleState), + Shape: deref(inst.Shape), + AvailabilityDomain: deref(inst.AvailabilityDomain), + Region: deref(inst.Region), + CompartmentID: deref(inst.CompartmentId), + ImageID: deref(inst.ImageId), + FreeformTags: inst.FreeformTags, + DefinedTags: inst.DefinedTags, + TimeCreated: sdkTime(inst.TimeCreated), + } + if inst.ShapeConfig != nil { + if inst.ShapeConfig.Ocpus != nil { + out.Ocpus = *inst.ShapeConfig.Ocpus + } + if inst.ShapeConfig.MemoryInGBs != nil { + out.MemoryInGBs = *inst.ShapeConfig.MemoryInGBs + } + } + return out +} diff --git a/internal/oci/instance_test.go b/internal/oci/instance_test.go new file mode 100644 index 0000000..ce9a91b --- /dev/null +++ b/internal/oci/instance_test.go @@ -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) + } +} diff --git a/internal/oci/ipv6.go b/internal/oci/ipv6.go new file mode 100644 index 0000000..3df0277 --- /dev/null +++ b/internal/oci/ipv6.go @@ -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"} +} diff --git a/internal/oci/ipv6_test.go b/internal/oci/ipv6_test.go new file mode 100644 index 0000000..668c109 --- /dev/null +++ b/internal/oci/ipv6_test.go @@ -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) + } + } + }) + } +} diff --git a/internal/oci/logrelay.go b/internal/oci/logrelay.go new file mode 100644 index 0000000..0cfc997 --- /dev/null +++ b/internal/oci/logrelay.go @@ -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 +} diff --git a/internal/oci/logrelay_test.go b/internal/oci/logrelay_test.go new file mode 100644 index 0000000..cd11a40 --- /dev/null +++ b/internal/oci/logrelay_test.go @@ -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) + } +} diff --git a/internal/oci/network.go b/internal/oci/network.go new file mode 100644 index 0000000..aa48f0f --- /dev/null +++ b/internal/oci/network.go @@ -0,0 +1,733 @@ +package oci + +import ( + "context" + "fmt" + "time" + + "github.com/oracle/oci-go-sdk/v65/core" +) + +// VCN 是一个虚拟云网络。 +type VCN struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + LifecycleState string `json:"lifecycleState"` + CompartmentID string `json:"compartmentId"` + CidrBlocks []string `json:"cidrBlocks"` + Ipv6CidrBlocks []string `json:"ipv6CidrBlocks"` + DnsLabel string `json:"dnsLabel"` + DefaultRouteTableID string `json:"defaultRouteTableId"` + DefaultSecurityListID string `json:"defaultSecurityListId"` + TimeCreated *time.Time `json:"timeCreated"` +} + +// CreateVCNInput 是创建 VCN 的输入。 +type CreateVCNInput struct { + Region string + CompartmentID string // 目标 compartment,空为租户根 + DisplayName string + CidrBlock string + DnsLabel string + EnableIPv6 bool // 创建时同时分配 Oracle GUA IPv6 /56 +} + +// Subnet 是 VCN 下的一个子网。 +type Subnet struct { + ID string `json:"id"` + VcnID string `json:"vcnId"` + DisplayName string `json:"displayName"` + LifecycleState string `json:"lifecycleState"` + CidrBlock string `json:"cidrBlock"` + Ipv6CidrBlock string `json:"ipv6CidrBlock"` + DnsLabel string `json:"dnsLabel"` + ProhibitPublicIP bool `json:"prohibitPublicIp"` + AvailabilityDomain string `json:"availabilityDomain"` + RouteTableID string `json:"routeTableId"` + TimeCreated *time.Time `json:"timeCreated"` +} + +// CreateSubnetInput 是创建子网的输入。 +type CreateSubnetInput struct { + Region string + VcnID string + DisplayName string + CidrBlock string + DnsLabel string + Ipv6CidrBlock string + ProhibitPublicIP bool +} + +// SecurityRule 是安全列表中的一条规则;TCP/UDP 只支持目标端口范围。 +type SecurityRule struct { + Protocol string `json:"protocol"` // all、6(TCP)、17(UDP)、1(ICMP)、58(ICMPv6) + Source string `json:"source,omitempty"` + Destination string `json:"destination,omitempty"` + IsStateless bool `json:"isStateless"` + PortMin *int `json:"portMin,omitempty"` + PortMax *int `json:"portMax,omitempty"` + IcmpType *int `json:"icmpType,omitempty"` + IcmpCode *int `json:"icmpCode,omitempty"` + Description string `json:"description,omitempty"` +} + +// SecurityList 是一个安全列表及其规则。 +type SecurityList struct { + ID string `json:"id"` + VcnID string `json:"vcnId"` + DisplayName string `json:"displayName"` + LifecycleState string `json:"lifecycleState"` + IngressRules []SecurityRule `json:"ingressRules"` + EgressRules []SecurityRule `json:"egressRules"` + TimeCreated *time.Time `json:"timeCreated"` +} + +// CreateSecurityListInput 是创建安全列表的输入。 +type CreateSecurityListInput struct { + Region string + VcnID string + DisplayName string + IngressRules []SecurityRule + EgressRules []SecurityRule +} + +// UpdateSecurityListInput 是更新安全列表的输入;nil 字段保持不变, +// 规则字段一经提供即整体替换。 +type UpdateSecurityListInput struct { + Region string + DisplayName string + IngressRules *[]SecurityRule + EgressRules *[]SecurityRule +} + +// 自动建网使用的固定名称,便于复用而非每次新建 VCN。 +const ( + autoVcnName = "oci-portal-auto-vcn" + autoSubnetName = "oci-portal-auto-subnet" + autoVcnCidr = "10.0.0.0/16" + autoSubnetCidr = "10.0.0.0/24" +) + +// ensureDefaultSubnet 在 subnetId 为空时准备一个可上公网的子网: +// 复用已有的 oci-portal-auto-vcn,否则新建 VCN + IGW + 默认路由 + 子网。 +func (c *RealClient) ensureDefaultSubnet(ctx context.Context, cred Credentials, region string) (string, error) { + vcns, err := c.ListVCNs(ctx, cred, region) + if err != nil { + return "", err + } + vn, err := c.vcnClient(cred, region) + if err != nil { + return "", err + } + for _, v := range vcns { + if v.DisplayName != autoVcnName { + continue + } + // 列表项自带 VCN 所在 compartment,直接用它列子网,免去重复 GetVCN + subnets, err := listSubnets(ctx, vn, *hostCompartment(cred, v.CompartmentID), v.ID) + if err == nil && len(subnets) > 0 { + return subnets[0].ID, nil + } + } + return c.createDefaultNetwork(ctx, cred, region) +} + +func (c *RealClient) createDefaultNetwork(ctx context.Context, cred Credentials, region string) (string, error) { + vcn, err := c.CreateVCN(ctx, cred, CreateVCNInput{ + Region: region, DisplayName: autoVcnName, CidrBlock: autoVcnCidr, DnsLabel: "ociportalauto", + EnableIPv6: true, + }) + if err != nil { + return "", err + } + vn, err := c.vcnClient(cred, region) + if err != nil { + return "", err + } + // IGW 与子网都建在新 VCN 自己的 compartment,与其余关联资源保持同域 + vcnCompartment := *hostCompartment(cred, vcn.CompartmentID) + igwID, err := ensureInternetGateway(ctx, vn, vcnCompartment, vcn.ID) + if err != nil { + return "", err + } + if err := addInternetRoute(ctx, vn, vcn.DefaultRouteTableID, igwID, "0.0.0.0/0"); err != nil { + return "", err + } + subnet, err := c.createSubnetIn(ctx, cred, vcnCompartment, CreateSubnetInput{ + Region: region, VcnID: vcn.ID, DisplayName: autoSubnetName, CidrBlock: autoSubnetCidr, DnsLabel: "ociauto", + Ipv6CidrBlock: autoSubnetIPv6Cidr(ctx, vn, vcn.ID, vcn.DefaultRouteTableID, igwID), + }) + if err != nil { + return "", err + } + return subnet.ID, nil +} + +// autoSubnetIPv6Cidr 等待自动 VCN 的 IPv6 /56 分配就绪并补 ::/0 路由, +// 返回子网可用的 /64;IPv6 尽力而为,失败返回空串降级纯 IPv4 不阻塞建网。 +func autoSubnetIPv6Cidr(ctx context.Context, vn core.VirtualNetworkClient, vcnID, routeTableID, igwID string) string { + vcnCidr, err := waitVCNIPv6Cidr(ctx, vn, vcnID) + if err != nil { + return "" + } + if err := addInternetRoute(ctx, vn, routeTableID, igwID, ipv6AnyCIDR); err != nil { + return "" + } + cidr, err := nextSubnetIPv6CIDR(vcnCidr, nil) + if err != nil { + return "" + } + return cidr +} + +// addInternetRoute 在路由表补 dest → IGW,已存在则跳过。 +func addInternetRoute(ctx context.Context, vn core.VirtualNetworkClient, routeTableID, igwID, dest string) error { + resp, err := vn.GetRouteTable(ctx, core.GetRouteTableRequest{RtId: &routeTableID}) + if err != nil { + return fmt.Errorf("get route table: %w", err) + } + for _, rule := range resp.RouteRules { + if deref(rule.Destination) == dest { + return nil + } + } + rules := append(resp.RouteRules, core.RouteRule{ + Destination: &dest, + DestinationType: core.RouteRuleDestinationTypeCidrBlock, + NetworkEntityId: &igwID, + }) + _, err = vn.UpdateRouteTable(ctx, core.UpdateRouteTableRequest{ + RtId: &routeTableID, + UpdateRouteTableDetails: core.UpdateRouteTableDetails{RouteRules: rules}, + }) + if err != nil { + return fmt.Errorf("add internet route %s: %w", dest, err) + } + return nil +} + +func (c *RealClient) vcnClient(cred Credentials, region string) (core.VirtualNetworkClient, error) { + vn, err := core.NewVirtualNetworkClientWithConfigurationProvider(provider(cred)) + if err != nil { + return vn, fmt.Errorf("new virtual network client: %w", err) + } + applyProxy(&vn.BaseClient, cred) + if region != "" { + vn.SetRegion(normalizeRegion(region)) + } + return vn, nil +} + +// ListVCNs 实现 Client。 +func (c *RealClient) ListVCNs(ctx context.Context, cred Credentials, region string) ([]VCN, error) { + vn, err := c.vcnClient(cred, region) + if err != nil { + return nil, err + } + var vcns []VCN + var page *string + for { + resp, err := vn.ListVcns(ctx, core.ListVcnsRequest{CompartmentId: cred.EffectiveCompartment(), Page: page}) + if err != nil { + return nil, fmt.Errorf("list vcns: %w", err) + } + for _, item := range resp.Items { + vcns = append(vcns, toVCN(item)) + } + if resp.OpcNextPage == nil { + return orEmpty(vcns), nil + } + page = resp.OpcNextPage + } +} + +// CreateVCN 实现 Client;创建后将默认安全列表放开为出入方向全放行。 +func (c *RealClient) CreateVCN(ctx context.Context, cred Credentials, in CreateVCNInput) (VCN, error) { + vn, err := c.vcnClient(cred, in.Region) + if err != nil { + return VCN{}, err + } + details := core.CreateVcnDetails{ + CompartmentId: cred.EffectiveCompartment(), + CidrBlocks: []string{in.CidrBlock}, + DisplayName: &in.DisplayName, + } + if in.DnsLabel != "" { + details.DnsLabel = &in.DnsLabel + } + if in.EnableIPv6 { + details.IsIpv6Enabled = &in.EnableIPv6 + } + resp, err := vn.CreateVcn(ctx, core.CreateVcnRequest{CreateVcnDetails: details}) + if err != nil { + return VCN{}, fmt.Errorf("create vcn: %w", err) + } + vcn := toVCN(resp.Vcn) + // VCN 初始化会异步写入默认安全列表的初始规则,未就绪时替换会被覆盖 + if err := waitVCNAvailable(ctx, vn, vcn.ID); err != nil { + return vcn, err + } + if err := openDefaultSecurityList(ctx, vn, vcn.DefaultSecurityListID, in.EnableIPv6); err != nil { + return vcn, err + } + return vcn, nil +} + +// waitVCNAvailable 轮询等待 VCN 进入 AVAILABLE,最长约 60 秒。 +func waitVCNAvailable(ctx context.Context, vn core.VirtualNetworkClient, vcnID string) error { + for i := 0; i < 30; i++ { + resp, err := vn.GetVcn(ctx, core.GetVcnRequest{VcnId: &vcnID}) + if err != nil { + return fmt.Errorf("poll vcn state: %w", err) + } + if resp.LifecycleState == core.VcnLifecycleStateAvailable { + return nil + } + select { + case <-ctx.Done(): + return fmt.Errorf("poll vcn state: %w", ctx.Err()) + case <-time.After(2 * time.Second): + } + } + return fmt.Errorf("poll vcn state: timed out waiting for AVAILABLE") +} + +// openDefaultSecurityList 将安全列表规则替换为出入方向全放行; +// includeIPv6 时附带 ::/0 规则(OCI 拒绝在未启用 IPv6 的 VCN 上写入)。 +func openDefaultSecurityList(ctx context.Context, vn core.VirtualNetworkClient, securityListID string, includeIPv6 bool) error { + protocol := "all" + v4, v6 := "0.0.0.0/0", ipv6AnyCIDR + ingress := []core.IngressSecurityRule{{Protocol: &protocol, Source: &v4}} + egress := []core.EgressSecurityRule{{Protocol: &protocol, Destination: &v4}} + if includeIPv6 { + ingress = append(ingress, core.IngressSecurityRule{Protocol: &protocol, Source: &v6}) + egress = append(egress, core.EgressSecurityRule{Protocol: &protocol, Destination: &v6}) + } + _, err := vn.UpdateSecurityList(ctx, core.UpdateSecurityListRequest{ + SecurityListId: &securityListID, + UpdateSecurityListDetails: core.UpdateSecurityListDetails{ + IngressSecurityRules: ingress, + EgressSecurityRules: egress, + }, + }) + if err != nil { + return fmt.Errorf("open default security list %s: %w", securityListID, err) + } + return nil +} + +// GetVCN 实现 Client。 +func (c *RealClient) GetVCN(ctx context.Context, cred Credentials, region, vcnID string) (VCN, error) { + vn, err := c.vcnClient(cred, region) + if err != nil { + return VCN{}, err + } + resp, err := vn.GetVcn(ctx, core.GetVcnRequest{VcnId: &vcnID}) + if err != nil { + return VCN{}, fmt.Errorf("get vcn %s: %w", vcnID, err) + } + return toVCN(resp.Vcn), nil +} + +// UpdateVCN 实现 Client:当前仅支持改名。 +func (c *RealClient) UpdateVCN(ctx context.Context, cred Credentials, region, vcnID, displayName string) (VCN, error) { + vn, err := c.vcnClient(cred, region) + if err != nil { + return VCN{}, err + } + resp, err := vn.UpdateVcn(ctx, core.UpdateVcnRequest{ + VcnId: &vcnID, + UpdateVcnDetails: core.UpdateVcnDetails{DisplayName: &displayName}, + }) + if err != nil { + return VCN{}, fmt.Errorf("update vcn %s: %w", vcnID, err) + } + return toVCN(resp.Vcn), nil +} + +// DeleteVCN 实现 Client:级联清理子网、网关与非默认路由表 / 安全列表 / +// DHCP 选项后删除 VCN(见 vcndelete.go);子网被实例占用时 OCI 返回 409。 +func (c *RealClient) DeleteVCN(ctx context.Context, cred Credentials, region, vcnID string) error { + vn, err := c.vcnClient(cred, region) + if err != nil { + return err + } + return deleteVcnCascade(ctx, vn, vcnID) +} + +// vcnScopedCompartment 返回按 VCN 定位的查询与创建应使用的 compartment: +// OCI 要求 compartmentId 与资源实际所在 compartment 一致,vcnID 非空时取 +// VCN 自身所在 compartment,为空(全 compartment 列表)时用生效 compartment。 +func (c *RealClient) vcnScopedCompartment(ctx context.Context, cred Credentials, region, vcnID string) (string, error) { + if vcnID == "" { + return *cred.EffectiveCompartment(), nil + } + vcn, err := c.GetVCN(ctx, cred, region, vcnID) + if err != nil { + return "", err + } + return *hostCompartment(cred, vcn.CompartmentID), nil +} + +// ListSubnets 实现 Client;vcnID 为空时列出全部子网。 +func (c *RealClient) ListSubnets(ctx context.Context, cred Credentials, region, vcnID string) ([]Subnet, error) { + vn, err := c.vcnClient(cred, region) + if err != nil { + return nil, err + } + compartmentID, err := c.vcnScopedCompartment(ctx, cred, region, vcnID) + if err != nil { + return nil, err + } + return listSubnets(ctx, vn, compartmentID, vcnID) +} + +func listSubnets(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID string) ([]Subnet, error) { + var subnets []Subnet + var page *string + for { + req := core.ListSubnetsRequest{CompartmentId: &compartmentID, Page: page} + if vcnID != "" { + req.VcnId = &vcnID + } + resp, err := vn.ListSubnets(ctx, req) + if err != nil { + return nil, fmt.Errorf("list subnets: %w", err) + } + for _, item := range resp.Items { + subnets = append(subnets, toSubnet(item)) + } + if resp.OpcNextPage == nil { + return orEmpty(subnets), nil + } + page = resp.OpcNextPage + } +} + +// CreateSubnet 实现 Client;不传 AD 时创建 regional 子网。 +// 子网跟随其 VCN 所在 compartment 创建,避免跨 compartment 悬挂资源。 +func (c *RealClient) CreateSubnet(ctx context.Context, cred Credentials, in CreateSubnetInput) (Subnet, error) { + compartmentID, err := c.vcnScopedCompartment(ctx, cred, in.Region, in.VcnID) + if err != nil { + return Subnet{}, err + } + return c.createSubnetIn(ctx, cred, compartmentID, in) +} + +// createSubnetIn 在指定 compartment 创建子网,供已知 VCN compartment +// 的调用方(如自动建网)复用,免去一次重复的 GetVCN。 +func (c *RealClient) createSubnetIn(ctx context.Context, cred Credentials, compartmentID string, in CreateSubnetInput) (Subnet, error) { + vn, err := c.vcnClient(cred, in.Region) + if err != nil { + return Subnet{}, err + } + resp, err := vn.CreateSubnet(ctx, core.CreateSubnetRequest{CreateSubnetDetails: buildSubnetDetails(compartmentID, in)}) + if err != nil { + return Subnet{}, fmt.Errorf("create subnet: %w", err) + } + return toSubnet(resp.Subnet), nil +} + +// buildSubnetDetails 组装创建子网请求体;可选字段仅在非零值时下发。 +func buildSubnetDetails(compartmentID string, in CreateSubnetInput) core.CreateSubnetDetails { + details := core.CreateSubnetDetails{ + CompartmentId: &compartmentID, + VcnId: &in.VcnID, + CidrBlock: &in.CidrBlock, + DisplayName: &in.DisplayName, + } + if in.DnsLabel != "" { + details.DnsLabel = &in.DnsLabel + } + if in.Ipv6CidrBlock != "" { + details.Ipv6CidrBlock = &in.Ipv6CidrBlock + } + if in.ProhibitPublicIP { + details.ProhibitPublicIpOnVnic = &in.ProhibitPublicIP + } + return details +} + +// GetSubnet 实现 Client。 +func (c *RealClient) GetSubnet(ctx context.Context, cred Credentials, region, subnetID string) (Subnet, error) { + vn, err := c.vcnClient(cred, region) + if err != nil { + return Subnet{}, err + } + resp, err := vn.GetSubnet(ctx, core.GetSubnetRequest{SubnetId: &subnetID}) + if err != nil { + return Subnet{}, fmt.Errorf("get subnet %s: %w", subnetID, err) + } + return toSubnet(resp.Subnet), nil +} + +// UpdateSubnet 实现 Client:当前仅支持改名。 +func (c *RealClient) UpdateSubnet(ctx context.Context, cred Credentials, region, subnetID, displayName string) (Subnet, error) { + vn, err := c.vcnClient(cred, region) + if err != nil { + return Subnet{}, err + } + resp, err := vn.UpdateSubnet(ctx, core.UpdateSubnetRequest{ + SubnetId: &subnetID, + UpdateSubnetDetails: core.UpdateSubnetDetails{DisplayName: &displayName}, + }) + if err != nil { + return Subnet{}, fmt.Errorf("update subnet %s: %w", subnetID, err) + } + return toSubnet(resp.Subnet), nil +} + +// DeleteSubnet 实现 Client。 +func (c *RealClient) DeleteSubnet(ctx context.Context, cred Credentials, region, subnetID string) error { + vn, err := c.vcnClient(cred, region) + if err != nil { + return err + } + if _, err := vn.DeleteSubnet(ctx, core.DeleteSubnetRequest{SubnetId: &subnetID}); err != nil { + return fmt.Errorf("delete subnet %s: %w", subnetID, err) + } + return nil +} + +// ListSecurityLists 实现 Client;vcnID 为空时列出全部安全列表。 +func (c *RealClient) ListSecurityLists(ctx context.Context, cred Credentials, region, vcnID string) ([]SecurityList, error) { + vn, err := c.vcnClient(cred, region) + if err != nil { + return nil, err + } + compartmentID, err := c.vcnScopedCompartment(ctx, cred, region, vcnID) + if err != nil { + return nil, err + } + var lists []SecurityList + var page *string + for { + req := core.ListSecurityListsRequest{CompartmentId: &compartmentID, Page: page} + if vcnID != "" { + req.VcnId = &vcnID + } + resp, err := vn.ListSecurityLists(ctx, req) + if err != nil { + return nil, fmt.Errorf("list security lists: %w", err) + } + for _, item := range resp.Items { + lists = append(lists, toSecurityList(item)) + } + if resp.OpcNextPage == nil { + return orEmpty(lists), nil + } + page = resp.OpcNextPage + } +} + +// CreateSecurityList 实现 Client。 +// 安全列表跟随其 VCN 所在 compartment 创建,避免跨 compartment 悬挂资源。 +func (c *RealClient) CreateSecurityList(ctx context.Context, cred Credentials, in CreateSecurityListInput) (SecurityList, error) { + vn, err := c.vcnClient(cred, in.Region) + if err != nil { + return SecurityList{}, err + } + compartmentID, err := c.vcnScopedCompartment(ctx, cred, in.Region, in.VcnID) + if err != nil { + return SecurityList{}, err + } + resp, err := vn.CreateSecurityList(ctx, core.CreateSecurityListRequest{ + CreateSecurityListDetails: core.CreateSecurityListDetails{ + CompartmentId: &compartmentID, + VcnId: &in.VcnID, + DisplayName: &in.DisplayName, + IngressSecurityRules: toIngressRules(in.IngressRules), + EgressSecurityRules: toEgressRules(in.EgressRules), + }, + }) + if err != nil { + return SecurityList{}, fmt.Errorf("create security list: %w", err) + } + return toSecurityList(resp.SecurityList), nil +} + +// GetSecurityList 实现 Client。 +func (c *RealClient) GetSecurityList(ctx context.Context, cred Credentials, region, securityListID string) (SecurityList, error) { + vn, err := c.vcnClient(cred, region) + if err != nil { + return SecurityList{}, err + } + resp, err := vn.GetSecurityList(ctx, core.GetSecurityListRequest{SecurityListId: &securityListID}) + if err != nil { + return SecurityList{}, fmt.Errorf("get security list %s: %w", securityListID, err) + } + return toSecurityList(resp.SecurityList), nil +} + +// UpdateSecurityList 实现 Client。 +func (c *RealClient) UpdateSecurityList(ctx context.Context, cred Credentials, securityListID string, in UpdateSecurityListInput) (SecurityList, error) { + vn, err := c.vcnClient(cred, in.Region) + if err != nil { + return SecurityList{}, err + } + details := core.UpdateSecurityListDetails{} + if in.DisplayName != "" { + details.DisplayName = &in.DisplayName + } + if in.IngressRules != nil { + details.IngressSecurityRules = toIngressRules(*in.IngressRules) + } + if in.EgressRules != nil { + details.EgressSecurityRules = toEgressRules(*in.EgressRules) + } + resp, err := vn.UpdateSecurityList(ctx, core.UpdateSecurityListRequest{ + SecurityListId: &securityListID, + UpdateSecurityListDetails: details, + }) + if err != nil { + return SecurityList{}, fmt.Errorf("update security list %s: %w", securityListID, err) + } + return toSecurityList(resp.SecurityList), nil +} + +// DeleteSecurityList 实现 Client。 +func (c *RealClient) DeleteSecurityList(ctx context.Context, cred Credentials, region, securityListID string) error { + vn, err := c.vcnClient(cred, region) + if err != nil { + return err + } + if _, err := vn.DeleteSecurityList(ctx, core.DeleteSecurityListRequest{SecurityListId: &securityListID}); err != nil { + return fmt.Errorf("delete security list %s: %w", securityListID, err) + } + return nil +} + +func toVCN(v core.Vcn) VCN { + return VCN{ + ID: deref(v.Id), + DisplayName: deref(v.DisplayName), + LifecycleState: string(v.LifecycleState), + CompartmentID: deref(v.CompartmentId), + CidrBlocks: orEmpty(v.CidrBlocks), + Ipv6CidrBlocks: orEmpty(v.Ipv6CidrBlocks), + DnsLabel: deref(v.DnsLabel), + DefaultRouteTableID: deref(v.DefaultRouteTableId), + DefaultSecurityListID: deref(v.DefaultSecurityListId), + TimeCreated: sdkTime(v.TimeCreated), + } +} + +func toSubnet(s core.Subnet) Subnet { + return Subnet{ + ID: deref(s.Id), + VcnID: deref(s.VcnId), + DisplayName: deref(s.DisplayName), + LifecycleState: string(s.LifecycleState), + CidrBlock: deref(s.CidrBlock), + Ipv6CidrBlock: deref(s.Ipv6CidrBlock), + DnsLabel: deref(s.DnsLabel), + ProhibitPublicIP: s.ProhibitPublicIpOnVnic != nil && *s.ProhibitPublicIpOnVnic, + AvailabilityDomain: deref(s.AvailabilityDomain), + RouteTableID: deref(s.RouteTableId), + TimeCreated: sdkTime(s.TimeCreated), + } +} + +func toSecurityList(sl core.SecurityList) SecurityList { + out := SecurityList{ + ID: deref(sl.Id), + VcnID: deref(sl.VcnId), + DisplayName: deref(sl.DisplayName), + LifecycleState: string(sl.LifecycleState), + TimeCreated: sdkTime(sl.TimeCreated), + IngressRules: make([]SecurityRule, 0, len(sl.IngressSecurityRules)), + EgressRules: make([]SecurityRule, 0, len(sl.EgressSecurityRules)), + } + for _, r := range sl.IngressSecurityRules { + rule := SecurityRule{ + Protocol: deref(r.Protocol), + Source: deref(r.Source), + IsStateless: r.IsStateless != nil && *r.IsStateless, + Description: deref(r.Description), + } + fillRuleOptions(&rule, r.TcpOptions, r.UdpOptions, r.IcmpOptions) + out.IngressRules = append(out.IngressRules, rule) + } + for _, r := range sl.EgressSecurityRules { + rule := SecurityRule{ + Protocol: deref(r.Protocol), + Destination: deref(r.Destination), + IsStateless: r.IsStateless != nil && *r.IsStateless, + Description: deref(r.Description), + } + fillRuleOptions(&rule, r.TcpOptions, r.UdpOptions, r.IcmpOptions) + out.EgressRules = append(out.EgressRules, rule) + } + return out +} + +func fillRuleOptions(rule *SecurityRule, tcp *core.TcpOptions, udp *core.UdpOptions, icmp *core.IcmpOptions) { + switch { + case tcp != nil && tcp.DestinationPortRange != nil: + rule.PortMin = tcp.DestinationPortRange.Min + rule.PortMax = tcp.DestinationPortRange.Max + case udp != nil && udp.DestinationPortRange != nil: + rule.PortMin = udp.DestinationPortRange.Min + rule.PortMax = udp.DestinationPortRange.Max + case icmp != nil: + rule.IcmpType = icmp.Type + rule.IcmpCode = icmp.Code + } +} + +func toIngressRules(rules []SecurityRule) []core.IngressSecurityRule { + out := make([]core.IngressSecurityRule, 0, len(rules)) + for i := range rules { + r := rules[i] + sdk := core.IngressSecurityRule{ + Protocol: &r.Protocol, + Source: &r.Source, + IsStateless: &rules[i].IsStateless, + } + if r.Description != "" { + sdk.Description = &rules[i].Description + } + sdk.TcpOptions, sdk.UdpOptions, sdk.IcmpOptions = buildRuleOptions(r) + out = append(out, sdk) + } + return out +} + +func toEgressRules(rules []SecurityRule) []core.EgressSecurityRule { + out := make([]core.EgressSecurityRule, 0, len(rules)) + for i := range rules { + r := rules[i] + sdk := core.EgressSecurityRule{ + Protocol: &r.Protocol, + Destination: &r.Destination, + IsStateless: &rules[i].IsStateless, + } + if r.Description != "" { + sdk.Description = &rules[i].Description + } + sdk.TcpOptions, sdk.UdpOptions, sdk.IcmpOptions = buildRuleOptions(r) + out = append(out, sdk) + } + return out +} + +// buildRuleOptions 按协议号构造端口或 ICMP 选项:6=TCP、17=UDP、1/58=ICMP(v6)。 +func buildRuleOptions(r SecurityRule) (*core.TcpOptions, *core.UdpOptions, *core.IcmpOptions) { + if r.PortMin != nil { + portMax := r.PortMax + if portMax == nil { + portMax = r.PortMin + } + pr := &core.PortRange{Min: r.PortMin, Max: portMax} + switch r.Protocol { + case "6": + return &core.TcpOptions{DestinationPortRange: pr}, nil, nil + case "17": + return nil, &core.UdpOptions{DestinationPortRange: pr}, nil + } + } + if r.IcmpType != nil && (r.Protocol == "1" || r.Protocol == "58") { + return nil, nil, &core.IcmpOptions{Type: r.IcmpType, Code: r.IcmpCode} + } + return nil, nil, nil +} diff --git a/internal/oci/proxyhttp.go b/internal/oci/proxyhttp.go new file mode 100644 index 0000000..6312aa0 --- /dev/null +++ b/internal/oci/proxyhttp.go @@ -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} +} diff --git a/internal/oci/publicip.go b/internal/oci/publicip.go new file mode 100644 index 0000000..ca85cb9 --- /dev/null +++ b/internal/oci/publicip.go @@ -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") +} diff --git a/internal/oci/real_federation_integration_test.go b/internal/oci/real_federation_integration_test.go new file mode 100644 index 0000000..06f4191 --- /dev/null +++ b/internal/oci/real_federation_integration_test.go @@ -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 +} diff --git a/internal/oci/real_instance_integration_test.go b/internal/oci/real_instance_integration_test.go new file mode 100644 index 0000000..c5df6ad --- /dev/null +++ b/internal/oci/real_instance_integration_test.go @@ -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) +} diff --git a/internal/oci/real_instance_ip_integration_test.go b/internal/oci/real_instance_ip_integration_test.go new file mode 100644 index 0000000..ddf86d8 --- /dev/null +++ b/internal/oci/real_instance_ip_integration_test.go @@ -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) +} diff --git a/internal/oci/real_integration_test.go b/internal/oci/real_integration_test.go new file mode 100644 index 0000000..a7ebf10 --- /dev/null +++ b/internal/oci/real_integration_test.go @@ -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) + } + }) + } +} diff --git a/internal/oci/real_network_integration_test.go b/internal/oci/real_network_integration_test.go new file mode 100644 index 0000000..cdb3971 --- /dev/null +++ b/internal/oci/real_network_integration_test.go @@ -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-。 +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 } diff --git a/internal/oci/real_tenant_integration_test.go b/internal/oci/real_tenant_integration_test.go new file mode 100644 index 0000000..b3c37d6 --- /dev/null +++ b/internal/oci/real_tenant_integration_test.go @@ -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) + } + } +} diff --git a/internal/oci/regions.go b/internal/oci/regions.go new file mode 100644 index 0000000..06933d0 --- /dev/null +++ b/internal/oci/regions.go @@ -0,0 +1,54 @@ +package oci + +import ( + _ "embed" + "encoding/json" + "fmt" + "sort" + "strings" + "sync" +) + +// regionsJSON 是本地维护的 OCI 区域表。官方 API(ListRegions、 +// ListRegionSubscriptions 等)只返回 key 和 name,控制台风格的 +// 友好名称(alias)官方不提供查询接口,因此在此文件旁的 +// regions.json 中单独维护,新增区域时直接编辑该文件。 +// +//go:embed regions.json +var regionsJSON []byte + +// RegionInfo 描述一个 OCI 区域。 +type RegionInfo struct { + Alias string `json:"alias"` + Key string `json:"key"` + Name string `json:"name"` +} + +var loadRegions = sync.OnceValues(func() ([]RegionInfo, error) { + var regions []RegionInfo + if err := json.Unmarshal(regionsJSON, ®ions); err != nil { + return nil, fmt.Errorf("parse embedded regions.json: %w", err) + } + // 全局统一按友好名排序,维护 regions.json 时无需关心条目顺序 + sort.SliceStable(regions, func(i, j int) bool { return regions[i].Alias < regions[j].Alias }) + return regions, nil +}) + +// AllRegions 返回本地维护的完整区域表。 +func AllRegions() ([]RegionInfo, error) { + return loadRegions() +} + +// RegionByKey 按三字码(如 FRA)查找区域,大小写不敏感。 +func RegionByKey(key string) (RegionInfo, bool) { + regions, err := loadRegions() + if err != nil { + return RegionInfo{}, false + } + for _, r := range regions { + if strings.EqualFold(r.Key, key) { + return r, true + } + } + return RegionInfo{}, false +} diff --git a/internal/oci/regions.json b/internal/oci/regions.json new file mode 100644 index 0000000..01dfaae --- /dev/null +++ b/internal/oci/regions.json @@ -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" } +] diff --git a/internal/oci/regions_test.go b/internal/oci/regions_test.go new file mode 100644 index 0000000..1a84b19 --- /dev/null +++ b/internal/oci/regions_test.go @@ -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) + } + }) + } +} diff --git a/internal/oci/scimerror.go b/internal/oci/scimerror.go new file mode 100644 index 0000000..f1fd7e2 --- /dev/null +++ b/internal/oci/scimerror.go @@ -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 +} diff --git a/internal/oci/scimerror_test.go b/internal/oci/scimerror_test.go new file mode 100644 index 0000000..0a07f26 --- /dev/null +++ b/internal/oci/scimerror_test.go @@ -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: `bad gateway`, + 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) + } + }) + } +} diff --git a/internal/oci/shape.go b/internal/oci/shape.go new file mode 100644 index 0000000..3578f32 --- /dev/null +++ b/internal/oci/shape.go @@ -0,0 +1,115 @@ +package oci + +import ( + "context" + "fmt" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/core" +) + +// shapeCacheTTL 是 shape 清单缓存时长:区域提供的 shape 极少变化。 +const shapeCacheTTL = 6 * time.Hour + +// ComputeShape 是租户在目标区域实际可用的一种实例规格。 +// Flex 规格带 Ocpus/Memory 的可选范围;固定规格带其固定值。 +type ComputeShape struct { + Name string `json:"name"` + BillingType string `json:"billingType"` // ALWAYS_FREE / LIMITED_FREE / PAID + IsFlexible bool `json:"isFlexible"` + Ocpus float32 `json:"ocpus,omitempty"` + MemoryInGBs float32 `json:"memoryInGBs,omitempty"` + OcpusMin float32 `json:"ocpusMin,omitempty"` + OcpusMax float32 `json:"ocpusMax,omitempty"` + MemoryMinGBs float32 `json:"memoryMinGBs,omitempty"` + MemoryMaxGBs float32 `json:"memoryMaxGBs,omitempty"` + Gpus int `json:"gpus,omitempty"` + ProcessorDescription string `json:"processorDescription,omitempty"` +} + +// shapeCacheEntry 是一份 shape 清单的缓存条目。 +type shapeCacheEntry struct { + expires time.Time + shapes []ComputeShape +} + +// ListShapes 实现 Client:列出租户在目标区域可用的实例规格(带 TTL 缓存)。 +// availabilityDomain 为空时返回区域级去重清单。 +func (c *RealClient) ListShapes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]ComputeShape, error) { + key := cred.TenancyOCID + "|" + region + "|" + availabilityDomain + if v, ok := c.shapes.Load(key); ok { + if e := v.(shapeCacheEntry); time.Now().Before(e.expires) { + return e.shapes, nil + } + } + shapes, err := c.fetchShapes(ctx, cred, region, availabilityDomain) + if err != nil { + return nil, err + } + c.shapes.Store(key, shapeCacheEntry{expires: time.Now().Add(shapeCacheTTL), shapes: shapes}) + return shapes, nil +} + +// fetchShapes 分页拉取 shape 清单并按名称去重(不带 AD 时 OCI 每个可用域各返回一份)。 +func (c *RealClient) fetchShapes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]ComputeShape, error) { + cc, err := c.computeClient(cred, region) + if err != nil { + return nil, err + } + shapes := []ComputeShape{} + seen := map[string]bool{} + var page *string + for { + req := core.ListShapesRequest{ + CompartmentId: cred.EffectiveCompartment(), + Limit: common.Int(500), + Page: page, + } + if availabilityDomain != "" { + req.AvailabilityDomain = &availabilityDomain + } + resp, err := cc.ListShapes(ctx, req) + if err != nil { + return nil, fmt.Errorf("list shapes: %w", err) + } + for _, item := range resp.Items { + if name := deref(item.Shape); !seen[name] { + seen[name] = true + shapes = append(shapes, toComputeShape(item)) + } + } + if resp.OpcNextPage == nil { + return shapes, nil + } + page = resp.OpcNextPage + } +} + +func toComputeShape(s core.Shape) ComputeShape { + out := ComputeShape{ + Name: deref(s.Shape), + BillingType: string(s.BillingType), + IsFlexible: s.OcpuOptions != nil, + ProcessorDescription: deref(s.ProcessorDescription), + } + out.Ocpus, out.MemoryInGBs = deref32(s.Ocpus), deref32(s.MemoryInGBs) + if s.Gpus != nil { + out.Gpus = *s.Gpus + } + if o := s.OcpuOptions; o != nil { + out.OcpusMin, out.OcpusMax = deref32(o.Min), deref32(o.Max) + } + if m := s.MemoryOptions; m != nil { + out.MemoryMinGBs, out.MemoryMaxGBs = deref32(m.MinInGBs), deref32(m.MaxInGBs) + } + return out +} + +// deref32 解引用 *float32,nil 时返回零值。 +func deref32(p *float32) float32 { + if p == nil { + return 0 + } + return *p +} diff --git a/internal/oci/signon.go b/internal/oci/signon.go new file mode 100644 index 0000000..a508a5a --- /dev/null +++ b/internal/oci/signon.go @@ -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}) +} diff --git a/internal/oci/subscription.go b/internal/oci/subscription.go new file mode 100644 index 0000000..b27d1a2 --- /dev/null +++ b/internal/oci/subscription.go @@ -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 +} diff --git a/internal/oci/tenant.go b/internal/oci/tenant.go new file mode 100644 index 0000000..c21cb21 --- /dev/null +++ b/internal/oci/tenant.go @@ -0,0 +1,361 @@ +package oci + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/identity" + "github.com/oracle/oci-go-sdk/v65/limits" +) + +// maxAvailabilityLookups 限制附带用量查询的条数: +// GetResourceAvailability 每条配额一次调用(OCI 无批量接口), +// 定义预筛 + 并发查询后 100 条约需 1~3 秒。 +const maxAvailabilityLookups = 100 + +// limitsPageSize 是 limits 系列 List 接口的显式分页大小(OCI 通用上限 1000); +// 不传时服务端默认约百条一页,全量拉取要反复翻页。 +const limitsPageSize = 1000 + +// availabilityConcurrency 是用量查询并发度;Limits 端点 TPS 不高,并发过高易撞限流。 +const availabilityConcurrency = 8 + +// limitDefTTL 是配额定义缓存时长:定义(是否支持用量查询)几乎不随时间变化。 +const limitDefTTL = 6 * time.Hour + +// RegionSubscription 是租户对一个区域的订阅状态。 +type RegionSubscription struct { + Key string `json:"key"` + Name string `json:"name"` + Status string `json:"status"` + IsHomeRegion bool `json:"isHomeRegion"` +} + +// LimitValue 是一条服务配额;Used/Available 仅在请求用量时填充, +// 且 OCI 对部分配额不提供用量数据,此时保持为 nil。 +type LimitValue struct { + Name string `json:"name"` + ScopeType string `json:"scopeType"` + AvailabilityDomain string `json:"availabilityDomain,omitempty"` + Value int64 `json:"value"` + Used *int64 `json:"used,omitempty"` + Available *int64 `json:"available,omitempty"` +} + +// LimitsQuery 是一次配额查询的条件。 +type LimitsQuery struct { + Region string // 目标区域,空则用凭据默认区域 + Service string // 服务名,如 compute + Name string // 可选,配额名模糊过滤(大小写不敏感子串,本地过滤) + ScopeType string // 可选 GLOBAL / REGION / AD,下推给 OCI 过滤 + AvailabilityDomain string // 可选,AD 全名,下推给 OCI 过滤 + NonZero bool // 过滤掉上限为 0 的配额项(在用量查询前生效,不占并发名额) + WithAvailability bool // 并发附带 used/available +} + +// LimitService 是配额体系支持的一个服务。 +type LimitService struct { + Name string `json:"name"` + Description string `json:"description"` +} + +// ListRegionSubscriptions 实现 Client。 +func (c *RealClient) ListRegionSubscriptions(ctx context.Context, cred Credentials) ([]RegionSubscription, error) { + ic, err := identity.NewIdentityClientWithConfigurationProvider(provider(cred)) + if err != nil { + return nil, fmt.Errorf("new identity client: %w", err) + } + applyProxy(&ic.BaseClient, cred) + resp, err := ic.ListRegionSubscriptions(ctx, identity.ListRegionSubscriptionsRequest{ + TenancyId: &cred.TenancyOCID, + }) + if err != nil { + return nil, fmt.Errorf("list region subscriptions: %w", err) + } + subs := make([]RegionSubscription, 0, len(resp.Items)) + for _, item := range resp.Items { + subs = append(subs, RegionSubscription{ + Key: deref(item.RegionKey), + Name: deref(item.RegionName), + Status: string(item.Status), + IsHomeRegion: item.IsHomeRegion != nil && *item.IsHomeRegion, + }) + } + SortRegionSubscriptions(subs) + return subs, nil +} + +// SortRegionSubscriptions 主区域置顶,其余按友好名(alias)字典序, +// 本地表查不到 alias 时回退区域名;实时接口与缓存读取共用,全局统一展示顺序。 +func SortRegionSubscriptions(subs []RegionSubscription) { + sortKey := func(sub RegionSubscription) string { + if info, ok := RegionByKey(sub.Key); ok { + return info.Alias + } + return sub.Name + } + sort.SliceStable(subs, func(i, j int) bool { + if subs[i].IsHomeRegion != subs[j].IsHomeRegion { + return subs[i].IsHomeRegion + } + return sortKey(subs[i]) < sortKey(subs[j]) + }) +} + +// SubscribeRegion 实现 Client:订阅新区域。OCI 要求该请求发往 +// home region,且区域订阅一经创建不可取消。 +func (c *RealClient) SubscribeRegion(ctx context.Context, cred Credentials, homeRegion, regionKey string) error { + ic, err := identity.NewIdentityClientWithConfigurationProvider(provider(cred)) + if err != nil { + return fmt.Errorf("new identity client: %w", err) + } + applyProxy(&ic.BaseClient, cred) + if homeRegion != "" { + ic.SetRegion(normalizeRegion(homeRegion)) + } + _, err = ic.CreateRegionSubscription(ctx, identity.CreateRegionSubscriptionRequest{ + TenancyId: &cred.TenancyOCID, + CreateRegionSubscriptionDetails: identity.CreateRegionSubscriptionDetails{ + RegionKey: ®ionKey, + }, + }) + if err != nil { + return fmt.Errorf("create region subscription %s: %w", regionKey, err) + } + return nil +} + +// limitsClient 构造 limits 服务客户端;region 为空时用凭据默认区域。 +func limitsClient(cred Credentials, region string) (limits.LimitsClient, error) { + lc, err := limits.NewLimitsClientWithConfigurationProvider(provider(cred)) + if err != nil { + return lc, fmt.Errorf("new limits client: %w", err) + } + applyProxy(&lc.BaseClient, cred) + if region != "" { + lc.SetRegion(normalizeRegion(region)) + } + return lc, nil +} + +// ListLimits 实现 Client:全量拉取配额(scopeType / AD 条件下推), +// 名称本地子串过滤,按需并发附带用量。 +func (c *RealClient) ListLimits(ctx context.Context, cred Credentials, q LimitsQuery) ([]LimitValue, error) { + lc, err := limitsClient(cred, q.Region) + if err != nil { + return nil, err + } + values, err := listLimitValues(ctx, lc, cred.TenancyOCID, q) + if err != nil { + return nil, err + } + values = filterLimitsByName(values, q.Name) + if q.NonZero { + values = filterNonZero(values) + } + if !q.WithAvailability { + return values, nil + } + if len(values) > maxAvailabilityLookups { + return nil, fmt.Errorf("list limits: %d entries exceed availability lookup cap %d, narrow with name filter", + len(values), maxAvailabilityLookups) + } + supported := c.availabilitySupport(ctx, lc, cred.TenancyOCID, q.Region, q.Service) + attachAvailability(ctx, lc, cred.TenancyOCID, q.Service, values, supported) + return values, nil +} + +// listLimitValues 分页取回一个服务的配额;scopeType / availabilityDomain 下推给 OCI。 +// OCI 的 name 参数只支持精确匹配,子串过滤由调用方本地完成。 +func listLimitValues(ctx context.Context, lc limits.LimitsClient, tenancyOCID string, q LimitsQuery) ([]LimitValue, error) { + var values []LimitValue + var page *string + for { + req := limits.ListLimitValuesRequest{ + CompartmentId: &tenancyOCID, + ServiceName: &q.Service, + Limit: common.Int(limitsPageSize), + Page: page, + } + if q.ScopeType != "" { + req.ScopeType = limits.ListLimitValuesScopeTypeEnum(q.ScopeType) + } + if q.AvailabilityDomain != "" { + req.AvailabilityDomain = &q.AvailabilityDomain + } + resp, err := lc.ListLimitValues(ctx, req) + if err != nil { + return nil, fmt.Errorf("list limit values of %s: %w", q.Service, err) + } + for _, item := range resp.Items { + values = append(values, toLimitValue(item)) + } + if resp.OpcNextPage == nil { + return orEmpty(values), nil + } + page = resp.OpcNextPage + } +} + +func toLimitValue(item limits.LimitValueSummary) LimitValue { + var value int64 + if item.Value != nil { + value = *item.Value + } + return LimitValue{ + Name: deref(item.Name), + ScopeType: string(item.ScopeType), + AvailabilityDomain: deref(item.AvailabilityDomain), + Value: value, + } +} + +// filterLimitsByName 按大小写不敏感的子串匹配过滤配额名。 +func filterLimitsByName(values []LimitValue, name string) []LimitValue { + if name == "" { + return values + } + needle := strings.ToLower(strings.TrimSpace(name)) + filtered := make([]LimitValue, 0, len(values)) + for _, v := range values { + if strings.Contains(strings.ToLower(v.Name), needle) { + filtered = append(filtered, v) + } + } + return filtered +} + +// filterNonZero 过滤掉上限为 0 的配额项。 +func filterNonZero(values []LimitValue) []LimitValue { + filtered := make([]LimitValue, 0, len(values)) + for _, v := range values { + if v.Value > 0 { + filtered = append(filtered, v) + } + } + return filtered +} + +// ListLimitServices 实现 Client:分页列出配额体系支持的全部服务。 +func (c *RealClient) ListLimitServices(ctx context.Context, cred Credentials, region string) ([]LimitService, error) { + lc, err := limitsClient(cred, region) + if err != nil { + return nil, err + } + var services []LimitService + var page *string + for { + resp, err := lc.ListServices(ctx, limits.ListServicesRequest{ + CompartmentId: &cred.TenancyOCID, + Limit: common.Int(limitsPageSize), + Page: page, + }) + if err != nil { + return nil, fmt.Errorf("list limit services: %w", err) + } + for _, item := range resp.Items { + services = append(services, LimitService{ + Name: deref(item.Name), + Description: deref(item.Description), + }) + } + if resp.OpcNextPage == nil { + return orEmpty(services), nil + } + page = resp.OpcNextPage + } +} + +// limitDefEntry 是一份配额定义预筛结果的缓存条目。 +type limitDefEntry struct { + expires time.Time + supported map[string]bool +} + +// availabilitySupport 返回配额名 → 是否支持用量查询的映射(带 TTL 缓存); +// 拉取失败时返回 nil,调用方视为全部照查(保守降级)。 +func (c *RealClient) availabilitySupport(ctx context.Context, lc limits.LimitsClient, tenancyOCID, region, service string) map[string]bool { + key := tenancyOCID + "|" + region + "|" + service + if v, ok := c.limitDefs.Load(key); ok { + if e := v.(limitDefEntry); time.Now().Before(e.expires) { + return e.supported + } + } + supported, err := listLimitDefinitions(ctx, lc, tenancyOCID, service) + if err != nil { + return nil + } + c.limitDefs.Store(key, limitDefEntry{expires: time.Now().Add(limitDefTTL), supported: supported}) + return supported +} + +// listLimitDefinitions 分页取回一个服务的配额定义,产出配额名 → 是否支持用量查询的映射; +// 字段缺失(nil)视为支持,仅明确 false 的会被预筛跳过。 +func listLimitDefinitions(ctx context.Context, lc limits.LimitsClient, tenancyOCID, service string) (map[string]bool, error) { + supported := map[string]bool{} + var page *string + for { + resp, err := lc.ListLimitDefinitions(ctx, limits.ListLimitDefinitionsRequest{ + CompartmentId: &tenancyOCID, + ServiceName: &service, + Limit: common.Int(limitsPageSize), + Page: page, + }) + if err != nil { + return nil, fmt.Errorf("list limit definitions of %s: %w", service, err) + } + for _, item := range resp.Items { + if item.Name == nil { + continue + } + supported[*item.Name] = item.IsResourceAvailabilitySupported == nil || *item.IsResourceAvailabilitySupported + } + if resp.OpcNextPage == nil { + return supported, nil + } + page = resp.OpcNextPage + } +} + +// attachAvailability 并发查询用量:预筛掉明确不支持用量查询的配额, +// 单条失败不影响整体,字段保持 nil。 +func attachAvailability(ctx context.Context, lc limits.LimitsClient, tenancyOCID, service string, values []LimitValue, supported map[string]bool) { + sem := make(chan struct{}, availabilityConcurrency) + var wg sync.WaitGroup + for i := range values { + if s, ok := supported[values[i].Name]; ok && !s { + continue + } + wg.Add(1) + sem <- struct{}{} + go func(v *LimitValue) { + defer wg.Done() + defer func() { <-sem }() + fetchAvailability(ctx, lc, tenancyOCID, service, v) + }(&values[i]) + } + wg.Wait() +} + +// fetchAvailability 查询单条配额的用量;AD 级配额必须带 availabilityDomain。 +func fetchAvailability(ctx context.Context, lc limits.LimitsClient, tenancyOCID, service string, v *LimitValue) { + req := limits.GetResourceAvailabilityRequest{ + ServiceName: &service, + LimitName: &v.Name, + CompartmentId: &tenancyOCID, + } + if strings.EqualFold(v.ScopeType, "AD") { + req.AvailabilityDomain = &v.AvailabilityDomain + } + resp, err := lc.GetResourceAvailability(ctx, req) + if err != nil { + return + } + v.Used = resp.Used + v.Available = resp.Available +} diff --git a/internal/oci/tenant_test.go b/internal/oci/tenant_test.go new file mode 100644 index 0000000..db07887 --- /dev/null +++ b/internal/oci/tenant_test.go @@ -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]) + } + } + }) + } +} diff --git a/internal/oci/tenantuser.go b/internal/oci/tenantuser.go new file mode 100644 index 0000000..04d5dad --- /dev/null +++ b/internal/oci/tenantuser.go @@ -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), + } +} diff --git a/internal/oci/tenantuser_test.go b/internal/oci/tenantuser_test.go new file mode 100644 index 0000000..5c30210 --- /dev/null +++ b/internal/oci/tenantuser_test.go @@ -0,0 +1,110 @@ +package oci + +import ( + "strings" + "testing" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/identitydomains" +) + +func TestGeneratePassword(t *testing.T) { + for i := 0; i < 20; i++ { + password, err := generatePassword() + if err != nil { + t.Fatalf("generatePassword: %v", err) + } + if len(password) != 16 { + t.Fatalf("len = %d, want 16", len(password)) + } + for _, set := range passwordCharsets { + if !strings.ContainsAny(password, set) { + t.Errorf("password %q missing charset %q", password, set) + } + } + } +} + +func TestToTenantUserDetail(t *testing.T) { + adminRoles := &identitydomains.ExtensionUserUser{ + AppRoles: []identitydomains.UserExtAppRoles{ + {Value: common.String("r1"), Display: common.String("User Administrator")}, + {Value: common.String("r2"), Display: common.String(domainAdminRoleName)}, + }, + } + tests := []struct { + name string + u identitydomains.User + want TenantUserDetail + }{ + { + name: "姓名与双管理员状态齐全", + u: identitydomains.User{ + Name: &identitydomains.UserName{ + GivenName: common.String("De"), + FamilyName: common.String("Wang"), + }, + Groups: []identitydomains.UserGroups{ + {Value: common.String("g1"), Display: common.String("Readers")}, + {Value: common.String("g2"), Display: common.String(administratorsGroupName)}, + }, + UrnIetfParamsScimSchemasOracleIdcsExtensionUserUser: adminRoles, + }, + want: TenantUserDetail{InDomain: true, GivenName: "De", FamilyName: "Wang", IsDomainAdmin: true, InAdminGroup: true}, + }, + { + name: "普通成员无管理员标记", + u: identitydomains.User{ + Name: &identitydomains.UserName{GivenName: common.String("A"), FamilyName: common.String("B")}, + Groups: []identitydomains.UserGroups{{Value: common.String("g1"), Display: common.String("Readers")}}, + }, + want: TenantUserDetail{InDomain: true, GivenName: "A", FamilyName: "B"}, + }, + { + name: "空档案仅置 InDomain", + u: identitydomains.User{}, + want: TenantUserDetail{InDomain: true}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := toTenantUserDetail(tt.u); got != tt.want { + t.Errorf("toTenantUserDetail() = %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestBuildUsageDetails(t *testing.T) { + start := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + tests := []struct { + name string + q CostQuery + wantErr bool + }{ + {name: "daily cost", q: CostQuery{Granularity: "DAILY", QueryType: "COST", GroupBy: "service"}}, + {name: "小写粒度可被归一化", q: CostQuery{Granularity: "monthly", QueryType: "usage", GroupBy: "skuName"}}, + {name: "非法粒度", q: CostQuery{Granularity: "HOURLY2", QueryType: "COST"}, wantErr: true}, + {name: "非法查询类型", q: CostQuery{Granularity: "DAILY", QueryType: "MONEY"}, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.q.StartTime, tt.q.EndTime = start, end + details, err := buildUsageDetails("ocid1.tenancy.oc1..test", tt.q) + if (err != nil) != tt.wantErr { + t.Fatalf("buildUsageDetails() error = %v, wantErr %v", err, tt.wantErr) + } + if err != nil { + return + } + if details.TenantId == nil || *details.TenantId != "ocid1.tenancy.oc1..test" { + t.Error("tenantId not set") + } + if len(details.GroupBy) != 1 || details.GroupBy[0] != tt.q.GroupBy { + t.Errorf("groupBy = %v, want [%s]", details.GroupBy, tt.q.GroupBy) + } + }) + } +} diff --git a/internal/oci/traffic.go b/internal/oci/traffic.go new file mode 100644 index 0000000..20bc462 --- /dev/null +++ b/internal/oci/traffic.go @@ -0,0 +1,129 @@ +package oci + +import ( + "context" + "fmt" + "time" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/monitoring" +) + +// trafficNamespace 是 VNIC 流量指标所在的 Monitoring 命名空间; +// 指标由 OCI 自动上报,原始点 1 分钟一个,这里按天聚合。 +const ( + trafficNamespace = "oci_vcn" + trafficResolution = "1440m" + metricVnicInbound = "VnicFromNetworkBytes" // 实例收到的字节 + metricVnicOutbund = "VnicToNetworkBytes" // 实例发出的字节 +) + +// TrafficQuery 是一次实例流量统计的条件。 +type TrafficQuery struct { + Region string + InstanceID string + StartTime time.Time + EndTime time.Time +} + +// TrafficPoint 是一个聚合窗口(天)的流量字节数。 +type TrafficPoint struct { + Timestamp time.Time `json:"timestamp"` + Bytes float64 `json:"bytes"` +} + +// VnicTraffic 是单块 VNIC 的进出流量。 +type VnicTraffic struct { + VnicID string `json:"vnicId"` + InboundBytes float64 `json:"inboundBytes"` + OutboundBytes float64 `json:"outboundBytes"` + Inbound []TrafficPoint `json:"inbound"` + Outbound []TrafficPoint `json:"outbound"` +} + +// InstanceTraffic 是实例全部 VNIC 的流量汇总。 +type InstanceTraffic struct { + InstanceID string `json:"instanceId"` + InboundBytes float64 `json:"inboundBytes"` + OutboundBytes float64 `json:"outboundBytes"` + Vnics []VnicTraffic `json:"vnics"` +} + +func (c *RealClient) monitoringClient(cred Credentials, region string) (monitoring.MonitoringClient, error) { + mc, err := monitoring.NewMonitoringClientWithConfigurationProvider(provider(cred)) + if err != nil { + return mc, fmt.Errorf("new monitoring client: %w", err) + } + applyProxy(&mc.BaseClient, cred) + if region != "" { + mc.SetRegion(normalizeRegion(region)) + } + return mc, nil +} + +// SummarizeInstanceTraffic 实现 Client:逐 VNIC 查询进出流量并汇总。 +func (c *RealClient) SummarizeInstanceTraffic(ctx context.Context, cred Credentials, q TrafficQuery) (InstanceTraffic, error) { + mc, err := c.monitoringClient(cred, q.Region) + if err != nil { + return InstanceTraffic{}, err + } + // VNIC 指标上报在实例所在 compartment,查询须用同一 compartment 才有数据 + vnics, compartmentID, err := c.instanceVnics(ctx, cred, q.Region, q.InstanceID) + if err != nil { + return InstanceTraffic{}, err + } + result := InstanceTraffic{InstanceID: q.InstanceID, Vnics: make([]VnicTraffic, 0, len(vnics))} + for _, vnic := range vnics { + vt := VnicTraffic{VnicID: deref(vnic.Id)} + if vt.Inbound, err = summarizeVnicMetric(ctx, mc, compartmentID, vt.VnicID, metricVnicInbound, q); err != nil { + return InstanceTraffic{}, err + } + if vt.Outbound, err = summarizeVnicMetric(ctx, mc, compartmentID, vt.VnicID, metricVnicOutbund, q); err != nil { + return InstanceTraffic{}, err + } + vt.Inbound, vt.Outbound = orEmpty(vt.Inbound), orEmpty(vt.Outbound) + vt.InboundBytes, vt.OutboundBytes = sumTraffic(vt.Inbound), sumTraffic(vt.Outbound) + result.InboundBytes += vt.InboundBytes + result.OutboundBytes += vt.OutboundBytes + result.Vnics = append(result.Vnics, vt) + } + return result, nil +} + +// summarizeVnicMetric 按天聚合查询单块 VNIC 的一个流量指标; +// compartmentID 须是实例所在 compartment,指标数据挂在该 compartment 下。 +func summarizeVnicMetric(ctx context.Context, mc monitoring.MonitoringClient, compartmentID, vnicID, metric string, q TrafficQuery) ([]TrafficPoint, error) { + query := fmt.Sprintf("%s[%s]{resourceId = %q}.sum()", metric, trafficResolution, vnicID) + resolution := trafficResolution + resp, err := mc.SummarizeMetricsData(ctx, monitoring.SummarizeMetricsDataRequest{ + CompartmentId: &compartmentID, + SummarizeMetricsDataDetails: monitoring.SummarizeMetricsDataDetails{ + Namespace: common.String(trafficNamespace), + Query: &query, + StartTime: &common.SDKTime{Time: q.StartTime}, + EndTime: &common.SDKTime{Time: q.EndTime}, + Resolution: &resolution, + }, + }) + if err != nil { + return nil, fmt.Errorf("summarize %s of vnic %s: %w", metric, vnicID, err) + } + var points []TrafficPoint + for _, item := range resp.Items { + for _, dp := range item.AggregatedDatapoints { + if dp.Timestamp == nil || dp.Value == nil { + continue + } + points = append(points, TrafficPoint{Timestamp: dp.Timestamp.Time, Bytes: *dp.Value}) + } + } + return orEmpty(points), nil +} + +func sumTraffic(points []TrafficPoint) float64 { + var total float64 + for _, p := range points { + total += p.Bytes + } + return total +} diff --git a/internal/oci/vcndelete.go b/internal/oci/vcndelete.go new file mode 100644 index 0000000..0716447 --- /dev/null +++ b/internal/oci/vcndelete.go @@ -0,0 +1,185 @@ +package oci + +import ( + "context" + "fmt" + "time" + + "github.com/oracle/oci-go-sdk/v65/core" +) + +// 本文件实现 VCN 的级联删除:OCI 要求 VCN 删除前先清空其子网、网关、 +// 非默认路由表 / 安全列表 / DHCP 选项,顺序参照控制台删除向导。 +// 子网仍被 VNIC 占用(实例未终止)时 OCI 返回 409 IncorrectState, +// 该错误经 api 层提炼后提示用户先终止实例,不在此做实例级清理。 + +// deleteVcnCascade 依次清理关联资源后删除 VCN。 +func deleteVcnCascade(ctx context.Context, vn core.VirtualNetworkClient, vcnID string) error { + vcnResp, err := vn.GetVcn(ctx, core.GetVcnRequest{VcnId: &vcnID}) + if err != nil { + return fmt.Errorf("get vcn %s: %w", vcnID, err) + } + comp := deref(vcnResp.CompartmentId) + if err := deleteVcnSubnets(ctx, vn, comp, vcnID); err != nil { + return err + } + if err := clearVcnRouteTables(ctx, vn, comp, vcnID, deref(vcnResp.DefaultRouteTableId)); err != nil { + return err + } + if err := deleteVcnGateways(ctx, vn, comp, vcnID); err != nil { + return err + } + if err := deleteVcnSecondaries(ctx, vn, comp, vcnID, vcnResp.Vcn); err != nil { + return err + } + if _, err := vn.DeleteVcn(ctx, core.DeleteVcnRequest{VcnId: &vcnID}); err != nil { + return fmt.Errorf("delete vcn %s: %w", vcnID, err) + } + return nil +} + +// deleteVcnSubnets 删除 VCN 下全部子网并等待终止完成。 +func deleteVcnSubnets(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID string) error { + resp, err := vn.ListSubnets(ctx, core.ListSubnetsRequest{CompartmentId: &compartmentID, VcnId: &vcnID}) + if err != nil { + return fmt.Errorf("list subnets: %w", err) + } + for _, s := range resp.Items { + if s.LifecycleState == core.SubnetLifecycleStateTerminated || + s.LifecycleState == core.SubnetLifecycleStateTerminating { + continue + } + if _, err := vn.DeleteSubnet(ctx, core.DeleteSubnetRequest{SubnetId: s.Id}); err != nil { + return fmt.Errorf("delete subnet %s: %w", deref(s.DisplayName), err) + } + } + return waitSubnetsGone(ctx, vn, compartmentID, vcnID) +} + +// waitSubnetsGone 轮询等待 VCN 下子网全部终止,超时 60 秒。 +func waitSubnetsGone(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID string) error { + for i := 0; i < 30; i++ { + resp, err := vn.ListSubnets(ctx, core.ListSubnetsRequest{CompartmentId: &compartmentID, VcnId: &vcnID}) + if err != nil { + return fmt.Errorf("wait subnets terminated: %w", err) + } + alive := 0 + for _, s := range resp.Items { + if s.LifecycleState != core.SubnetLifecycleStateTerminated { + alive++ + } + } + if alive == 0 { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + } + return fmt.Errorf("wait subnets terminated: timeout after 60s") +} + +// clearVcnRouteTables 清空所有路由表规则(解除对网关的引用)并删除非默认路由表。 +func clearVcnRouteTables(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID, defaultRtID string) error { + resp, err := vn.ListRouteTables(ctx, core.ListRouteTablesRequest{CompartmentId: &compartmentID, VcnId: &vcnID}) + if err != nil { + return fmt.Errorf("list route tables: %w", err) + } + for _, rt := range resp.Items { + if len(rt.RouteRules) > 0 { + if _, err := vn.UpdateRouteTable(ctx, core.UpdateRouteTableRequest{ + RtId: rt.Id, + UpdateRouteTableDetails: core.UpdateRouteTableDetails{RouteRules: []core.RouteRule{}}, + }); err != nil { + return fmt.Errorf("clear route table %s: %w", deref(rt.DisplayName), err) + } + } + if deref(rt.Id) != defaultRtID { + if _, err := vn.DeleteRouteTable(ctx, core.DeleteRouteTableRequest{RtId: rt.Id}); err != nil { + return fmt.Errorf("delete route table %s: %w", deref(rt.DisplayName), err) + } + } + } + return nil +} + +// deleteVcnGateways 删除 VCN 下的互联网 / NAT / 服务网关。 +func deleteVcnGateways(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID string) error { + if err := deleteInternetGateways(ctx, vn, compartmentID, vcnID); err != nil { + return err + } + if err := deleteNatGateways(ctx, vn, compartmentID, vcnID); err != nil { + return err + } + return deleteServiceGateways(ctx, vn, compartmentID, vcnID) +} + +func deleteInternetGateways(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID string) error { + resp, err := vn.ListInternetGateways(ctx, core.ListInternetGatewaysRequest{CompartmentId: &compartmentID, VcnId: &vcnID}) + if err != nil { + return fmt.Errorf("list internet gateways: %w", err) + } + for _, g := range resp.Items { + if _, err := vn.DeleteInternetGateway(ctx, core.DeleteInternetGatewayRequest{IgId: g.Id}); err != nil { + return fmt.Errorf("delete internet gateway %s: %w", deref(g.DisplayName), err) + } + } + return nil +} + +func deleteNatGateways(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID string) error { + resp, err := vn.ListNatGateways(ctx, core.ListNatGatewaysRequest{CompartmentId: &compartmentID, VcnId: &vcnID}) + if err != nil { + return fmt.Errorf("list nat gateways: %w", err) + } + for _, g := range resp.Items { + if _, err := vn.DeleteNatGateway(ctx, core.DeleteNatGatewayRequest{NatGatewayId: g.Id}); err != nil { + return fmt.Errorf("delete nat gateway %s: %w", deref(g.DisplayName), err) + } + } + return nil +} + +func deleteServiceGateways(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID string) error { + resp, err := vn.ListServiceGateways(ctx, core.ListServiceGatewaysRequest{CompartmentId: &compartmentID, VcnId: &vcnID}) + if err != nil { + return fmt.Errorf("list service gateways: %w", err) + } + for _, g := range resp.Items { + if _, err := vn.DeleteServiceGateway(ctx, core.DeleteServiceGatewayRequest{ServiceGatewayId: g.Id}); err != nil { + return fmt.Errorf("delete service gateway %s: %w", deref(g.DisplayName), err) + } + } + return nil +} + +// deleteVcnSecondaries 删除非默认安全列表与非默认 DHCP 选项。 +func deleteVcnSecondaries(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID string, vcn core.Vcn) error { + slResp, err := vn.ListSecurityLists(ctx, core.ListSecurityListsRequest{CompartmentId: &compartmentID, VcnId: &vcnID}) + if err != nil { + return fmt.Errorf("list security lists: %w", err) + } + for _, sl := range slResp.Items { + if deref(sl.Id) == deref(vcn.DefaultSecurityListId) { + continue + } + if _, err := vn.DeleteSecurityList(ctx, core.DeleteSecurityListRequest{SecurityListId: sl.Id}); err != nil { + return fmt.Errorf("delete security list %s: %w", deref(sl.DisplayName), err) + } + } + dhResp, err := vn.ListDhcpOptions(ctx, core.ListDhcpOptionsRequest{CompartmentId: &compartmentID, VcnId: &vcnID}) + if err != nil { + return fmt.Errorf("list dhcp options: %w", err) + } + for _, d := range dhResp.Items { + if deref(d.Id) == deref(vcn.DefaultDhcpOptionsId) { + continue + } + if _, err := vn.DeleteDhcpOptions(ctx, core.DeleteDhcpOptionsRequest{DhcpId: d.Id}); err != nil { + return fmt.Errorf("delete dhcp options %s: %w", deref(d.DisplayName), err) + } + } + return nil +} diff --git a/internal/oci/vnic.go b/internal/oci/vnic.go new file mode 100644 index 0000000..54ced3a --- /dev/null +++ b/internal/oci/vnic.go @@ -0,0 +1,156 @@ +package oci + +import ( + "context" + "fmt" + "time" + + "github.com/oracle/oci-go-sdk/v65/core" +) + +// Vnic 是实例已附加的虚拟网卡(附加关系与地址信息的合并视图)。 +type Vnic struct { + AttachmentID string `json:"attachmentId"` + VnicID string `json:"vnicId"` + DisplayName string `json:"displayName"` + IsPrimary bool `json:"isPrimary"` + PrivateIP string `json:"privateIp"` + PublicIP string `json:"publicIp"` + Ipv6Addresses []string `json:"ipv6Addresses"` + SubnetID string `json:"subnetId"` + SubnetName string `json:"subnetName,omitempty"` + LifecycleState string `json:"lifecycleState"` + TimeCreated *time.Time `json:"timeCreated"` +} + +// AttachVnicInput 是附加新 VNIC 的输入;PrivateIP 留空自动分配。 +type AttachVnicInput struct { + SubnetID string `json:"subnetId" binding:"required"` + DisplayName string `json:"displayName"` + PrivateIP string `json:"privateIp"` + AssignPublicIP bool `json:"assignPublicIp"` + // AssignIpv6 为 true 时同时自动分配一个 IPv6(要求子网已启用 IPv6)。 + AssignIpv6 bool `json:"assignIpv6"` +} + +// ListInstanceVnics 实现 Client:列出实例全部 VNIC(含附加中/分离中的关系)。 +func (c *RealClient) ListInstanceVnics(ctx context.Context, cred Credentials, region, instanceID string) ([]Vnic, 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,传租户根会漏查 + attResp, err := cc.ListVnicAttachments(ctx, core.ListVnicAttachmentsRequest{ + CompartmentId: hostCompartment(cred, deref(instResp.CompartmentId)), + InstanceId: &instanceID, + }) + if err != nil { + return nil, fmt.Errorf("list vnic attachments: %w", err) + } + vn, err := c.vcnClient(cred, region) + if err != nil { + return nil, err + } + out := make([]Vnic, 0, len(attResp.Items)) + subnetNames := map[string]string{} + for _, att := range attResp.Items { + out = append(out, buildVnic(ctx, vn, att, subnetNames)) + } + return out, nil +} + +// buildVnic 合并附加关系与 VNIC 详情;VNIC 未就绪(附加中)时只有关系字段。 +func buildVnic(ctx context.Context, vn core.VirtualNetworkClient, att core.VnicAttachment, subnetNames map[string]string) Vnic { + v := Vnic{ + AttachmentID: deref(att.Id), + VnicID: deref(att.VnicId), + DisplayName: deref(att.DisplayName), + SubnetID: deref(att.SubnetId), + LifecycleState: string(att.LifecycleState), + TimeCreated: sdkTime(att.TimeCreated), + } + if att.VnicId != nil { + if resp, err := vn.GetVnic(ctx, core.GetVnicRequest{VnicId: att.VnicId}); err == nil { + v.DisplayName = deref(resp.DisplayName) + v.PrivateIP = deref(resp.PrivateIp) + v.PublicIP = deref(resp.PublicIp) + v.Ipv6Addresses = resp.Ipv6Addresses + if resp.IsPrimary != nil { + v.IsPrimary = *resp.IsPrimary + } + if deref(resp.SubnetId) != "" { + v.SubnetID = deref(resp.SubnetId) + } + } + } + v.SubnetName = subnetNameOf(ctx, vn, v.SubnetID, subnetNames) + return v +} + +// subnetNameOf 查子网显示名,map 去重;失败留空不影响列表。 +func subnetNameOf(ctx context.Context, vn core.VirtualNetworkClient, id string, cache map[string]string) string { + if id == "" { + return "" + } + if name, ok := cache[id]; ok { + return name + } + name := "" + if resp, err := vn.GetSubnet(ctx, core.GetSubnetRequest{SubnetId: &id}); err == nil { + name = deref(resp.DisplayName) + } + cache[id] = name + return name +} + +// AttachVnic 实现 Client:为实例附加次要 VNIC;实例须运行中且 shape 有空余 VNIC 配额。 +func (c *RealClient) AttachVnic(ctx context.Context, cred Credentials, region, instanceID string, in AttachVnicInput) (Vnic, error) { + cc, err := c.computeClient(cred, region) + if err != nil { + return Vnic{}, err + } + details := &core.CreateVnicDetails{ + SubnetId: &in.SubnetID, + AssignPublicIp: &in.AssignPublicIP, + } + if in.AssignIpv6 { + details.AssignIpv6Ip = &in.AssignIpv6 + } + if in.DisplayName != "" { + details.DisplayName = &in.DisplayName + } + if in.PrivateIP != "" { + details.PrivateIp = &in.PrivateIP + } + resp, err := cc.AttachVnic(ctx, core.AttachVnicRequest{ + AttachVnicDetails: core.AttachVnicDetails{InstanceId: &instanceID, CreateVnicDetails: details}, + }) + if err != nil { + return Vnic{}, fmt.Errorf("attach vnic: %w", err) + } + att := resp.VnicAttachment + return Vnic{ + AttachmentID: deref(att.Id), + VnicID: deref(att.VnicId), + DisplayName: deref(att.DisplayName), + SubnetID: deref(att.SubnetId), + LifecycleState: string(att.LifecycleState), + TimeCreated: sdkTime(att.TimeCreated), + }, nil +} + +// DetachVnic 实现 Client:分离次要 VNIC(主 VNIC 由 OCI 拒绝)。 +func (c *RealClient) DetachVnic(ctx context.Context, cred Credentials, region, attachmentID string) error { + cc, err := c.computeClient(cred, region) + if err != nil { + return err + } + if _, err := cc.DetachVnic(ctx, core.DetachVnicRequest{VnicAttachmentId: &attachmentID}); err != nil { + return fmt.Errorf("detach vnic %s: %w", attachmentID, err) + } + return nil +} diff --git a/internal/service/aiconvert.go b/internal/service/aiconvert.go new file mode 100644 index 0000000..cf012f7 --- /dev/null +++ b/internal/service/aiconvert.go @@ -0,0 +1,332 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + + "oci-portal/internal/aiwire" +) + +// ErrAiUnsupportedBlock 表示请求含网关无法承接的内容块(仅支持文本与图片)。 +var ErrAiUnsupportedBlock = fmt.Errorf("暂不支持文本与图片以外的内容块") + +// AnthropicToIR 把 Anthropic Messages 请求转为 IR(OpenAI 线格式)。 +// tool_result 块拆为独立 tool 角色消息(一拆多),顶层 system 变首条 system 消息。 +func AnthropicToIR(req aiwire.MessagesRequest) (aiwire.ChatRequest, error) { + ir := aiwire.ChatRequest{ + Model: req.Model, + Temperature: req.Temperature, + TopP: req.TopP, + TopK: req.TopK, + Stop: req.StopSequences, + Stream: req.Stream, + Tools: anthTools(req.Tools), + ToolChoice: anthToolChoice(req.ToolChoice), + } + if req.MaxTokens > 0 { + mt := req.MaxTokens + ir.MaxTokens = &mt + } + if sys := req.SystemText(); sys != "" { + ir.Messages = append(ir.Messages, aiwire.ChatMessage{Role: "system", Content: aiwire.NewTextContent(sys)}) + } + for _, m := range req.Messages { + msgs, err := anthMessageToIR(m) + if err != nil { + return ir, err + } + ir.Messages = append(ir.Messages, msgs...) + } + return ir, nil +} + +// anthMessageToIR 拆解单条 Anthropic 消息;user 消息里的 tool_result 前置为独立 tool 消息, +// image 块转为 IR image_url 部件。 +func anthMessageToIR(m aiwire.AnthMessage) ([]aiwire.ChatMessage, error) { + var out []aiwire.ChatMessage + var parts []aiwire.ContentPart + var toolCalls []aiwire.ToolCall + hasImage := false + for _, b := range m.Content.AllBlocks() { + switch b.Type { + case "text": + parts = append(parts, aiwire.ContentPart{Type: "text", Text: b.Text}) + case "image": + p, err := anthImagePart(b.Source) + if err != nil { + return nil, err + } + parts, hasImage = append(parts, p), true + case "tool_use": + toolCalls = append(toolCalls, aiwire.ToolCall{ID: b.ID, Type: "function", + Function: aiwire.FunctionCall{Name: b.Name, Arguments: string(b.Input)}}) + case "tool_result": + out = append(out, aiwire.ChatMessage{Role: "tool", ToolCallID: b.ToolUseID, + Content: aiwire.NewTextContent(b.ResultText())}) + case "thinking", "redacted_thinking": + // 一期忽略 thinking 块 + default: + return nil, ErrAiUnsupportedBlock + } + } + content := partsContent(parts, hasImage) + if hasImage || content.JoinText() != "" || len(toolCalls) > 0 { + out = append(out, aiwire.ChatMessage{Role: m.Role, Content: content, ToolCalls: toolCalls}) + } + return out, nil +} + +// anthImagePart 把 Anthropic image 块转为 IR image_url 部件(base64 → data URI)。 +func anthImagePart(source json.RawMessage) (aiwire.ContentPart, error) { + var src struct { + Type string `json:"type"` + MediaType string `json:"media_type"` + Data string `json:"data"` + URL string `json:"url"` + } + if err := json.Unmarshal(source, &src); err != nil { + return aiwire.ContentPart{}, fmt.Errorf("image source 解析失败: %w", err) + } + switch src.Type { + case "base64": + if src.MediaType == "" || src.Data == "" { + return aiwire.ContentPart{}, fmt.Errorf("image source 缺少 media_type 或 data") + } + url := "data:" + src.MediaType + ";base64," + src.Data + return aiwire.ContentPart{Type: "image_url", ImageURL: &aiwire.ImageURL{URL: url}}, nil + case "url": + if src.URL == "" { + return aiwire.ContentPart{}, fmt.Errorf("image source 缺少 url") + } + return aiwire.ContentPart{Type: "image_url", ImageURL: &aiwire.ImageURL{URL: src.URL}}, nil + } + return aiwire.ContentPart{}, fmt.Errorf("不支持的 image source 类型 %q", src.Type) +} + +// partsContent 无图片时退回字符串形态(与上游线格式习惯一致),含图片时保留块数组。 +func partsContent(parts []aiwire.ContentPart, hasImage bool) aiwire.Content { + if !hasImage { + var sb strings.Builder + for _, p := range parts { + sb.WriteString(p.Text) + } + return aiwire.NewTextContent(sb.String()) + } + return aiwire.NewPartsContent(parts) +} + +func anthTools(tools []aiwire.AnthTool) []aiwire.Tool { + if len(tools) == 0 { + return nil + } + out := make([]aiwire.Tool, 0, len(tools)) + for _, t := range tools { + out = append(out, aiwire.Tool{Type: "function", Function: aiwire.FunctionDef{ + Name: t.Name, Description: t.Description, Parameters: t.InputSchema}}) + } + return out +} + +// anthToolChoice 映射 {type:auto|any|tool,name} → OpenAI 形态。 +func anthToolChoice(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return nil + } + var tc struct { + Type string `json:"type"` + Name string `json:"name"` + } + if json.Unmarshal(raw, &tc) != nil { + return nil + } + switch tc.Type { + case "auto": + return json.RawMessage(`"auto"`) + case "any": + return json.RawMessage(`"required"`) + case "tool": + b, _ := json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": tc.Name}}) + return b + case "none": + return json.RawMessage(`"none"`) + } + return nil +} + +// IRRespToAnthropic 把 IR 非流式响应转为 Anthropic Messages 响应。 +func IRRespToAnthropic(resp *aiwire.ChatResponse, id string) aiwire.MessagesResponse { + out := aiwire.MessagesResponse{ID: id, Type: "message", Role: "assistant", Model: resp.Model, Content: []aiwire.AnthBlock{}} + if len(resp.Choices) > 0 { + choice := resp.Choices[0] + if text := choice.Message.Content.JoinText(); text != "" { + out.Content = append(out.Content, aiwire.AnthBlock{Type: "text", Text: text}) + } + for _, tc := range choice.Message.ToolCalls { + out.Content = append(out.Content, aiwire.AnthBlock{Type: "tool_use", ID: tc.ID, + Name: tc.Function.Name, Input: argsToJSON(tc.Function.Arguments)}) + } + out.StopReason = anthStopReason(choice.FinishReason) + } + if resp.Usage != nil { + out.Usage = aiwire.AnthUsage{InputTokens: resp.Usage.PromptTokens, OutputTokens: resp.Usage.CompletionTokens, + CacheReadInputTokens: resp.Usage.CachedTokens()} + } + return out +} + +// argsToJSON 保证 tool_use.input 是合法 JSON 对象(模型可能产出非法片段)。 +func argsToJSON(args string) json.RawMessage { + trimmed := strings.TrimSpace(args) + if trimmed == "" { + return json.RawMessage(`{}`) + } + if json.Valid([]byte(trimmed)) { + return json.RawMessage(trimmed) + } + b, _ := json.Marshal(map[string]string{"_raw": args}) + return b +} + +func anthStopReason(finish string) string { + switch finish { + case "length": + return "max_tokens" + case "tool_calls": + return "tool_use" + default: + return "end_turn" + } +} + +// ---- Anthropic 流式状态机 ---- + +// AnthEvent 是一条待写出的 Anthropic SSE 事件。 +type AnthEvent struct { + Event string + Data any +} + +// AnthStream 把 IR chunk 流聚合为 Anthropic 事件序列: +// message_start → content_block_start/delta/stop(text 与 tool_use 分块)→ message_delta → message_stop。 +type AnthStream struct { + id, model string + started bool + blockOpen bool + blockIsTool bool + toolID string + blockIndex int + stopReason string + usage aiwire.AnthUsage +} + +// NewAnthStream 构造状态机;id 为响应消息 ID。 +func NewAnthStream(id, model string) *AnthStream { + return &AnthStream{id: id, model: model, blockIndex: -1, stopReason: "end_turn"} +} + +// Feed 消费一个 IR chunk,返回应立即写出的事件。 +func (st *AnthStream) Feed(chunk aiwire.ChatChunk) []AnthEvent { + var events []AnthEvent + if !st.started { + st.started = true + events = append(events, st.startEvent()) + } + if chunk.Usage != nil { + st.usage = aiwire.AnthUsage{InputTokens: chunk.Usage.PromptTokens, OutputTokens: chunk.Usage.CompletionTokens, + CacheReadInputTokens: chunk.Usage.CachedTokens()} + } + for _, choice := range chunk.Choices { + events = append(events, st.feedDelta(choice.Delta)...) + if choice.FinishReason != nil && *choice.FinishReason != "" { + st.stopReason = anthStopReason(*choice.FinishReason) + } + } + return events +} + +func (st *AnthStream) startEvent() AnthEvent { + return AnthEvent{Event: "message_start", Data: map[string]any{ + "type": "message_start", + "message": map[string]any{ + "id": st.id, "type": "message", "role": "assistant", "model": st.model, + "content": []any{}, "stop_reason": nil, + "usage": map[string]int{"input_tokens": 0, "output_tokens": 0}, + }, + }} +} + +// feedDelta 处理文本与工具调用增量,必要时切块。 +func (st *AnthStream) feedDelta(d aiwire.Delta) []AnthEvent { + var events []AnthEvent + if d.Content != "" { + if !st.blockOpen || st.blockIsTool { + events = append(events, st.openBlock(false, "", "")...) + } + events = append(events, AnthEvent{Event: "content_block_delta", Data: map[string]any{ + "type": "content_block_delta", "index": st.blockIndex, + "delta": map[string]string{"type": "text_delta", "text": d.Content}, + }}) + } + for _, tc := range d.ToolCalls { + if tc.ID != "" && (!st.blockOpen || !st.blockIsTool || st.toolID != tc.ID) { + events = append(events, st.openBlock(true, tc.ID, tc.Function.Name)...) + } + if tc.Function.Arguments != "" && st.blockOpen && st.blockIsTool { + events = append(events, AnthEvent{Event: "content_block_delta", Data: map[string]any{ + "type": "content_block_delta", "index": st.blockIndex, + "delta": map[string]string{"type": "input_json_delta", "partial_json": tc.Function.Arguments}, + }}) + } + } + return events +} + +// openBlock 关闭当前块并打开新块(text 或 tool_use)。 +func (st *AnthStream) openBlock(isTool bool, toolID, toolName string) []AnthEvent { + var events []AnthEvent + if st.blockOpen { + events = append(events, st.closeBlockEvent()) + } + st.blockOpen, st.blockIsTool, st.toolID = true, isTool, toolID + st.blockIndex++ + block := map[string]any{"type": "text", "text": ""} + if isTool { + block = map[string]any{"type": "tool_use", "id": toolID, "name": toolName, "input": map[string]any{}} + } + events = append(events, AnthEvent{Event: "content_block_start", Data: map[string]any{ + "type": "content_block_start", "index": st.blockIndex, "content_block": block, + }}) + return events +} + +func (st *AnthStream) closeBlockEvent() AnthEvent { + return AnthEvent{Event: "content_block_stop", Data: map[string]any{ + "type": "content_block_stop", "index": st.blockIndex, + }} +} + +// Finish 在上游流结束后收尾:关块 → message_delta(stop_reason+usage)→ message_stop。 +func (st *AnthStream) Finish() []AnthEvent { + var events []AnthEvent + if !st.started { + events = append(events, st.startEvent()) + st.started = true + } + if st.blockOpen { + events = append(events, st.closeBlockEvent()) + st.blockOpen = false + } + events = append(events, + AnthEvent{Event: "message_delta", Data: map[string]any{ + "type": "message_delta", + "delta": map[string]any{"stop_reason": st.stopReason, "stop_sequence": nil}, + "usage": map[string]int{"output_tokens": st.usage.OutputTokens}, + }}, + AnthEvent{Event: "message_stop", Data: map[string]any{"type": "message_stop"}}, + ) + return events +} + +// Usage 返回聚合到的用量(供调用日志)。 +func (st *AnthStream) Usage() aiwire.AnthUsage { return st.usage } diff --git a/internal/service/aiconvert_test.go b/internal/service/aiconvert_test.go new file mode 100644 index 0000000..540028c --- /dev/null +++ b/internal/service/aiconvert_test.go @@ -0,0 +1,136 @@ +package service + +import ( + "encoding/json" + "strings" + "testing" + + "oci-portal/internal/aiwire" +) + +func mustAnthReq(t *testing.T, body string) aiwire.MessagesRequest { + t.Helper() + var req aiwire.MessagesRequest + if err := json.Unmarshal([]byte(body), &req); err != nil { + t.Fatalf("unmarshal anthropic request: %v", err) + } + return req +} + +func TestAnthropicToIR(t *testing.T) { + req := mustAnthReq(t, `{ + "model": "meta.llama-3.3-70b-instruct", "max_tokens": 128, "system": "你是助手", + "messages": [ + {"role": "user", "content": "东京天气?"}, + {"role": "assistant", "content": [ + {"type": "text", "text": "查询中"}, + {"type": "tool_use", "id": "t1", "name": "get_weather", "input": {"city": "东京"}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "晴 25 度"}, + {"type": "text", "text": "继续"} + ]} + ], + "tools": [{"name": "get_weather", "input_schema": {"type": "object"}}], + "tool_choice": {"type": "any"} + }`) + ir, err := AnthropicToIR(req) + if err != nil { + t.Fatalf("AnthropicToIR: %v", err) + } + roles := make([]string, 0, len(ir.Messages)) + for _, m := range ir.Messages { + roles = append(roles, m.Role) + } + want := []string{"system", "user", "assistant", "tool", "user"} + if strings.Join(roles, ",") != strings.Join(want, ",") { + t.Fatalf("roles = %v, want %v", roles, want) + } + if ir.Messages[2].ToolCalls[0].Function.Name != "get_weather" { + t.Errorf("tool_use 未映射: %+v", ir.Messages[2]) + } + if ir.Messages[3].ToolCallID != "t1" || ir.Messages[3].Content.JoinText() != "晴 25 度" { + t.Errorf("tool_result 未拆为 tool 消息: %+v", ir.Messages[3]) + } + if ir.MaxTokens == nil || *ir.MaxTokens != 128 || len(ir.Tools) != 1 { + t.Errorf("max_tokens/tools 未直通") + } + if string(ir.ToolChoice) != `"required"` { + t.Errorf("tool_choice any → %s, want required", ir.ToolChoice) + } + // image 块拒绝 + bad := mustAnthReq(t, `{"model":"m","max_tokens":1,"messages":[{"role":"user","content":[{"type":"image","source":{}}]}]}`) + if _, err := AnthropicToIR(bad); err == nil { + t.Error("image 块应被拒绝") + } +} + +func TestIRRespToAnthropic(t *testing.T) { + resp := &aiwire.ChatResponse{ + Model: "m1", + Choices: []aiwire.Choice{{ + Message: aiwire.ChatMessage{ + Role: "assistant", + Content: aiwire.NewTextContent("查到了"), + ToolCalls: []aiwire.ToolCall{{ID: "t1", Type: "function", + Function: aiwire.FunctionCall{Name: "get_weather", Arguments: `{"city":"东京"}`}}}, + }, + FinishReason: "tool_calls", + }}, + Usage: &aiwire.Usage{PromptTokens: 10, CompletionTokens: 5}, + } + out := IRRespToAnthropic(resp, "msg_1") + if len(out.Content) != 2 || out.Content[0].Type != "text" || out.Content[1].Type != "tool_use" { + t.Fatalf("content 块 = %+v", out.Content) + } + if out.StopReason != "tool_use" || out.Usage.InputTokens != 10 || out.Usage.OutputTokens != 5 { + t.Errorf("stop_reason/usage 未映射: %+v", out) + } + if string(out.Content[1].Input) != `{"city":"东京"}` { + t.Errorf("tool input = %s", out.Content[1].Input) + } +} + +// eventTypes 提取事件类型序列便于断言。 +func eventTypes(events []AnthEvent) string { + types := make([]string, 0, len(events)) + for _, e := range events { + types = append(types, e.Event) + } + return strings.Join(types, ",") +} + +func TestAnthStreamTextAndTool(t *testing.T) { + st := NewAnthStream("msg_1", "m1") + fr := "tool_calls" + var events []AnthEvent + events = append(events, st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{Content: "你"}}}})...) + events = append(events, st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{Content: "好"}}}})...) + events = append(events, st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{ + ToolCalls: []aiwire.ToolCallDelta{{ID: "t1", Function: aiwire.FunctionCallDelta{Name: "get_weather", Arguments: `{"ci`}}}, + }}}})...) + events = append(events, st.Feed(aiwire.ChatChunk{ + Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{ToolCalls: []aiwire.ToolCallDelta{{ID: "t1", Function: aiwire.FunctionCallDelta{Arguments: `ty":"东京"}`}}}}, FinishReason: &fr}}, + Usage: &aiwire.Usage{PromptTokens: 8, CompletionTokens: 4}, + })...) + events = append(events, st.Finish()...) + + got := eventTypes(events) + want := "message_start,content_block_start,content_block_delta,content_block_delta," + + "content_block_stop,content_block_start,content_block_delta,content_block_delta," + + "content_block_stop,message_delta,message_stop" + if got != want { + t.Fatalf("事件序列:\n got %s\nwant %s", got, want) + } + if st.Usage().OutputTokens != 4 { + t.Errorf("usage 未聚合: %+v", st.Usage()) + } +} + +func TestAnthStreamEmpty(t *testing.T) { + st := NewAnthStream("msg_1", "m1") + events := st.Finish() + if got := eventTypes(events); got != "message_start,message_delta,message_stop" { + t.Errorf("空流事件序列 = %s", got) + } +} diff --git a/internal/service/aigateway.go b/internal/service/aigateway.go new file mode 100644 index 0000000..aa53abf --- /dev/null +++ b/internal/service/aigateway.go @@ -0,0 +1,641 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "log" + "sort" + "strings" + "sync" + "time" + + "gorm.io/gorm" + + "oci-portal/internal/aiwire" + "oci-portal/internal/model" + "oci-portal/internal/oci" +) + +const ( + aiLogRetention = 90 * 24 * time.Hour + aiLogMaxRows = 50000 + aiLogCleanupTick = 24 * time.Hour + // aiFailThreshold 起连续失败次数触发熔断,退避 2^(n-阈值) 分钟,封顶 30 分钟 + aiFailThreshold = 5 + aiBackoffCap = 30 * time.Minute + // aiKeyTouchGap 是 LastUsedAt 的最小写库间隔,避免高频调用刷库 + aiKeyTouchGap = time.Minute + // 内容日志(红线例外)约束:开启必须限时(上限 7 天),正文截断,短保留 + aiContentLogMaxHours = 168 + aiContentLogRetention = 7 * 24 * time.Hour + aiContentLogMaxRows = 10000 + aiContentBodyLimit = 64 * 1024 +) + +var ( + // ErrAiKeyInvalid 表示网关密钥不存在或已禁用。 + ErrAiKeyInvalid = errors.New("无效或已禁用的 API 密钥") + // ErrAiUnknownModel 表示没有任何渠道支持请求的模型。 + ErrAiUnknownModel = errors.New("未知模型:没有渠道提供该模型") + // ErrAiNoChannel 表示模型有渠道支持但当前全部不可用(禁用/熔断)。 + ErrAiNoChannel = errors.New("暂无可用渠道,请稍后重试") +) + +// AiGatewayService 是 AI 网关核心:密钥、渠道号池、模型缓存与调用编排。 +type AiGatewayService struct { + db *gorm.DB + configs *OciConfigService + client oci.Client + wg sync.WaitGroup + // touchMu 保护各密钥的最近触达时间(内存节流,不追求跨实例精确) + touchMu sync.Mutex + lastTouch map[uint]time.Time + // onChannelsChanged 在渠道增删后触发,由 main 装配为探测任务同步钩子 + onChannelsChanged func(context.Context) +} + +// NewAiGatewayService 组装依赖;调用 StartCleanup 后开始调用日志周期清理。 +func NewAiGatewayService(db *gorm.DB, configs *OciConfigService, client oci.Client) *AiGatewayService { + return &AiGatewayService{db: db, configs: configs, client: client, lastTouch: map[uint]time.Time{}} +} + +// SetOnChannelsChanged 注册渠道数量变化钩子(渠道创建/删除成功后调用)。 +func (s *AiGatewayService) SetOnChannelsChanged(fn func(context.Context)) { + s.onChannelsChanged = fn +} + +func (s *AiGatewayService) fireChannelsChanged(ctx context.Context) { + if s.onChannelsChanged != nil { + s.onChannelsChanged(ctx) + } +} + +// ---- 网关密钥 ---- + +// CreateKey 生成网关密钥并返回明文(仅此一次);customValue 非空时使用给定值, +// group 非空时该密钥只在同分组渠道内路由。 +func (s *AiGatewayService) CreateKey(ctx context.Context, name, customValue, group string) (string, *model.AiKey, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", nil, fmt.Errorf("密钥名称不能为空") + } + raw := strings.TrimSpace(customValue) + if raw == "" { + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return "", nil, fmt.Errorf("generate key: %w", err) + } + raw = "sk-" + hex.EncodeToString(buf) + } + if len(raw) < 8 { + return "", nil, fmt.Errorf("自定义密钥至少 8 个字符") + } + key := &model.AiKey{Name: name, KeyHash: hashKey(raw), Tail: raw[len(raw)-4:], Group: strings.TrimSpace(group), Enabled: true} + if err := s.db.WithContext(ctx).Create(key).Error; err != nil { + return "", nil, fmt.Errorf("密钥名称或取值与现有密钥重复") + } + return raw, key, nil +} + +func hashKey(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +// Keys 列出全部密钥(不含任何明文信息)。 +func (s *AiGatewayService) Keys(ctx context.Context) ([]model.AiKey, error) { + var keys []model.AiKey + err := s.db.WithContext(ctx).Order("id DESC").Find(&keys).Error + return keys, err +} + +// UpdateKey 修改密钥名称 / 启用状态 / 分组(group 指针非空即覆盖,可置空)。 +func (s *AiGatewayService) UpdateKey(ctx context.Context, id uint, name string, enabled *bool, group *string) error { + updates := map[string]any{} + if name = strings.TrimSpace(name); name != "" { + updates["name"] = name + } + if enabled != nil { + updates["enabled"] = *enabled + } + if group != nil { + updates["key_group"] = strings.TrimSpace(*group) + } + if len(updates) == 0 { + return nil + } + return s.db.WithContext(ctx).Model(&model.AiKey{}).Where("id = ?", id).Updates(updates).Error +} + +// DeleteKey 删除密钥,立即使其失效。 +func (s *AiGatewayService) DeleteKey(ctx context.Context, id uint) error { + return s.db.WithContext(ctx).Delete(&model.AiKey{}, id).Error +} + +// VerifyKey 校验请求携带的密钥;通过后节流更新 LastUsedAt。 +func (s *AiGatewayService) VerifyKey(ctx context.Context, raw string) (*model.AiKey, error) { + if raw == "" { + return nil, ErrAiKeyInvalid + } + var key model.AiKey + err := s.db.WithContext(ctx).Where("key_hash = ?", hashKey(raw)).First(&key).Error + if err != nil || !key.Enabled { + return nil, ErrAiKeyInvalid + } + s.touchKey(ctx, key.ID) + return &key, nil +} + +// touchKey 更新最近使用时间,间隔小于 aiKeyTouchGap 时跳过写库。 +func (s *AiGatewayService) touchKey(ctx context.Context, id uint) { + now := time.Now() + s.touchMu.Lock() + last, ok := s.lastTouch[id] + if ok && now.Sub(last) < aiKeyTouchGap { + s.touchMu.Unlock() + return + } + s.lastTouch[id] = now + s.touchMu.Unlock() + s.db.WithContext(ctx).Model(&model.AiKey{}).Where("id = ?", id).Update("last_used_at", now) +} + +// ---- 渠道 ---- + +// ChannelInput 是创建 / 更新渠道的输入;Group 指针非空即覆盖分组(可置空)。 +type ChannelInput struct { + OciConfigID uint `json:"ociConfigId"` + Region string `json:"region"` + Name string `json:"name"` + Group *string `json:"group"` + Enabled *bool `json:"enabled"` + Priority *int `json:"priority"` + Weight *int `json:"weight"` +} + +// CreateChannel 新建渠道(租户×区域唯一);探测由前端随后显式触发。 +func (s *AiGatewayService) CreateChannel(ctx context.Context, in ChannelInput) (*model.AiChannel, error) { + if in.OciConfigID == 0 || strings.TrimSpace(in.Region) == "" { + return nil, fmt.Errorf("渠道需要指定租户配置与区域") + } + cfg, err := s.configs.Get(ctx, in.OciConfigID) + if err != nil { + return nil, err + } + ch := &model.AiChannel{ + Name: strings.TrimSpace(in.Name), + OciConfigID: in.OciConfigID, + Region: strings.TrimSpace(in.Region), + Enabled: true, + Priority: valueOr(in.Priority, 1), + Weight: valueOr(in.Weight, 1), + } + if in.Group != nil { + ch.Group = strings.TrimSpace(*in.Group) + } + if ch.Name == "" { + ch.Name = fmt.Sprintf("%s·%s", cfg.Alias, ch.Region) + } + if err := s.db.WithContext(ctx).Create(ch).Error; err != nil { + return nil, fmt.Errorf("该租户与区域的渠道已存在") + } + s.fireChannelsChanged(ctx) + return ch, nil +} + +func valueOr(p *int, def int) int { + if p != nil { + return *p + } + return def +} + +// Channels 列出全部渠道。 +func (s *AiGatewayService) Channels(ctx context.Context) ([]model.AiChannel, error) { + var chs []model.AiChannel + err := s.db.WithContext(ctx).Order("priority ASC, id ASC").Find(&chs).Error + return chs, err +} + +// UpdateChannel 修改渠道名称 / 分组 / 启停 / 优先级 / 权重。 +func (s *AiGatewayService) UpdateChannel(ctx context.Context, id uint, in ChannelInput) error { + updates := map[string]any{} + if name := strings.TrimSpace(in.Name); name != "" { + updates["name"] = name + } + if in.Group != nil { + updates["channel_group"] = strings.TrimSpace(*in.Group) + } + if in.Enabled != nil { + updates["enabled"] = *in.Enabled + } + if in.Priority != nil { + updates["priority"] = *in.Priority + } + if in.Weight != nil { + updates["weight"] = *in.Weight + } + if len(updates) == 0 { + return nil + } + return s.db.WithContext(ctx).Model(&model.AiChannel{}).Where("id = ?", id).Updates(updates).Error +} + +// DeleteChannel 删除渠道并清空其模型缓存。 +func (s *AiGatewayService) DeleteChannel(ctx context.Context, id uint) error { + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("channel_id = ?", id).Delete(&model.AiModelCache{}).Error; err != nil { + return err + } + return tx.Delete(&model.AiChannel{}, id).Error + }) + if err == nil { + s.fireChannelsChanged(ctx) + } + return err +} + +// ---- 探测与模型同步 ---- + +// ProbeChannel 探测渠道可用性:服务可见性 → 模型同步 → maxTokens=1 配额试调。 +func (s *AiGatewayService) ProbeChannel(ctx context.Context, id uint) (*model.AiChannel, error) { + var ch model.AiChannel + if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil { + return nil, fmt.Errorf("渠道不存在") + } + cred, err := s.configs.credentialsByID(ctx, ch.OciConfigID) + if err != nil { + return nil, err + } + status, probeErr := s.probe(ctx, cred, &ch) + now := time.Now() + updates := map[string]any{"probe_status": status, "probe_error": probeErr, "last_probe_at": now} + if status == "ok" { + updates["fail_count"] = 0 + updates["disabled_until"] = gorm.Expr("NULL") + } + if err := s.db.WithContext(ctx).Model(&ch).Updates(updates).Error; err != nil { + return nil, err + } + // 重读用新变量:gorm 扫描 NULL 列到已有值的结构体时会保留旧值 + var fresh model.AiChannel + if err := s.db.WithContext(ctx).First(&fresh, id).Error; err != nil { + return nil, err + } + return &fresh, nil +} + +// probe 执行探测并返回 (状态, 错误摘要);同时完成模型缓存同步。 +func (s *AiGatewayService) probe(ctx context.Context, cred oci.Credentials, ch *model.AiChannel) (string, string) { + models, err := s.client.ListGenAiModels(ctx, cred, ch.Region) + if err != nil { + return classifyProbeErr(err), truncateErr(oci.CompactError(err)) + } + if len(models) == 0 { + _ = s.replaceModels(ctx, ch.ID, nil) + return "no_service", "区域无可用模型(GenAI 服务不可用或未开放)" + } + if err := s.replaceModels(ctx, ch.ID, models); err != nil { + return "error", truncateErr(err.Error()) + } + return s.probeChat(ctx, cred, ch, models) +} + +// probeChat 按偏好挑选至多 3 个模型依次试调:部分模型元数据标 CHAT 但实际 +// 不可对话(如 voice agent),遇 400/5xx 换下一个;401/403/404 属租户级直接定论。 +func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials, ch *model.AiChannel, models []oci.GenAiModel) (string, string) { + status, detail := "error", "无可试调对话模型" + for _, m := range probeCandidates(models) { + code, err := s.client.GenAiProbeChat(ctx, cred, ch.Region, m.Ocid, m.Name) + switch { + case code == 200 || code == 429: + return "ok", "" + case code == 401 || code == 403 || code == 404: + return "no_quota", truncateErr(oci.CompactError(err)) + default: + status, detail = "error", truncateErr(fmt.Sprintf("%s: %s", m.Name, oci.CompactError(err))) + } + } + return status, detail +} + +// probeCandidates 只取对话模型并按可靠度排序取前 3:主流文本模型优先, +// voice 等非常规形态殿后(embedding / rerank 已被能力筛选排除)。 +func probeCandidates(models []oci.GenAiModel) []oci.GenAiModel { + var sorted []oci.GenAiModel + for _, m := range models { + if m.Capability == "" || m.Capability == "CHAT" { + sorted = append(sorted, m) + } + } + sort.SliceStable(sorted, func(i, j int) bool { + return probeScore(sorted[i].Name) > probeScore(sorted[j].Name) + }) + if len(sorted) > 3 { + sorted = sorted[:3] + } + return sorted +} + +func probeScore(name string) int { + n := strings.ToLower(name) + switch { + case strings.Contains(n, "voice") || strings.Contains(n, "embed") || strings.Contains(n, "rerank"): + return -10 + case strings.Contains(n, "llama") && !strings.Contains(n, "vision"): + return 5 + case strings.Contains(n, "gemini") || strings.Contains(n, "gpt-oss"): + return 4 + case strings.Contains(n, "command"): + return 3 + case strings.Contains(n, "grok") && !strings.Contains(n, "multi-agent"): + return 2 + default: + return 0 + } +} + +// classifyProbeErr 区分「区域无服务端点」与其他错误。 +func classifyProbeErr(err error) string { + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "no such host") || strings.Contains(msg, "timeout") || + strings.Contains(msg, "connection refused") || strings.Contains(msg, "dial tcp") { + return "no_service" + } + return "error" +} + +func truncateErr(msg string) string { + if len(msg) > 500 { + return msg[:500] + } + return msg +} + +// SyncModels 重新拉取渠道区域的模型列表并覆盖缓存。 +func (s *AiGatewayService) SyncModels(ctx context.Context, id uint) ([]model.AiModelCache, error) { + var ch model.AiChannel + if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil { + return nil, fmt.Errorf("渠道不存在") + } + cred, err := s.configs.credentialsByID(ctx, ch.OciConfigID) + if err != nil { + return nil, err + } + models, err := s.client.ListGenAiModels(ctx, cred, ch.Region) + if err != nil { + return nil, fmt.Errorf("同步模型失败:%s", oci.CompactError(err)) + } + if err := s.replaceModels(ctx, id, models); err != nil { + return nil, err + } + return s.channelModels(ctx, id) +} + +// replaceModels 以事务整组覆盖渠道模型缓存。 +func (s *AiGatewayService) replaceModels(ctx context.Context, channelID uint, models []oci.GenAiModel) error { + rows := make([]model.AiModelCache, 0, len(models)) + now := time.Now() + for _, m := range models { + rows = append(rows, model.AiModelCache{ChannelID: channelID, ModelOcid: m.Ocid, Name: m.Name, Vendor: m.Vendor, + Capability: m.Capability, SyncedAt: now, DeprecatedAt: m.Deprecated, RetiredAt: m.Retired}) + } + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("channel_id = ?", channelID).Delete(&model.AiModelCache{}).Error; err != nil { + return err + } + if len(rows) == 0 { + return nil + } + return tx.Create(&rows).Error + }) +} + +func (s *AiGatewayService) channelModels(ctx context.Context, channelID uint) ([]model.AiModelCache, error) { + var rows []model.AiModelCache + err := s.db.WithContext(ctx).Where("channel_id = ?", channelID).Order("name ASC").Find(&rows).Error + return rows, err +} + +// GatewayModels 聚合启用渠道的模型(按名称去重),供 /ai/v1/models; +// group 非空时仅聚合该分组渠道(与密钥分组路由口径一致)。 +func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiwire.ModelList, error) { + q := s.db.WithContext(ctx). + Joins("JOIN ai_channels ON ai_channels.id = ai_model_caches.channel_id AND ai_channels.enabled = ?", true) + if group != "" { + q = q.Where("ai_channels.channel_group = ?", group) + } + var rows []model.AiModelCache + err := q.Order("ai_model_caches.name ASC").Find(&rows).Error + list := aiwire.ModelList{Object: "list", Data: []aiwire.Model{}} + if err != nil { + return list, err + } + seen := map[string]bool{} + for _, r := range rows { + if seen[r.Name] { + continue + } + seen[r.Name] = true + list.Data = append(list.Data, aiwire.Model{ID: r.Name, Object: "model", Created: r.SyncedAt.Unix(), OwnedBy: r.Vendor}) + } + return list, nil +} + +// DeprecatingModels 返回 within 窗口内即将退役或即将弃用的在池模型(按名称去重): +// 退役(TimeOnDemandRetired)才导致不可调用,单独标注;已过弃用日但未到退役日的 +// 模型仍可正常调用,不再反复告警;已过退役日的在同步层剔除,不会出现在池中。 +func (s *AiGatewayService) DeprecatingModels(ctx context.Context, within time.Duration) ([]string, error) { + now := time.Now() + deadline := now.Add(within) + var rows []model.AiModelCache + err := s.db.WithContext(ctx). + Where("(retired_at IS NOT NULL AND retired_at > ? AND retired_at <= ?) OR (deprecated_at IS NOT NULL AND deprecated_at >= ? AND deprecated_at <= ?)", + now, deadline, now, deadline). + Order("name ASC").Find(&rows).Error + if err != nil { + return nil, err + } + seen := map[string]bool{} + var out []string + for _, r := range rows { + if seen[r.Name] { + continue + } + seen[r.Name] = true + if r.RetiredAt != nil && r.RetiredAt.After(now) && !r.RetiredAt.After(deadline) { + out = append(out, fmt.Sprintf("%s(%s 退役,届时无法调用)", r.Name, r.RetiredAt.Format("2006-01-02"))) + continue + } + out = append(out, fmt.Sprintf("%s(%s 宣布弃用,退役前仍可调用)", r.Name, r.DeprecatedAt.Format("2006-01-02"))) + } + return out, nil +} + +// ProbeAll 逐个探测全部渠道,返回状态汇总;供 AI 探测后台任务调用。 +func (s *AiGatewayService) ProbeAll(ctx context.Context) (string, error) { + chs, err := s.Channels(ctx) + if err != nil { + return "", err + } + counts := map[string]int{} + var failures []string + for _, ch := range chs { + fresh, err := s.ProbeChannel(ctx, ch.ID) + if err != nil { + counts["error"]++ + failures = append(failures, fmt.Sprintf("#%d %s", ch.ID, truncateErr(err.Error()))) + continue + } + counts[fresh.ProbeStatus]++ + } + msg := fmt.Sprintf("probed %d: %d ok, %d no_service, %d no_quota, %d error", + len(chs), counts["ok"], counts["no_service"], counts["no_quota"], counts["error"]) + if len(failures) > 0 { + msg += "; " + strings.Join(failures, "; ") + } + return msg, nil +} + +// ---- 调用日志 ---- + +// LogCall 落一条调用日志(仅元数据与用量,永不含请求 / 响应正文),返回落库 ID 供内容日志关联(失败为 0)。 +func (s *AiGatewayService) LogCall(entry model.AiCallLog) uint { + entry.ErrMsg = truncateErr(entry.ErrMsg) + if err := s.db.Create(&entry).Error; err != nil { + log.Printf("ai call log: %v", err) + return 0 + } + return entry.ID +} + +// CallLogs 分页查询调用日志。 +func (s *AiGatewayService) CallLogs(ctx context.Context, page, size int) ([]model.AiCallLog, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 || size > 200 { + size = 50 + } + var total int64 + q := s.db.WithContext(ctx).Model(&model.AiCallLog{}) + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []model.AiCallLog + err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error + return rows, total, err +} + +// ---- 内容日志(红线例外,按密钥显式限时开启) ---- + +// UpdateKeyContentLog 设置密钥内容日志窗口:hours=0 立即关闭,>0 从现在起开启 N 小时(上限 7 天)。 +func (s *AiGatewayService) UpdateKeyContentLog(ctx context.Context, id uint, hours int) (*model.AiKey, error) { + if hours < 0 || hours > aiContentLogMaxHours { + return nil, fmt.Errorf("内容日志时长需在 0-%d 小时之间", aiContentLogMaxHours) + } + updates := map[string]any{"content_log_until": gorm.Expr("NULL")} + if hours > 0 { + updates["content_log_until"] = time.Now().Add(time.Duration(hours) * time.Hour) + } + if err := s.db.WithContext(ctx).Model(&model.AiKey{}).Where("id = ?", id).Updates(updates).Error; err != nil { + return nil, err + } + // 重读用新变量:gorm 扫描 NULL 列到已有值的结构体时会保留旧值 + var fresh model.AiKey + if err := s.db.WithContext(ctx).First(&fresh, id).Error; err != nil { + return nil, err + } + return &fresh, nil +} + +// LogContent 写一条内容日志(调用方已确认密钥开启且未过期);正文截断至 64KB。 +func (s *AiGatewayService) LogContent(entry model.AiContentLog) { + entry.RequestBody = truncateBody(entry.RequestBody) + entry.ResponseBody = truncateBody(entry.ResponseBody) + if err := s.db.Create(&entry).Error; err != nil { + log.Printf("ai content log: %v", err) + } +} + +func truncateBody(s string) string { + if len(s) > aiContentBodyLimit { + return s[:aiContentBodyLimit] + } + return s +} + +// ContentLogs 分页查询内容日志(keyID / callLogID 为 0 时不过滤)。 +func (s *AiGatewayService) ContentLogs(ctx context.Context, keyID, callLogID uint, page, size int) ([]model.AiContentLog, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 || size > 100 { + size = 20 + } + q := s.db.WithContext(ctx).Model(&model.AiContentLog{}) + if keyID > 0 { + q = q.Where("key_id = ?", keyID) + } + if callLogID > 0 { + q = q.Where("call_log_id = ?", callLogID) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []model.AiContentLog + err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error + return rows, total, err +} + +// StartCleanup 启动调用日志周期清理:启动即清一次,之后每 24h 一次。 +func (s *AiGatewayService) StartCleanup(ctx context.Context) { + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.cleanupOnce(ctx) + ticker := time.NewTicker(aiLogCleanupTick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.cleanupOnce(ctx) + } + } + }() +} + +func (s *AiGatewayService) cleanupOnce(ctx context.Context) { + s.cleanupTable(ctx, &model.AiCallLog{}, aiLogRetention, aiLogMaxRows, "ai log") + s.cleanupTable(ctx, &model.AiContentLog{}, aiContentLogRetention, aiContentLogMaxRows, "ai content log") +} + +// cleanupTable 按保留期与行数上限清理日志表(超限删最旧)。 +func (s *AiGatewayService) cleanupTable(ctx context.Context, m any, retention time.Duration, maxRows int, tag string) { + cutoff := time.Now().Add(-retention) + if err := s.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(m).Error; err != nil { + log.Printf("%s cleanup: %v", tag, err) + return + } + var total int64 + if err := s.db.WithContext(ctx).Model(m).Count(&total).Error; err != nil { + return + } + if overflow := int(total) - maxRows; overflow > 0 { + var ids []uint + s.db.WithContext(ctx).Model(m).Order("id ASC").Limit(overflow).Pluck("id", &ids) + if len(ids) > 0 { + s.db.WithContext(ctx).Delete(m, ids) + } + } +} + +// Wait 等待后台清理 goroutine 退出。 +func (s *AiGatewayService) Wait() { s.wg.Wait() } diff --git a/internal/service/aigateway_chat.go b/internal/service/aigateway_chat.go new file mode 100644 index 0000000..b3179cb --- /dev/null +++ b/internal/service/aigateway_chat.go @@ -0,0 +1,283 @@ +package service + +import ( + "context" + "errors" + "math/rand" + "time" + + "gorm.io/gorm" + + "oci-portal/internal/aiwire" + "oci-portal/internal/model" + "oci-portal/internal/oci" +) + +// ChatMeta 是一次网关调用的路由结果,供 API 层写调用日志。 +type ChatMeta struct { + ChannelID uint + ChannelName string + Retries int +} + +// aiCandidate 是某模型的一个可用渠道及其区域内模型 OCID。 +type aiCandidate struct { + ch model.AiChannel + modelOcid string +} + +// Chat 编排非流式调用:选渠道 → 调用 → 可重试错误换渠道(整请求上限 3 次)。 +// group 非空时只在同分组渠道内路由(取自调用密钥)。 +func (s *AiGatewayService) Chat(ctx context.Context, ir aiwire.ChatRequest, group string) (*aiwire.ChatResponse, ChatMeta, error) { + meta := ChatMeta{} + excluded := map[uint]bool{} + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + cand, err := s.pick(ctx, ir.Model, group, "CHAT", excluded) + if err != nil { + return nil, meta, firstErr(lastErr, err) + } + meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name + resp, err := s.callOnce(ctx, cand, ir) + if err == nil { + s.markSuccess(ctx, cand.ch.ID) + return resp, meta, nil + } + if !retryable(err) { + return nil, meta, err + } + s.markFailure(ctx, cand.ch.ID) + excluded[cand.ch.ID] = true + meta.Retries++ + lastErr = err + } + return nil, meta, lastErr +} + +func (s *AiGatewayService) callOnce(ctx context.Context, cand *aiCandidate, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error) { + cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID) + if err != nil { + return nil, err + } + return s.client.GenAiChat(ctx, cred, cand.ch.Region, cand.modelOcid, ir) +} + +// OpenStream 编排流式调用:流建立成功后即绑定渠道,建立失败可换渠道重试。 +// group 语义与 Chat 相同。 +func (s *AiGatewayService) OpenStream(ctx context.Context, ir aiwire.ChatRequest, group string) (oci.GenAiStream, ChatMeta, error) { + meta := ChatMeta{} + excluded := map[uint]bool{} + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + cand, err := s.pick(ctx, ir.Model, group, "CHAT", excluded) + if err != nil { + return nil, meta, firstErr(lastErr, err) + } + meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name + cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID) + if err != nil { + return nil, meta, err + } + stream, err := s.client.GenAiChatStream(ctx, cred, cand.ch.Region, cand.modelOcid, ir) + if err == nil { + s.markSuccess(ctx, cand.ch.ID) + return stream, meta, nil + } + if !retryable(err) { + return nil, meta, err + } + s.markFailure(ctx, cand.ch.ID) + excluded[cand.ch.ID] = true + meta.Retries++ + lastErr = err + } + return nil, meta, lastErr +} + +// firstErr 在换渠道后仍失败时优先返回上游错误(而非「无渠道」)。 +func firstErr(lastErr, pickErr error) error { + if lastErr != nil && errors.Is(pickErr, ErrAiNoChannel) { + return lastErr + } + return pickErr +} + +// retryable 判定是否换渠道重试:429 / 5xx / 网络错误可重试,其余 4xx 直接透传。 +func retryable(err error) bool { + if status, ok := oci.ServiceStatus(err); ok { + return status == 429 || status >= 500 + } + return true +} + +// pick 选出支持该模型的最优渠道:能力匹配 → 启用 → 分组匹配 → 未熔断 → 最小优先级组 → 加权随机。 +func (s *AiGatewayService) pick(ctx context.Context, modelName, group, capability string, excluded map[uint]bool) (*aiCandidate, error) { + ocids, channelIDs, err := s.modelChannels(ctx, modelName, capability) + if err != nil { + return nil, err + } + if len(channelIDs) == 0 { + return nil, ErrAiUnknownModel + } + q := s.db.WithContext(ctx).Where("id IN ? AND enabled = ?", channelIDs, true) + if group != "" { + q = q.Where("channel_group = ?", group) + } + var channels []model.AiChannel + if err := q.Find(&channels).Error; err != nil { + return nil, err + } + now := time.Now() + avail := channels[:0] + for _, ch := range channels { + if excluded[ch.ID] || (ch.DisabledUntil != nil && ch.DisabledUntil.After(now)) { + continue + } + avail = append(avail, ch) + } + if len(avail) == 0 { + return nil, ErrAiNoChannel + } + chosen := weightedPick(topPriority(avail)) + return &aiCandidate{ch: chosen, modelOcid: ocids[chosen.ID]}, nil +} + +// modelChannels 查出提供该模型的渠道 ID 及各自的模型 OCID; +// capability=CHAT 时兼容存量空串(加列前只同步对话模型)。 +func (s *AiGatewayService) modelChannels(ctx context.Context, modelName, capability string) (map[uint]string, []uint, error) { + q := s.db.WithContext(ctx).Where("name = ?", modelName) + if capability == "CHAT" { + q = q.Where("capability IN ?", []string{"CHAT", ""}) + } else { + q = q.Where("capability = ?", capability) + } + var rows []model.AiModelCache + if err := q.Find(&rows).Error; err != nil { + return nil, nil, err + } + ocids := make(map[uint]string, len(rows)) + ids := make([]uint, 0, len(rows)) + for _, r := range rows { + ocids[r.ChannelID] = r.ModelOcid + ids = append(ids, r.ChannelID) + } + return ocids, ids, nil +} + +// topPriority 保留最小 Priority 值的渠道组。 +func topPriority(chs []model.AiChannel) []model.AiChannel { + best := chs[0].Priority + for _, ch := range chs[1:] { + if ch.Priority < best { + best = ch.Priority + } + } + out := chs[:0] + for _, ch := range chs { + if ch.Priority == best { + out = append(out, ch) + } + } + return out +} + +// weightedPick 组内按权重随机;权重全部非正时等权。 +func weightedPick(chs []model.AiChannel) model.AiChannel { + total := 0 + for _, ch := range chs { + if ch.Weight > 0 { + total += ch.Weight + } + } + if total <= 0 { + return chs[rand.Intn(len(chs))] + } + r := rand.Intn(total) + for _, ch := range chs { + if ch.Weight <= 0 { + continue + } + r -= ch.Weight + if r < 0 { + return ch + } + } + return chs[len(chs)-1] +} + +// Embeddings 编排向量化调用:按 EMBEDDING 能力选渠道,可重试错误换渠道(整请求上限 3 次)。 +func (s *AiGatewayService) Embeddings(ctx context.Context, req aiwire.EmbeddingsRequest, group string) (*aiwire.EmbeddingsResponse, ChatMeta, error) { + meta := ChatMeta{} + excluded := map[uint]bool{} + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + cand, err := s.pick(ctx, req.Model, group, "EMBEDDING", excluded) + if err != nil { + return nil, meta, firstErr(lastErr, err) + } + meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name + resp, err := s.embedOnce(ctx, cand, req) + if err == nil { + s.markSuccess(ctx, cand.ch.ID) + return resp, meta, nil + } + if !retryable(err) { + return nil, meta, err + } + s.markFailure(ctx, cand.ch.ID) + excluded[cand.ch.ID] = true + meta.Retries++ + lastErr = err + } + return nil, meta, lastErr +} + +// embedOnce 调用渠道向量化并装配 OpenAI 形态响应。 +func (s *AiGatewayService) embedOnce(ctx context.Context, cand *aiCandidate, req aiwire.EmbeddingsRequest) (*aiwire.EmbeddingsResponse, error) { + cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID) + if err != nil { + return nil, err + } + vecs, usage, err := s.client.GenAiEmbed(ctx, cred, cand.ch.Region, cand.modelOcid, req.Input, req.Dimensions) + if err != nil { + return nil, err + } + out := &aiwire.EmbeddingsResponse{Object: "list", Model: req.Model, Data: make([]aiwire.Embedding, 0, len(vecs))} + for i, v := range vecs { + out.Data = append(out.Data, aiwire.Embedding{Object: "embedding", Index: i, Embedding: v}) + } + if usage != nil { + out.Usage = &aiwire.EmbedUsage{PromptTokens: usage.PromptTokens, TotalTokens: usage.TotalTokens} + } + return out, nil +} + +// markSuccess 复位失败计数与熔断窗口(无异常状态时零写库)。 +func (s *AiGatewayService) markSuccess(ctx context.Context, id uint) { + s.db.WithContext(ctx).Model(&model.AiChannel{}). + Where("id = ? AND (fail_count > 0 OR disabled_until IS NOT NULL)", id). + Updates(map[string]any{"fail_count": 0, "disabled_until": gorm.Expr("NULL")}) +} + +// markFailure 递增失败计数;达到阈值后按 2^(超出次数) 分钟指数退避,封顶 30 分钟。 +func (s *AiGatewayService) markFailure(ctx context.Context, id uint) { + s.db.WithContext(ctx).Model(&model.AiChannel{}).Where("id = ?", id). + Update("fail_count", gorm.Expr("fail_count + 1")) + var ch model.AiChannel + if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil { + return + } + if ch.FailCount < aiFailThreshold { + return + } + n := ch.FailCount - aiFailThreshold + if n > 10 { + n = 10 + } + backoff := time.Duration(1< aiBackoffCap { + backoff = aiBackoffCap + } + until := time.Now().Add(backoff) + s.db.WithContext(ctx).Model(&model.AiChannel{}).Where("id = ?", id).Update("disabled_until", until) +} diff --git a/internal/service/aigateway_test.go b/internal/service/aigateway_test.go new file mode 100644 index 0000000..59a199a --- /dev/null +++ b/internal/service/aigateway_test.go @@ -0,0 +1,508 @@ +package service + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "oci-portal/internal/aiwire" + "oci-portal/internal/model" + "oci-portal/internal/oci" +) + +// stubServiceError 实现 common.ServiceError,用于模拟带状态码的 OCI 服务端错误。 +type stubServiceError struct{ status int } + +func (e stubServiceError) Error() string { return "stub service error" } +func (e stubServiceError) GetHTTPStatusCode() int { return e.status } +func (e stubServiceError) GetMessage() string { return "stub" } +func (e stubServiceError) GetCode() string { return "Stub" } +func (e stubServiceError) GetOpcRequestID() string { return "req-1" } + +// gatewayStubClient 覆写 GenAI 四方法;chatErrs 逐次弹出以模拟先失败后成功。 +type gatewayStubClient struct { + *fakeClient + + models []oci.GenAiModel + modelsErr error + probeCode int + probeErr error + chatResp *aiwire.ChatResponse + chatErrs []error + chatCalls int + regions []string + + embedVecs [][]float32 + embedUsage *aiwire.Usage + embedErr error +} + +func (f *gatewayStubClient) GenAiEmbed(ctx context.Context, cred oci.Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error) { + return f.embedVecs, f.embedUsage, f.embedErr +} + +func (f *gatewayStubClient) ListGenAiModels(ctx context.Context, cred oci.Credentials, region string) ([]oci.GenAiModel, error) { + return f.models, f.modelsErr +} + +func (f *gatewayStubClient) GenAiProbeChat(ctx context.Context, cred oci.Credentials, region, modelOcid, modelName string) (int, error) { + return f.probeCode, f.probeErr +} + +func (f *gatewayStubClient) GenAiChat(ctx context.Context, cred oci.Credentials, region, modelOcid string, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error) { + f.chatCalls++ + f.regions = append(f.regions, region) + if len(f.chatErrs) > 0 { + err := f.chatErrs[0] + f.chatErrs = f.chatErrs[1:] + if err != nil { + return nil, err + } + } + return f.chatResp, nil +} + +func newTestGateway(t *testing.T, client oci.Client) (*AiGatewayService, *OciConfigService) { + t.Helper() + svc := newTestService(t, client) + if err := svc.db.AutoMigrate(&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{}); err != nil { + t.Fatalf("auto migrate ai tables: %v", err) + } + return NewAiGatewayService(svc.db, svc, client), svc +} + +func TestAiKeyLifecycle(t *testing.T) { + gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}) + ctx := context.Background() + + raw, key, err := gw.CreateKey(ctx, "免费-ai-api-key", "test-api-key", "") + if err != nil || raw != "test-api-key" || key.Tail != "-key" { + t.Fatalf("CreateKey 自定义值 = %q %+v, %v", raw, key, err) + } + if _, _, err := gw.CreateKey(ctx, "short", "abc", ""); err == nil { + t.Error("过短自定义密钥应被拒绝") + } + auto, _, err := gw.CreateKey(ctx, "auto", "", "") + if err != nil || len(auto) < 40 || auto[:3] != "sk-" { + t.Fatalf("CreateKey 随机值 = %q, %v", auto, err) + } + got, err := gw.VerifyKey(ctx, "test-api-key") + if err != nil || got.Name != "免费-ai-api-key" { + t.Fatalf("VerifyKey = %+v, %v", got, err) + } + if _, err := gw.VerifyKey(ctx, "wrong"); !errors.Is(err, ErrAiKeyInvalid) { + t.Errorf("错误密钥 err = %v", err) + } + off := false + _ = gw.UpdateKey(ctx, key.ID, "", &off, nil) + if _, err := gw.VerifyKey(ctx, "test-api-key"); !errors.Is(err, ErrAiKeyInvalid) { + t.Errorf("禁用后 VerifyKey err = %v", err) + } +} + +func TestAiChannelProbe(t *testing.T) { + client := &gatewayStubClient{ + fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, + models: []oci.GenAiModel{{Ocid: "ocid1.generativeaimodel.oc1..m1", Name: "meta.llama-3.3-70b-instruct", Vendor: "meta"}}, + probeCode: 200, + } + gw, svc := newTestGateway(t, client) + cfg := importAliveConfig(t, svc) + ctx := context.Background() + + ch, err := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"}) + if err != nil || ch.Name == "" || !ch.Enabled { + t.Fatalf("CreateChannel = %+v, %v", ch, err) + } + if _, err := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"}); err == nil { + t.Error("重复渠道应被拒绝") + } + probed, err := gw.ProbeChannel(ctx, ch.ID) + if err != nil || probed.ProbeStatus != "ok" { + t.Fatalf("ProbeChannel = %+v, %v", probed, err) + } + models, err := gw.channelModels(ctx, ch.ID) + if err != nil || len(models) != 1 || models[0].Name != "meta.llama-3.3-70b-instruct" { + t.Fatalf("模型缓存未同步: %+v, %v", models, err) + } + // 配额拒绝 → no_quota + client.probeCode, client.probeErr = 403, stubServiceError{status: 403} + probed, _ = gw.ProbeChannel(ctx, ch.ID) + if probed.ProbeStatus != "no_quota" { + t.Errorf("403 探测 status = %q, want no_quota", probed.ProbeStatus) + } + // 区域无模型 → no_service + client.models = nil + probed, _ = gw.ProbeChannel(ctx, ch.ID) + if probed.ProbeStatus != "no_service" { + t.Errorf("空模型探测 status = %q, want no_service", probed.ProbeStatus) + } + list, _ := gw.GatewayModels(ctx, "") + if len(list.Data) != 0 { + t.Errorf("no_service 后模型缓存应清空, got %+v", list.Data) + } +} + +// seedChannel 直插渠道与模型缓存,绕过探测。 +func seedChannel(t *testing.T, gw *AiGatewayService, cfgID uint, region string, priority, weight int) *model.AiChannel { + t.Helper() + ch := &model.AiChannel{Name: region, OciConfigID: cfgID, Region: region, Enabled: true, Priority: priority, Weight: weight} + if err := gw.db.Create(ch).Error; err != nil { + t.Fatalf("seed channel: %v", err) + } + cache := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..m-" + region, Name: "meta.llama-3.3-70b-instruct", Vendor: "meta", SyncedAt: time.Now()} + if err := gw.db.Create(cache).Error; err != nil { + t.Fatalf("seed cache: %v", err) + } + return ch +} + +func TestAiChatRetrySwitchesChannel(t *testing.T) { + client := &gatewayStubClient{ + fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, + chatResp: &aiwire.ChatResponse{Model: "meta.llama-3.3-70b-instruct", Choices: []aiwire.Choice{{Message: aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent("hi")}, FinishReason: "stop"}}}, + chatErrs: []error{stubServiceError{status: 429}}, + } + gw, svc := newTestGateway(t, client) + cfg := importAliveConfig(t, svc) + // 两个同优先级渠道 + seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1) + seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1) + ctx := context.Background() + + resp, meta, err := gw.Chat(ctx, aiwire.ChatRequest{Model: "meta.llama-3.3-70b-instruct", Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("你好")}}}, "") + if err != nil || resp == nil { + t.Fatalf("Chat = %v, %v", resp, err) + } + if meta.Retries != 1 || client.chatCalls != 2 { + t.Errorf("应换渠道重试一次: retries=%d calls=%d", meta.Retries, client.chatCalls) + } + if len(client.regions) != 2 && client.regions[0] == client.regions[1] { + t.Errorf("重试未换渠道: %v", client.regions) + } + // 未知模型 + if _, _, err := gw.Chat(ctx, aiwire.ChatRequest{Model: "no-such-model"}, ""); !errors.Is(err, ErrAiUnknownModel) { + t.Errorf("未知模型 err = %v", err) + } +} + +func TestAiChatNonRetryablePassThrough(t *testing.T) { + client := &gatewayStubClient{ + fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, + chatErrs: []error{stubServiceError{status: 400}}, + } + gw, svc := newTestGateway(t, client) + cfg := importAliveConfig(t, svc) + seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1) + seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1) + + _, meta, err := gw.Chat(context.Background(), aiwire.ChatRequest{Model: "meta.llama-3.3-70b-instruct"}, "") + if err == nil || meta.Retries != 0 || client.chatCalls != 1 { + t.Errorf("400 应直接透传不重试: err=%v retries=%d calls=%d", err, meta.Retries, client.chatCalls) + } +} + +func TestPickPriorityAndBreaker(t *testing.T) { + gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}) + cfg := importAliveConfig(t, svc) + high := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1) + seedChannel(t, gw, cfg.ID, "us-chicago-1", 2, 1) + ctx := context.Background() + + cand, err := gw.pick(ctx, "meta.llama-3.3-70b-instruct", "", "CHAT", map[uint]bool{}) + if err != nil || cand.ch.ID != high.ID { + t.Fatalf("应选高优先级渠道: %+v, %v", cand, err) + } + // 高优先级熔断 → 降级到低优先级 + until := time.Now().Add(10 * time.Minute) + gw.db.Model(&model.AiChannel{}).Where("id = ?", high.ID).Update("disabled_until", until) + cand, err = gw.pick(ctx, "meta.llama-3.3-70b-instruct", "", "CHAT", map[uint]bool{}) + if err != nil || cand.ch.ID == high.ID { + t.Fatalf("熔断渠道应被跳过: %+v, %v", cand, err) + } + // 全部排除 → ErrAiNoChannel + _, err = gw.pick(ctx, "meta.llama-3.3-70b-instruct", "", "CHAT", map[uint]bool{cand.ch.ID: true}) + if !errors.Is(err, ErrAiNoChannel) { + t.Errorf("全排除 err = %v", err) + } +} + +func TestMarkFailureBackoff(t *testing.T) { + gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}) + cfg := importAliveConfig(t, svc) + ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1) + ctx := context.Background() + + for i := 0; i < aiFailThreshold; i++ { + gw.markFailure(ctx, ch.ID) + } + var got model.AiChannel + gw.db.First(&got, ch.ID) + if got.FailCount != aiFailThreshold || got.DisabledUntil == nil { + t.Fatalf("达到阈值应熔断: fail=%d until=%v", got.FailCount, got.DisabledUntil) + } + gw.markSuccess(ctx, ch.ID) + // 新变量重读:gorm 对 NULL 列不覆盖已有值的结构体字段 + var reset model.AiChannel + gw.db.First(&reset, ch.ID) + if reset.FailCount != 0 || reset.DisabledUntil != nil { + t.Errorf("成功后应复位: %+v", reset) + } +} + +func TestAiGroupRouting(t *testing.T) { + client := &gatewayStubClient{ + fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, + chatResp: &aiwire.ChatResponse{Model: "meta.llama-3.3-70b-instruct", Choices: []aiwire.Choice{{Message: aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent("hi")}, FinishReason: "stop"}}}, + } + gw, svc := newTestGateway(t, client) + cfg := importAliveConfig(t, svc) + vip := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1) + other := seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1) + gw.db.Model(vip).Update("channel_group", "vip") + gw.db.Create(&model.AiModelCache{ChannelID: other.ID, ModelOcid: "ocid1..cr", Name: "cohere.command-r", Vendor: "cohere", SyncedAt: time.Now()}) + ctx := context.Background() + req := aiwire.ChatRequest{Model: "meta.llama-3.3-70b-instruct", Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("你好")}}} + + // 分组密钥只落同分组渠道 + for i := 0; i < 5; i++ { + _, meta, err := gw.Chat(ctx, req, "vip") + if err != nil || meta.ChannelID != vip.ID { + t.Fatalf("vip 组第 %d 次落点 = %d, err %v, want %d", i, meta.ChannelID, err, vip.ID) + } + } + // 分组内无渠道 → 无可用渠道 + if _, _, err := gw.Chat(ctx, req, "nope"); !errors.Is(err, ErrAiNoChannel) { + t.Errorf("空分组 err = %v, want ErrAiNoChannel", err) + } + // 模型列表按分组过滤 + list, _ := gw.GatewayModels(ctx, "vip") + if len(list.Data) != 1 || list.Data[0].ID != "meta.llama-3.3-70b-instruct" { + t.Errorf("vip 组模型 = %+v", list.Data) + } + all, _ := gw.GatewayModels(ctx, "") + if len(all.Data) != 2 { + t.Errorf("不限组模型数 = %d, want 2", len(all.Data)) + } + // 密钥分组落库 + _, key, err := gw.CreateKey(ctx, "vip-key", "vip-secret-1234", "vip") + if err != nil || key.Group != "vip" { + t.Fatalf("CreateKey group = %+v, %v", key, err) + } + empty := "" + _ = gw.UpdateKey(ctx, key.ID, "", nil, &empty) + var fresh model.AiKey + gw.db.First(&fresh, key.ID) + if fresh.Group != "" { + t.Errorf("清空分组后 = %q", fresh.Group) + } +} + +func TestSyncAiProbeTask(t *testing.T) { + client := &gatewayStubClient{ + fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, + models: []oci.GenAiModel{{Ocid: "ocid1.generativeaimodel.oc1..m1", Name: "meta.llama-3.3-70b-instruct", Vendor: "meta"}}, + probeCode: 200, + } + gw, svc := newTestGateway(t, client) + if err := gw.db.AutoMigrate(&model.Task{}, &model.TaskLog{}, &model.Setting{}); err != nil { + t.Fatalf("migrate task tables: %v", err) + } + tasks := NewTaskService(gw.db, svc, nil, nil) + tasks.AttachAiGateway(gw) + gw.SetOnChannelsChanged(tasks.SyncAiProbeTask) + cfg := importAliveConfig(t, svc) + ctx := context.Background() + + // 创建渠道 → 自动建任务并激活 + ch, err := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"}) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + var task model.Task + if err := gw.db.Where("type = ?", model.TaskTypeAiProbe).First(&task).Error; err != nil { + t.Fatalf("探测任务未自动创建: %v", err) + } + if task.Status != model.TaskStatusActive { + t.Errorf("任务状态 = %q, want active", task.Status) + } + if task.CronExpr != "0 0 * * *" { + t.Errorf("cron = %q, want 每天 00:00", task.CronExpr) + } + // 存量旧 cron 自动对齐 + gw.db.Model(&task).Update("cron_expr", "*/10 * * * *") + tasks.SyncAiProbeTask(ctx) + var aligned model.Task + gw.db.First(&aligned, task.ID) + if aligned.CronExpr != "0 0 * * *" { + t.Errorf("存量 cron 未对齐: %q", aligned.CronExpr) + } + // 手动重复创建被拒 + if _, err := tasks.CreateTask(ctx, CreateTaskInput{Name: "dup", Type: model.TaskTypeAiProbe, CronExpr: "*/10 * * * *"}); err == nil { + t.Error("重复 AI 探测任务应被拒绝") + } + // 立即执行一次:探测 ok + entry, err := tasks.RunTaskNow(ctx, task.ID) + if err != nil || entry == nil || !entry.Success { + t.Fatalf("RunTaskNow = %+v, %v", entry, err) + } + if !strings.Contains(entry.Message, "1 ok") { + t.Errorf("探测汇总 = %q", entry.Message) + } + // 手动删除被拒:任务由系统维护 + if err := tasks.DeleteTask(ctx, task.ID); err == nil { + t.Error("手动删除 AI 探测任务应被拒绝") + } + // 渠道归零 → 任务连同日志自动删除 + if err := gw.DeleteChannel(ctx, ch.ID); err != nil { + t.Fatalf("DeleteChannel: %v", err) + } + var gone int64 + gw.db.Model(&model.Task{}).Where("type = ?", model.TaskTypeAiProbe).Count(&gone) + if gone != 0 { + t.Errorf("渠道归零后任务应被删除,剩 %d", gone) + } + var logsLeft int64 + gw.db.Model(&model.TaskLog{}).Where("task_id = ?", task.ID).Count(&logsLeft) + if logsLeft != 0 { + t.Errorf("任务日志应随任务删除,剩 %d", logsLeft) + } + // 再建渠道 → 自动新建任务 + if _, err := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "us-chicago-1"}); err != nil { + t.Fatalf("CreateChannel again: %v", err) + } + var again model.Task + if err := gw.db.Where("type = ?", model.TaskTypeAiProbe).First(&again).Error; err != nil { + t.Fatalf("渠道恢复后任务未重建: %v", err) + } + if again.Status != model.TaskStatusActive { + t.Errorf("渠道恢复后任务状态 = %q, want active", again.Status) + } +} + +func TestProbeCandidates(t *testing.T) { + models := []oci.GenAiModel{ + {Ocid: "o1", Name: "xai.grok-voice-agent"}, + {Ocid: "o2", Name: "cohere.command-r-plus"}, + {Ocid: "o3", Name: "meta.llama-3.3-70b-instruct"}, + {Ocid: "o4", Name: "google.gemini-2.5-flash"}, + } + got := probeCandidates(models) + if len(got) != 3 || got[0].Name != "meta.llama-3.3-70b-instruct" || got[1].Name != "google.gemini-2.5-flash" { + t.Errorf("候选排序 = %+v", got) + } + for _, m := range got { + if m.Name == "xai.grok-voice-agent" { + t.Error("voice 模型不应进候选前 3") + } + } +} + +func TestDeprecatingModels(t *testing.T) { + gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}) + ctx := context.Background() + now := time.Now() + soon := now.Add(10 * 24 * time.Hour) + far := now.Add(90 * 24 * time.Hour) + past := now.Add(-30 * 24 * time.Hour) + retireSoon := now.Add(15 * 24 * time.Hour) + rows := []model.AiModelCache{ + {ChannelID: 1, Name: "meta.llama-old", SyncedAt: now, DeprecatedAt: &soon}, + {ChannelID: 2, Name: "meta.llama-old", SyncedAt: now, DeprecatedAt: &soon}, // 跨渠道去重 + {ChannelID: 1, Name: "xai.grok-4.3", SyncedAt: now, DeprecatedAt: &far}, // 弃用窗口外 + {ChannelID: 1, Name: "cohere.command-latest", SyncedAt: now}, // 未宣布 + {ChannelID: 1, Name: "xai.grok-3", SyncedAt: now, DeprecatedAt: &past}, // 已过弃用日仍可用:不再告警 + {ChannelID: 1, Name: "meta.llama-3.2-11b", SyncedAt: now, DeprecatedAt: &past, RetiredAt: &retireSoon}, // 即将退役:重点告警 + {ChannelID: 1, Name: "cohere.embed-img", SyncedAt: now, DeprecatedAt: &past, RetiredAt: &far}, // 退役窗口外 + } + if err := gw.db.Create(&rows).Error; err != nil { + t.Fatalf("seed: %v", err) + } + names, err := gw.DeprecatingModels(ctx, 30*24*time.Hour) + if err != nil || len(names) != 2 { + t.Fatalf("DeprecatingModels = %v, %v, want 2 条", names, err) + } + joined := strings.Join(names, "\n") + if !strings.Contains(joined, "meta.llama-3.2-11b(") || !strings.Contains(joined, "退役,届时无法调用") { + t.Errorf("缺少退役告警: %v", names) + } + if !strings.Contains(joined, "meta.llama-old(") || !strings.Contains(joined, "退役前仍可调用") { + t.Errorf("缺少弃用预告: %v", names) + } + if strings.Contains(joined, "xai.grok-3(") { + t.Errorf("已过弃用日且未近退役的模型不应告警: %v", names) + } +} + +func TestAiEmbeddings(t *testing.T) { + client := &gatewayStubClient{ + fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, + embedVecs: [][]float32{{0.1, 0.2}, {0.3, 0.4}}, + embedUsage: &aiwire.Usage{PromptTokens: 6, TotalTokens: 6}, + } + gw, svc := newTestGateway(t, client) + cfg := importAliveConfig(t, svc) + ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1) + gw.db.Create(&model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..emb", Name: "cohere.embed-v4.0", Vendor: "cohere", Capability: "EMBEDDING", SyncedAt: time.Now()}) + ctx := context.Background() + + resp, meta, err := gw.Embeddings(ctx, aiwire.EmbeddingsRequest{Model: "cohere.embed-v4.0", Input: aiwire.StringList{"a", "b"}}, "") + if err != nil || len(resp.Data) != 2 || resp.Data[1].Index != 1 || resp.Usage.TotalTokens != 6 { + t.Fatalf("Embeddings = %+v, meta=%+v, %v", resp, meta, err) + } + if resp.Object != "list" || resp.Data[0].Object != "embedding" { + t.Errorf("响应形态 = %+v", resp) + } + // 对话模型名打 embeddings:能力不匹配 → 未知模型 + if _, _, err := gw.Embeddings(ctx, aiwire.EmbeddingsRequest{Model: "meta.llama-3.3-70b-instruct", Input: aiwire.StringList{"a"}}, ""); !errors.Is(err, ErrAiUnknownModel) { + t.Errorf("chat 模型走 embeddings err = %v, want ErrAiUnknownModel", err) + } + // embedding 模型名打 chat:同样未知模型 + if _, _, err := gw.Chat(ctx, aiwire.ChatRequest{Model: "cohere.embed-v4.0", Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("x")}}}, ""); !errors.Is(err, ErrAiUnknownModel) { + t.Errorf("embedding 模型走 chat err = %v, want ErrAiUnknownModel", err) + } +} + +func TestAiContentLogSwitch(t *testing.T) { + gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}) + if err := gw.db.AutoMigrate(&model.AiContentLog{}); err != nil { + t.Fatalf("migrate content log: %v", err) + } + ctx := context.Background() + _, key, err := gw.CreateKey(ctx, "k1", "content-key-1234", "") + if err != nil { + t.Fatalf("CreateKey: %v", err) + } + if key.ContentLogUntil != nil { + t.Error("新密钥内容日志应默认永关") + } + // 开启 24 小时 + fresh, err := gw.UpdateKeyContentLog(ctx, key.ID, 24) + if err != nil || fresh.ContentLogUntil == nil || time.Until(*fresh.ContentLogUntil) < 23*time.Hour { + t.Fatalf("开启失败: %+v, %v", fresh.ContentLogUntil, err) + } + // 超上限拒绝 + if _, err := gw.UpdateKeyContentLog(ctx, key.ID, 169); err == nil { + t.Error("超过 7 天上限应被拒绝") + } + // 写入与截断(带调用日志关联) + gw.LogContent(model.AiContentLog{CallLogID: 42, KeyID: key.ID, KeyName: "k1", Endpoint: "openai", Model: "m", RequestBody: strings.Repeat("x", 70*1024)}) + rows, total, err := gw.ContentLogs(ctx, key.ID, 0, 1, 20) + if err != nil || total != 1 || len(rows[0].RequestBody) != 64*1024 { + t.Fatalf("ContentLogs = total %d len %d, %v", total, len(rows[0].RequestBody), err) + } + // 按调用日志 ID 反查:命中与不命中 + if rows, total, err = gw.ContentLogs(ctx, 0, 42, 1, 20); err != nil || total != 1 || rows[0].CallLogID != 42 { + t.Fatalf("按 callLogId 反查 = total %d, %v", total, err) + } + if _, total, err = gw.ContentLogs(ctx, 0, 999, 1, 20); err != nil || total != 0 { + t.Fatalf("callLogId 不命中应为空 = total %d, %v", total, err) + } + // 关闭 + fresh, err = gw.UpdateKeyContentLog(ctx, key.ID, 0) + if err != nil || fresh.ContentLogUntil != nil { + t.Fatalf("关闭失败: %+v, %v", fresh.ContentLogUntil, err) + } +} diff --git a/internal/service/airesponses.go b/internal/service/airesponses.go new file mode 100644 index 0000000..6c03b4d --- /dev/null +++ b/internal/service/airesponses.go @@ -0,0 +1,243 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + + "oci-portal/internal/aiwire" +) + +// ResponsesToIR 把 Responses API 请求转换为网关 IR(OpenAI Chat 形态)。 +// 有状态特性与内置工具不支持,直接报错(API 层 400)。 +func ResponsesToIR(req aiwire.RespRequest) (aiwire.ChatRequest, error) { + if err := respRejectUnsupported(req); err != nil { + return aiwire.ChatRequest{}, err + } + ir := aiwire.ChatRequest{ + Model: req.Model, + MaxTokens: req.MaxOutputTokens, + Temperature: req.Temperature, + TopP: req.TopP, + Stream: req.Stream, + ToolChoice: respToolChoice(req.ToolChoice), + } + if req.Reasoning != nil { + ir.ReasoningEffort = req.Reasoning.Effort + } + if req.Instructions != "" { + ir.Messages = append(ir.Messages, aiwire.ChatMessage{Role: "system", Content: aiwire.NewTextContent(req.Instructions)}) + } + msgs, err := respInputToMessages(req.Input) + if err != nil { + return aiwire.ChatRequest{}, err + } + ir.Messages = append(ir.Messages, msgs...) + ir.Tools = respTools(req.Tools) + ir.ResponseFormat = respFormat(req.Text) + return ir, nil +} + +// respRejectUnsupported 拒绝无状态网关无法承接的请求特性。 +func respRejectUnsupported(req aiwire.RespRequest) error { + if req.PreviousResponseID != "" { + return fmt.Errorf("previous_response_id 不支持:网关不保存历史响应,请在 input 中自带完整上下文(store:false 模式)") + } + if len(req.Conversation) > 0 && string(req.Conversation) != "null" { + return fmt.Errorf("conversation 不支持:网关不保存对话状态") + } + if req.Background != nil && *req.Background { + return fmt.Errorf("background 模式不支持") + } + for _, t := range req.Tools { + if t.Type != "function" { + return fmt.Errorf("不支持的工具类型 %q:仅支持 function 工具", t.Type) + } + } + return nil +} + +// respInputToMessages 把 input(string 或 item 数组)展开为 IR 消息序列。 +func respInputToMessages(in aiwire.RespInput) ([]aiwire.ChatMessage, error) { + if !in.IsArray { + if strings.TrimSpace(in.Text) == "" { + return nil, fmt.Errorf("input 不能为空") + } + return []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent(in.Text)}}, nil + } + var msgs []aiwire.ChatMessage + for _, it := range in.Items { + out, err := respItemToMessages(it, msgs) + if err != nil { + return nil, err + } + msgs = out + } + return msgs, nil +} + +// respItemToMessages 追加一个 input item;连续 function_call 合并进同一 assistant 消息。 +func respItemToMessages(it aiwire.RespItem, msgs []aiwire.ChatMessage) ([]aiwire.ChatMessage, error) { + switch it.Type { + case "", "message": + if it.Content.HasUnsupported() { + return nil, ErrAiUnsupportedBlock + } + role := respRole(it.Role) + return append(msgs, aiwire.ChatMessage{Role: role, Content: respContentToIR(it.Content)}), nil + case "function_call": + call := aiwire.ToolCall{ID: it.CallID, Type: "function", + Function: aiwire.FunctionCall{Name: it.Name, Arguments: it.Arguments}} + if n := len(msgs); n > 0 && msgs[n-1].Role == "assistant" && len(msgs[n-1].ToolCalls) > 0 { + msgs[n-1].ToolCalls = append(msgs[n-1].ToolCalls, call) + return msgs, nil + } + return append(msgs, aiwire.ChatMessage{Role: "assistant", ToolCalls: []aiwire.ToolCall{call}}), nil + case "function_call_output": + return append(msgs, aiwire.ChatMessage{Role: "tool", ToolCallID: it.CallID, + Content: aiwire.NewTextContent(it.OutputText())}), nil + case "reasoning": + return msgs, nil // 推理块不回灌上游 + default: + return nil, fmt.Errorf("不支持的 input item 类型 %q", it.Type) + } +} + +// respRole 归一 item 角色;developer 视为 system,缺省 user。 +func respRole(role string) string { + switch role { + case "system", "developer": + return "system" + case "assistant": + return "assistant" + default: + return "user" + } +} + +// respContentToIR 把 Responses 内容转 IR;纯文本拍平,含图片时保留部件顺序。 +func respContentToIR(c aiwire.RespContent) aiwire.Content { + var parts []aiwire.ContentPart + hasImage := false + for _, p := range c.Parts { + switch p.Type { + case "input_image": + parts = append(parts, aiwire.ContentPart{Type: "image_url", + ImageURL: &aiwire.ImageURL{URL: p.ImageURL, Detail: p.Detail}}) + hasImage = true + default: + if p.Text != "" { + parts = append(parts, aiwire.ContentPart{Type: "text", Text: p.Text}) + } + } + } + if !c.IsArray || !hasImage { + return aiwire.NewTextContent(c.JoinText()) + } + return aiwire.NewPartsContent(parts) +} + +// respTools 把扁平工具定义还原为嵌套 Chat 形态;strict 无 OCI 对应,忽略。 +func respTools(tools []aiwire.RespTool) []aiwire.Tool { + if len(tools) == 0 { + return nil + } + out := make([]aiwire.Tool, 0, len(tools)) + for _, t := range tools { + out = append(out, aiwire.Tool{Type: "function", + Function: aiwire.FunctionDef{Name: t.Name, Description: t.Description, Parameters: t.Parameters}}) + } + return out +} + +// respToolChoice 转换 tool_choice:字符串直通;{type:function,name} 转嵌套; +// allowed_tools 等对象形态降级 auto。 +func respToolChoice(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return nil + } + t := strings.TrimSpace(string(raw)) + if strings.HasPrefix(t, "\"") { + return raw + } + var obj struct { + Type string `json:"type"` + Name string `json:"name"` + } + if err := json.Unmarshal(raw, &obj); err != nil || obj.Type != "function" || obj.Name == "" { + return json.RawMessage(`"auto"`) + } + out, _ := json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": obj.Name}}) + return out +} + +// respFormat 把 text.format 转成 response_format;text 形态无需显式指定。 +func respFormat(text *aiwire.RespText) *aiwire.ResponseFormat { + if text == nil || text.Format == nil { + return nil + } + switch text.Format.Type { + case "json_object": + return &aiwire.ResponseFormat{Type: "json_object"} + case "json_schema": + spec, _ := json.Marshal(map[string]any{ + "name": text.Format.Name, "schema": json.RawMessage(text.Format.Schema), "strict": text.Format.Strict, + }) + return &aiwire.ResponseFormat{Type: "json_schema", JSONSchema: spec} + default: + return nil + } +} + +// IRRespToResponses 把 IR 非流式响应装配为 Response 对象。 +func IRRespToResponses(resp *aiwire.ChatResponse, id string, created int64) aiwire.Response { + out := aiwire.Response{ID: id, Object: "response", CreatedAt: created, + Status: "completed", Model: resp.Model, Output: []aiwire.RespOutItem{}, Store: false} + if len(resp.Choices) == 0 { + return out + } + choice := resp.Choices[0] + if text := choice.Message.Content.JoinText(); text != "" { + out.Output = append(out.Output, respMessageItem(id, 0, text)) + } + for i, tc := range choice.Message.ToolCalls { + out.Output = append(out.Output, aiwire.RespOutItem{Type: "function_call", + ID: respItemID(id, "fc", len(out.Output)+i), Status: "completed", + CallID: tc.ID, Name: tc.Function.Name, Arguments: respArgs(tc.Function.Arguments)}) + } + if choice.FinishReason == "length" { + out.Status = "incomplete" + out.IncompleteDetails = &aiwire.RespIncomplete{Reason: "max_output_tokens"} + } + out.Usage = respUsage(resp.Usage) + return out +} + +func respMessageItem(respID string, idx int, text string) aiwire.RespOutItem { + return aiwire.RespOutItem{Type: "message", ID: respItemID(respID, "msg", idx), + Status: "completed", Role: "assistant", + Content: []aiwire.RespOutPart{{Type: "output_text", Text: text, Annotations: []any{}}}} +} + +// respItemID 从响应 ID 派生确定性 item ID。 +func respItemID(respID, kind string, idx int) string { + return fmt.Sprintf("%s_%s_%d", kind, strings.TrimPrefix(respID, "resp_"), idx) +} + +// respArgs 保证 arguments 是合法 JSON 字符串(空实参回退 {})。 +func respArgs(args string) string { + if strings.TrimSpace(args) == "" { + return "{}" + } + return args +} + +// respUsage 把 IR 用量改写为 Responses 命名口径。 +func respUsage(u *aiwire.Usage) *aiwire.RespUsage { + if u == nil { + return nil + } + return &aiwire.RespUsage{InputTokens: u.PromptTokens, OutputTokens: u.CompletionTokens, + TotalTokens: u.TotalTokens, + InputTokensDetails: aiwire.RespInDetails{CachedTokens: u.CachedTokens()}} +} diff --git a/internal/service/airesponses_stream.go b/internal/service/airesponses_stream.go new file mode 100644 index 0000000..351f66a --- /dev/null +++ b/internal/service/airesponses_stream.go @@ -0,0 +1,199 @@ +package service + +import ( + "strings" + + "oci-portal/internal/aiwire" +) + +// RespEvent 是一条 Responses SSE 语义事件(event 名 + data 载荷)。 +type RespEvent struct { + Event string + Data map[string]any +} + +// RespStream 把 IR chunk 流聚合为 Responses 语义事件序列。 +// 与 AnthStream 同构,但终态事件须携带完整 Response 快照,故全程缓冲文本与实参。 +type RespStream struct { + id string + model string + created int64 + seq int + started bool + items []aiwire.RespOutItem + msgOpen bool + text strings.Builder + tool aiwire.RespOutItem + toolIdx int + tOpen bool + args strings.Builder + finish string + usage *aiwire.Usage +} + +// NewRespStream 构造状态机;id 形如 resp_*,created 为响应时间戳。 +func NewRespStream(id, model string, created int64) *RespStream { + return &RespStream{id: id, model: model, created: created} +} + +// Feed 消化一个上游 chunk,返回应立即下发的事件。 +func (s *RespStream) Feed(chunk aiwire.ChatChunk) []RespEvent { + var evs []RespEvent + if !s.started { + s.started = true + evs = append(evs, s.respEvent("response.created", "in_progress"), + s.respEvent("response.in_progress", "in_progress")) + } + if chunk.Usage != nil { + s.usage = chunk.Usage + } + if len(chunk.Choices) == 0 { + return evs + } + choice := chunk.Choices[0] + if choice.Delta.Content != "" { + evs = append(evs, s.feedText(choice.Delta.Content)...) + } + for _, tc := range choice.Delta.ToolCalls { + evs = append(evs, s.feedTool(tc)...) + } + if choice.FinishReason != nil { + s.finish = *choice.FinishReason + } + return evs +} + +// Finish 关闭未闭合的块并产出终态事件(带完整 Response 快照)。 +func (s *RespStream) Finish() []RespEvent { + var evs []RespEvent + if !s.started { + s.started = true + evs = append(evs, s.respEvent("response.created", "in_progress")) + } + evs = append(evs, s.closeMsg()...) + evs = append(evs, s.closeTool()...) + status := "completed" + if s.finish == "length" { + status = "incomplete" + } + return append(evs, s.respEvent("response."+status, status)) +} + +// Usage 返回聚合到的用量(可能为 nil),供调用日志。 +func (s *RespStream) Usage() *aiwire.Usage { return s.usage } + +// ev 构造带自增 sequence_number 的事件。 +func (s *RespStream) ev(typ string, kv map[string]any) RespEvent { + s.seq++ + data := map[string]any{"type": typ, "sequence_number": s.seq} + for k, v := range kv { + data[k] = v + } + return RespEvent{Event: typ, Data: data} +} + +// respEvent 构造携带 Response 快照的生命周期事件。 +func (s *RespStream) respEvent(typ, status string) RespEvent { + return s.ev(typ, map[string]any{"response": s.snapshot(status)}) +} + +// snapshot 组装当前累计状态的 Response 对象。 +func (s *RespStream) snapshot(status string) aiwire.Response { + resp := aiwire.Response{ID: s.id, Object: "response", CreatedAt: s.created, + Status: status, Model: s.model, Store: false, + Output: append([]aiwire.RespOutItem{}, s.items...)} + resp.Usage = respUsage(s.usage) + if status == "incomplete" { + resp.IncompleteDetails = &aiwire.RespIncomplete{Reason: "max_output_tokens"} + } + return resp +} + +// feedText 处理文本增量:必要时开 message item 与 content part。 +func (s *RespStream) feedText(delta string) []RespEvent { + evs := s.closeTool() + if !s.msgOpen { + s.msgOpen = true + s.text.Reset() + item := aiwire.RespOutItem{Type: "message", ID: s.itemID("msg"), Status: "in_progress", + Role: "assistant", Content: []aiwire.RespOutPart{}} + evs = append(evs, s.ev("response.output_item.added", map[string]any{ + "output_index": len(s.items), "item": item})) + evs = append(evs, s.ev("response.content_part.added", map[string]any{ + "item_id": item.ID, "output_index": len(s.items), "content_index": 0, + "part": aiwire.RespOutPart{Type: "output_text", Text: "", Annotations: []any{}}})) + } + s.text.WriteString(delta) + return append(evs, s.ev("response.output_text.delta", map[string]any{ + "item_id": s.itemID("msg"), "output_index": len(s.items), "content_index": 0, "delta": delta})) +} + +// closeMsg 闭合当前 message item(text done → part done → item done)。 +func (s *RespStream) closeMsg() []RespEvent { + if !s.msgOpen { + return nil + } + s.msgOpen = false + id, idx, full := s.itemID("msg"), len(s.items), s.text.String() + part := aiwire.RespOutPart{Type: "output_text", Text: full, Annotations: []any{}} + item := aiwire.RespOutItem{Type: "message", ID: id, Status: "completed", + Role: "assistant", Content: []aiwire.RespOutPart{part}} + evs := []RespEvent{ + s.ev("response.output_text.done", map[string]any{"item_id": id, "output_index": idx, "content_index": 0, "text": full}), + s.ev("response.content_part.done", map[string]any{"item_id": id, "output_index": idx, "content_index": 0, "part": part}), + s.ev("response.output_item.done", map[string]any{"output_index": idx, "item": item}), + } + s.items = append(s.items, item) + return evs +} + +// feedTool 处理工具调用增量:新 Index 先闭合旧调用再开新 item。 +func (s *RespStream) feedTool(tc aiwire.ToolCallDelta) []RespEvent { + evs := s.closeMsg() + if s.tOpen && tc.Index != s.toolIdx { + evs = append(evs, s.closeTool()...) + } + if !s.tOpen { + s.tOpen, s.toolIdx = true, tc.Index + s.args.Reset() + s.tool = aiwire.RespOutItem{Type: "function_call", ID: s.itemID("fc"), Status: "in_progress", + CallID: tc.ID, Name: tc.Function.Name} + evs = append(evs, s.ev("response.output_item.added", map[string]any{ + "output_index": len(s.items), "item": s.tool})) + } + if tc.ID != "" && s.tool.CallID == "" { + s.tool.CallID = tc.ID + } + if tc.Function.Name != "" && s.tool.Name == "" { + s.tool.Name = tc.Function.Name + } + if tc.Function.Arguments == "" { + return evs + } + s.args.WriteString(tc.Function.Arguments) + return append(evs, s.ev("response.function_call_arguments.delta", map[string]any{ + "item_id": s.tool.ID, "output_index": len(s.items), "delta": tc.Function.Arguments})) +} + +// closeTool 闭合当前 function_call item(arguments done → item done)。 +func (s *RespStream) closeTool() []RespEvent { + if !s.tOpen { + return nil + } + s.tOpen = false + s.tool.Arguments = respArgs(s.args.String()) + s.tool.Status = "completed" + idx := len(s.items) + evs := []RespEvent{ + s.ev("response.function_call_arguments.done", map[string]any{ + "item_id": s.tool.ID, "output_index": idx, "arguments": s.tool.Arguments}), + s.ev("response.output_item.done", map[string]any{"output_index": idx, "item": s.tool}), + } + s.items = append(s.items, s.tool) + return evs +} + +// itemID 按当前 output 序号派生确定性 item ID。 +func (s *RespStream) itemID(kind string) string { + return respItemID(s.id, kind, len(s.items)) +} diff --git a/internal/service/airesponses_test.go b/internal/service/airesponses_test.go new file mode 100644 index 0000000..208114a --- /dev/null +++ b/internal/service/airesponses_test.go @@ -0,0 +1,176 @@ +package service + +import ( + "encoding/json" + "strings" + "testing" + + "oci-portal/internal/aiwire" +) + +func respReq(t *testing.T, raw string) aiwire.RespRequest { + t.Helper() + var req aiwire.RespRequest + if err := json.Unmarshal([]byte(raw), &req); err != nil { + t.Fatalf("unmarshal req: %v", err) + } + return req +} + +func TestResponsesToIR(t *testing.T) { + req := respReq(t, `{ + "model": "meta.llama-3.3-70b-instruct", + "instructions": "你是助手", + "max_output_tokens": 128, + "input": [ + {"role": "user", "content": "查天气"}, + {"type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"上海\"}"}, + {"type": "function_call_output", "call_id": "call_1", "output": "{\"temp\":31}"}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "31 度"}]} + ], + "tools": [{"type": "function", "name": "get_weather", "description": "查天气", "parameters": {"type": "object"}, "strict": true}], + "tool_choice": {"type": "function", "name": "get_weather"}, + "text": {"format": {"type": "json_schema", "name": "out", "schema": {"type": "object"}}} + }`) + ir, err := ResponsesToIR(req) + if err != nil { + t.Fatalf("ResponsesToIR: %v", err) + } + roles := make([]string, 0, len(ir.Messages)) + for _, m := range ir.Messages { + roles = append(roles, m.Role) + } + want := []string{"system", "user", "assistant", "tool", "assistant"} + if strings.Join(roles, ",") != strings.Join(want, ",") { + t.Errorf("roles = %v, want %v", roles, want) + } + if ir.Messages[2].ToolCalls[0].ID != "call_1" || ir.Messages[3].ToolCallID != "call_1" { + t.Errorf("tool call 链接错误: %+v", ir.Messages) + } + if deref(ir.MaxTokens) != 128 || len(ir.Tools) != 1 || ir.Tools[0].Function.Name != "get_weather" { + t.Errorf("参数映射错误: max=%v tools=%+v", ir.MaxTokens, ir.Tools) + } + if !strings.Contains(string(ir.ToolChoice), `"function"`) || !strings.Contains(string(ir.ToolChoice), "get_weather") { + t.Errorf("tool_choice = %s", ir.ToolChoice) + } + if ir.ResponseFormat == nil || ir.ResponseFormat.Type != "json_schema" { + t.Errorf("response_format = %+v", ir.ResponseFormat) + } +} + +func deref(p *int) int { + if p == nil { + return 0 + } + return *p +} + +func TestResponsesToIRRejects(t *testing.T) { + cases := []struct{ name, raw string }{ + {"previous_response_id", `{"model":"m","input":"hi","previous_response_id":"resp_x"}`}, + {"conversation", `{"model":"m","input":"hi","conversation":{"id":"conv_1"}}`}, + {"background", `{"model":"m","input":"hi","background":true}`}, + {"builtin tool", `{"model":"m","input":"hi","tools":[{"type":"web_search"}]}`}, + {"file_id image", `{"model":"m","input":[{"role":"user","content":[{"type":"input_image","file_id":"file_1"}]}]}`}, + {"unknown item", `{"model":"m","input":[{"type":"item_reference","id":"x"}]}`}, + } + for _, c := range cases { + if _, err := ResponsesToIR(respReq(t, c.raw)); err == nil { + t.Errorf("%s 应被拒绝", c.name) + } + } + // input 字符串形态 + store/reasoning 忽略项不报错 + req := respReq(t, `{"model":"m","input":"你好","store":false,"reasoning":{"effort":"low"},"parallel_tool_calls":true}`) + ir, err := ResponsesToIR(req) + if err != nil || len(ir.Messages) != 1 || ir.Messages[0].Role != "user" || ir.ReasoningEffort != "low" { + t.Errorf("字符串 input = %+v, %v", ir.Messages, err) + } + // input_image(url 形态)放行并保留图文顺序 + req2 := respReq(t, `{"model":"m","input":[{"role":"user","content":[{"type":"input_text","text":"看图"},{"type":"input_image","image_url":"https://x/1.png","detail":"low"}]}]}`) + ir2, err := ResponsesToIR(req2) + if err != nil { + t.Fatalf("input_image 应放行: %v", err) + } + parts := ir2.Messages[0].Content.Parts + if len(parts) != 2 || parts[1].Type != "image_url" || parts[1].ImageURL == nil || parts[1].ImageURL.URL != "https://x/1.png" { + t.Errorf("parts = %+v", parts) + } +} + +func TestIRRespToResponses(t *testing.T) { + resp := &aiwire.ChatResponse{Model: "m", Choices: []aiwire.Choice{{ + Message: aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent("你好"), + ToolCalls: []aiwire.ToolCall{{ID: "call_9", Type: "function", + Function: aiwire.FunctionCall{Name: "f", Arguments: ""}}}}, + FinishReason: "length", + }}, Usage: &aiwire.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}} + out := IRRespToResponses(resp, "resp_abc", 1751966400) + if out.Status != "incomplete" || out.IncompleteDetails == nil || out.IncompleteDetails.Reason != "max_output_tokens" { + t.Errorf("length 应映射 incomplete: %+v", out) + } + if len(out.Output) != 2 || out.Output[0].Type != "message" || out.Output[1].Type != "function_call" { + t.Fatalf("output = %+v", out.Output) + } + if out.Output[0].Content[0].Text != "你好" || out.Output[1].CallID != "call_9" || out.Output[1].Arguments != "{}" { + t.Errorf("item 装配错误: %+v", out.Output) + } + if out.Usage.InputTokens != 10 || out.Usage.OutputTokens != 5 || out.Store { + t.Errorf("usage/store 错误: %+v", out) + } +} + +func chunkText(text string) aiwire.ChatChunk { + return aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{Content: text}}}} +} + +func TestRespStreamTextAndTool(t *testing.T) { + st := NewRespStream("resp_x", "m", 1751966400) + var types []string + collect := func(evs []RespEvent) { + for _, ev := range evs { + types = append(types, ev.Event) + } + } + collect(st.Feed(chunkText("你"))) + collect(st.Feed(chunkText("好"))) + collect(st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{ + ToolCalls: []aiwire.ToolCallDelta{{Index: 0, ID: "call_1", Function: aiwire.FunctionCallDelta{Name: "f", Arguments: "{\"a\""}}}}}}})) + collect(st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{ + ToolCalls: []aiwire.ToolCallDelta{{Index: 0, Function: aiwire.FunctionCallDelta{Arguments: ":1}"}}}}}}, + Usage: &aiwire.Usage{PromptTokens: 3, CompletionTokens: 7, TotalTokens: 10}})) + finish := st.Finish() + collect(finish) + want := []string{ + "response.created", "response.in_progress", + "response.output_item.added", "response.content_part.added", "response.output_text.delta", + "response.output_text.delta", + "response.output_text.done", "response.content_part.done", "response.output_item.done", + "response.output_item.added", "response.function_call_arguments.delta", + "response.function_call_arguments.delta", + "response.function_call_arguments.done", "response.output_item.done", + "response.completed", + } + if strings.Join(types, "\n") != strings.Join(want, "\n") { + t.Errorf("事件序列 =\n%s\nwant\n%s", strings.Join(types, "\n"), strings.Join(want, "\n")) + } + last := finish[len(finish)-1].Data + resp := last["response"].(aiwire.Response) + if len(resp.Output) != 2 || resp.Output[0].Content[0].Text != "你好" || resp.Output[1].Arguments != "{\"a\":1}" { + t.Errorf("终态快照 = %+v", resp.Output) + } + if resp.Usage == nil || resp.Usage.TotalTokens != 10 { + t.Errorf("终态 usage = %+v", resp.Usage) + } + // sequence_number 严格递增 + if seq, ok := last["sequence_number"].(int); !ok || seq != len(want) { + t.Errorf("最终 sequence_number = %v, want %d", last["sequence_number"], len(want)) + } +} + +func TestRespStreamEmpty(t *testing.T) { + st := NewRespStream("resp_e", "m", 1) + evs := st.Finish() + if len(evs) != 2 || evs[0].Event != "response.created" || evs[1].Event != "response.completed" { + t.Errorf("空流事件 = %+v", evs) + } +} diff --git a/internal/service/attachment.go b/internal/service/attachment.go new file mode 100644 index 0000000..033fbc8 --- /dev/null +++ b/internal/service/attachment.go @@ -0,0 +1,161 @@ +package service + +import ( + "context" + "fmt" + + "oci-portal/internal/oci" +) + +// ChangeInstancePublicIP 更换实例主 VNIC 的临时公网 IP,返回新地址。 +func (s *OciConfigService) ChangeInstancePublicIP(ctx context.Context, id uint, region, instanceID string) (string, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return "", err + } + return s.client.ChangeInstancePublicIP(ctx, cred, region, instanceID) +} + +// AddInstanceIpv6 为实例主 VNIC 分配 IPv6 地址;address 为空时自动分配。 +func (s *OciConfigService) AddInstanceIpv6(ctx context.Context, id uint, region, instanceID, address string) (string, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return "", err + } + return s.client.AddInstanceIpv6(ctx, cred, region, instanceID, address) +} + +// DeleteInstanceIpv6 取消分配实例上的指定 IPv6 地址。 +func (s *OciConfigService) DeleteInstanceIpv6(ctx context.Context, id uint, region, instanceID, address string) error { + if address == "" { + return fmt.Errorf("delete ipv6: address is required") + } + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return err + } + return s.client.DeleteInstanceIpv6(ctx, cred, region, instanceID, address) +} + +// InstanceVnics 列出实例的全部 VNIC。 +func (s *OciConfigService) InstanceVnics(ctx context.Context, id uint, region, instanceID string) ([]oci.Vnic, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return nil, err + } + return s.client.ListInstanceVnics(ctx, cred, region, instanceID) +} + +// AttachVnic 为实例附加次要 VNIC。 +func (s *OciConfigService) AttachVnic(ctx context.Context, id uint, region, instanceID string, in oci.AttachVnicInput) (oci.Vnic, error) { + if instanceID == "" || in.SubnetID == "" { + return oci.Vnic{}, fmt.Errorf("attach vnic: instanceId and subnetId are required") + } + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.Vnic{}, err + } + return s.client.AttachVnic(ctx, cred, region, instanceID, in) +} + +// DetachVnic 分离 VNIC 附加关系(主 VNIC 由 OCI 拒绝)。 +func (s *OciConfigService) DetachVnic(ctx context.Context, id uint, region, attachmentID string) error { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return err + } + return s.client.DetachVnic(ctx, cred, region, attachmentID) +} + +// AddVnicIpv6 为指定 VNIC 分配 IPv6;address 留空自动分配。 +func (s *OciConfigService) AddVnicIpv6(ctx context.Context, id uint, region, vnicID, address string) (string, error) { + if vnicID == "" { + return "", fmt.Errorf("add vnic ipv6: vnicId is required") + } + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return "", err + } + return s.client.AddVnicIpv6(ctx, cred, region, vnicID, address) +} + +// BootVolumeAttachments 列出实例的引导卷挂载。 +func (s *OciConfigService) BootVolumeAttachments(ctx context.Context, id uint, region, instanceID string) ([]oci.BootVolumeAttachment, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return nil, err + } + return s.client.ListBootVolumeAttachments(ctx, cred, region, instanceID) +} + +// AttachBootVolume 为实例挂载引导卷(实例需 STOPPED)。 +func (s *OciConfigService) AttachBootVolume(ctx context.Context, id uint, region, instanceID, bootVolumeID string) (oci.BootVolumeAttachment, error) { + if instanceID == "" || bootVolumeID == "" { + return oci.BootVolumeAttachment{}, fmt.Errorf("attach boot volume: instanceId and bootVolumeId are required") + } + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.BootVolumeAttachment{}, err + } + return s.client.AttachBootVolume(ctx, cred, region, instanceID, bootVolumeID) +} + +// DetachBootVolume 分离引导卷挂载(实例需 STOPPED)。 +func (s *OciConfigService) DetachBootVolume(ctx context.Context, id uint, region, attachmentID string) error { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return err + } + return s.client.DetachBootVolume(ctx, cred, region, attachmentID) +} + +// ReplaceBootVolume 替换实例引导卷(实例需 STOPPED)。 +func (s *OciConfigService) ReplaceBootVolume(ctx context.Context, id uint, region, instanceID, bootVolumeID string) (oci.BootVolumeAttachment, error) { + if instanceID == "" || bootVolumeID == "" { + return oci.BootVolumeAttachment{}, fmt.Errorf("replace boot volume: instanceId and bootVolumeId are required") + } + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.BootVolumeAttachment{}, err + } + return s.client.ReplaceBootVolume(ctx, cred, region, instanceID, bootVolumeID) +} + +// BlockVolumes 列出块存储卷;compartmentID 为空表示租户根。 +func (s *OciConfigService) BlockVolumes(ctx context.Context, id uint, region, availabilityDomain, compartmentID string) ([]oci.BlockVolume, error) { + cred, err := s.credentialsInCompartment(ctx, id, compartmentID) + if err != nil { + return nil, err + } + return s.client.ListBlockVolumes(ctx, cred, region, availabilityDomain) +} + +// VolumeAttachments 列出实例的块卷挂载。 +func (s *OciConfigService) VolumeAttachments(ctx context.Context, id uint, region, instanceID string) ([]oci.VolumeAttachment, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return nil, err + } + return s.client.ListVolumeAttachments(ctx, cred, region, instanceID) +} + +// AttachVolume 为实例附加块卷。 +func (s *OciConfigService) AttachVolume(ctx context.Context, id uint, region, instanceID, volumeID string, readOnly bool) (oci.VolumeAttachment, error) { + if instanceID == "" || volumeID == "" { + return oci.VolumeAttachment{}, fmt.Errorf("attach volume: instanceId and volumeId are required") + } + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.VolumeAttachment{}, err + } + return s.client.AttachVolume(ctx, cred, region, instanceID, volumeID, readOnly) +} + +// DetachVolume 分离块卷挂载。 +func (s *OciConfigService) DetachVolume(ctx context.Context, id uint, region, attachmentID string) error { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return err + } + return s.client.DetachVolume(ctx, cred, region, attachmentID) +} diff --git a/internal/service/audit.go b/internal/service/audit.go new file mode 100644 index 0000000..4dbf04f --- /dev/null +++ b/internal/service/audit.go @@ -0,0 +1,136 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "time" + + "oci-portal/internal/oci" +) + +// 审计查询时间窗(小时)上下限。 +const ( + minAuditHours = 1 + maxAuditHours = 72 +) + +// 审计原始事件缓存:约 3KB/条,上限 2000 条 ≈ 6MB;TTL 内详情秒开。 +const ( + auditRawMax = 2000 + auditRawTTL = 10 * time.Minute +) + +// ErrInvalidAuditHours 表示审计时间窗参数越界,handler 据此返回 400。 +var ErrInvalidAuditHours = errors.New("audit events: hours must be between 1 and 72") + +// ErrInvalidAuditWindow 表示续查的绝对时间窗非法(格式/顺序/跨度),handler 返回 400。 +var ErrInvalidAuditWindow = errors.New("audit events: invalid start/end window") + +// ErrAuditEventGone 表示原始事件缓存过期且小窗重查未找回;handler 映射 404。 +var ErrAuditEventGone = errors.New("原始事件已不可取回,请刷新列表后重试") + +// AuditQuery 是审计查询参数:Hours 为首查(相对当前时刻); +// Start/End(RFC3339)为续查绝对窗,配合 Page 从截断游标断点续翻。 +type AuditQuery struct { + Region string + Hours int + Start string + End string + Page string +} + +// AuditEventsView 是审计查询响应:列表不含 raw(详情接口取回); +// Start/End 回传本次实际使用的绝对窗,截断续查必须原样带回(游标绑定查询参数)。 +type AuditEventsView struct { + Items []oci.AuditEvent `json:"items"` + Truncated bool `json:"truncated"` + NextPage string `json:"nextPage,omitempty"` + Start time.Time `json:"start"` + End time.Time `json:"end"` +} + +// AuditEvents 实时查询租户 OCI 审计事件,纯透传不入库;region 为空时用配置 +// 默认区域。原始事件剥离进进程内缓存,响应体瘦身约 10 倍(调研④方案 A1+B1)。 +func (s *OciConfigService) AuditEvents(ctx context.Context, id uint, q AuditQuery) (AuditEventsView, error) { + start, end, err := auditWindow(q) + if err != nil { + return AuditEventsView{}, err + } + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return AuditEventsView{}, err + } + res, err := s.client.ListAuditEvents(ctx, cred, q.Region, start, end, q.Page) + if err != nil { + return AuditEventsView{}, err + } + return AuditEventsView{ + Items: s.stripAuditRaw(res.Items), + Truncated: res.Truncated, + NextPage: res.NextPage, + // 与实际请求同粒度(分钟),续查回传时窗口逐字节一致 + Start: start.UTC().Truncate(time.Minute), + End: end.UTC().Truncate(time.Minute), + }, nil +} + +// auditWindow 解析查询窗口:带 start/end/page 走绝对窗校验,否则按 hours 相对窗。 +func auditWindow(q AuditQuery) (time.Time, time.Time, error) { + if q.Start != "" || q.End != "" || q.Page != "" { + start, err1 := time.Parse(time.RFC3339, q.Start) + end, err2 := time.Parse(time.RFC3339, q.End) + if err1 != nil || err2 != nil || !start.Before(end) || + end.Sub(start) > maxAuditHours*time.Hour+time.Minute { + return time.Time{}, time.Time{}, ErrInvalidAuditWindow + } + return start, end, nil + } + if q.Hours < minAuditHours || q.Hours > maxAuditHours { + return time.Time{}, time.Time{}, ErrInvalidAuditHours + } + end := time.Now().UTC() + return end.Add(-time.Duration(q.Hours) * time.Hour), end, nil +} + +// stripAuditRaw 把每条原始事件按 eventId 放进缓存并从列表剥离。 +func (s *OciConfigService) stripAuditRaw(items []oci.AuditEvent) []oci.AuditEvent { + for i := range items { + if items[i].EventId != "" && items[i].Raw != nil { + s.auditRaw.Set(items[i].EventId, items[i].Raw, auditRawTTL) + } + items[i].Raw = nil + } + return items +} + +// AuditEventDetail 取回单条事件的原始 JSON:缓存命中即回;miss 按事件时间 +// 所在分钟起 2 分钟小窗重查兜底(调研④方案 A2),仍未命中报 ErrAuditEventGone。 +func (s *OciConfigService) AuditEventDetail(ctx context.Context, id uint, region, eventID string, eventTime time.Time) (json.RawMessage, error) { + if raw, ok := s.auditRaw.Get(eventID); ok { + return raw.(json.RawMessage), nil + } + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return nil, err + } + start := eventTime.UTC().Truncate(time.Minute) + res, err := s.client.ListAuditEvents(ctx, cred, region, start, start.Add(2*time.Minute), "") + if err != nil { + return nil, err + } + var found json.RawMessage + for _, ev := range res.Items { + if ev.EventId == "" || ev.Raw == nil { + continue + } + s.auditRaw.Set(ev.EventId, ev.Raw, auditRawTTL) + if ev.EventId == eventID { + found = ev.Raw + } + } + if found == nil { + return nil, ErrAuditEventGone + } + return found, nil +} diff --git a/internal/service/audit_test.go b/internal/service/audit_test.go new file mode 100644 index 0000000..a7284aa --- /dev/null +++ b/internal/service/audit_test.go @@ -0,0 +1,186 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "oci-portal/internal/oci" +) + +// auditStubClient 覆写审计查询并记录透传参数,其余行为沿用 fakeClient。 +type auditStubClient struct { + *fakeClient + result oci.AuditEventsResult + gotRegion string + gotStart time.Time + gotEnd time.Time + gotPage string + calls int +} + +func (f *auditStubClient) ListAuditEvents(ctx context.Context, cred oci.Credentials, region string, start, end time.Time, page string) (oci.AuditEventsResult, error) { + f.calls++ + f.gotRegion, f.gotStart, f.gotEnd, f.gotPage = region, start, end, page + return f.result, nil +} + +func TestAuditEventsHoursValidation(t *testing.T) { + tests := []struct { + name string + hours int + wantErr bool + }{ + {name: "下界 1 小时", hours: 1}, + {name: "默认 24 小时", hours: 24}, + {name: "上界 72 小时", hours: 72}, + {name: "0 越界", hours: 0, wantErr: true}, + {name: "负数越界", hours: -3, wantErr: true}, + {name: "100 越界", hours: 100, wantErr: true}, + } + client := &auditStubClient{ + fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, + result: oci.AuditEventsResult{Items: []oci.AuditEvent{{EventName: "GetInstance"}}}, + } + svc := newTestService(t, client) + cfg := importAliveConfig(t, svc) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := svc.AuditEvents(context.Background(), cfg.ID, AuditQuery{Hours: tt.hours}) + if tt.wantErr { + if !errors.Is(err, ErrInvalidAuditHours) { + t.Fatalf("AuditEvents(hours=%d) error = %v, want ErrInvalidAuditHours", tt.hours, err) + } + return + } + if err != nil { + t.Fatalf("AuditEvents(hours=%d): %v", tt.hours, err) + } + if len(got.Items) != 1 { + t.Errorf("items = %d, want 1", len(got.Items)) + } + }) + } +} + +func TestAuditEventsWindowAndRegion(t *testing.T) { + client := &auditStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}} + svc := newTestService(t, client) + cfg := importAliveConfig(t, svc) + + got, err := svc.AuditEvents(context.Background(), cfg.ID, AuditQuery{Region: "ap-tokyo-1", Hours: 6}) + if err != nil { + t.Fatalf("AuditEvents: %v", err) + } + if client.calls != 1 { + t.Fatalf("calls = %d, want 1", client.calls) + } + if client.gotRegion != "ap-tokyo-1" { + t.Errorf("region = %q, want %q", client.gotRegion, "ap-tokyo-1") + } + if window := client.gotEnd.Sub(client.gotStart); window != 6*time.Hour { + t.Errorf("window = %v, want %v", window, 6*time.Hour) + } + // 响应回传分钟粒度的绝对窗,续查据此原样带回 + if got.Start.Second() != 0 || !got.End.After(got.Start) { + t.Errorf("响应窗口 = [%v, %v), 应为分钟粒度且有序", got.Start, got.End) + } +} + +func TestAuditEventsResume(t *testing.T) { + client := &auditStubClient{ + fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, + result: oci.AuditEventsResult{Items: []oci.AuditEvent{}, Truncated: true, NextPage: "tok-2"}, + } + svc := newTestService(t, client) + cfg := importAliveConfig(t, svc) + ctx := context.Background() + + // 续查:绝对窗 + 游标透传;NextPage 原样回传 + got, err := svc.AuditEvents(ctx, cfg.ID, AuditQuery{ + Start: "2026-07-08T10:00:00Z", End: "2026-07-09T10:00:00Z", Page: "tok-1", + }) + if err != nil { + t.Fatalf("续查: %v", err) + } + if client.gotPage != "tok-1" || !got.Truncated || got.NextPage != "tok-2" { + t.Errorf("游标透传 page=%q next=%q truncated=%v", client.gotPage, got.NextPage, got.Truncated) + } + if !client.gotStart.Equal(time.Date(2026, 7, 8, 10, 0, 0, 0, time.UTC)) { + t.Errorf("续查未用绝对窗: start=%v", client.gotStart) + } + // 非法窗口逐项拒绝 + bad := []AuditQuery{ + {Start: "not-a-time", End: "2026-07-09T10:00:00Z"}, + {Start: "2026-07-09T10:00:00Z", End: "2026-07-08T10:00:00Z"}, // 倒序 + {Start: "2026-07-01T00:00:00Z", End: "2026-07-09T10:00:00Z"}, // 超 72h + {Page: "tok-only"}, // 带游标缺窗口 + } + for i, q := range bad { + if _, err := svc.AuditEvents(ctx, cfg.ID, q); !errors.Is(err, ErrInvalidAuditWindow) { + t.Errorf("bad[%d] err = %v, want ErrInvalidAuditWindow", i, err) + } + } +} + +func TestAuditRawStrippedAndDetail(t *testing.T) { + raw := json.RawMessage(`{"eventId":"evt-1","full":true}`) + eventTime := time.Date(2026, 7, 9, 10, 30, 40, 0, time.UTC) + client := &auditStubClient{ + fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, + result: oci.AuditEventsResult{Items: []oci.AuditEvent{ + {EventId: "evt-1", EventTime: &eventTime, EventName: "TerminateInstance", Raw: raw}, + }}, + } + svc := newTestService(t, client) + cfg := importAliveConfig(t, svc) + ctx := context.Background() + + got, err := svc.AuditEvents(ctx, cfg.ID, AuditQuery{Hours: 1}) + if err != nil { + t.Fatalf("AuditEvents: %v", err) + } + if got.Items[0].Raw != nil { + t.Error("列表响应应剥离 raw") + } + // 详情走缓存,不再回源 + detail, err := svc.AuditEventDetail(ctx, cfg.ID, "", "evt-1", eventTime) + if err != nil || string(detail) != string(raw) { + t.Fatalf("AuditEventDetail = %s, %v", detail, err) + } + if client.calls != 1 { + t.Errorf("缓存命中不应回源, calls = %d", client.calls) + } +} + +func TestAuditDetailRequeryFallback(t *testing.T) { + raw := json.RawMessage(`{"eventId":"evt-2"}`) + eventTime := time.Date(2026, 7, 9, 10, 30, 40, 0, time.UTC) + client := &auditStubClient{ + fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, + result: oci.AuditEventsResult{Items: []oci.AuditEvent{ + {EventId: "evt-2", EventTime: &eventTime, Raw: raw}, + }}, + } + svc := newTestService(t, client) + cfg := importAliveConfig(t, svc) + ctx := context.Background() + + // 缓存未预热:miss 走事件时间小窗重查找回 + detail, err := svc.AuditEventDetail(ctx, cfg.ID, "", "evt-2", eventTime) + if err != nil || string(detail) != string(raw) { + t.Fatalf("兜底重查 = %s, %v", detail, err) + } + if client.calls != 1 { + t.Fatalf("应回源一次, calls = %d", client.calls) + } + if win := client.gotEnd.Sub(client.gotStart); win != 2*time.Minute || client.gotStart.Second() != 0 { + t.Errorf("兜底窗口 = [%v, +%v), 应为事件分钟起 2 分钟", client.gotStart, win) + } + // 重查也找不到 → ErrAuditEventGone + if _, err := svc.AuditEventDetail(ctx, cfg.ID, "", "no-such", eventTime); !errors.Is(err, ErrAuditEventGone) { + t.Errorf("未找回 err = %v, want ErrAuditEventGone", err) + } +} diff --git a/internal/service/auth.go b/internal/service/auth.go new file mode 100644 index 0000000..de8d2ea --- /dev/null +++ b/internal/service/auth.go @@ -0,0 +1,234 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + + "oci-portal/internal/cache" + "oci-portal/internal/crypto" + "oci-portal/internal/model" +) + +// ErrInvalidCredentials 表示用户名或密码错误;不区分两者以免泄露账号是否存在。 +var ErrInvalidCredentials = errors.New("invalid username or password") + +// ErrLoginLocked 表示该 IP+用户名组合因连续失败被锁定;不提示剩余次数与时长细节。 +var ErrLoginLocked = errors.New("too many failed attempts, try again later") + +// tokenTTL 是登录令牌有效期。 +const tokenTTL = 24 * time.Hour + +// dummyBcryptHash 是恒定失败的占位哈希("dummy-password"), +// 用户不存在分支比对它以对齐耗时,防用户枚举与时序侧信道。 +const dummyBcryptHash = "$2a$10$N9qo8uLOickgx2ZMRZoMye3xW1Wq8p1zEIfQpXCXbXyE3xY5C6P6W" + +// AuthService 负责账号初始化、登录校验(密码 + 可选 TOTP)和 JWT 签发与验证。 +type AuthService struct { + db *gorm.DB + jwtSecret []byte + guard *loginGuard + // revoked 是登出令牌黑名单(哈希→占位),TTL 对齐令牌剩余有效期,过期自动清出 + revoked *cache.Cache + + notifier *Notifier + settings *SettingService + cipher *crypto.Cipher // TOTP 密钥加密落库用,SetCipher 注入 + + totpMu sync.Mutex + totpPending map[string]pendingTotp // username → setup 暂存密钥 +} + +// NewAuthService 组装依赖。 +func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService { + return &AuthService{ + db: db, jwtSecret: []byte(jwtSecret), + guard: newLoginGuard(), + revoked: cache.New(4096), + totpPending: map[string]pendingTotp{}, + } +} + +// SetNotifier 注入锁定告警依赖;不注入(nil)则只锁定不推送。 +func (s *AuthService) SetNotifier(n *Notifier, settings *SettingService) { + s.notifier = n + s.settings = settings +} + +// SetCipher 注入加密组件(TOTP 密钥落库),main 启动时调用。 +func (s *AuthService) SetCipher(c *crypto.Cipher) { s.cipher = c } + +// EnsureAdmin 保证管理员账号可用:用户不存在时以给定密码创建; +// 已存在则不重置密码。库中无任何用户且未提供密码时报错,避免服务无法登录。 +func (s *AuthService) EnsureAdmin(username, password string) error { + if password == "" { + var count int64 + if err := s.db.Model(&model.User{}).Count(&count).Error; err != nil { + return fmt.Errorf("count users: %w", err) + } + if count == 0 { + return fmt.Errorf("ensure admin: no user exists, set ADMIN_PASSWORD to create one") + } + return nil + } + err := s.db.Where("username = ?", username).First(&model.User{}).Error + if err == nil { + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("find admin user: %w", err) + } + return s.createUser(username, password) +} + +func (s *AuthService) createUser(username, password string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + user := &model.User{Username: username, PasswordHash: string(hash)} + if err := s.db.Create(user).Error; err != nil { + return fmt.Errorf("create user %s: %w", username, err) + } + return nil +} + +// Login 校验用户名密码与可选 TOTP,成功后签发 JWT;按「IP+用户名」滑动窗口防爆破, +// 锁定期内一律 ErrLoginLocked(正确密码同样拒绝);阈值与时长取安全设置。 +// 已启用两步验证时:密码通过但缺验证码返回 ErrTotpRequired(不计失败),验证码错误计入守卫。 +func (s *AuthService) Login(ctx context.Context, username, password, clientIP, totpCode string) (string, time.Time, error) { + key := clientIP + "|" + username + now := time.Now() + sec := securityOf(s.settings) + lockFor := time.Duration(sec.LoginLockMinutes) * time.Minute + if s.guard.locked(key, now, lockFor) { + return "", time.Time{}, ErrLoginLocked + } + // 禁用密码登录后直接拒绝(不计失败);读取失败按未禁用处理,防配置故障锁死 + if off, err := s.PasswordLoginDisabled(ctx); err == nil && off { + return "", time.Time{}, ErrPasswordLoginDisabled + } + var user model.User + if err := s.db.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // 与密码错误分支对齐耗时,防用户枚举 + _ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(password)) + return "", time.Time{}, s.failLogin(key, now, username, clientIP, sec) + } + return "", time.Time{}, fmt.Errorf("find user: %w", err) + } + if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil { + return "", time.Time{}, s.failLogin(key, now, username, clientIP, sec) + } + if user.TotpSecretEnc != "" { + if totpCode == "" { + return "", time.Time{}, ErrTotpRequired + } + if !s.verifyTotp(&user, totpCode) { + return "", time.Time{}, s.failLogin(key, now, username, clientIP, sec) + } + } + s.guard.success(key) + return s.signToken(user.Username) +} + +// failLogin 记失败;达到阈值转锁定并推送告警(开关 login_lock,缺省开)。 +func (s *AuthService) failLogin(key string, now time.Time, username, clientIP string, sec SecuritySettings) error { + if !s.guard.fail(key, now, sec.LoginFailLimit, time.Duration(sec.LoginLockMinutes)*time.Minute) { + return ErrInvalidCredentials + } + s.notifyLock(username, clientIP, sec) + return ErrLoginLocked +} + +// notifyLock 异步推送登录锁定告警;notifier 未注入或开关关闭则跳过。 +func (s *AuthService) notifyLock(username, clientIP string, sec SecuritySettings) { + if s.notifier == nil { + return + } + if s.settings != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if !s.settings.NotifyEventEnabled(ctx, "login_lock") { + return + } + } + s.notifier.SendTemplateAsync("login_lock", map[string]string{ + "username": username, "ip": clientIP, + "fail_count": fmt.Sprintf("%d", sec.LoginFailLimit), + "lock_minutes": fmt.Sprintf("%d", sec.LoginLockMinutes), + }) +} + +func (s *AuthService) signToken(username string) (string, time.Time, error) { + now := time.Now() + expires := now.Add(tokenTTL) + claims := jwt.RegisteredClaims{ + Subject: username, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(expires), + // jti:同一秒签发的令牌若无唯一 ID 字节全同,登出一个会连坐全部 + ID: newTokenID(), + } + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret) + if err != nil { + return "", time.Time{}, fmt.Errorf("sign token: %w", err) + } + return token, expires, nil +} + +// newTokenID 生成 128 位随机令牌 ID(crypto/rand 自 Go 1.24 起不会失败)。 +func newTokenID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// ParseToken 验证 JWT 签名与有效期,返回其中的用户名。 +func (s *AuthService) ParseToken(tokenString string) (string, error) { + claims := &jwt.RegisteredClaims{} + _, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method %v", t.Header["alg"]) + } + return s.jwtSecret, nil + }) + if err != nil { + return "", fmt.Errorf("parse token: %w", err) + } + if _, hit := s.revoked.Get(tokenHash(tokenString)); hit { + return "", errors.New("token revoked") + } + return claims.Subject, nil +} + +// Logout 把令牌拉进黑名单直至其自然过期;无效/已过期令牌直接视为成功(幂等)。 +func (s *AuthService) Logout(tokenString string) { + claims := &jwt.RegisteredClaims{} + _, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) { + return s.jwtSecret, nil + }) + if err != nil || claims.ExpiresAt == nil { + return + } + ttl := time.Until(claims.ExpiresAt.Time) + if ttl <= 0 { + return + } + s.revoked.Set(tokenHash(tokenString), struct{}{}, ttl) +} + +// tokenHash 取令牌 SHA-256 摘要作黑名单键,不在内存长期保留原令牌串。 +func tokenHash(token string) string { + sum := sha256.Sum256([]byte(token)) + return "revoked|" + hex.EncodeToString(sum[:]) +} diff --git a/internal/service/auth_test.go b/internal/service/auth_test.go new file mode 100644 index 0000000..3d880cf --- /dev/null +++ b/internal/service/auth_test.go @@ -0,0 +1,163 @@ +package service + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "oci-portal/internal/model" +) + +func newTestAuth(t *testing.T) *AuthService { + t.Helper() + 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) + } + if err := db.AutoMigrate(&model.User{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + return NewAuthService(db, "test-jwt-secret") +} + +func TestEnsureAdminCreatesUser(t *testing.T) { + auth := newTestAuth(t) + if err := auth.EnsureAdmin("admin", "pass123"); err != nil { + t.Fatalf("EnsureAdmin: %v", err) + } + if _, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", ""); err != nil { + t.Errorf("Login after EnsureAdmin: %v", err) + } +} + +func TestEnsureAdminDoesNotResetPassword(t *testing.T) { + auth := newTestAuth(t) + if err := auth.EnsureAdmin("admin", "first"); err != nil { + t.Fatalf("EnsureAdmin: %v", err) + } + if err := auth.EnsureAdmin("admin", "second"); err != nil { + t.Fatalf("EnsureAdmin twice: %v", err) + } + if _, _, err := auth.Login(context.Background(), "admin", "first", "127.0.0.1", ""); err != nil { + t.Errorf("Login with original password: %v", err) + } + if _, _, err := auth.Login(context.Background(), "admin", "second", "127.0.0.1", ""); !errors.Is(err, ErrInvalidCredentials) { + t.Errorf("Login with new password: got %v, want ErrInvalidCredentials", err) + } +} + +func TestEnsureAdminNoPasswordNoUsers(t *testing.T) { + auth := newTestAuth(t) + err := auth.EnsureAdmin("admin", "") + if err == nil { + t.Fatal("EnsureAdmin with no password and no users: got nil error, want failure") + } + if !strings.Contains(err.Error(), "ADMIN_PASSWORD") { + t.Errorf("error = %q, want mention of ADMIN_PASSWORD", err) + } +} + +func TestEnsureAdminNoPasswordWithExistingUser(t *testing.T) { + auth := newTestAuth(t) + if err := auth.EnsureAdmin("admin", "pass"); err != nil { + t.Fatalf("EnsureAdmin: %v", err) + } + if err := auth.EnsureAdmin("admin", ""); err != nil { + t.Errorf("EnsureAdmin without password but user exists: %v", err) + } +} + +func TestLoginFailures(t *testing.T) { + auth := newTestAuth(t) + if err := auth.EnsureAdmin("admin", "pass123"); err != nil { + t.Fatalf("EnsureAdmin: %v", err) + } + tests := []struct { + name string + username string + password string + }{ + {name: "密码错误", username: "admin", password: "wrong"}, + {name: "用户不存在", username: "ghost", password: "pass123"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := auth.Login(context.Background(), tt.username, tt.password, "127.0.0.1", "") + if !errors.Is(err, ErrInvalidCredentials) { + t.Errorf("Login(%q, %q) error = %v, want ErrInvalidCredentials", tt.username, tt.password, err) + } + }) + } +} + +func TestTokenRoundTrip(t *testing.T) { + auth := newTestAuth(t) + if err := auth.EnsureAdmin("admin", "pass123"); err != nil { + t.Fatalf("EnsureAdmin: %v", err) + } + token, expires, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "") + if err != nil { + t.Fatalf("Login: %v", err) + } + if expires.IsZero() { + t.Error("expires is zero, want future time") + } + username, err := auth.ParseToken(token) + if err != nil { + t.Fatalf("ParseToken: %v", err) + } + if got, want := username, "admin"; got != want { + t.Errorf("username = %q, want %q", got, want) + } +} + +func TestParseTokenRejectsForged(t *testing.T) { + auth := newTestAuth(t) + other := newTestAuth(t) + other.jwtSecret = []byte("different-secret") + if err := other.EnsureAdmin("admin", "pass"); err != nil { + t.Fatalf("EnsureAdmin: %v", err) + } + forged, _, err := other.Login(context.Background(), "admin", "pass", "127.0.0.1", "") + if err != nil { + t.Fatalf("Login: %v", err) + } + if _, err := auth.ParseToken(forged); err == nil { + t.Error("ParseToken(forged): got nil error, want failure") + } + if _, err := auth.ParseToken("not.a.token"); err == nil { + t.Error("ParseToken(garbage): got nil error, want failure") + } +} + +func TestLogoutRevokesToken(t *testing.T) { + auth := newTestAuth(t) + token, _, err := auth.signToken("admin") + if err != nil { + t.Fatalf("signToken: %v", err) + } + if _, err := auth.ParseToken(token); err != nil { + t.Fatalf("ParseToken before logout: %v", err) + } + auth.Logout(token) + if _, err := auth.ParseToken(token); err == nil { + t.Error("ParseToken after logout: got nil error, want revoked") + } + // 幂等:重复登出与无效令牌登出都不应 panic,也不影响其他令牌 + auth.Logout(token) + auth.Logout("not.a.token") + fresh, _, err := auth.signToken("admin") + if err != nil { + t.Fatalf("signToken fresh: %v", err) + } + if _, err := auth.ParseToken(fresh); err != nil { + t.Errorf("ParseToken(fresh) after revoking old: %v", err) + } +} diff --git a/internal/service/compartment.go b/internal/service/compartment.go new file mode 100644 index 0000000..71f76dd --- /dev/null +++ b/internal/service/compartment.go @@ -0,0 +1,26 @@ +package service + +import ( + "context" + + "oci-portal/internal/oci" +) + +// Compartments 递归列出租户下全部 ACTIVE compartment(不含租户根)。 +func (s *OciConfigService) Compartments(ctx context.Context, id uint) ([]oci.Compartment, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return nil, err + } + return s.client.ListCompartments(ctx, cred) +} + +// credentialsInCompartment 取配置凭据并限定资源操作的目标 compartment(空为租户根)。 +func (s *OciConfigService) credentialsInCompartment(ctx context.Context, id uint, compartmentID string) (oci.Credentials, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.Credentials{}, err + } + cred.CompartmentID = compartmentID + return cred, nil +} diff --git a/internal/service/console.go b/internal/service/console.go new file mode 100644 index 0000000..d38b569 --- /dev/null +++ b/internal/service/console.go @@ -0,0 +1,43 @@ +package service + +import ( + "context" + "fmt" + "strings" + + "oci-portal/internal/oci" +) + +// CreateConsoleConnection 为实例创建控制台连接(VNC / 串口)。 +// OCI 控制台连接只接受 RSA 公钥,ed25519 等类型会被拒绝。 +func (s *OciConfigService) CreateConsoleConnection(ctx context.Context, id uint, region, instanceID, sshPublicKey string) (oci.ConsoleConnection, error) { + if sshPublicKey == "" { + return oci.ConsoleConnection{}, fmt.Errorf("create console connection: sshPublicKey is required") + } + if !strings.HasPrefix(strings.TrimSpace(sshPublicKey), "ssh-rsa ") { + return oci.ConsoleConnection{}, fmt.Errorf("create console connection: only ssh-rsa public keys are supported by OCI console connections") + } + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.ConsoleConnection{}, err + } + return s.client.CreateConsoleConnection(ctx, cred, region, instanceID, sshPublicKey) +} + +// ConsoleConnections 列出实例的控制台连接。 +func (s *OciConfigService) ConsoleConnections(ctx context.Context, id uint, region, instanceID string) ([]oci.ConsoleConnection, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return nil, err + } + return s.client.ListConsoleConnections(ctx, cred, region, instanceID) +} + +// DeleteConsoleConnection 删除控制台连接。 +func (s *OciConfigService) DeleteConsoleConnection(ctx context.Context, id uint, region, connectionID string) error { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return err + } + return s.client.DeleteConsoleConnection(ctx, cred, region, connectionID) +} diff --git a/internal/service/credentials.go b/internal/service/credentials.go new file mode 100644 index 0000000..aa302ce --- /dev/null +++ b/internal/service/credentials.go @@ -0,0 +1,136 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/bcrypt" + + "oci-portal/internal/model" +) + +// ErrCredentialConfirm 表示当前密码校验失败;api 层映射 401。 +var ErrCredentialConfirm = errors.New("当前密码不正确") + +// ErrCredentialInvalid 标记凭据输入非法;api 层映射 400。 +var ErrCredentialInvalid = errors.New("凭据输入非法") + +// ErrPasswordLoginDisabled 表示密码登录已被禁用;api 层映射 403。 +var ErrPasswordLoginDisabled = errors.New("密码登录已禁用,请使用外部身份登录") + +// ErrNeedIdentity 表示未绑定外部身份不可禁用密码登录;api 层映射 409。 +var ErrNeedIdentity = errors.New("至少绑定一个外部身份后才能禁用密码登录") + +// ErrLastIdentity 表示密码登录禁用期间不可解绑最后一个身份(防自锁);api 层映射 409。 +var ErrLastIdentity = errors.New("密码登录已禁用,不能解绑最后一个外部身份;请先允许密码登录") + +// UpdateCredentialsInput 是修改登录凭据的请求体;两项至少改一项,当前密码必填。 +type UpdateCredentialsInput struct { + NewUsername string `json:"newUsername"` + NewPassword string `json:"newPassword"` + CurrentPassword string `json:"currentPassword" binding:"required"` +} + +// UpdateCredentials 修改用户名 / 密码:当前密码必验;改名后旧 JWT 的 +// sub 不再命中账号,前端应强制重新登录。 +func (s *AuthService) UpdateCredentials(ctx context.Context, username string, in UpdateCredentialsInput) error { + user, err := s.findUser(ctx, username) + if err != nil { + return err + } + if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(in.CurrentPassword)) != nil { + return ErrCredentialConfirm + } + newName := strings.TrimSpace(in.NewUsername) + if err := validateCredentialChange(user, newName, in.NewPassword); err != nil { + return err + } + updates := map[string]any{} + if newName != "" && newName != user.Username { + taken, err := s.usernameTaken(ctx, newName, user.ID) + if err != nil { + return err + } + if taken { + return fmt.Errorf("用户名已被占用: %w", ErrCredentialInvalid) + } + updates["username"] = newName + } + if in.NewPassword != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(in.NewPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + updates["password_hash"] = string(hash) + } + if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil { + return fmt.Errorf("update credentials: %w", err) + } + return nil +} + +// validateCredentialChange 校验改名 / 改密输入;两者均无实际变更时报非法。 +func validateCredentialChange(user *model.User, newName, newPassword string) error { + if newName != "" && len(newName) > 64 { + return fmt.Errorf("用户名最长 64 字符: %w", ErrCredentialInvalid) + } + // bcrypt 只取前 72 字节,超长部分静默截断,直接拒绝 + if newPassword != "" && (len(newPassword) < 8 || len(newPassword) > 72) { + return fmt.Errorf("新密码长度须在 8-72 之间: %w", ErrCredentialInvalid) + } + if (newName == "" || newName == user.Username) && newPassword == "" { + return fmt.Errorf("没有需要保存的变更: %w", ErrCredentialInvalid) + } + return nil +} + +func (s *AuthService) usernameTaken(ctx context.Context, name string, selfID uint) (bool, error) { + var count int64 + err := s.db.WithContext(ctx).Model(&model.User{}). + Where("username = ? AND id <> ?", name, selfID).Count(&count).Error + if err != nil { + return false, fmt.Errorf("check username: %w", err) + } + return count > 0, nil +} + +// PasswordLoginDisabled 读密码登录禁用开关;settings 未注入(测试)视为未禁用。 +func (s *AuthService) PasswordLoginDisabled(ctx context.Context) (bool, error) { + if s.settings == nil { + return false, nil + } + return s.settings.PasswordLoginDisabled(ctx) +} + +// SetPasswordLoginDisabled 保存开关;开启前须至少绑定一个外部身份,防止自锁。 +func (s *AuthService) SetPasswordLoginDisabled(ctx context.Context, username string, disabled bool) error { + if s.settings == nil { + return errors.New("settings unavailable") + } + if disabled { + user, err := s.findUser(ctx, username) + if err != nil { + return err + } + n, err := s.identityCount(ctx, user.ID) + if err != nil { + return err + } + if n == 0 { + return ErrNeedIdentity + } + } + return s.settings.SetPasswordLoginDisabled(ctx, disabled) +} + +func (s *AuthService) identityCount(ctx context.Context, userID uint) (int64, error) { + var count int64 + err := s.db.WithContext(ctx).Model(&model.UserIdentity{}). + Where("user_id = ?", userID).Count(&count).Error + if err != nil { + return 0, fmt.Errorf("count identities: %w", err) + } + return count, nil +} diff --git a/internal/service/credentials_test.go b/internal/service/credentials_test.go new file mode 100644 index 0000000..edf36e2 --- /dev/null +++ b/internal/service/credentials_test.go @@ -0,0 +1,103 @@ +package service + +import ( + "context" + "errors" + "testing" + + "oci-portal/internal/crypto" + "oci-portal/internal/model" +) + +// newCredEnv 复用 totp 环境并注入 settings(密码登录开关落 Setting 表)。 +func newCredEnv(t *testing.T) *AuthService { + t.Helper() + auth, db := newTotpEnv(t) + cipher, err := crypto.NewCipher("test-key") + if err != nil { + t.Fatalf("new cipher: %v", err) + } + auth.SetNotifier(nil, NewSettingService(db, cipher)) + return auth +} + +func TestUpdateCredentials(t *testing.T) { + auth := newCredEnv(t) + ctx := context.Background() + + // 当前密码错误 + err := auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewPassword: "newpass-123", CurrentPassword: "wrong"}) + if !errors.Is(err, ErrCredentialConfirm) { + t.Fatalf("wrong current password: err = %v, want ErrCredentialConfirm", err) + } + // 短密码 / 无变更均拒绝 + err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewPassword: "short", CurrentPassword: "pass123"}) + if !errors.Is(err, ErrCredentialInvalid) { + t.Fatalf("short password: err = %v, want ErrCredentialInvalid", err) + } + err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewUsername: "admin", CurrentPassword: "pass123"}) + if !errors.Is(err, ErrCredentialInvalid) { + t.Fatalf("no-op change: err = %v, want ErrCredentialInvalid", err) + } + + // 同时改名改密 + err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{ + NewUsername: "root", NewPassword: "newpass-123", CurrentPassword: "pass123", + }) + if err != nil { + t.Fatalf("UpdateCredentials: %v", err) + } + if _, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", ""); !errors.Is(err, ErrInvalidCredentials) { + t.Errorf("old username login: err = %v, want ErrInvalidCredentials", err) + } + if _, _, err := auth.Login(ctx, "root", "newpass-123", "127.0.0.2", ""); err != nil { + t.Errorf("new credentials login: %v", err) + } +} + +func TestPasswordLoginToggle(t *testing.T) { + auth := newCredEnv(t) + ctx := context.Background() + + // 未绑定外部身份不可禁用 + if err := auth.SetPasswordLoginDisabled(ctx, "admin", true); !errors.Is(err, ErrNeedIdentity) { + t.Fatalf("disable without identity: err = %v, want ErrNeedIdentity", err) + } + + var user model.User + if err := auth.db.Where("username = ?", "admin").First(&user).Error; err != nil { + t.Fatalf("find admin: %v", err) + } + ident := model.UserIdentity{UserID: user.ID, Provider: "github", Subject: "1", Display: "tester"} + if err := auth.db.Create(&ident).Error; err != nil { + t.Fatalf("seed identity: %v", err) + } + + if err := auth.SetPasswordLoginDisabled(ctx, "admin", true); err != nil { + t.Fatalf("disable with identity: %v", err) + } + if off, err := auth.PasswordLoginDisabled(ctx); err != nil || !off { + t.Fatalf("PasswordLoginDisabled = %v, %v; want true", off, err) + } + // 密码登录被拒且不计失败(正确密码亦拒) + if _, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", ""); !errors.Is(err, ErrPasswordLoginDisabled) { + t.Fatalf("login while disabled: err = %v, want ErrPasswordLoginDisabled", err) + } + + // 开关开着不能解绑最后一个身份 + oauth := NewOAuthService(auth.db, nil, auth) + if err := oauth.Unbind(ctx, "admin", ident.ID); !errors.Is(err, ErrLastIdentity) { + t.Fatalf("unbind last identity: err = %v, want ErrLastIdentity", err) + } + + // 恢复密码登录后可解绑、可登录 + if err := auth.SetPasswordLoginDisabled(ctx, "admin", false); err != nil { + t.Fatalf("enable password login: %v", err) + } + if err := oauth.Unbind(ctx, "admin", ident.ID); err != nil { + t.Fatalf("unbind after enable: %v", err) + } + if _, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.3", ""); err != nil { + t.Errorf("login after enable: %v", err) + } +} diff --git a/internal/service/doc.go b/internal/service/doc.go new file mode 100644 index 0000000..aaf7473 --- /dev/null +++ b/internal/service/doc.go @@ -0,0 +1,2 @@ +// Package service 实现业务逻辑和本地快照同步。 +package service diff --git a/internal/service/federation.go b/internal/service/federation.go new file mode 100644 index 0000000..039c370 --- /dev/null +++ b/internal/service/federation.go @@ -0,0 +1,146 @@ +package service + +import ( + "context" + "fmt" + "strings" + + "oci-portal/internal/oci" +) + +// IdentityProviders 列出域内 SAML 身份提供者。 +func (s *OciConfigService) IdentityProviders(ctx context.Context, id uint) ([]oci.IdentityProviderInfo, error) { + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return nil, err + } + return s.client.ListIdentityProviders(ctx, cred, homeRegion) +} + +// CreateIdentityProvider 创建禁用态 SAML IdP,激活另走 activate。 +// 映射与 JIT 字段缺省时填控制台默认值(normalizeIdpInput)。 +func (s *OciConfigService) CreateIdentityProvider(ctx context.Context, id uint, in oci.CreateIdpInput) (oci.IdentityProviderInfo, error) { + if strings.TrimSpace(in.Name) == "" { + return oci.IdentityProviderInfo{}, fmt.Errorf("create identity provider: name is required") + } + if !strings.Contains(in.Metadata, "EntityDescriptor") { + return oci.IdentityProviderInfo{}, fmt.Errorf("create identity provider: metadata must be a SAML metadata XML") + } + // IDCS 校验 iconUrl 必须是 http(s) URL,data URI 会被 400 拒绝 + if in.IconURL != "" && !strings.HasPrefix(in.IconURL, "http://") && !strings.HasPrefix(in.IconURL, "https://") { + return oci.IdentityProviderInfo{}, fmt.Errorf("create identity provider: iconUrl 需为 http(s) 外链图片 URL") + } + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return oci.IdentityProviderInfo{}, err + } + return s.client.CreateSamlIdentityProvider(ctx, cred, homeRegion, normalizeIdpInput(in)) +} + +// normalizeIdpInput 填充映射字段的控制台默认值:名称 ID 格式「无」、 +// 目标域用户属性「用户名」(JIT 开关组合由 API 层的指针缺省决定)。 +func normalizeIdpInput(in oci.CreateIdpInput) oci.CreateIdpInput { + if in.NameIDFormat == "" { + in.NameIDFormat = "saml-none" + } + if in.UserStoreAttribute == "" { + in.UserStoreAttribute = "userName" + } + return in +} + +// SetIdentityProviderEnabled 激活/停用 IdP 并同步登录页显示。 +func (s *OciConfigService) SetIdentityProviderEnabled(ctx context.Context, id uint, idpID string, enabled bool) (oci.IdentityProviderInfo, error) { + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return oci.IdentityProviderInfo{}, err + } + return s.client.SetIdentityProviderEnabled(ctx, cred, homeRegion, idpID, enabled) +} + +// DeleteIdentityProvider 级联删除 IdP:先删关联的免 MFA 规则,再从登录页 +// 移除、停用、删除 IdP 本体。 +func (s *OciConfigService) DeleteIdentityProvider(ctx context.Context, id uint, idpID string) error { + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return err + } + if err := s.deleteIdpExemptions(ctx, cred, homeRegion, idpID); err != nil { + return err + } + return s.client.DeleteIdentityProvider(ctx, cred, homeRegion, idpID) +} + +// deleteIdpExemptions 删除 sign-on 策略中引用目标 IdP 的免 MFA 规则。 +func (s *OciConfigService) deleteIdpExemptions(ctx context.Context, cred oci.Credentials, region, idpID string) error { + rules, err := s.client.ListConsoleSignOnRules(ctx, cred, region) + if err != nil { + return fmt.Errorf("list sign-on rules before delete idp: %w", err) + } + for _, r := range rules { + if r.BuiltIn || !strings.Contains(r.ConditionValue, idpID) { + continue + } + if err := s.client.DeleteMfaExemptionRule(ctx, cred, region, r.ID); err != nil { + return fmt.Errorf("delete exemption rule %s: %w", r.Name, err) + } + } + return nil +} + +// DomainSamlMetadata 下载域的 SP SAML 元数据 XML(提供给 IdP 侧配置)。 +func (s *OciConfigService) DomainSamlMetadata(ctx context.Context, id uint) ([]byte, error) { + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return nil, err + } + return s.client.DownloadDomainSamlMetadata(ctx, cred, homeRegion) +} + +// ConsoleSignOnRules 按优先级列出 OCI Console sign-on 策略的规则。 +func (s *OciConfigService) ConsoleSignOnRules(ctx context.Context, id uint) ([]oci.SignOnRuleInfo, error) { + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return nil, err + } + return s.client.ListConsoleSignOnRules(ctx, cred, homeRegion) +} + +// CreateMfaExemption 为指定 IdP 创建免 MFA 规则并置为最高优先级。 +func (s *OciConfigService) CreateMfaExemption(ctx context.Context, id uint, idpID string) (oci.SignOnRuleInfo, error) { + if strings.TrimSpace(idpID) == "" { + return oci.SignOnRuleInfo{}, fmt.Errorf("create mfa exemption: identityProviderId is required") + } + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return oci.SignOnRuleInfo{}, err + } + name, err := s.exemptionRuleName(ctx, cred, homeRegion, idpID) + if err != nil { + return oci.SignOnRuleInfo{}, err + } + return s.client.CreateMfaExemptionRule(ctx, cred, homeRegion, idpID, name) +} + +// exemptionRuleName 校验 IdP 存在并用其名称生成规则名。 +func (s *OciConfigService) exemptionRuleName(ctx context.Context, cred oci.Credentials, region, idpID string) (string, error) { + idps, err := s.client.ListIdentityProviders(ctx, cred, region) + if err != nil { + return "", err + } + for _, idp := range idps { + if idp.ID == idpID { + return "skip-mfa-" + idp.Name, nil + } + } + return "", fmt.Errorf("create mfa exemption: identity provider %s not found", idpID) +} + +// DeleteMfaExemption 删除免 MFA 规则并恢复其余规则优先级。 +func (s *OciConfigService) DeleteMfaExemption(ctx context.Context, id uint, ruleID string) error { + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return err + } + return s.client.DeleteMfaExemptionRule(ctx, cred, homeRegion, ruleID) +} diff --git a/internal/service/instance.go b/internal/service/instance.go new file mode 100644 index 0000000..ae041be --- /dev/null +++ b/internal/service/instance.go @@ -0,0 +1,196 @@ +package service + +import ( + "context" + "crypto/rand" + "fmt" + "math/big" + + "oci-portal/internal/oci" +) + +// AvailabilityDomains 列出区域内可用域。 +func (s *OciConfigService) AvailabilityDomains(ctx context.Context, id uint, region string) ([]string, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return nil, err + } + return s.client.ListAvailabilityDomains(ctx, cred, region) +} + +// Instances 列出实例(含 IP 补全);compartmentID 为空表示租户根。 +func (s *OciConfigService) Instances(ctx context.Context, id uint, region, compartmentID string) ([]oci.Instance, error) { + cred, err := s.credentialsInCompartment(ctx, id, compartmentID) + if err != nil { + return nil, err + } + return s.client.ListInstances(ctx, cred, region) +} + +// maxBatchCreate 是单次批量创建实例的数量上限。 +const maxBatchCreate = 10 + +// CreateInstances 批量创建实例,逐台尝试互不影响;count 大于 1 时 +// 名称自动追加 -1、-2 等序号。返回成功实例与逐台失败信息。 +// in.CompartmentID 为空时建在租户根。 +func (s *OciConfigService) CreateInstances(ctx context.Context, id uint, in oci.CreateInstanceInput, count int) ([]oci.Instance, []string, error) { + if count < 1 || count > maxBatchCreate { + return nil, nil, fmt.Errorf("create instances: count must be between 1 and %d", maxBatchCreate) + } + if err := validateCreateInstance(in); err != nil { + return nil, nil, err + } + cred, err := s.credentialsInCompartment(ctx, id, in.CompartmentID) + if err != nil { + return nil, nil, err + } + instances := make([]oci.Instance, 0, count) + var failures []string + for i := 1; i <= count; i++ { + one := in + if count > 1 { + one.DisplayName = fmt.Sprintf("%s-%d", in.DisplayName, i) + } + if err := applyGeneratedRootPassword(&one); err != nil { + return nil, nil, err + } + instance, err := s.client.LaunchInstance(ctx, cred, one) + if err != nil { + failures = append(failures, fmt.Sprintf("%s: %s", one.DisplayName, oci.CompactError(err))) + continue + } + instances = append(instances, instance) + } + return instances, failures, nil +} + +// applyGeneratedRootPassword 在开启随机密码时为单台实例生成 root 密码, +// 同时写入 FreeformTags["RootPassword"] 便于面板回显;批量创建时每台独立。 +func applyGeneratedRootPassword(one *oci.CreateInstanceInput) error { + if !one.GenerateRootPassword { + return nil + } + pwd, err := randomPassword(16) + if err != nil { + return fmt.Errorf("generate root password: %w", err) + } + one.RootPassword = pwd + tags := make(map[string]string, len(one.FreeformTags)+1) + for k, v := range one.FreeformTags { + tags[k] = v + } + tags["RootPassword"] = pwd + one.FreeformTags = tags + return nil +} + +// randomPassword 生成含大小写字母、数字与安全符号的随机密码; +// 字符集避开引号、冒号等会破坏 cloud-init chpasswd 语法的字符。 +func randomPassword(length int) (string, error) { + const charset = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789@#%^*-_=+" + out := make([]byte, length) + for i := range out { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + return "", err + } + out[i] = charset[n.Int64()] + } + return string(out), nil +} + +func validateCreateInstance(in oci.CreateInstanceInput) error { + switch { + case in.DisplayName == "": + return fmt.Errorf("create instance: displayName is required") + case in.Shape == "": + return fmt.Errorf("create instance: shape is required") + case in.ImageID == "" && in.BootVolumeID == "": + return fmt.Errorf("create instance: imageId or bootVolumeId is required") + case in.ImageID != "" && in.BootVolumeID != "": + return fmt.Errorf("create instance: imageId and bootVolumeId are mutually exclusive") + case in.SSHPublicKey != "" && in.RootPassword != "": + return fmt.Errorf("create instance: sshPublicKey and rootPassword are mutually exclusive") + case in.GenerateRootPassword && (in.SSHPublicKey != "" || in.RootPassword != ""): + return fmt.Errorf("create instance: generateRootPassword conflicts with sshPublicKey/rootPassword") + } + return nil +} + +// Instance 查询单个实例(含 IP 补全)。 +func (s *OciConfigService) Instance(ctx context.Context, id uint, region, instanceID string) (oci.Instance, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.Instance{}, err + } + return s.client.GetInstance(ctx, cred, region, instanceID) +} + +// UpdateInstance 更新实例名称、shape 或 Flex 规格。 +func (s *OciConfigService) UpdateInstance(ctx context.Context, id uint, instanceID string, in oci.UpdateInstanceInput) (oci.Instance, error) { + if in.DisplayName == "" && in.Shape == "" && in.Ocpus == 0 { + return oci.Instance{}, fmt.Errorf("update instance: nothing to update") + } + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.Instance{}, err + } + return s.client.UpdateInstance(ctx, cred, instanceID, in) +} + +// TerminateInstance 终止实例;preserveBootVolume 为 true 时保留引导卷。 +func (s *OciConfigService) TerminateInstance(ctx context.Context, id uint, region, instanceID string, preserveBootVolume bool) error { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return err + } + return s.client.TerminateInstance(ctx, cred, region, instanceID, preserveBootVolume) +} + +// InstanceAction 执行电源操作:START/STOP/RESET/SOFTSTOP/SOFTRESET。 +func (s *OciConfigService) InstanceAction(ctx context.Context, id uint, region, instanceID, action string) (oci.Instance, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.Instance{}, err + } + return s.client.InstanceAction(ctx, cred, region, instanceID, action) +} + +// BootVolumes 列出引导卷;compartmentID 为空表示租户根。 +func (s *OciConfigService) BootVolumes(ctx context.Context, id uint, region, availabilityDomain, compartmentID string) ([]oci.BootVolume, error) { + cred, err := s.credentialsInCompartment(ctx, id, compartmentID) + if err != nil { + return nil, err + } + return s.client.ListBootVolumes(ctx, cred, region, availabilityDomain) +} + +// BootVolume 查询引导卷详情(含挂载实例)。 +func (s *OciConfigService) BootVolume(ctx context.Context, id uint, region, bootVolumeID string) (oci.BootVolume, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.BootVolume{}, err + } + return s.client.GetBootVolume(ctx, cred, region, bootVolumeID) +} + +// UpdateBootVolume 更新引导卷名称、容量或性能。 +func (s *OciConfigService) UpdateBootVolume(ctx context.Context, id uint, bootVolumeID string, in oci.UpdateBootVolumeInput) (oci.BootVolume, error) { + if in.DisplayName == "" && in.SizeInGBs == 0 && in.VpusPerGB == 0 { + return oci.BootVolume{}, fmt.Errorf("update boot volume: nothing to update") + } + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.BootVolume{}, err + } + return s.client.UpdateBootVolume(ctx, cred, bootVolumeID, in) +} + +// DeleteBootVolume 删除引导卷。 +func (s *OciConfigService) DeleteBootVolume(ctx context.Context, id uint, region, bootVolumeID string) error { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return err + } + return s.client.DeleteBootVolume(ctx, cred, region, bootVolumeID) +} diff --git a/internal/service/instance_test.go b/internal/service/instance_test.go new file mode 100644 index 0000000..6f729a7 --- /dev/null +++ b/internal/service/instance_test.go @@ -0,0 +1,178 @@ +package service + +import ( + "context" + "strings" + "testing" + + "oci-portal/internal/oci" +) + +func TestCreateInstanceValidation(t *testing.T) { + valid := oci.CreateInstanceInput{ + AvailabilityDomain: "AD-1", + DisplayName: "vm1", + Shape: "VM.Standard.A1.Flex", + ImageID: "ocid1.image..a", + SubnetID: "ocid1.subnet..s", + } + tests := []struct { + name string + mutate func(*oci.CreateInstanceInput) + wantErr string + }{ + {name: "合法输入", mutate: func(*oci.CreateInstanceInput) {}}, + {name: "缺名称", mutate: func(in *oci.CreateInstanceInput) { in.DisplayName = "" }, wantErr: "displayName"}, + {name: "可用域可空(默认 ad-1)", mutate: func(in *oci.CreateInstanceInput) { in.AvailabilityDomain = "" }}, + {name: "缺 shape", mutate: func(in *oci.CreateInstanceInput) { in.Shape = "" }, wantErr: "shape"}, + {name: "缺启动源", mutate: func(in *oci.CreateInstanceInput) { in.ImageID = "" }, wantErr: "imageId or bootVolumeId"}, + {name: "启动源互斥", mutate: func(in *oci.CreateInstanceInput) { in.BootVolumeID = "bv" }, wantErr: "mutually exclusive"}, + {name: "子网可空(自动建网)", mutate: func(in *oci.CreateInstanceInput) { in.SubnetID = "" }}, + {name: "密钥与密码互斥", mutate: func(in *oci.CreateInstanceInput) { + in.SSHPublicKey = "ssh-ed25519 AAA" + in.RootPassword = "pw" + }, wantErr: "sshPublicKey and rootPassword"}, + {name: "随机密码与密钥互斥", mutate: func(in *oci.CreateInstanceInput) { + in.GenerateRootPassword = true + in.SSHPublicKey = "ssh-ed25519 AAA" + }, wantErr: "generateRootPassword"}, + {name: "随机密码与固定密码互斥", mutate: func(in *oci.CreateInstanceInput) { + in.GenerateRootPassword = true + in.RootPassword = "pw" + }, wantErr: "generateRootPassword"}, + } + svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}) + cfg := importAliveConfig(t, svc) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + in := valid + tt.mutate(&in) + _, _, err := svc.CreateInstances(context.Background(), cfg.ID, in, 1) + if tt.wantErr == "" { + if err != nil { + t.Errorf("CreateInstances: %v, want success", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("CreateInstances error = %v, want contains %q", err, tt.wantErr) + } + }) + } +} + +func TestCreateInstancesBatch(t *testing.T) { + client := &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}} + svc := newTestService(t, client) + cfg := importAliveConfig(t, svc) + in := oci.CreateInstanceInput{DisplayName: "vm", Shape: "VM.Standard.A1.Flex", ImageID: "img"} + + for _, count := range []int{0, 11} { + if _, _, err := svc.CreateInstances(context.Background(), cfg.ID, in, count); err == nil { + t.Errorf("count=%d: got nil error, want out-of-range failure", count) + } + } + + instances, failures, err := svc.CreateInstances(context.Background(), cfg.ID, in, 3) + if err != nil { + t.Fatalf("CreateInstances: %v", err) + } + if len(failures) != 0 { + t.Errorf("failures = %v, want none", failures) + } + got := make([]string, 0, len(instances)) + for _, inst := range instances { + got = append(got, inst.DisplayName) + } + want := []string{"vm-1", "vm-2", "vm-3"} + if len(got) != len(want) { + t.Fatalf("created %d instances %v, want %v", len(got), got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("instance %d name = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestCreateInstancesGeneratedRootPassword(t *testing.T) { + client := &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}} + svc := newTestService(t, client) + cfg := importAliveConfig(t, svc) + in := oci.CreateInstanceInput{ + DisplayName: "vm", + Shape: "VM.Standard.A1.Flex", + ImageID: "img", + GenerateRootPassword: true, + FreeformTags: map[string]string{"env": "prod"}, + } + + if _, _, err := svc.CreateInstances(context.Background(), cfg.ID, in, 2); err != nil { + t.Fatalf("CreateInstances: %v", err) + } + if len(client.launched) != 2 { + t.Fatalf("launched %d instances, want 2", len(client.launched)) + } + pwds := map[string]bool{} + for i, got := range client.launched { + if got.RootPassword == "" { + t.Errorf("instance %d: RootPassword empty, want generated", i) + } + if got.FreeformTags["RootPassword"] != got.RootPassword { + t.Errorf("instance %d: tag RootPassword = %q, want %q", i, got.FreeformTags["RootPassword"], got.RootPassword) + } + if got.FreeformTags["env"] != "prod" { + t.Errorf("instance %d: tag env = %q, want prod (原有标签须保留)", i, got.FreeformTags["env"]) + } + pwds[got.RootPassword] = true + } + if len(pwds) != 2 { + t.Errorf("批量创建密码应各台独立, got %d 个不同密码", len(pwds)) + } + if in.FreeformTags["RootPassword"] != "" { + t.Error("原始输入 FreeformTags 被修改, 应复制后再写入") + } +} + +func TestUpdateInstanceRequiresChange(t *testing.T) { + svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}) + cfg := importAliveConfig(t, svc) + if _, err := svc.UpdateInstance(context.Background(), cfg.ID, "ocid1.instance..i", oci.UpdateInstanceInput{}); err == nil { + t.Error("UpdateInstance with empty input: got nil error, want failure") + } +} + +func TestCreateConsoleConnectionValidation(t *testing.T) { + svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}) + cfg := importAliveConfig(t, svc) + tests := []struct { + name string + key string + }{ + {name: "空公钥", key: ""}, + {name: "非 RSA 公钥", key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA test"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := svc.CreateConsoleConnection(context.Background(), cfg.ID, "", "ocid1.instance..i", tt.key); err == nil { + t.Error("CreateConsoleConnection: got nil error, want failure") + } + }) + } +} + +func TestDeleteInstanceIpv6RequiresAddress(t *testing.T) { + svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}) + cfg := importAliveConfig(t, svc) + if err := svc.DeleteInstanceIpv6(context.Background(), cfg.ID, "", "ocid1.instance..i", ""); err == nil { + t.Error("DeleteInstanceIpv6 with empty address: got nil error, want failure") + } +} + +func TestUpdateBootVolumeRequiresChange(t *testing.T) { + svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}) + cfg := importAliveConfig(t, svc) + if _, err := svc.UpdateBootVolume(context.Background(), cfg.ID, "ocid1.bootvolume..b", oci.UpdateBootVolumeInput{}); err == nil { + t.Error("UpdateBootVolume with empty input: got nil error, want failure") + } +} diff --git a/internal/service/logevent.go b/internal/service/logevent.go new file mode 100644 index 0000000..8a19917 --- /dev/null +++ b/internal/service/logevent.go @@ -0,0 +1,467 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "net" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "oci-portal/internal/model" + "oci-portal/internal/oci" +) + +// 日志回传的保留策略与后台节奏(方案A §2.4/§3)。 +const ( + logEventRetention = 90 * 24 * time.Hour // 过期删除阈值 + logEventMaxRows = 20000 // 总量兜底上限(Payload 大,低于系统日志的 5 万) + logEventCleanupTick = 24 * time.Hour // 周期清理间隔 + logEventParseTick = 30 * time.Second // 解析器轮询间隔 + logEventParseBatch = 200 // 单轮解析行数上限 + logEventConfirmWait = 10 * time.Second // 订阅确认 GET 超时 +) + +// 日志回传查询分页默认值与上限。 +const ( + logEventDefaultPageSize = 20 + logEventMaxPageSize = 100 +) + +// logWebhookSecretPrefix 是每租户回传 secret 的 Setting 键前缀,后接 cfgID。 +const logWebhookSecretPrefix = "log_webhook_secret:" + +// LogEventService 承接 OCI 日志回传:secret 管理、事件入库、异步解析与清理, +// 以及 P1 引导创建(SetRelayDeps 注入)与 P2 告警联动(SetNotifier 注入)。 +type LogEventService struct { + db *gorm.DB + wg sync.WaitGroup + confirm func(ctx context.Context, url string) error // 订阅确认 GET,测试可注入 + + configs *OciConfigService // 凭据来源(P1) + relayClient oci.Client // 云端链路操作(P1) + publicURL string // 面板公网基址,拼接回调 endpoint(P1) + notifier *Notifier // 关键事件推送(P2) + settings *SettingService // log_event 事件开关(P2) + + relayPollTick time.Duration // 订阅确认轮询间隔,零值用默认 + relayPollTimeout time.Duration // 订阅确认轮询上限,零值用默认 +} + +// NewLogEventService 组装依赖;调用 StartParser / StartCleanup 后台协程后生效。 +func NewLogEventService(db *gorm.DB) *LogEventService { + s := &LogEventService{db: db} + s.confirm = s.confirmSubscription + return s +} + +// secretKey 拼接指定配置的 Setting 键。 +func secretKey(cfgID uint) string { + return fmt.Sprintf("%s%d", logWebhookSecretPrefix, cfgID) +} + +// LogWebhookInfo 是回传回调地址视图;Path 供前端以公网域名拼接完整 URL。 +type LogWebhookInfo struct { + Path string `json:"path"` + Secret string `json:"secret"` + CreatedAt time.Time `json:"createdAt"` +} + +// EnsureSecret 为配置生成(或幂等返回)回传 secret。 +func (s *LogEventService) EnsureSecret(ctx context.Context, cfgID uint) (LogWebhookInfo, error) { + if err := s.requireConfig(ctx, cfgID); err != nil { + return LogWebhookInfo{}, err + } + if info, ok, err := s.SecretInfo(ctx, cfgID); err != nil || ok { + return info, err + } + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return LogWebhookInfo{}, fmt.Errorf("generate webhook secret: %w", err) + } + secret := hex.EncodeToString(buf) + st := model.Setting{Key: secretKey(cfgID), Value: secret, UpdatedAt: time.Now()} + if err := s.db.WithContext(ctx).Save(&st).Error; err != nil { + return LogWebhookInfo{}, fmt.Errorf("save webhook secret: %w", err) + } + return webhookInfo(secret, st.UpdatedAt), nil +} + +// requireConfig 校验配置存在,不存在透传 gorm.ErrRecordNotFound(api 层映射 404)。 +func (s *LogEventService) requireConfig(ctx context.Context, cfgID uint) error { + var cfg model.OciConfig + if err := s.db.WithContext(ctx).Select("id").First(&cfg, cfgID).Error; err != nil { + return fmt.Errorf("find oci config %d: %w", cfgID, err) + } + return nil +} + +// SecretInfo 查询配置是否已生成 secret;未生成时 ok 为 false。 +func (s *LogEventService) SecretInfo(ctx context.Context, cfgID uint) (LogWebhookInfo, bool, error) { + var st model.Setting + err := s.db.WithContext(ctx).First(&st, "key = ?", secretKey(cfgID)).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return LogWebhookInfo{}, false, nil + } + return LogWebhookInfo{}, false, fmt.Errorf("get webhook secret: %w", err) + } + if st.Value == "" { + return LogWebhookInfo{}, false, nil + } + return webhookInfo(st.Value, st.UpdatedAt), true, nil +} + +// webhookInfo 由 secret 组装回调地址视图。 +func webhookInfo(secret string, at time.Time) LogWebhookInfo { + return LogWebhookInfo{ + Path: "/api/v1/webhooks/oci-logs/" + secret, + Secret: secret, + CreatedAt: at, + } +} + +// RevokeSecret 撤销配置的回传 secret;之后旧回调地址一律 404。 +func (s *LogEventService) RevokeSecret(ctx context.Context, cfgID uint) error { + err := s.db.WithContext(ctx).Delete(&model.Setting{}, "key = ?", secretKey(cfgID)).Error + if err != nil { + return fmt.Errorf("revoke webhook secret: %w", err) + } + return nil +} + +// ResolveSecret 由 secret 反查归属配置;比对使用常数时间比较,防时序侧信道。 +func (s *LogEventService) ResolveSecret(ctx context.Context, secret string) (uint, bool) { + if secret == "" { + return 0, false + } + var rows []model.Setting + err := s.db.WithContext(ctx). + Where("key LIKE ?", logWebhookSecretPrefix+"%").Find(&rows).Error + if err != nil { + log.Printf("resolve webhook secret: %v", err) + return 0, false + } + for _, row := range rows { + if subtle.ConstantTimeCompare([]byte(row.Value), []byte(secret)) == 1 { + id, err := strconv.ParseUint(strings.TrimPrefix(row.Key, logWebhookSecretPrefix), 10, 64) + if err != nil { + return 0, false + } + return uint(id), true + } + } + return 0, false +} + +// Ingest 落一条回传事件;MessageID 唯一索引冲突即静默忽略(at-least-once 幂等)。 +func (s *LogEventService) Ingest(ctx context.Context, cfgID uint, messageID string, payload []byte, truncated bool) error { + event := model.LogEvent{ + OciConfigID: cfgID, + MessageID: messageID, + Payload: string(payload), + Truncated: truncated, + ReceivedAt: time.Now(), + } + err := s.db.WithContext(ctx). + Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "message_id"}}, DoNothing: true}). + Create(&event).Error + if err != nil { + return fmt.Errorf("ingest log event: %w", err) + } + return nil +} + +// LogEventQuery 是回传事件查询参数;CfgID 为 0 表示全部租户。 +type LogEventQuery struct { + CfgID uint + Page int + PageSize int +} + +// normalize 补分页默认值并钳制上限。 +func (q LogEventQuery) normalize() LogEventQuery { + if q.Page < 1 { + q.Page = 1 + } + if q.PageSize < 1 { + q.PageSize = logEventDefaultPageSize + } + if q.PageSize > logEventMaxPageSize { + q.PageSize = logEventMaxPageSize + } + return q +} + +// List 按接收顺序倒序分页查询回传事件。 +func (s *LogEventService) List(ctx context.Context, q LogEventQuery) ([]model.LogEvent, int64, error) { + q = q.normalize() + tx := s.db.WithContext(ctx).Model(&model.LogEvent{}) + if q.CfgID > 0 { + tx = tx.Where("oci_config_id = ?", q.CfgID) + } + var total int64 + if err := tx.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("count log events: %w", err) + } + items := make([]model.LogEvent, 0, q.PageSize) + err := tx.Order("id DESC").Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize).Find(&items).Error + if err != nil { + return nil, 0, fmt.Errorf("list log events: %w", err) + } + return items, total, nil +} + +// Wait 等待在途后台协程(确认/解析/清理)退出,供进程收尾与测试同步。 +func (s *LogEventService) Wait() { + s.wg.Wait() +} + +// ConfirmAsync 异步 GET 订阅确认链接完成激活;失败仅记系统日志可人工重发, +// URL 白名单校验由 api 层完成,此处不再信任外部输入以外的假设。 +func (s *LogEventService) ConfirmAsync(confirmURL string) { + s.wg.Add(1) + go func() { + defer s.wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), logEventConfirmWait) + defer cancel() + if err := s.confirm(ctx, confirmURL); err != nil { + log.Printf("confirm ons subscription: %v", err) + } + }() +} + +// confirmSubscription 执行确认 GET;重定向只允许留在 Oracle 域内,响应体丢弃。 +func (s *LogEventService) confirmSubscription(ctx context.Context, confirmURL string) error { + client := &http.Client{ + CheckRedirect: func(req *http.Request, _ []*http.Request) error { + if !IsOracleHost(req.URL.Host) { + return fmt.Errorf("redirect outside oracle domain") + } + return nil + }, + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, confirmURL, nil) + if err != nil { + return fmt.Errorf("build confirm request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("confirm request: %w", sanitizeURLError(err)) + } + defer resp.Body.Close() + if resp.StatusCode >= http.StatusBadRequest { + return fmt.Errorf("confirm request: status %d", resp.StatusCode) + } + return nil +} + +// IsOracleHost 判定 host 是否属于 Oracle 云域(订阅确认/验签证书源白名单)。 +func IsOracleHost(host string) bool { + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + host = strings.ToLower(host) + return host == "oraclecloud.com" || strings.HasSuffix(host, ".oraclecloud.com") +} + +// StartParser 启动解析协程:周期消费未解析事件,回填类型/来源/事件时间。 +func (s *LogEventService) StartParser(ctx context.Context) { + s.wg.Add(1) + go func() { + defer s.wg.Done() + ticker := time.NewTicker(logEventParseTick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.parseOnce(ctx) + } + } + }() +} + +// parseOnce 消费一批未解析事件;失败只记日志,不中断周期调度。 +func (s *LogEventService) parseOnce(ctx context.Context) { + var events []model.LogEvent + err := s.db.WithContext(ctx). + Where("processed = ?", false).Order("id").Limit(logEventParseBatch). + Find(&events).Error + if err != nil { + log.Printf("log event parse load: %v", err) + return + } + for i := range events { + e := &events[i] + parsed := parseLogEvent([]byte(e.Payload)) + e.EventType, e.Source, e.SourceIP, e.EventTime = + parsed.EventType, parsed.Source, parsed.SourceIP, parsed.EventTime + e.Processed = true + if err := s.db.WithContext(ctx).Save(e).Error; err != nil { + log.Printf("log event parse save %d: %v", e.ID, err) + return + } + s.notifyCritical(ctx, e, parsed) + } +} + +// onsEnvelope 覆盖 ONS 消息与 CloudEvents 审计事件的常见字段; +// 真实格式以联调实测为准,提不出字段时只置 Processed 不回填。 +type onsEnvelope struct { + EventType string `json:"eventType"` + Type string `json:"type"` + Source string `json:"source"` + EventTime string `json:"eventTime"` + ResourceName string `json:"resourceName"` + Data json.RawMessage `json:"data"` + Identity *onsIdentity `json:"identity"` +} + +// onsIdentity 是 Audit 事件 data.identity 中与展示相关的字段。 +type onsIdentity struct { + IPAddress string `json:"ipAddress"` +} + +// parsedEvent 是从消息原文提取的展示字段集;ResourceName 仅供 P2 推送文案,不落库。 +type parsedEvent struct { + EventType string + Source string + SourceIP string + ResourceName string + EventTime *time.Time +} + +// parseLogEvent 从消息原文宽松提取事件字段;非 JSON 返回零值。 +func parseLogEvent(payload []byte) parsedEvent { + var env onsEnvelope + if err := json.Unmarshal(payload, &env); err != nil { + var batch []onsEnvelope + if err := json.Unmarshal(payload, &batch); err != nil || len(batch) == 0 { + return parsedEvent{} + } + env = batch[0] + } + if len(env.Data) > 0 { + var inner onsEnvelope + if err := json.Unmarshal(env.Data, &inner); err == nil { + return envelopeFields(mergeEnvelope(env, inner)) + } + } + return envelopeFields(env) +} + +// mergeEnvelope 外层缺失字段时以 data 内层补齐(Audit 的 identity 在内层)。 +func mergeEnvelope(outer, inner onsEnvelope) onsEnvelope { + if outer.EventType == "" { + outer.EventType = inner.EventType + } + if outer.Type == "" { + outer.Type = inner.Type + } + if outer.Source == "" { + outer.Source = inner.Source + } + if outer.EventTime == "" { + outer.EventTime = inner.EventTime + } + if outer.ResourceName == "" { + outer.ResourceName = inner.ResourceName + } + if outer.Identity == nil { + outer.Identity = inner.Identity + } + return outer +} + +// envelopeFields 收敛字段别名并解析事件时间。 +func envelopeFields(env onsEnvelope) parsedEvent { + eventType := env.EventType + if eventType == "" { + eventType = env.Type + } + var eventTime *time.Time + if env.EventTime != "" { + if ts, err := time.Parse(time.RFC3339, env.EventTime); err == nil { + eventTime = &ts + } + } + ip := "" + if env.Identity != nil { + ip = env.Identity.IPAddress + } + return parsedEvent{ + EventType: clip(eventType, 128), + Source: clip(env.Source, 64), + SourceIP: clip(ip, 64), + ResourceName: clip(env.ResourceName, 128), + EventTime: eventTime, + } +} + +// clip 按模型列宽截断解析出的字段。 +func clip(s string, max int) string { + if len(s) > max { + return s[:max] + } + return s +} + +// StartCleanup 启动周期清理:启动即清一次,之后每 24h 一次,随 ctx 取消退出。 +func (s *LogEventService) StartCleanup(ctx context.Context) { + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.cleanupOnce(ctx) + ticker := time.NewTicker(logEventCleanupTick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.cleanupOnce(ctx) + } + } + }() +} + +// cleanupOnce 执行一轮清理,失败只记日志、不中断周期调度。 +func (s *LogEventService) cleanupOnce(ctx context.Context) { + if err := s.cleanup(ctx, logEventRetention, logEventMaxRows); err != nil { + log.Printf("log event cleanup: %v", err) + } +} + +// cleanup 先删过期记录,再对超量部分删最旧;阈值参数化便于测试。 +func (s *LogEventService) cleanup(ctx context.Context, retention time.Duration, maxRows int) error { + cutoff := time.Now().Add(-retention) + if err := s.db.WithContext(ctx).Where("received_at < ?", cutoff).Delete(&model.LogEvent{}).Error; err != nil { + return fmt.Errorf("delete expired log events: %w", err) + } + var total int64 + if err := s.db.WithContext(ctx).Model(&model.LogEvent{}).Count(&total).Error; err != nil { + return fmt.Errorf("count log events: %w", err) + } + overflow := int(total) - maxRows + if overflow <= 0 { + return nil + } + oldest := s.db.Model(&model.LogEvent{}).Select("id").Order("id ASC").Limit(overflow) + if err := s.db.WithContext(ctx).Where("id IN (?)", oldest).Delete(&model.LogEvent{}).Error; err != nil { + return fmt.Errorf("trim log events over cap: %w", err) + } + return nil +} diff --git a/internal/service/logevent_test.go b/internal/service/logevent_test.go new file mode 100644 index 0000000..4767267 --- /dev/null +++ b/internal/service/logevent_test.go @@ -0,0 +1,271 @@ +package service + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "oci-portal/internal/model" +) + +// newLogEventEnv 建含回传所需表的内存库环境,并预置一个 OCI 配置。 +func newLogEventEnv(t *testing.T) (*LogEventService, *gorm.DB, uint) { + t.Helper() + 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.Setting{}, &model.OciConfig{}, &model.LogEvent{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + cfg := model.OciConfig{Alias: "测试租户"} + if err := db.Create(&cfg).Error; err != nil { + t.Fatalf("create config: %v", err) + } + return NewLogEventService(db), db, cfg.ID +} + +func TestEnsureAndResolveSecret(t *testing.T) { + svc, db, cfgID := newLogEventEnv(t) + ctx := context.Background() + other := model.OciConfig{Alias: "另一租户"} + if err := db.Create(&other).Error; err != nil { + t.Fatalf("create config: %v", err) + } + + first, err := svc.EnsureSecret(ctx, cfgID) + if err != nil { + t.Fatalf("ensure secret: %v", err) + } + if len(first.Secret) != 64 { + t.Fatalf("secret length = %d, want 64", len(first.Secret)) + } + if want := "/api/v1/webhooks/oci-logs/" + first.Secret; first.Path != want { + t.Errorf("path = %s, want %s", first.Path, want) + } + + again, err := svc.EnsureSecret(ctx, cfgID) + if err != nil || again.Secret != first.Secret { + t.Errorf("ensure not idempotent: %v, secret changed %v", err, again.Secret != first.Secret) + } + otherInfo, err := svc.EnsureSecret(ctx, other.ID) + if err != nil { + t.Fatalf("ensure other secret: %v", err) + } + + tests := []struct { + name string + secret string + wantID uint + wantHit bool + }{ + {name: "命中本租户", secret: first.Secret, wantID: cfgID, wantHit: true}, + {name: "命中另一租户", secret: otherInfo.Secret, wantID: other.ID, wantHit: true}, + {name: "未知 secret 不命中", secret: "deadbeef", wantHit: false}, + {name: "空 secret 不命中", secret: "", wantHit: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id, ok := svc.ResolveSecret(ctx, tt.secret) + if ok != tt.wantHit || id != tt.wantID { + t.Errorf("resolve = (%d,%v), want (%d,%v)", id, ok, tt.wantID, tt.wantHit) + } + }) + } + + if err := svc.RevokeSecret(ctx, cfgID); err != nil { + t.Fatalf("revoke: %v", err) + } + if _, ok := svc.ResolveSecret(ctx, first.Secret); ok { + t.Error("revoked secret still resolves") + } + if _, exists, _ := svc.SecretInfo(ctx, cfgID); exists { + t.Error("secret info exists after revoke") + } +} + +func TestEnsureSecretRejectsUnknownConfig(t *testing.T) { + svc, _, _ := newLogEventEnv(t) + if _, err := svc.EnsureSecret(context.Background(), 9999); err == nil { + t.Fatal("ensure secret for unknown config succeeded, want error") + } +} + +func TestIngestIdempotent(t *testing.T) { + svc, db, cfgID := newLogEventEnv(t) + ctx := context.Background() + for i := 0; i < 2; i++ { + if err := svc.Ingest(ctx, cfgID, "msg-1", []byte(`{"eventType":"t"}`), false); err != nil { + t.Fatalf("ingest #%d: %v", i+1, err) + } + } + if err := svc.Ingest(ctx, cfgID, "msg-2", []byte("cut"), true); err != nil { + t.Fatalf("ingest truncated: %v", err) + } + var total int64 + db.Model(&model.LogEvent{}).Count(&total) + if total != 2 { + t.Errorf("rows = %d, want 2(重复 messageId 不落新行)", total) + } + var truncated model.LogEvent + db.First(&truncated, "message_id = ?", "msg-2") + if !truncated.Truncated { + t.Error("truncated flag not persisted") + } +} + +func TestParseLogEvent(t *testing.T) { + ts := "2026-07-07T08:00:00Z" + tests := []struct { + name string + payload string + wantType string + wantSource string + wantIP string + wantTime bool + }{ + { + name: "CloudEvents 单事件", + payload: `{"eventType":"com.oraclecloud.ComputeApi.TerminateInstance","source":"ComputeApi","eventTime":"` + ts + `"}`, + wantType: "com.oraclecloud.ComputeApi.TerminateInstance", + wantSource: "ComputeApi", + wantTime: true, + }, + { + name: "type 别名", + payload: `{"type":"custom.event"}`, + wantType: "custom.event", + }, + { + name: "外层缺失取 data 内层", + payload: `{"data":{"eventType":"inner.event","source":"Audit"}}`, + wantType: "inner.event", + wantSource: "Audit", + }, + { + name: "数组取首个", + payload: `[{"eventType":"batch.first"},{"eventType":"batch.second"}]`, + wantType: "batch.first", + }, + {name: "非 JSON 全空", payload: "plain text message"}, + {name: "时间非法只丢时间", payload: `{"eventType":"x","eventTime":"not-a-time"}`, wantType: "x"}, + { + name: "Audit 事件提取 data.identity.ipAddress", + payload: `{"eventType":"com.oraclecloud.limits.ListLimitValues","data":{"identity":{"ipAddress":"203.0.113.9"}}}`, + wantType: "com.oraclecloud.limits.ListLimitValues", + wantIP: "203.0.113.9", + }, + { + name: "identity 缺失 IP 为空", + payload: `{"eventType":"x","data":{"identity":{}}}`, + wantType: "x", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseLogEvent([]byte(tt.payload)) + if got.EventType != tt.wantType || got.Source != tt.wantSource || got.SourceIP != tt.wantIP { + t.Errorf("parse = (%q,%q,%q), want (%q,%q,%q)", + got.EventType, got.Source, got.SourceIP, tt.wantType, tt.wantSource, tt.wantIP) + } + if (got.EventTime != nil) != tt.wantTime { + t.Errorf("eventTime present = %v, want %v", got.EventTime != nil, tt.wantTime) + } + }) + } +} + +func TestParseOnceMarksProcessed(t *testing.T) { + svc, db, cfgID := newLogEventEnv(t) + ctx := context.Background() + if err := svc.Ingest(ctx, cfgID, "m-json", []byte(`{"eventType":"a.b","source":"S"}`), false); err != nil { + t.Fatalf("ingest: %v", err) + } + if err := svc.Ingest(ctx, cfgID, "m-text", []byte("opaque"), false); err != nil { + t.Fatalf("ingest: %v", err) + } + svc.parseOnce(ctx) + var parsed, opaque model.LogEvent + db.First(&parsed, "message_id = ?", "m-json") + db.First(&opaque, "message_id = ?", "m-text") + if !parsed.Processed || parsed.EventType != "a.b" || parsed.Source != "S" { + t.Errorf("parsed = %+v, want processed with type/source", parsed) + } + if !opaque.Processed || opaque.EventType != "" { + t.Errorf("opaque = %+v, want processed without fields", opaque) + } +} + +func TestLogEventCleanup(t *testing.T) { + svc, db, cfgID := newLogEventEnv(t) + ctx := context.Background() + old := model.LogEvent{OciConfigID: cfgID, MessageID: "old", ReceivedAt: time.Now().Add(-100 * 24 * time.Hour)} + db.Create(&old) + for i := 0; i < 4; i++ { + db.Create(&model.LogEvent{ + OciConfigID: cfgID, MessageID: fmt.Sprintf("new-%d", i), ReceivedAt: time.Now(), + }) + } + if err := svc.cleanup(ctx, logEventRetention, 2); err != nil { + t.Fatalf("cleanup: %v", err) + } + var total int64 + db.Model(&model.LogEvent{}).Count(&total) + if total != 2 { + t.Errorf("rows after cleanup = %d, want 2(过期删除+超量删最旧)", total) + } + var gone model.LogEvent + if err := db.First(&gone, "message_id = ?", "old").Error; err == nil { + t.Error("expired row survived cleanup") + } +} + +func TestLogEventListFilters(t *testing.T) { + svc, db, cfgID := newLogEventEnv(t) + ctx := context.Background() + other := model.OciConfig{Alias: "旁租户"} + db.Create(&other) + for i := 0; i < 3; i++ { + db.Create(&model.LogEvent{OciConfigID: cfgID, MessageID: fmt.Sprintf("a-%d", i), ReceivedAt: time.Now()}) + } + db.Create(&model.LogEvent{OciConfigID: other.ID, MessageID: "b-0", ReceivedAt: time.Now()}) + + tests := []struct { + name string + query LogEventQuery + wantTotal int64 + wantLen int + wantFirst string + }{ + {name: "全部倒序", query: LogEventQuery{}, wantTotal: 4, wantLen: 4, wantFirst: "b-0"}, + {name: "按租户过滤", query: LogEventQuery{CfgID: cfgID}, wantTotal: 3, wantLen: 3, wantFirst: "a-2"}, + {name: "分页第二页", query: LogEventQuery{CfgID: cfgID, Page: 2, PageSize: 2}, wantTotal: 3, wantLen: 1, wantFirst: "a-0"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + items, total, err := svc.List(ctx, tt.query) + if err != nil { + t.Fatalf("list: %v", err) + } + if total != tt.wantTotal || len(items) != tt.wantLen { + t.Fatalf("total=%d len=%d, want %d/%d", total, len(items), tt.wantTotal, tt.wantLen) + } + if items[0].MessageID != tt.wantFirst { + t.Errorf("first = %s, want %s", items[0].MessageID, tt.wantFirst) + } + }) + } +} diff --git a/internal/service/loginguard.go b/internal/service/loginguard.go new file mode 100644 index 0000000..1943e22 --- /dev/null +++ b/internal/service/loginguard.go @@ -0,0 +1,64 @@ +package service + +import ( + "sync" + "time" +) + +// loginGuard 按「IP|用户名」双维度做滑动窗口失败计数与锁定; +// 窗口与锁定时长同值、阈值由调用方按安全设置传入(探索文档主题三); +// 内存实现(单体面板),重启清零可接受。 +type loginGuard struct { + mu sync.Mutex + failures map[string][]time.Time + lockedAt map[string]time.Time +} + +func newLoginGuard() *loginGuard { + return &loginGuard{ + failures: map[string][]time.Time{}, + lockedAt: map[string]time.Time{}, + } +} + +// locked 判定 key 是否处于锁定期;过期锁惰性清除。 +func (g *loginGuard) locked(key string, now time.Time, lockFor time.Duration) bool { + g.mu.Lock() + defer g.mu.Unlock() + at, ok := g.lockedAt[key] + if !ok { + return false + } + if now.Sub(at) >= lockFor { + delete(g.lockedAt, key) + return false + } + return true +} + +// fail 记一次失败并裁剪窗口;达到阈值转入锁定并返回 true(仅触发那一次)。 +func (g *loginGuard) fail(key string, now time.Time, limit int, window time.Duration) bool { + g.mu.Lock() + defer g.mu.Unlock() + kept := g.failures[key][:0] + for _, t := range g.failures[key] { + if now.Sub(t) < window { + kept = append(kept, t) + } + } + kept = append(kept, now) + if len(kept) >= limit { + delete(g.failures, key) + g.lockedAt[key] = now + return true + } + g.failures[key] = kept + return false +} + +// success 清空 key 的失败计数(成功登录重置窗口)。 +func (g *loginGuard) success(key string) { + g.mu.Lock() + defer g.mu.Unlock() + delete(g.failures, key) +} diff --git a/internal/service/logrelay.go b/internal/service/logrelay.go new file mode 100644 index 0000000..54b38b9 --- /dev/null +++ b/internal/service/logrelay.go @@ -0,0 +1,357 @@ +package service + +import ( + "context" + "errors" + "fmt" + "log" + "strings" + "time" + + "oci-portal/internal/model" + "oci-portal/internal/oci" +) + +// ErrRelayNotConfigured 表示 P1 引导创建不可用:面板地址未配置或依赖未注入。 +var ErrRelayNotConfigured = errors.New("日志回传引导创建需要配置面板地址(设置 → 安全 → 面板地址,或 PUBLIC_URL 环境变量)") + +// 订阅确认轮询节奏:创建订阅后 ONS 向面板投递确认消息,面板回访后订阅转 ACTIVE。 +const ( + relaySubPollTick = 5 * time.Second + relaySubPollTimeout = 90 * time.Second + relayRollbackWait = 60 * time.Second +) + +// RelayCriticalEvents 是回传的关键事件清单(Connector Log Filter 与 P2 告警共用): +// 实例生命周期、用户与凭据、区域订阅、策略变更、控制台登录。 +var RelayCriticalEvents = []string{ + "LaunchInstance", "TerminateInstance", "InstanceAction", + "CreateUser", "DeleteUser", "UpdateUser", + "CreateApiKey", "DeleteApiKey", "UpdateUserCapabilities", + "CreateRegionSubscription", + "CreatePolicy", "UpdatePolicy", "DeletePolicy", + "InteractiveLogin", +} + +// relayCriticalSet 供 P2 按事件短名 O(1) 判定。 +var relayCriticalSet = func() map[string]bool { + m := make(map[string]bool, len(RelayCriticalEvents)) + for _, e := range RelayCriticalEvents { + m[e] = true + } + return m +}() + +// relayEventClass 把关键事件归入通知子类,对应设置键 notify_event_log_event_; +// 通知管理「云端事件」按此细分开关。 +func relayEventClass(name string) string { + switch name { + case "LaunchInstance", "TerminateInstance", "InstanceAction": + return "instance" + case "CreateUser", "DeleteUser", "UpdateUser", + "CreateApiKey", "DeleteApiKey", "UpdateUserCapabilities": + return "identity" + case "CreatePolicy", "UpdatePolicy", "DeletePolicy": + return "policy" + case "CreateRegionSubscription": + return "region" + case "InteractiveLogin", "FederatedInteractiveLogin": + return "login" + } + return "" +} + +// RelayView 是 OCI 侧链路状态视图;Events 为关键事件清单,Ready 表示全链路可用。 +type RelayView struct { + Webhook *LogWebhookInfo `json:"webhook,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Topic oci.RelayResource `json:"topic"` + Subscription oci.RelayResource `json:"subscription"` + Connector oci.RelayResource `json:"connector"` + Policy oci.RelayResource `json:"policy"` + Ready bool `json:"ready"` + Events []string `json:"events"` +} + +// SetRelayDeps 注入 P1 引导创建依赖;publicURL 为面板公网基址(如 https://demo.example.com)。 +func (s *LogEventService) SetRelayDeps(configs *OciConfigService, client oci.Client, publicURL string) { + s.configs = configs + s.relayClient = client + s.publicURL = strings.TrimRight(publicURL, "/") +} + +// SetNotifier 注入 P2 告警联动依赖;settings 控制 log_event 事件开关。 +func (s *LogEventService) SetNotifier(n *Notifier, settings *SettingService) { + s.notifier = n + s.settings = settings +} + +// relayBase 返回生效的公网基址:设置里的 app_url 优先(经 settings),回退启动时的 PUBLIC_URL。 +func (s *LogEventService) relayBase() string { + if s.settings != nil { + if u := s.settings.EffectiveAppURL(); u != "" { + return u + } + } + return s.publicURL +} + +// relayEndpoint 以公网基址拼接回调完整 URL。 +func (s *LogEventService) relayEndpoint(path string) string { + return s.relayBase() + path +} + +// relayCreds 加载配置凭据与 home region(IAM 写操作路由用)。 +func (s *LogEventService) relayCreds(ctx context.Context, cfgID uint) (oci.Credentials, string, error) { + var cfg model.OciConfig + if err := s.db.WithContext(ctx).First(&cfg, cfgID).Error; err != nil { + return oci.Credentials{}, "", fmt.Errorf("find oci config %d: %w", cfgID, err) + } + cred, err := s.configs.credentialsOf(&cfg) + if err != nil { + return oci.Credentials{}, "", err + } + return cred, cfg.HomeRegionKey, nil +} + +// SetupRelay 一键建立回传链路:secret → Topic → 订阅(等待面板自动确认)→ Policy → Connector; +// 任一步失败逆序回滚本次新建的资源。 +func (s *LogEventService) SetupRelay(ctx context.Context, cfgID uint) (RelayView, error) { + if s.relayClient == nil || s.relayBase() == "" { + return RelayView{}, ErrRelayNotConfigured + } + info, err := s.EnsureSecret(ctx, cfgID) + if err != nil { + return RelayView{}, err + } + cred, home, err := s.relayCreds(ctx, cfgID) + if err != nil { + return RelayView{}, err + } + if err := s.buildRelay(ctx, cred, home, s.relayEndpoint(info.Path)); err != nil { + return RelayView{}, err + } + return s.RelayStatus(ctx, cfgID) +} + +// buildRelay 依序创建链路资源;失败时逆序删除本次新建的部分后返回原错误。 +func (s *LogEventService) buildRelay(ctx context.Context, cred oci.Credentials, home, endpoint string) (err error) { + var undo []func(context.Context) error + defer func() { + if err != nil { + s.rollbackRelay(undo) + } + }() + topic, err := s.relayClient.EnsureRelayTopic(ctx, cred) + undo = appendRelayUndo(undo, topic, func(c context.Context) error { return s.relayClient.DeleteRelayTopic(c, cred, topic.ID) }) + if err != nil { + return fmt.Errorf("创建 Topic: %w", err) + } + sub, err := s.relayClient.EnsureRelaySubscription(ctx, cred, topic.ID, endpoint) + undo = appendRelayUndo(undo, sub, func(c context.Context) error { return s.relayClient.DeleteRelaySubscription(c, cred, sub.ID) }) + if err != nil { + return fmt.Errorf("创建订阅: %w", err) + } + if err = s.waitSubscriptionActive(ctx, cred, sub.ID); err != nil { + return err + } + policy, err := s.relayClient.EnsureRelayPolicy(ctx, cred, home) + undo = appendRelayUndo(undo, policy, func(c context.Context) error { return s.relayClient.DeleteRelayPolicy(c, cred, home, policy.ID) }) + if err != nil { + return fmt.Errorf("创建 Policy: %w", err) + } + conn, err := s.relayClient.EnsureRelayConnector(ctx, cred, topic.ID, oci.RelayEventCondition(RelayCriticalEvents)) + undo = appendRelayUndo(undo, conn, func(c context.Context) error { return s.relayClient.DeleteRelayConnector(c, cred, conn.ID) }) + if err != nil { + return fmt.Errorf("创建 Connector: %w", err) + } + return nil +} + +// appendRelayUndo 只为本次新建且拿到 ID 的资源登记回滚动作。 +func appendRelayUndo(undo []func(context.Context) error, res oci.RelayResource, del func(context.Context) error) []func(context.Context) error { + if res.Created && res.ID != "" { + return append(undo, del) + } + return undo +} + +// rollbackRelay 逆序执行回滚;使用独立超时上下文,原请求取消不影响清理。 +func (s *LogEventService) rollbackRelay(undo []func(context.Context) error) { + ctx, cancel := context.WithTimeout(context.Background(), relayRollbackWait) + defer cancel() + for i := len(undo) - 1; i >= 0; i-- { + if err := undo[i](ctx); err != nil { + log.Printf("relay rollback: %v", err) + } + } +} + +// waitSubscriptionActive 轮询订阅至 ACTIVE;确认动作由 webhook 端点收到 ONS 消息后自动完成, +// 超时通常意味着面板公网不可达或回调地址配置有误。 +func (s *LogEventService) waitSubscriptionActive(ctx context.Context, cred oci.Credentials, subID string) error { + deadline := time.Now().Add(s.subPollTimeout()) + for { + res, err := s.relayClient.GetRelaySubscription(ctx, cred, subID) + if err != nil { + return fmt.Errorf("查询订阅状态: %w", err) + } + if res.State == "ACTIVE" { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("订阅确认超时(当前 %s):请确认面板公网可达后重试", res.State) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(s.subPollTick()): + } + } +} + +// subPollTick / subPollTimeout 返回订阅轮询节奏;测试注入短间隔。 +func (s *LogEventService) subPollTick() time.Duration { + if s.relayPollTick > 0 { + return s.relayPollTick + } + return relaySubPollTick +} + +func (s *LogEventService) subPollTimeout() time.Duration { + if s.relayPollTimeout > 0 { + return s.relayPollTimeout + } + return relaySubPollTimeout +} + +// RelayStatus 查询 OCI 侧链路现状与关键事件清单。 +func (s *LogEventService) RelayStatus(ctx context.Context, cfgID uint) (RelayView, error) { + if s.relayClient == nil { + return RelayView{}, ErrRelayNotConfigured + } + info, exists, err := s.SecretInfo(ctx, cfgID) + if err != nil { + return RelayView{}, err + } + view := RelayView{Events: RelayCriticalEvents} + endpoint := "" + if exists && s.relayBase() != "" { + endpoint = s.relayEndpoint(info.Path) + view.Webhook, view.Endpoint = &info, endpoint + } + cred, _, err := s.relayCreds(ctx, cfgID) + if err != nil { + return RelayView{}, err + } + st, err := s.relayClient.RelayState(ctx, cred, endpoint) + if err != nil { + return RelayView{}, fmt.Errorf("查询链路状态: %w", err) + } + view.Topic, view.Subscription, view.Connector, view.Policy = st.Topic, st.Subscription, st.Connector, st.Policy + view.Ready = relayReady(exists, st) + return view, nil +} + +// relayReady 判定全链路可用:secret 已生成且四资源均处于活跃状态。 +func relayReady(secretExists bool, st oci.RelayState) bool { + return secretExists && st.Topic.State == "ACTIVE" && st.Subscription.State == "ACTIVE" && + st.Connector.State == "ACTIVE" && st.Policy.ID != "" +} + +// TeardownRelay 逆序销毁链路(Connector → Policy → 订阅 → Topic)并撤销 secret。 +func (s *LogEventService) TeardownRelay(ctx context.Context, cfgID uint) error { + if s.relayClient == nil { + return ErrRelayNotConfigured + } + info, exists, err := s.SecretInfo(ctx, cfgID) + if err != nil { + return err + } + cred, home, err := s.relayCreds(ctx, cfgID) + if err != nil { + return err + } + endpoint := "" + if exists { + endpoint = s.relayEndpoint(info.Path) + } + st, err := s.relayClient.RelayState(ctx, cred, endpoint) + if err != nil { + return fmt.Errorf("查询链路状态: %w", err) + } + if err := s.teardownResources(ctx, cred, home, st); err != nil { + return err + } + return s.RevokeSecret(ctx, cfgID) +} + +// teardownResources 按依赖逆序删除存在的链路资源。 +func (s *LogEventService) teardownResources(ctx context.Context, cred oci.Credentials, home string, st oci.RelayState) error { + if st.Connector.ID != "" { + if err := s.relayClient.DeleteRelayConnector(ctx, cred, st.Connector.ID); err != nil { + return fmt.Errorf("删除 Connector: %w", err) + } + } + if st.Policy.ID != "" { + if err := s.relayClient.DeleteRelayPolicy(ctx, cred, home, st.Policy.ID); err != nil { + return fmt.Errorf("删除 Policy: %w", err) + } + } + if st.Subscription.ID != "" { + if err := s.relayClient.DeleteRelaySubscription(ctx, cred, st.Subscription.ID); err != nil { + return fmt.Errorf("删除订阅: %w", err) + } + } + if st.Topic.ID != "" { + if err := s.relayClient.DeleteRelayTopic(ctx, cred, st.Topic.ID); err != nil { + return fmt.Errorf("删除 Topic: %w", err) + } + } + return nil +} + +// ---- P2 告警联动:解析出关键事件后经 Notifier 推送 ---- + +// relayEventShortName 取 CloudEvents type 末段(com.oraclecloud.ComputeApi.LaunchInstance → LaunchInstance)。 +func relayEventShortName(eventType string) string { + if i := strings.LastIndex(eventType, "."); i >= 0 { + return eventType[i+1:] + } + return eventType +} + +// criticalEventVars 判定关键事件并生成模板变量;非关键事件 ok 为 false。 +func criticalEventVars(alias string, p parsedEvent) (map[string]string, bool) { + name := relayEventShortName(p.EventType) + if name == "" || !relayCriticalSet[name] { + return nil, false + } + return map[string]string{"tenant": alias, "event": name, "resource": p.ResourceName}, true +} + +// notifyCritical 对关键事件推送告警;依赖未注入或对应子类开关关闭时跳过。 +// MessageID 幂等入库保证同一事件只解析一次,天然防重复推送。 +func (s *LogEventService) notifyCritical(ctx context.Context, e *model.LogEvent, p parsedEvent) { + if s.notifier == nil { + return + } + vars, ok := criticalEventVars(s.configAlias(ctx, e.OciConfigID), p) + if !ok { + return + } + class := relayEventClass(relayEventShortName(p.EventType)) + if s.settings != nil && !s.settings.NotifyEventEnabled(ctx, "log_event_"+class) { + return + } + s.notifier.SendTemplateAsync("log_event_"+class, vars) +} + +// configAlias 查配置别名,失败时退化为编号占位。 +func (s *LogEventService) configAlias(ctx context.Context, cfgID uint) string { + var cfg model.OciConfig + if err := s.db.WithContext(ctx).Select("alias").First(&cfg, cfgID).Error; err != nil { + return fmt.Sprintf("租户#%d", cfgID) + } + return cfg.Alias +} diff --git a/internal/service/logrelay_test.go b/internal/service/logrelay_test.go new file mode 100644 index 0000000..9b221c6 --- /dev/null +++ b/internal/service/logrelay_test.go @@ -0,0 +1,385 @@ +package service + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "oci-portal/internal/crypto" + "oci-portal/internal/model" + "oci-portal/internal/oci" +) + +// fakeRelayClient 是可编程的回传链路假客户端;内嵌接口,未覆写方法不被调用。 +// fail 按步骤名注入错误;calls 记录动作序列供断言编排与回滚顺序。 +type fakeRelayClient struct { + oci.Client + mu sync.Mutex + calls []string + fail map[string]error + subStates []string // GetRelaySubscription 依次返回的状态,超出取末位 + subIdx int + endpoint string // 最近一次订阅创建收到的 endpoint + connectorTimeout bool // 模拟 Connector 已建但轮询超时 + state oci.RelayState +} + +func (f *fakeRelayClient) step(name string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, name) + return f.fail[name] +} + +func (f *fakeRelayClient) EnsureRelayTopic(context.Context, oci.Credentials) (oci.RelayResource, error) { + if err := f.step("topic"); err != nil { + return oci.RelayResource{}, err + } + return oci.RelayResource{ID: "t1", State: "ACTIVE", Created: true}, nil +} + +func (f *fakeRelayClient) EnsureRelaySubscription(_ context.Context, _ oci.Credentials, topicID, endpoint string) (oci.RelayResource, error) { + f.mu.Lock() + f.endpoint = endpoint + f.mu.Unlock() + if err := f.step("sub"); err != nil { + return oci.RelayResource{}, err + } + return oci.RelayResource{ID: "s1", State: "PENDING", Created: true}, nil +} + +func (f *fakeRelayClient) GetRelaySubscription(context.Context, oci.Credentials, string) (oci.RelayResource, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, "getsub") + state := "ACTIVE" + if len(f.subStates) > 0 { + i := f.subIdx + if i >= len(f.subStates) { + i = len(f.subStates) - 1 + } + state = f.subStates[i] + f.subIdx++ + } + return oci.RelayResource{ID: "s1", State: state}, nil +} + +func (f *fakeRelayClient) EnsureRelayPolicy(_ context.Context, _ oci.Credentials, homeRegion string) (oci.RelayResource, error) { + if err := f.step("policy:" + homeRegion); err != nil { + return oci.RelayResource{}, err + } + return oci.RelayResource{ID: "p1", State: "ACTIVE", Created: true}, nil +} + +func (f *fakeRelayClient) EnsureRelayConnector(context.Context, oci.Credentials, string, string) (oci.RelayResource, error) { + if err := f.step("connector"); err != nil { + return oci.RelayResource{}, err + } + if f.connectorTimeout { + return oci.RelayResource{ID: "c1", State: "CREATING", Created: true}, errors.New("service connector not active after 90s") + } + return oci.RelayResource{ID: "c1", State: "ACTIVE", Created: true}, nil +} + +func (f *fakeRelayClient) RelayState(context.Context, oci.Credentials, string) (oci.RelayState, error) { + if err := f.step("state"); err != nil { + return oci.RelayState{}, err + } + return f.state, nil +} + +func (f *fakeRelayClient) DeleteRelayConnector(context.Context, oci.Credentials, string) error { + return f.step("del-connector") +} + +func (f *fakeRelayClient) DeleteRelayPolicy(context.Context, oci.Credentials, string, string) error { + return f.step("del-policy") +} + +func (f *fakeRelayClient) DeleteRelaySubscription(context.Context, oci.Credentials, string) error { + return f.step("del-sub") +} + +func (f *fakeRelayClient) DeleteRelayTopic(context.Context, oci.Credentials, string) error { + return f.step("del-topic") +} + +// deletes 过滤出删除动作序列,断言回滚/销毁顺序。 +func (f *fakeRelayClient) deletes() []string { + f.mu.Lock() + defer f.mu.Unlock() + var out []string + for _, c := range f.calls { + if strings.HasPrefix(c, "del-") { + out = append(out, c) + } + } + return out +} + +// newRelayEnv 在 logevent 环境上补齐 relay 依赖:可解密凭据 + 假客户端 + 短轮询。 +func newRelayEnv(t *testing.T, fc *fakeRelayClient) (*LogEventService, uint) { + t.Helper() + svc, db, cfgID := newLogEventEnv(t) + cipher, err := crypto.NewCipher("test-key") + if err != nil { + t.Fatalf("new cipher: %v", err) + } + enc, err := cipher.EncryptString("-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----") + if err != nil { + t.Fatalf("encrypt key: %v", err) + } + var cfg model.OciConfig + if err := db.First(&cfg, cfgID).Error; err != nil { + t.Fatalf("load config: %v", err) + } + cfg.PrivateKeyEnc = enc + cfg.TenancyOCID = "ocid1.tenancy.oc1..t" + cfg.UserOCID = "ocid1.user.oc1..u" + cfg.Region = "us-ashburn-1" + cfg.Fingerprint = "aa:bb" + cfg.HomeRegionKey = "IAD" + if err := db.Save(&cfg).Error; err != nil { + t.Fatalf("seed credentials: %v", err) + } + svc.SetRelayDeps(NewOciConfigService(db, cipher, fc), fc, "https://demo.example.com/") + svc.relayPollTick = time.Millisecond + svc.relayPollTimeout = 30 * time.Millisecond + return svc, cfgID +} + +func TestSetupRelayHappyPath(t *testing.T) { + fc := &fakeRelayClient{state: oci.RelayState{ + Topic: oci.RelayResource{ID: "t1", State: "ACTIVE"}, + Subscription: oci.RelayResource{ID: "s1", State: "ACTIVE"}, + Connector: oci.RelayResource{ID: "c1", State: "ACTIVE"}, + Policy: oci.RelayResource{ID: "p1", State: "ACTIVE"}, + }} + svc, cfgID := newRelayEnv(t, fc) + view, err := svc.SetupRelay(context.Background(), cfgID) + if err != nil { + t.Fatalf("SetupRelay: %v", err) + } + if !view.Ready { + t.Errorf("Ready = false, want true; view = %+v", view) + } + if view.Webhook == nil || !strings.HasPrefix(view.Endpoint, "https://demo.example.com/api/v1/webhooks/oci-logs/") { + t.Errorf("endpoint = %q, want 公网基址拼接", view.Endpoint) + } + if len(view.Events) != len(RelayCriticalEvents) { + t.Errorf("events = %d 项, want %d", len(view.Events), len(RelayCriticalEvents)) + } + if dels := fc.deletes(); len(dels) != 0 { + t.Errorf("成功路径不应有删除调用,got %v", dels) + } + // 编排顺序:Topic → 订阅(endpoint 已拼接)→ 确认轮询 → Policy(home region)→ Connector + joined := strings.Join(fc.calls, ",") + for _, frag := range []string{"topic", "sub", "getsub", "policy:IAD", "connector"} { + if !strings.Contains(joined, frag) { + t.Errorf("calls = %s, 缺少 %s", joined, frag) + } + } + if !strings.HasPrefix(fc.endpoint, "https://demo.example.com/api/v1/webhooks/oci-logs/") { + t.Errorf("订阅 endpoint = %q, want 公网基址拼接", fc.endpoint) + } +} + +func TestSetupRelayRollback(t *testing.T) { + tests := []struct { + name string + program func(fc *fakeRelayClient) + wantErr string + wantDelSeq []string + }{ + { + name: "订阅创建失败回滚 Topic", + program: func(fc *fakeRelayClient) { fc.fail = map[string]error{"sub": nil} }, + wantErr: "创建订阅", + wantDelSeq: []string{"del-topic"}, + }, + { + name: "Policy 失败回滚订阅与 Topic", + program: func(fc *fakeRelayClient) { fc.fail = map[string]error{"policy:IAD": errors.New("boom")} }, + wantErr: "创建 Policy", + wantDelSeq: []string{"del-sub", "del-topic"}, + }, + { + name: "Connector 创建失败回滚全部前置", + program: func(fc *fakeRelayClient) { fc.fail = map[string]error{"connector": errors.New("boom")} }, + wantErr: "创建 Connector", + wantDelSeq: []string{"del-policy", "del-sub", "del-topic"}, + }, + { + name: "Connector 超时时连自身一并回滚", + program: func(fc *fakeRelayClient) { fc.connectorTimeout = true }, + wantErr: "创建 Connector", + wantDelSeq: []string{"del-connector", "del-policy", "del-sub", "del-topic"}, + }, + { + name: "订阅确认超时回滚订阅与 Topic", + program: func(fc *fakeRelayClient) { fc.subStates = []string{"PENDING"} }, + wantErr: "订阅确认超时", + wantDelSeq: []string{"del-sub", "del-topic"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fc := &fakeRelayClient{} + tt.program(fc) + // map 值为 nil 的注入替换为真实错误(subtest 里统一) + for k, v := range fc.fail { + if v == nil { + fc.fail[k] = errors.New("boom") + } + } + svc, cfgID := newRelayEnv(t, fc) + _, err := svc.SetupRelay(context.Background(), cfgID) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err = %v, want 含 %q", err, tt.wantErr) + } + dels := fc.deletes() + if strings.Join(dels, ",") != strings.Join(tt.wantDelSeq, ",") { + t.Errorf("回滚序列 = %v, want %v", dels, tt.wantDelSeq) + } + }) + } +} + +func TestSetupRelayRequiresPublicURL(t *testing.T) { + svc, _, cfgID := newLogEventEnv(t) + if _, err := svc.SetupRelay(context.Background(), cfgID); !errors.Is(err, ErrRelayNotConfigured) { + t.Errorf("err = %v, want ErrRelayNotConfigured", err) + } +} + +func TestTeardownRelay(t *testing.T) { + fc := &fakeRelayClient{state: oci.RelayState{ + Topic: oci.RelayResource{ID: "t1", State: "ACTIVE"}, + Subscription: oci.RelayResource{ID: "s1", State: "ACTIVE"}, + Connector: oci.RelayResource{ID: "c1", State: "ACTIVE"}, + Policy: oci.RelayResource{ID: "p1", State: "ACTIVE"}, + }} + svc, cfgID := newRelayEnv(t, fc) + ctx := context.Background() + if _, err := svc.EnsureSecret(ctx, cfgID); err != nil { + t.Fatalf("ensure secret: %v", err) + } + if err := svc.TeardownRelay(ctx, cfgID); err != nil { + t.Fatalf("TeardownRelay: %v", err) + } + want := []string{"del-connector", "del-policy", "del-sub", "del-topic"} + if dels := fc.deletes(); strings.Join(dels, ",") != strings.Join(want, ",") { + t.Errorf("销毁序列 = %v, want %v", dels, want) + } + if _, exists, err := svc.SecretInfo(ctx, cfgID); err != nil || exists { + t.Errorf("teardown 后 secret 仍存在 (exists=%v, err=%v)", exists, err) + } +} + +func TestRelayReady(t *testing.T) { + active := func() oci.RelayState { + return oci.RelayState{ + Topic: oci.RelayResource{ID: "t", State: "ACTIVE"}, + Subscription: oci.RelayResource{ID: "s", State: "ACTIVE"}, + Connector: oci.RelayResource{ID: "c", State: "ACTIVE"}, + Policy: oci.RelayResource{ID: "p", State: "ACTIVE"}, + } + } + tests := []struct { + name string + secret bool + mutate func(*oci.RelayState) + want bool + }{ + {name: "全链路活跃", secret: true, mutate: func(*oci.RelayState) {}, want: true}, + {name: "secret 未生成", secret: false, mutate: func(*oci.RelayState) {}, want: false}, + {name: "订阅 PENDING", secret: true, mutate: func(s *oci.RelayState) { s.Subscription.State = "PENDING" }, want: false}, + {name: "Connector 缺失", secret: true, mutate: func(s *oci.RelayState) { s.Connector = oci.RelayResource{} }, want: false}, + {name: "Policy 缺失", secret: true, mutate: func(s *oci.RelayState) { s.Policy = oci.RelayResource{} }, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + st := active() + tt.mutate(&st) + if got := relayReady(tt.secret, st); got != tt.want { + t.Errorf("relayReady = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCriticalEventText(t *testing.T) { + tests := []struct { + name string + event parsedEvent + wantOK bool + wantText string + }{ + { + name: "实例终止命中", + event: parsedEvent{EventType: "com.oraclecloud.ComputeApi.TerminateInstance", ResourceName: "vm-1"}, + wantOK: true, + wantText: "☁️ 云端事件:免费01 TerminateInstance vm-1", + }, + { + name: "无资源名省略尾段", + event: parsedEvent{EventType: "com.oraclecloud.IdentityControlPlane.CreateApiKey"}, + wantOK: true, + wantText: "☁️ 云端事件:免费01 CreateApiKey", + }, + {name: "List 噪声不推", event: parsedEvent{EventType: "com.oraclecloud.ComputeApi.ListInstances"}, wantOK: false}, + {name: "空类型不推", event: parsedEvent{}, wantOK: false}, + {name: "短名直接命中", event: parsedEvent{EventType: "LaunchInstance"}, wantOK: true, wantText: "☁️ 云端事件:免费01 LaunchInstance"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + vars, ok := criticalEventVars("免费01", tt.event) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v", ok, tt.wantOK) + } + if ok { + got := "☁️ 云端事件:" + vars["tenant"] + " " + vars["event"] + if vars["resource"] != "" { + got += " " + vars["resource"] + } + if got != tt.wantText { + t.Errorf("vars 渲染 = %q, want %q", got, tt.wantText) + } + } + }) + } +} + +func TestRelayEventClass(t *testing.T) { + cases := []struct { + name string + class string + }{ + {"LaunchInstance", "instance"}, + {"TerminateInstance", "instance"}, + {"InstanceAction", "instance"}, + {"CreateUser", "identity"}, + {"DeleteApiKey", "identity"}, + {"UpdateUserCapabilities", "identity"}, + {"CreatePolicy", "policy"}, + {"DeletePolicy", "policy"}, + {"CreateRegionSubscription", "region"}, + {"InteractiveLogin", "login"}, + {"FederatedInteractiveLogin", "login"}, + {"ListInstances", ""}, + } + for _, tc := range cases { + if got := relayEventClass(tc.name); got != tc.class { + t.Errorf("relayEventClass(%s) = %q, want %q", tc.name, got, tc.class) + } + } + // 关键事件清单里的每个事件都必须有归属子类,防新增事件漏配开关 + for _, e := range RelayCriticalEvents { + if relayEventClass(e) == "" { + t.Errorf("critical event %s has no notify class", e) + } + } +} diff --git a/internal/service/network.go b/internal/service/network.go new file mode 100644 index 0000000..2dcac25 --- /dev/null +++ b/internal/service/network.go @@ -0,0 +1,196 @@ +package service + +import ( + "context" + "fmt" + + "oci-portal/internal/oci" +) + +// Shapes 列出租户在目标区域可用的实例规格。 +func (s *OciConfigService) Shapes(ctx context.Context, id uint, region, availabilityDomain string) ([]oci.ComputeShape, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return nil, err + } + return s.client.ListShapes(ctx, cred, region, availabilityDomain) +} + +// Images 列出可用的实例镜像。 +func (s *OciConfigService) Images(ctx context.Context, id uint, q oci.ImagesQuery) ([]oci.Image, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return nil, err + } + return s.client.ListImages(ctx, cred, q) +} + +// Image 查询单个镜像。 +func (s *OciConfigService) Image(ctx context.Context, id uint, region, imageID string) (oci.Image, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.Image{}, err + } + return s.client.GetImage(ctx, cred, region, imageID) +} + +// VCNs 列出 VCN;compartmentID 为空表示租户根。 +func (s *OciConfigService) VCNs(ctx context.Context, id uint, region, compartmentID string) ([]oci.VCN, error) { + cred, err := s.credentialsInCompartment(ctx, id, compartmentID) + if err != nil { + return nil, err + } + return s.client.ListVCNs(ctx, cred, region) +} + +// CreateVCN 创建 VCN;in.CompartmentID 为空时建在租户根。 +func (s *OciConfigService) CreateVCN(ctx context.Context, id uint, in oci.CreateVCNInput) (oci.VCN, error) { + if in.DisplayName == "" || in.CidrBlock == "" { + return oci.VCN{}, fmt.Errorf("create vcn: displayName and cidrBlock are required") + } + cred, err := s.credentialsInCompartment(ctx, id, in.CompartmentID) + if err != nil { + return oci.VCN{}, err + } + return s.client.CreateVCN(ctx, cred, in) +} + +// VCN 查询单个 VCN。 +func (s *OciConfigService) VCN(ctx context.Context, id uint, region, vcnID string) (oci.VCN, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.VCN{}, err + } + return s.client.GetVCN(ctx, cred, region, vcnID) +} + +// UpdateVCN 重命名 VCN。 +func (s *OciConfigService) UpdateVCN(ctx context.Context, id uint, region, vcnID, displayName string) (oci.VCN, error) { + if displayName == "" { + return oci.VCN{}, fmt.Errorf("update vcn: displayName is required") + } + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.VCN{}, err + } + return s.client.UpdateVCN(ctx, cred, region, vcnID, displayName) +} + +// DeleteVCN 删除 VCN。 +func (s *OciConfigService) DeleteVCN(ctx context.Context, id uint, region, vcnID string) error { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return err + } + return s.client.DeleteVCN(ctx, cred, region, vcnID) +} + +// EnableVCNIPv6 为 VCN 一键启用 IPv6,返回逐步执行报告。 +func (s *OciConfigService) EnableVCNIPv6(ctx context.Context, id uint, region, vcnID string) ([]oci.IPv6Step, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return nil, err + } + return s.client.EnableVCNIPv6(ctx, cred, region, vcnID) +} + +// Subnets 列出子网;vcnID 非空时按其所在 compartment 查询, +// 否则 compartmentID 为空表示租户根。 +func (s *OciConfigService) Subnets(ctx context.Context, id uint, region, vcnID, compartmentID string) ([]oci.Subnet, error) { + cred, err := s.credentialsInCompartment(ctx, id, compartmentID) + if err != nil { + return nil, err + } + return s.client.ListSubnets(ctx, cred, region, vcnID) +} + +// CreateSubnet 创建子网。 +func (s *OciConfigService) CreateSubnet(ctx context.Context, id uint, in oci.CreateSubnetInput) (oci.Subnet, error) { + if in.VcnID == "" || in.DisplayName == "" || in.CidrBlock == "" { + return oci.Subnet{}, fmt.Errorf("create subnet: vcnId, displayName and cidrBlock are required") + } + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.Subnet{}, err + } + return s.client.CreateSubnet(ctx, cred, in) +} + +// Subnet 查询单个子网。 +func (s *OciConfigService) Subnet(ctx context.Context, id uint, region, subnetID string) (oci.Subnet, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.Subnet{}, err + } + return s.client.GetSubnet(ctx, cred, region, subnetID) +} + +// UpdateSubnet 重命名子网。 +func (s *OciConfigService) UpdateSubnet(ctx context.Context, id uint, region, subnetID, displayName string) (oci.Subnet, error) { + if displayName == "" { + return oci.Subnet{}, fmt.Errorf("update subnet: displayName is required") + } + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.Subnet{}, err + } + return s.client.UpdateSubnet(ctx, cred, region, subnetID, displayName) +} + +// DeleteSubnet 删除子网。 +func (s *OciConfigService) DeleteSubnet(ctx context.Context, id uint, region, subnetID string) error { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return err + } + return s.client.DeleteSubnet(ctx, cred, region, subnetID) +} + +// SecurityLists 列出安全列表;vcnID 非空时按其所在 compartment 查询, +// 否则 compartmentID 为空表示租户根。 +func (s *OciConfigService) SecurityLists(ctx context.Context, id uint, region, vcnID, compartmentID string) ([]oci.SecurityList, error) { + cred, err := s.credentialsInCompartment(ctx, id, compartmentID) + if err != nil { + return nil, err + } + return s.client.ListSecurityLists(ctx, cred, region, vcnID) +} + +// CreateSecurityList 创建安全列表。 +func (s *OciConfigService) CreateSecurityList(ctx context.Context, id uint, in oci.CreateSecurityListInput) (oci.SecurityList, error) { + if in.VcnID == "" || in.DisplayName == "" { + return oci.SecurityList{}, fmt.Errorf("create security list: vcnId and displayName are required") + } + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.SecurityList{}, err + } + return s.client.CreateSecurityList(ctx, cred, in) +} + +// SecurityList 查询单个安全列表。 +func (s *OciConfigService) SecurityList(ctx context.Context, id uint, region, securityListID string) (oci.SecurityList, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.SecurityList{}, err + } + return s.client.GetSecurityList(ctx, cred, region, securityListID) +} + +// UpdateSecurityList 更新安全列表;规则字段一经提供即整体替换。 +func (s *OciConfigService) UpdateSecurityList(ctx context.Context, id uint, securityListID string, in oci.UpdateSecurityListInput) (oci.SecurityList, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.SecurityList{}, err + } + return s.client.UpdateSecurityList(ctx, cred, securityListID, in) +} + +// DeleteSecurityList 删除安全列表。 +func (s *OciConfigService) DeleteSecurityList(ctx context.Context, id uint, region, securityListID string) error { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return err + } + return s.client.DeleteSecurityList(ctx, cred, region, securityListID) +} diff --git a/internal/service/notify.go b/internal/service/notify.go new file mode 100644 index 0000000..b5935d1 --- /dev/null +++ b/internal/service/notify.go @@ -0,0 +1,209 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "html" + "log" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// notifySendTimeout 是单条通知发送的超时时间。 +const notifySendTimeout = 10 * time.Second + +// notifyTextLimit 是通知正文长度上限(Telegram 上限 4096 字符,预留转义余量)。 +const notifyTextLimit = 3800 + +// telegramAPIBase 是 Telegram Bot API 官方根地址。 +const telegramAPIBase = "https://api.telegram.org" + +// Notifier 通过 Telegram Bot API 推送通知;未启用或未配置时静默跳过。 +// 云外 HTTP 调用,不属于 internal/oci 的职责范围。 +type Notifier struct { + settings *SettingService + client *http.Client + base string // API 根地址,测试时注入 httptest 服务 + wg sync.WaitGroup +} + +// NewNotifier 组装依赖,指向官方 API 地址。 +func NewNotifier(settings *SettingService) *Notifier { + return &Notifier{ + settings: settings, + client: &http.Client{Timeout: notifySendTimeout}, + base: telegramAPIBase, + } +} + +// Send 读取配置并发送一条消息;未启用或未配置时直接返回 nil。 +func (n *Notifier) Send(ctx context.Context, text string) error { + cfg, err := n.settings.TelegramConfig(ctx) + if err != nil { + return err + } + if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" { + return nil + } + return n.post(ctx, cfg, text) +} + +// SendAsync 异步发送并自带超时,失败只记日志,绝不影响调用链路。 +func (n *Notifier) SendAsync(text string) { + n.wg.Add(1) + go func() { + defer n.wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), notifySendTimeout) + defer cancel() + if err := n.Send(ctx, text); err != nil { + log.Printf("telegram notify: %v", err) + } + }() +} + +// SendTemplateAsync 按 kind 的生效模板(自定义或默认)渲染变量后异步发送; +// 模板支持轻量 Markdown,按 Telegram HTML 语义投递。 +func (n *Notifier) SendTemplateAsync(kind string, vars map[string]string) { + n.wg.Add(1) + go func() { + defer n.wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), notifySendTimeout) + defer cancel() + if err := n.sendTemplate(ctx, kind, "", vars); err != nil { + log.Printf("telegram notify %s: %v", kind, err) + } + }() +} + +// SendTemplateTest 用 kind 的示例变量同步发送一条测试消息; +// tplOverride 非空时按给定模板渲染(供编辑表单预览未保存内容)。 +func (n *Notifier) SendTemplateTest(ctx context.Context, kind, tplOverride string) error { + def, ok := notifyTplDefs[kind] + if !ok { + return fmt.Errorf("未知通知类型 %q", kind) + } + cfg, err := n.settings.TelegramConfig(ctx) + if err != nil { + return err + } + if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" { + return fmt.Errorf("Telegram 通知未启用或未配置,请先在「通知方式」保存配置") + } + vars := map[string]string{} + for k, v := range def.Sample { + vars[k] = v + } + return n.sendTemplate(ctx, kind, tplOverride, vars) +} + +// sendTemplate 渲染并发送:取生效模板 → 变量替换 → 截断 → Markdown 转 Telegram HTML。 +func (n *Notifier) sendTemplate(ctx context.Context, kind, tplOverride string, vars map[string]string) error { + cfg, err := n.settings.TelegramConfig(ctx) + if err != nil { + return err + } + if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" { + return nil + } + tpl := tplOverride + if strings.TrimSpace(tpl) == "" { + custom, err := n.settings.NotifyTemplate(ctx, kind) + if err != nil { + log.Printf("notify template %s: %v", kind, err) + } + tpl = notifyTplText(custom, kind) + } + text := renderNotifyTemplate(tpl, vars) + return n.postHTML(ctx, cfg, mdToTelegramHTML(truncateText(text, notifyTextLimit))) +} + +// Wait 等待全部在途异步通知发完,供退出前收尾与测试同步。 +func (n *Notifier) Wait() { + n.wg.Wait() +} + +// Test 同步发送一条测试消息,配置无效时透出可读错误。 +func (n *Notifier) Test(ctx context.Context) error { + cfg, err := n.settings.TelegramConfig(ctx) + if err != nil { + return err + } + if cfg.BotToken == "" || cfg.ChatID == "" { + return fmt.Errorf("telegram test: bot token and chat id are required") + } + return n.post(ctx, cfg, "✅ oci-portal 测试消息:Telegram 通知配置成功") +} + +// post 调用 sendMessage;先截断再做 HTML 转义,避免把转义实体截成两半。 +func (n *Notifier) post(ctx context.Context, cfg TelegramConfig, text string) error { + return n.postHTML(ctx, cfg, html.EscapeString(truncateText(text, notifyTextLimit))) +} + +// postHTML 调用 sendMessage 发送已按 Telegram HTML 组装的文本(调用方保证转义)。 +func (n *Notifier) postHTML(ctx context.Context, cfg TelegramConfig, htmlText string) error { + body, err := json.Marshal(map[string]any{ + "chat_id": cfg.ChatID, + "text": htmlText, + "parse_mode": "HTML", + "disable_web_page_preview": true, + }) + if err != nil { + return fmt.Errorf("telegram send: %w", err) + } + apiURL := fmt.Sprintf("%s/bot%s/sendMessage", n.base, cfg.BotToken) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("telegram send: %w", sanitizeURLError(err)) + } + req.Header.Set("Content-Type", "application/json") + resp, err := n.client.Do(req) + if err != nil { + return fmt.Errorf("telegram send: %w", sanitizeURLError(err)) + } + defer resp.Body.Close() + return parseTelegramResponse(resp) +} + +// telegramResponse 是 Telegram Bot API 的通用响应包。 +type telegramResponse struct { + OK bool `json:"ok"` + Description string `json:"description"` +} + +// parseTelegramResponse 解析响应;ok=false 时透出 Telegram 的错误描述。 +func parseTelegramResponse(resp *http.Response) error { + var r telegramResponse + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return fmt.Errorf("telegram response (%d): %w", resp.StatusCode, err) + } + if !r.OK { + return fmt.Errorf("telegram: %s", r.Description) + } + return nil +} + +// sanitizeURLError 剥掉 *url.Error 外壳只保留底层原因: +// 其 Error() 会拼出完整请求 URL,而路径中含 bot token, +// 原样向上返回会把 token 泄漏进日志与接口响应。 +func sanitizeURLError(err error) error { + var uerr *url.Error + if errors.As(err, &uerr) { + return uerr.Err + } + return err +} + +// truncateText 按 rune 截断超长文本,追加省略号提示。 +func truncateText(text string, limit int) string { + runes := []rune(text) + if len(runes) <= limit { + return text + } + return string(runes[:limit]) + "…" +} diff --git a/internal/service/notify_test.go b/internal/service/notify_test.go new file mode 100644 index 0000000..4fe6986 --- /dev/null +++ b/internal/service/notify_test.go @@ -0,0 +1,292 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "oci-portal/internal/model" +) + +// telegramCapture 记录假 Telegram 服务收到的 sendMessage 请求。 +type telegramCapture struct { + mu sync.Mutex + paths []string + texts []string +} + +// snapshot 返回目前收到的全部文本副本。 +func (r *telegramCapture) snapshot() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.texts...) +} + +// newFakeTelegram 起一个假 Telegram API,固定返回 reply 响应体。 +func newFakeTelegram(t *testing.T, reply string) (*httptest.Server, *telegramCapture) { + t.Helper() + rec := &telegramCapture{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload struct { + Text string `json:"text"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + rec.mu.Lock() + rec.paths = append(rec.paths, r.URL.Path) + rec.texts = append(rec.texts, payload.Text) + rec.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(reply)) + })) + t.Cleanup(srv.Close) + return srv, rec +} + +// newTestNotifier 组装指向假 Telegram 服务的 Notifier;in 为 nil 表示不预置配置。 +func newTestNotifier(t *testing.T, base string, in *UpdateTelegramInput) *Notifier { + t.Helper() + settings, _ := newSettingEnv(t) + if in != nil { + if err := settings.UpdateTelegram(context.Background(), *in); err != nil { + t.Fatalf("update telegram: %v", err) + } + } + n := NewNotifier(settings) + n.base = base + return n +} + +func TestNotifierSend(t *testing.T) { + token := "123456:AAfake" + tests := []struct { + name string + setup *UpdateTelegramInput + reply string + wantSent bool + wantErr string // 空串表示期望无错误 + }{ + { + name: "启用且配置完整时发送", + setup: &UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"}, + reply: `{"ok":true}`, + wantSent: true, + }, + { + name: "未启用时跳过", + setup: &UpdateTelegramInput{Enabled: false, BotToken: &token, ChatID: "42"}, + reply: `{"ok":true}`, + }, + { + name: "未配置 token 时跳过", + setup: &UpdateTelegramInput{Enabled: true, ChatID: "42"}, + reply: `{"ok":true}`, + }, + { + name: "完全未配置时跳过", + reply: `{"ok":true}`, + }, + { + name: "telegram 报错时透出 description", + setup: &UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"}, + reply: `{"ok":false,"error_code":401,"description":"Unauthorized"}`, + wantSent: true, + wantErr: "Unauthorized", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv, rec := newFakeTelegram(t, tt.reply) + n := newTestNotifier(t, srv.URL, tt.setup) + err := n.Send(context.Background(), "hi &") + if tt.wantErr == "" && err != nil { + t.Fatalf("Send: %v, want nil", err) + } + if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) { + t.Fatalf("Send err = %v, want contains %q", err, tt.wantErr) + } + texts := rec.snapshot() + if sent := len(texts) > 0; sent != tt.wantSent { + t.Fatalf("sent = %v (texts %v), want %v", sent, texts, tt.wantSent) + } + if tt.wantSent { + if got, want := rec.paths[0], "/bot"+token+"/sendMessage"; got != want { + t.Errorf("path = %q, want %q", got, want) + } + if got, want := texts[0], "hi <b>&"; got != want { + t.Errorf("text = %q, want HTML 转义后 %q", got, want) + } + } + }) + } +} + +func TestNotifierTest(t *testing.T) { + token := "123456:AAfake" + tests := []struct { + name string + setup *UpdateTelegramInput + reply string + wantErr string + }{ + { + name: "配置有效时发送成功", + setup: &UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"}, + reply: `{"ok":true}`, + }, + { + name: "未配置时报可读错误", + wantErr: "bot token and chat id are required", + }, + { + name: "chat 不存在时透出描述", + setup: &UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"}, + reply: `{"ok":false,"error_code":400,"description":"Bad Request: chat not found"}`, + wantErr: "chat not found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv, _ := newFakeTelegram(t, tt.reply) + n := newTestNotifier(t, srv.URL, tt.setup) + err := n.Test(context.Background()) + if tt.wantErr == "" && err != nil { + t.Fatalf("Test: %v, want nil", err) + } + if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) { + t.Fatalf("Test err = %v, want contains %q", err, tt.wantErr) + } + }) + } +} + +// TestNotifierSendErrorHidesToken 验证网络层失败的错误信息不含 bot token: +// *url.Error 会拼出完整请求 URL(路径含 token),必须剥壳后再向上返回。 +func TestNotifierSendErrorHidesToken(t *testing.T) { + token := "123456:AAfake" + srv, _ := newFakeTelegram(t, `{"ok":true}`) + base := srv.URL + srv.Close() // 立刻关闭:请求必然连接失败 + n := newTestNotifier(t, base, &UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"}) + err := n.Send(context.Background(), "hi") + if err == nil { + t.Fatal("Send = nil, want connection error") + } + if strings.Contains(err.Error(), token) { + t.Errorf("err = %v, 错误信息泄漏了 bot token", err) + } +} + +func TestNotifyEvents(t *testing.T) { + tests := []struct { + name string + prev taskSnapshot + cur taskSnapshot + want []notifyEvent // Kind 精确匹配,Vars 为需包含的变量键值;空表示不发 + }{ + { + name: "成功转失败发失败通知", + prev: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive}, + cur: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive, LastError: "Out of host capacity"}, + want: []notifyEvent{{Kind: notifyTaskFail, Vars: map[string]string{"task_name": "抢机", "error": "Out of host capacity"}}}, + }, + { + name: "连续失败不重复发", + prev: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive, LastError: "Out of host capacity"}, + cur: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive, LastError: "Out of host capacity"}, + want: nil, + }, + { + name: "失败换了错误内容也不重复发", + prev: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive, LastError: "capacity"}, + cur: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive, LastError: "timeout"}, + want: nil, + }, + { + name: "失败恢复成功发恢复通知", + prev: taskSnapshot{Name: "测活", Status: model.TaskStatusActive, LastError: "boom"}, + cur: taskSnapshot{Name: "测活", Status: model.TaskStatusActive}, + want: []notifyEvent{{Kind: notifyTaskRecover, Vars: map[string]string{"task_name": "测活"}}}, + }, + { + name: "持续正常不发", + prev: taskSnapshot{Name: "测活", Status: model.TaskStatusActive}, + cur: taskSnapshot{Name: "测活", Status: model.TaskStatusActive}, + want: nil, + }, + { + name: "非成功转成功发抢机成功", + prev: taskSnapshot{Name: "抢A1", Status: model.TaskStatusActive}, + cur: taskSnapshot{Name: "抢A1", Status: model.TaskStatusSucceeded, Message: "created 1: ocid1"}, + want: []notifyEvent{{Kind: notifySnatchSuccess, Vars: map[string]string{"task_name": "抢A1", "message": "created 1: ocid1"}}}, + }, + { + name: "失败后直接抢满只发抢机成功不叠加恢复", + prev: taskSnapshot{Name: "抢A1", Status: model.TaskStatusActive, LastError: "capacity"}, + cur: taskSnapshot{Name: "抢A1", Status: model.TaskStatusSucceeded, Message: "created 1: ocid1"}, + want: []notifyEvent{{Kind: notifySnatchSuccess, Vars: map[string]string{"task_name": "抢A1", "message": "created 1: ocid1"}}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := notifyEvents(tt.prev, tt.cur) + if len(got) != len(tt.want) { + t.Fatalf("events = %v (%d 条), want %d 条", got, len(got), len(tt.want)) + } + for i, w := range tt.want { + if got[i].Kind != w.Kind { + t.Errorf("event[%d].Kind = %q, want %q", i, got[i].Kind, w.Kind) + } + for k, v := range w.Vars { + if got[i].Vars[k] != v { + t.Errorf("event[%d].Vars[%s] = %q, want %q", i, k, got[i].Vars[k], v) + } + } + } + }) + } +} + +func TestSameStringSet(t *testing.T) { + tests := []struct { + name string + a, b []string + want bool + }{ + {name: "都为空", a: nil, b: nil, want: true}, + {name: "顺序不同视为相同", a: []string{"x", "y"}, b: []string{"y", "x"}, want: true}, + {name: "长度不同", a: []string{"x"}, b: []string{"x", "y"}, want: false}, + {name: "元素不同", a: []string{"x"}, b: []string{"y"}, want: false}, + {name: "重复元素次数不同", a: []string{"x", "y"}, b: []string{"x", "x"}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sameStringSet(tt.a, tt.b); got != tt.want { + t.Errorf("sameStringSet(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want) + } + }) + } +} + +func TestTruncateText(t *testing.T) { + tests := []struct { + name string + text string + limit int + want string + }{ + {name: "未超限原样返回", text: "hello", limit: 10, want: "hello"}, + {name: "超限截断加省略号", text: "hello", limit: 3, want: "hel…"}, + {name: "多字节按 rune 截断", text: "抢机成功", limit: 2, want: "抢机…"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := truncateText(tt.text, tt.limit); got != tt.want { + t.Errorf("truncateText(%q, %d) = %q, want %q", tt.text, tt.limit, got, tt.want) + } + }) + } +} diff --git a/internal/service/notifytpl.go b/internal/service/notifytpl.go new file mode 100644 index 0000000..4dbc74f --- /dev/null +++ b/internal/service/notifytpl.go @@ -0,0 +1,163 @@ +package service + +import ( + "html" + "regexp" + "strings" + "time" +) + +// notifyTplDef 描述一类通知的模板元数据与示例变量(测试发送用)。 +// 所有模板额外提供公共变量 {{time}}(发送时刻)。 +type notifyTplDef struct { + Label string + Default string + Vars []string + Sample map[string]string +} + +// notifyTplOrder 是模板列表的稳定输出顺序(与通知管理事件一致)。 +var notifyTplOrder = []string{ + "task_fail", "task_recover", "snatch_success", "tenant_dead", "task_stop", + "login_lock", "model_deprecated", + "log_event_instance", "log_event_identity", "log_event_policy", "log_event_region", "log_event_login", +} + +var notifyTplDefs = map[string]notifyTplDef{ + "task_fail": { + Label: "任务失败", + Default: "❌ 任务失败:{{task_name}}\n{{error}}", + Vars: []string{"task_name", "error"}, + Sample: map[string]string{"task_name": "示例测活任务", "error": "鉴权失败: 401 NotAuthenticated"}, + }, + "task_recover": { + Label: "任务恢复", + Default: "✅ 任务恢复:{{task_name}}", + Vars: []string{"task_name"}, + Sample: map[string]string{"task_name": "示例测活任务"}, + }, + "snatch_success": { + Label: "抢机成功", + Default: "🎉 抢机成功:{{task_name}}\n{{message}}", + Vars: []string{"task_name", "message"}, + Sample: map[string]string{"task_name": "示例抢机任务", "message": "已创建 1/1 台实例"}, + }, + "tenant_dead": { + Label: "租户失联", + Default: "⚠️ 租户失联:{{tenants}}", + Vars: []string{"tenants"}, + Sample: map[string]string{"tenants": "免费01、免费02"}, + }, + "task_stop": { + Label: "任务停止", + Default: "⛔ 任务已停止:{{task_name}}\n{{error}}", + Vars: []string{"task_name", "error"}, + Sample: map[string]string{"task_name": "示例抢机任务", "error": "连续鉴权失败 5 次,任务熔断"}, + }, + "login_lock": { + Label: "登录锁定", + Default: "🚫 登录锁定:用户 {{username}}(来自 {{ip}})连续失败 {{fail_count}} 次,已锁定 {{lock_minutes}} 分钟", + Vars: []string{"username", "ip", "fail_count", "lock_minutes"}, + Sample: map[string]string{"username": "admin", "ip": "203.0.113.8", "fail_count": "5", "lock_minutes": "15"}, + }, + "model_deprecated": { + Label: "模型弃用预警", + Default: "⚠️ AI 网关:以下在池模型即将被 OCI 退役或弃用(退役后无法调用,弃用仅为预告)\n{{models}}", + Vars: []string{"models"}, + Sample: map[string]string{"models": "meta.llama-3.1-70b-instruct(2026-08-01 退役,届时无法调用)"}, + }, + "log_event_instance": { + Label: "实例生命周期", + Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}", + Vars: []string{"tenant", "event", "resource"}, + Sample: map[string]string{"tenant": "免费01", "event": "TerminateInstance", "resource": "web-server-1"}, + }, + "log_event_identity": { + Label: "用户与凭据", + Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}", + Vars: []string{"tenant", "event", "resource"}, + Sample: map[string]string{"tenant": "免费01", "event": "CreateApiKey", "resource": "user/demo"}, + }, + "log_event_policy": { + Label: "策略变更", + Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}", + Vars: []string{"tenant", "event", "resource"}, + Sample: map[string]string{"tenant": "免费01", "event": "UpdatePolicy", "resource": "admin-policy"}, + }, + "log_event_region": { + Label: "区域订阅", + Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}", + Vars: []string{"tenant", "event", "resource"}, + Sample: map[string]string{"tenant": "免费01", "event": "CreateRegionSubscription", "resource": "ap-osaka-1"}, + }, + "log_event_login": { + Label: "控制台登录", + Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}", + Vars: []string{"tenant", "event", "resource"}, + Sample: map[string]string{"tenant": "免费01", "event": "InteractiveLogin", "resource": "demo@example.com"}, + }, +} + +var notifyVarPattern = regexp.MustCompile(`\{\{(\w+)\}\}`) + +// renderNotifyTemplate 用 {{var}} 占位符渲染模板;未知变量原样保留, +// 公共变量 time 自动注入(调用方未提供时)。 +func renderNotifyTemplate(tpl string, vars map[string]string) string { + if _, ok := vars["time"]; !ok { + if vars == nil { + vars = map[string]string{} + } + vars["time"] = time.Now().Format("2006-01-02 15:04:05") + } + return notifyVarPattern.ReplaceAllStringFunc(tpl, func(m string) string { + if v, ok := vars[m[2:len(m)-2]]; ok { + return v + } + return m + }) +} + +var ( + notifyMdPre = regexp.MustCompile("(?s)```\n?(.+?)\n?```") + notifyMdCode = regexp.MustCompile("`([^`\n]+)`") + notifyMdBold = regexp.MustCompile(`\*([^*\n]+)\*`) + notifyMdItalic = regexp.MustCompile(`_([^_\n]+)_`) +) + +// mdToTelegramHTML 把轻量 Markdown(*粗体* / _斜体_ / `代码` / ```代码块```)转为 +// Telegram HTML;先整体 HTML 转义,未闭合标记原样保留,天然不产生残缺标签。 +func mdToTelegramHTML(text string) string { + out := html.EscapeString(text) + out = notifyMdPre.ReplaceAllString(out, "
$1
") + out = notifyMdCode.ReplaceAllString(out, "$1") + out = notifyMdBold.ReplaceAllString(out, "$1") + out = notifyMdItalic.ReplaceAllString(out, "$1") + return out +} + +// NotifyTemplateView 是模板设置接口的列表项。 +type NotifyTemplateView struct { + Kind string `json:"kind"` + Label string `json:"label"` + Vars []string `json:"vars"` + DefaultTemplate string `json:"defaultTemplate"` + // Template 是自定义模板;空串表示未自定义(按默认发送) + Template string `json:"template"` +} + +// notifyTplKey 是 kind 对应的 Setting 键。 +func notifyTplKey(kind string) string { return "notify_tpl_" + kind } + +// notifyTplValid 报告 kind 是否为已知通知类型。 +func notifyTplValid(kind string) bool { + _, ok := notifyTplDefs[kind] + return ok +} + +// notifyTplText 返回 kind 生效模板:自定义非空用自定义,否则默认。 +func notifyTplText(custom, kind string) string { + if strings.TrimSpace(custom) != "" { + return custom + } + return notifyTplDefs[kind].Default +} diff --git a/internal/service/notifytpl_test.go b/internal/service/notifytpl_test.go new file mode 100644 index 0000000..441fe18 --- /dev/null +++ b/internal/service/notifytpl_test.go @@ -0,0 +1,76 @@ +package service + +import ( + "context" + "strings" + "testing" +) + +func TestRenderNotifyTemplate(t *testing.T) { + out := renderNotifyTemplate("❌ {{task_name}}\n{{error}}\n{{unknown}}", map[string]string{ + "task_name": "抢机", "error": "boom", + }) + if !strings.Contains(out, "抢机") || !strings.Contains(out, "boom") { + t.Errorf("渲染缺变量: %q", out) + } + if !strings.Contains(out, "{{unknown}}") { + t.Errorf("未知变量应原样保留: %q", out) + } + // 公共变量 time 自动注入 + out = renderNotifyTemplate("at {{time}}", nil) + if strings.Contains(out, "{{time}}") { + t.Errorf("time 未注入: %q", out) + } +} + +func TestMdToTelegramHTML(t *testing.T) { + got := mdToTelegramHTML("*任务失败*:`err<1>` _注意_\n```\nraw & code\n```") + for _, want := range []string{"任务失败", "err<1>", "注意", "
raw & code
"} { + if !strings.Contains(got, want) { + t.Errorf("got %q, want contains %q", got, want) + } + } + // 未闭合标记原样保留,不产生残缺标签 + if got := mdToTelegramHTML("孤立*星号 和 `反引号"); strings.Contains(got, "") || strings.Contains(got, "") { + t.Errorf("未闭合标记不应转换: %q", got) + } +} + +func TestNotifyTemplateReadWrite(t *testing.T) { + svc, _ := newSettingEnv(t) + ctx := context.Background() + + items, err := svc.NotifyTemplates(ctx) + if err != nil || len(items) != len(notifyTplOrder) { + t.Fatalf("NotifyTemplates = %d 项, %v", len(items), err) + } + if items[0].Kind != "task_fail" || items[0].DefaultTemplate == "" || items[0].Template != "" { + t.Errorf("默认视图 = %+v", items[0]) + } + // 保存自定义 → 读回 + if err := svc.UpdateNotifyTemplate(ctx, "task_fail", "自定义 {{task_name}}"); err != nil { + t.Fatalf("UpdateNotifyTemplate: %v", err) + } + custom, _ := svc.NotifyTemplate(ctx, "task_fail") + if custom != "自定义 {{task_name}}" { + t.Errorf("读回 = %q", custom) + } + if notifyTplText(custom, "task_fail") != "自定义 {{task_name}}" { + t.Error("生效模板应为自定义") + } + // 清空恢复默认 + if err := svc.UpdateNotifyTemplate(ctx, "task_fail", ""); err != nil { + t.Fatalf("清空: %v", err) + } + custom, _ = svc.NotifyTemplate(ctx, "task_fail") + if notifyTplText(custom, "task_fail") != notifyTplDefs["task_fail"].Default { + t.Error("清空后应回落默认模板") + } + // 未知 kind / 超长拒绝 + if err := svc.UpdateNotifyTemplate(ctx, "nope", "x"); err == nil { + t.Error("未知 kind 应被拒绝") + } + if err := svc.UpdateNotifyTemplate(ctx, "task_fail", strings.Repeat("字", 2001)); err == nil { + t.Error("超长模板应被拒绝") + } +} diff --git a/internal/service/oauth.go b/internal/service/oauth.go new file mode 100644 index 0000000..5a7c5eb --- /dev/null +++ b/internal/service/oauth.go @@ -0,0 +1,349 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + githubep "golang.org/x/oauth2/github" + "gorm.io/gorm" + + "oci-portal/internal/model" +) + +// OAuth 流程错误;api 层映射为用户可读的回跳提示。 +var ( + // ErrOAuthNotConfigured 表示 provider 未配置(clientID 缺失)。 + ErrOAuthNotConfigured = errors.New("该登录方式未配置") + // ErrOAuthNoAppURL 表示面板地址缺失,回调 URL 无从拼接。 + ErrOAuthNoAppURL = errors.New("面板地址未设置,请先在「设置 → 安全 → 网络与地址」保存面板地址") + // ErrOAuthDisabled 表示 provider 已被禁用,登录入口不可用(绑定不受影响)。 + ErrOAuthDisabled = errors.New("该登录方式已禁用") + // ErrOAuthState 表示 state 无效或已过期(CSRF 防护)。 + ErrOAuthState = errors.New("授权状态无效或已过期,请重新发起") + // ErrOAuthNotBound 表示外部身份未绑定任何账号,拒绝登录。 + ErrOAuthNotBound = errors.New("该外部身份未绑定面板账号,请先登录后在设置中绑定") + // ErrOAuthBound 表示身份已被绑定(重复绑定)。 + ErrOAuthBound = errors.New("该外部身份已绑定过") +) + +// oauthPendingTTL 是授权流程 state 的有效期。 +const oauthPendingTTL = 10 * time.Minute + +// oauthPending 是一次进行中的授权流程上下文;state 一次性使用。 +type oauthPending struct { + provider string + mode string // "login" / "bind" + username string // bind 模式的绑定目标账号 + nonce string // OIDC 防 id_token 重放 + expires time.Time +} + +// OAuthService 承接外部身份登录与绑定(OIDC / GitHub,探索文档主题二)。 +type OAuthService struct { + db *gorm.DB + settings *SettingService + auth *AuthService + + mu sync.Mutex + pending map[string]oauthPending +} + +// NewOAuthService 组装依赖。 +func NewOAuthService(db *gorm.DB, settings *SettingService, auth *AuthService) *OAuthService { + return &OAuthService{db: db, settings: settings, auth: auth, pending: map[string]oauthPending{}} +} + +// ProviderInfo 是登录页公开的 provider 信息(displayName 空配置时为默认名)。 +type ProviderInfo struct { + Provider string `json:"provider"` + DisplayName string `json:"displayName"` +} + +// Providers 返回可登录的 provider 列表(clientID 非空且未禁用),登录页据此显示按钮。 +func (o *OAuthService) Providers(ctx context.Context) []ProviderInfo { + out := []ProviderInfo{} + for _, p := range []string{"oidc", "github"} { + id, _, _, err := o.settings.oauthClient(ctx, p) + if err != nil || id == "" { + continue + } + display, disabled, err := o.settings.oauthProviderMeta(ctx, p) + if err != nil || disabled { + continue + } + out = append(out, ProviderInfo{Provider: p, DisplayName: display}) + } + return out +} + +// randHex 生成 n 字节随机数的 hex 编码。 +func randHex(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("random: %w", err) + } + return hex.EncodeToString(buf), nil +} + +// callbackURL 是 provider 回调地址,固定拼自面板地址(白名单即此一条)。 +func (o *OAuthService) callbackURL(provider string) string { + return o.settings.EffectiveAppURL() + "/api/v1/auth/oauth/" + provider + "/callback" +} + +// oauth2Config 构造 provider 的 oauth2 配置;oidc 时一并返回已发现的 provider。 +func (o *OAuthService) oauth2Config(ctx context.Context, provider string) (*oauth2.Config, *oidc.Provider, error) { + clientID, secret, issuer, err := o.settings.oauthClient(ctx, provider) + if err != nil { + return nil, nil, err + } + if clientID == "" { + return nil, nil, ErrOAuthNotConfigured + } + if o.settings.EffectiveAppURL() == "" { + return nil, nil, ErrOAuthNoAppURL + } + cfg := &oauth2.Config{ClientID: clientID, ClientSecret: secret, RedirectURL: o.callbackURL(provider)} + if provider == "github" { + cfg.Endpoint = githubep.Endpoint + cfg.Scopes = []string{"read:user"} + return cfg, nil, nil + } + op, err := oidc.NewProvider(ctx, issuer) + if err != nil { + return nil, nil, fmt.Errorf("oidc discovery: %w", err) + } + cfg.Endpoint = op.Endpoint() + cfg.Scopes = []string{oidc.ScopeOpenID, "email", "profile"} + return cfg, op, nil +} + +// AuthorizeURL 构造授权跳转 URL 并登记一次性 state;mode 为 bind 时 username 必填。 +// login 模式拒绝已禁用的 provider;bind 模式不受禁用影响(管理员仍可绑定)。 +func (o *OAuthService) AuthorizeURL(ctx context.Context, provider, mode, username string) (string, error) { + if mode == "login" { + if _, disabled, err := o.settings.oauthProviderMeta(ctx, provider); err == nil && disabled { + return "", ErrOAuthDisabled + } + } + cfg, _, err := o.oauth2Config(ctx, provider) + if err != nil { + return "", err + } + state, err := randHex(16) + if err != nil { + return "", err + } + nonce, err := randHex(16) + if err != nil { + return "", err + } + o.mu.Lock() + o.gcPendingLocked() + o.pending[state] = oauthPending{provider: provider, mode: mode, username: username, nonce: nonce, expires: time.Now().Add(oauthPendingTTL)} + o.mu.Unlock() + opts := []oauth2.AuthCodeOption{} + if provider == "oidc" { + opts = append(opts, oidc.Nonce(nonce)) + } + return cfg.AuthCodeURL(state, opts...), nil +} + +// gcPendingLocked 清理过期流程;调用方须持锁。 +func (o *OAuthService) gcPendingLocked() { + now := time.Now() + for k, p := range o.pending { + if now.After(p.expires) { + delete(o.pending, k) + } + } +} + +// takeState 取出并消费 state(一次性);provider 不匹配或过期视为无效。 +func (o *OAuthService) takeState(provider, state string) (oauthPending, error) { + o.mu.Lock() + defer o.mu.Unlock() + p, ok := o.pending[state] + delete(o.pending, state) + if !ok || p.provider != provider || time.Now().After(p.expires) { + return oauthPending{}, ErrOAuthState + } + return p, nil +} + +// externalIdentity 是从 provider 换回的外部身份。 +type externalIdentity struct { + Subject string + Display string +} + +// HandleCallback 完成授权码回调:换取身份后,bind 模式写绑定、login 模式签发 JWT; +// token 仅 login 模式非空;mode 尽力返回(state 无效时为空),供 api 决定错误回跳页面。 +func (o *OAuthService) HandleCallback(ctx context.Context, provider, state, code string) (token, display, mode string, err error) { + p, err := o.takeState(provider, state) + if err != nil { + return "", "", "", err + } + ident, err := o.fetchIdentity(ctx, provider, code, p.nonce) + if err != nil { + return "", "", p.mode, err + } + if p.mode == "bind" { + return "", ident.Display, p.mode, o.bind(ctx, p.username, provider, ident) + } + token, display, err = o.loginByIdentity(ctx, provider, ident) + return token, display, p.mode, err +} + +// fetchIdentity 用授权码向 provider 换取稳定 subject 与展示名。 +func (o *OAuthService) fetchIdentity(ctx context.Context, provider, code, nonce string) (externalIdentity, error) { + cfg, op, err := o.oauth2Config(ctx, provider) + if err != nil { + return externalIdentity{}, err + } + tok, err := cfg.Exchange(ctx, code) + if err != nil { + return externalIdentity{}, fmt.Errorf("exchange code: %w", err) + } + if provider == "github" { + return githubIdentity(ctx, cfg, tok) + } + return oidcIdentity(ctx, cfg, op, tok, nonce) +} + +// githubIdentity 调 GitHub /user 拿数字 id(login 可改名,不可作 subject)。 +func githubIdentity(ctx context.Context, cfg *oauth2.Config, tok *oauth2.Token) (externalIdentity, error) { + client := cfg.Client(ctx, tok) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user", nil) + if err != nil { + return externalIdentity{}, fmt.Errorf("github user request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return externalIdentity{}, fmt.Errorf("github user: %w", sanitizeURLError(err)) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return externalIdentity{}, fmt.Errorf("github user: status %d", resp.StatusCode) + } + var u struct { + ID int64 `json:"id"` + Login string `json:"login"` + } + if err := json.NewDecoder(resp.Body).Decode(&u); err != nil || u.ID == 0 { + return externalIdentity{}, fmt.Errorf("github user: invalid response") + } + return externalIdentity{Subject: fmt.Sprintf("%d", u.ID), Display: u.Login}, nil +} + +// oidcIdentity 验证 id_token 签名 / audience / nonce,取 sub 作 subject。 +func oidcIdentity(ctx context.Context, cfg *oauth2.Config, op *oidc.Provider, tok *oauth2.Token, nonce string) (externalIdentity, error) { + raw, ok := tok.Extra("id_token").(string) + if !ok || raw == "" { + return externalIdentity{}, fmt.Errorf("oidc: id_token missing") + } + idToken, err := op.Verifier(&oidc.Config{ClientID: cfg.ClientID}).Verify(ctx, raw) + if err != nil { + return externalIdentity{}, fmt.Errorf("oidc verify: %w", err) + } + if idToken.Nonce != nonce { + return externalIdentity{}, fmt.Errorf("oidc: nonce mismatch") + } + var claims struct { + Email string `json:"email"` + } + _ = idToken.Claims(&claims) + display := claims.Email + if display == "" { + display = idToken.Subject + } + return externalIdentity{Subject: idToken.Subject, Display: display}, nil +} + +// bind 把外部身份绑定到账号;(provider, subject) 唯一,重复绑定报错。 +func (o *OAuthService) bind(ctx context.Context, username, provider string, ident externalIdentity) error { + user, err := o.auth.findUser(ctx, username) + if err != nil { + return err + } + var count int64 + err = o.db.WithContext(ctx).Model(&model.UserIdentity{}). + Where("provider = ? AND subject = ?", provider, ident.Subject).Count(&count).Error + if err != nil { + return fmt.Errorf("check identity: %w", err) + } + if count > 0 { + return ErrOAuthBound + } + row := model.UserIdentity{UserID: user.ID, Provider: provider, Subject: ident.Subject, Display: ident.Display} + if err := o.db.WithContext(ctx).Create(&row).Error; err != nil { + return fmt.Errorf("bind identity: %w", err) + } + return nil +} + +// loginByIdentity 查绑定关系并签发面板 JWT;未绑定一律拒绝(不开放注册)。 +func (o *OAuthService) loginByIdentity(ctx context.Context, provider string, ident externalIdentity) (string, string, error) { + var row model.UserIdentity + err := o.db.WithContext(ctx). + Where("provider = ? AND subject = ?", provider, ident.Subject).First(&row).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", "", ErrOAuthNotBound + } + return "", "", fmt.Errorf("find identity: %w", err) + } + var user model.User + if err := o.db.WithContext(ctx).First(&user, row.UserID).Error; err != nil { + return "", "", fmt.Errorf("find bound user: %w", err) + } + token, _, err := o.auth.signToken(user.Username) + return token, ident.Display, err +} + +// Identities 列出账号已绑定的外部身份。 +func (o *OAuthService) Identities(ctx context.Context, username string) ([]model.UserIdentity, error) { + user, err := o.auth.findUser(ctx, username) + if err != nil { + return nil, err + } + items := []model.UserIdentity{} + err = o.db.WithContext(ctx).Where("user_id = ?", user.ID).Order("id").Find(&items).Error + if err != nil { + return nil, fmt.Errorf("list identities: %w", err) + } + return items, nil +} + +// Unbind 解绑外部身份(校验归属);密码登录被禁用时不允许解绑最后一个身份,防自锁。 +func (o *OAuthService) Unbind(ctx context.Context, username string, id uint) error { + user, err := o.auth.findUser(ctx, username) + if err != nil { + return err + } + n, err := o.auth.identityCount(ctx, user.ID) + if err != nil { + return err + } + if n <= 1 { + if off, err := o.auth.PasswordLoginDisabled(ctx); err == nil && off { + return ErrLastIdentity + } + } + res := o.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, user.ID).Delete(&model.UserIdentity{}) + if res.Error != nil { + return fmt.Errorf("unbind identity: %w", res.Error) + } + if res.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} diff --git a/internal/service/oauthconfig.go b/internal/service/oauthconfig.go new file mode 100644 index 0000000..75cdb4f --- /dev/null +++ b/internal/service/oauthconfig.go @@ -0,0 +1,154 @@ +package service + +import ( + "context" + "fmt" + "strings" +) + +// OAuth provider 配置键;client secret 以 AES-GCM 密文落库。 +const ( + settingOauthOidcIssuer = "oauth_oidc_issuer" + settingOauthOidcClientID = "oauth_oidc_client_id" + settingOauthOidcClientSecret = "oauth_oidc_client_secret" + settingOauthOidcDisplayName = "oauth_oidc_display_name" + settingOauthOidcDisabled = "oauth_oidc_disabled" + settingOauthGithubClientID = "oauth_github_client_id" + settingOauthGithubClientSecret = "oauth_github_client_secret" + settingOauthGithubDisplayName = "oauth_github_display_name" + settingOauthGithubDisabled = "oauth_github_disabled" +) + +// OAuthProvidersView 是 OAuth provider 配置视图;绝不返回 secret 明文。 +type OAuthProvidersView struct { + OidcIssuer string `json:"oidcIssuer"` + OidcClientID string `json:"oidcClientId"` + OidcSecretSet bool `json:"oidcSecretSet"` + OidcDisplayName string `json:"oidcDisplayName"` + OidcDisabled bool `json:"oidcDisabled"` + GithubClientID string `json:"githubClientId"` + GithubSecretSet bool `json:"githubSecretSet"` + GithubDisplayName string `json:"githubDisplayName"` + GithubDisabled bool `json:"githubDisabled"` +} + +// UpdateOAuthInput 是保存 provider 配置的输入; +// secret 为 nil 沿用已存值,非 nil 覆盖(空串清除)。 +type UpdateOAuthInput struct { + OidcIssuer string `json:"oidcIssuer"` + OidcClientID string `json:"oidcClientId"` + OidcClientSecret *string `json:"oidcClientSecret"` + OidcDisplayName string `json:"oidcDisplayName"` + OidcDisabled bool `json:"oidcDisabled"` + GithubClientID string `json:"githubClientId"` + GithubClientSecret *string `json:"githubClientSecret"` + GithubDisplayName string `json:"githubDisplayName"` + GithubDisabled bool `json:"githubDisabled"` +} + +// OAuthView 返回脱敏后的 provider 配置。 +func (s *SettingService) OAuthView(ctx context.Context) (OAuthProvidersView, error) { + var view OAuthProvidersView + vals, err := s.getMany(ctx, + settingOauthOidcIssuer, settingOauthOidcClientID, settingOauthOidcClientSecret, + settingOauthOidcDisplayName, settingOauthOidcDisabled, + settingOauthGithubClientID, settingOauthGithubClientSecret, + settingOauthGithubDisplayName, settingOauthGithubDisabled) + if err != nil { + return view, err + } + view.OidcIssuer = vals[settingOauthOidcIssuer] + view.OidcClientID = vals[settingOauthOidcClientID] + view.OidcSecretSet = vals[settingOauthOidcClientSecret] != "" + view.OidcDisplayName = vals[settingOauthOidcDisplayName] + view.OidcDisabled = vals[settingOauthOidcDisabled] == "1" + view.GithubClientID = vals[settingOauthGithubClientID] + view.GithubSecretSet = vals[settingOauthGithubClientSecret] != "" + view.GithubDisplayName = vals[settingOauthGithubDisplayName] + view.GithubDisabled = vals[settingOauthGithubDisabled] == "1" + return view, nil +} + +// boolFlag 把开关序列化为 settings 存储值。 +func boolFlag(on bool) string { + if on { + return "1" + } + return "" +} + +// UpdateOAuth 保存 provider 配置;issuer 规范化去尾斜杠,secret 加密落库。 +func (s *SettingService) UpdateOAuth(ctx context.Context, in UpdateOAuthInput) error { + plain := map[string]string{ + settingOauthOidcIssuer: strings.TrimRight(strings.TrimSpace(in.OidcIssuer), "/"), + settingOauthOidcClientID: strings.TrimSpace(in.OidcClientID), + settingOauthOidcDisplayName: strings.TrimSpace(in.OidcDisplayName), + settingOauthOidcDisabled: boolFlag(in.OidcDisabled), + settingOauthGithubClientID: strings.TrimSpace(in.GithubClientID), + settingOauthGithubDisplayName: strings.TrimSpace(in.GithubDisplayName), + settingOauthGithubDisabled: boolFlag(in.GithubDisabled), + } + for key, value := range plain { + if err := s.set(ctx, key, value); err != nil { + return err + } + } + if err := s.saveOAuthSecret(ctx, settingOauthOidcClientSecret, in.OidcClientSecret); err != nil { + return err + } + return s.saveOAuthSecret(ctx, settingOauthGithubClientSecret, in.GithubClientSecret) +} + +// saveOAuthSecret 加密保存 secret;nil 沿用,空串清除。 +func (s *SettingService) saveOAuthSecret(ctx context.Context, key string, secret *string) error { + if secret == nil { + return nil + } + if *secret == "" { + return s.set(ctx, key, "") + } + enc, err := s.cipher.EncryptString(*secret) + if err != nil { + return fmt.Errorf("encrypt oauth secret: %w", err) + } + return s.set(ctx, key, enc) +} + +// oauthClient 返回 provider 的 clientID/明文 secret/issuer(仅 oidc);未配置时 clientID 为空。 +func (s *SettingService) oauthClient(ctx context.Context, provider string) (clientID, secret, issuer string, err error) { + idKey, secKey := settingOauthGithubClientID, settingOauthGithubClientSecret + if provider == "oidc" { + idKey, secKey = settingOauthOidcClientID, settingOauthOidcClientSecret + if issuer, err = s.get(ctx, settingOauthOidcIssuer); err != nil { + return + } + } + if clientID, err = s.get(ctx, idKey); err != nil { + return + } + enc, err := s.get(ctx, secKey) + if err != nil || enc == "" { + return + } + if secret, err = s.cipher.DecryptString(enc); err != nil { + err = fmt.Errorf("decrypt oauth secret: %w", err) + } + return +} + +// oauthProviderMeta 返回 provider 的展示名(空则给默认名)与禁用态。 +func (s *SettingService) oauthProviderMeta(ctx context.Context, provider string) (display string, disabled bool, err error) { + nameKey, offKey, def := settingOauthGithubDisplayName, settingOauthGithubDisabled, "GitHub" + if provider == "oidc" { + nameKey, offKey, def = settingOauthOidcDisplayName, settingOauthOidcDisabled, "OIDC 单点登录" + } + if display, err = s.get(ctx, nameKey); err != nil { + return + } + if display == "" { + display = def + } + off, err := s.get(ctx, offKey) + disabled = off == "1" + return +} diff --git a/internal/service/ociconfig.go b/internal/service/ociconfig.go new file mode 100644 index 0000000..e612b66 --- /dev/null +++ b/internal/service/ociconfig.go @@ -0,0 +1,442 @@ +package service + +import ( + "context" + "fmt" + "time" + + "gorm.io/gorm" + + "oci-portal/internal/cache" + "oci-portal/internal/crypto" + "oci-portal/internal/model" + "oci-portal/internal/oci" +) + +// OciConfigService 管理 API Key 配置的导入、测活与快照同步。 +type OciConfigService struct { + db *gorm.DB + cipher *crypto.Cipher + client oci.Client + // auditRaw 暂存审计原始事件(eventId → raw,TTL 10 分钟), + // 列表响应剥离 raw 后详情接口据此秒开;miss 走小窗重查兜底。 + auditRaw *cache.Cache +} + +// NewOciConfigService 组装依赖。 +func NewOciConfigService(db *gorm.DB, cipher *crypto.Cipher, client oci.Client) *OciConfigService { + return &OciConfigService{db: db, cipher: cipher, client: client, auditRaw: cache.New(auditRawMax)} +} + +// ImportInput 是导入一份 API Key 的输入: +// ConfigINI 与显式字段二选一(ConfigINI 优先),私钥内容必填。 +type ImportInput struct { + Alias string + Group string + ConfigINI string + TenancyOCID string + UserOCID string + Region string + Fingerprint string + PrivateKey string + Passphrase string + // 多区域 / 多区间支持:开启后订阅区域与 compartment 入库缓存 + MultiRegion bool + MultiCompartment bool + // ProxyID 关联出站代理,nil 直连 + ProxyID *uint +} + +// Changes 记录一次测活前后云端信息字段的变化,值为 [旧值, 新值]。 +type Changes map[string][2]string + +// Import 保存一份新的 API Key 配置,随后立即测活并获取账户类别。 +// 测活失败不回滚导入,结果记录在快照的状态字段里。 +func (s *OciConfigService) Import(ctx context.Context, in ImportInput) (*model.OciConfig, error) { + cred, err := s.buildCredentials(in) + if err != nil { + return nil, err + } + cfg, err := s.storeConfig(in.Alias, cred) + if err != nil { + return nil, err + } + cfg.Group = in.Group + cfg.MultiRegion = in.MultiRegion + cfg.MultiCompartment = in.MultiCompartment + cfg.ProxyID = in.ProxyID + if err := s.refresh(ctx, cfg, cred); err != nil { + return nil, err + } + return cfg, nil +} + +// Verify 重新测活并拉取云端信息,返回更新后的快照和字段变更集。 +func (s *OciConfigService) Verify(ctx context.Context, id uint) (*model.OciConfig, Changes, error) { + var cfg model.OciConfig + if err := s.db.First(&cfg, id).Error; err != nil { + return nil, nil, fmt.Errorf("find oci config %d: %w", id, err) + } + cred, err := s.credentialsOf(&cfg) + if err != nil { + return nil, nil, err + } + before := cfg + if err := s.refresh(ctx, &cfg, cred); err != nil { + return nil, nil, err + } + return &cfg, diffSnapshots(before, cfg), nil +} + +// UpdateInput 是修改配置的输入;空字段保持不变。 +// 替换私钥时必须同时提供新指纹;Passphrase 非 nil 时整体覆盖(空串表示清除口令)。 +// Group 非 nil 时整体覆盖(空串表示取消分组)。 +// MultiRegion / MultiCompartment 非 nil 时切换开关,开启后随本次测活同步缓存。 +type UpdateInput struct { + Alias string + Group *string + Region string + Fingerprint string + PrivateKey string + Passphrase *string + MultiRegion *bool + MultiCompartment *bool + // ProxyID 非 nil 时切换关联:0 表示解除代理,>0 表示关联到该代理 + ProxyID *uint +} + +// Update 修改配置的别名 / 默认区域 / 凭据字段,随后立即重新测活。 +func (s *OciConfigService) Update(ctx context.Context, id uint, in UpdateInput) (*model.OciConfig, Changes, error) { + if in.PrivateKey != "" && in.Fingerprint == "" { + return nil, nil, fmt.Errorf("update oci config: fingerprint is required when replacing private key") + } + var cfg model.OciConfig + if err := s.db.WithContext(ctx).First(&cfg, id).Error; err != nil { + return nil, nil, fmt.Errorf("find oci config %d: %w", id, err) + } + if err := s.applyUpdate(&cfg, in); err != nil { + return nil, nil, err + } + cred, err := s.credentialsOf(&cfg) + if err != nil { + return nil, nil, err + } + before := cfg + if err := s.refresh(ctx, &cfg, cred); err != nil { + return nil, nil, err + } + return &cfg, diffSnapshots(before, cfg), nil +} + +// applyUpdate 把非空输入写进快照字段,凭据字段交由 applyCredentialUpdate 加密。 +func (s *OciConfigService) applyUpdate(cfg *model.OciConfig, in UpdateInput) error { + if in.Alias != "" { + cfg.Alias = in.Alias + } + if in.Group != nil { + cfg.Group = *in.Group + } + if in.Region != "" { + cfg.Region = in.Region + } + if in.Fingerprint != "" { + cfg.Fingerprint = in.Fingerprint + } + if in.MultiRegion != nil { + cfg.MultiRegion = *in.MultiRegion + } + if in.MultiCompartment != nil { + cfg.MultiCompartment = *in.MultiCompartment + } + if in.ProxyID != nil { + if *in.ProxyID == 0 { + cfg.ProxyID = nil + } else { + cfg.ProxyID = in.ProxyID + } + } + return s.applyCredentialUpdate(cfg, in) +} + +// applyCredentialUpdate 重新加密私钥与口令;Passphrase 非 nil 时整体覆盖。 +func (s *OciConfigService) applyCredentialUpdate(cfg *model.OciConfig, in UpdateInput) error { + if in.PrivateKey != "" { + enc, err := s.cipher.EncryptString(in.PrivateKey) + if err != nil { + return fmt.Errorf("encrypt private key: %w", err) + } + cfg.PrivateKeyEnc = enc + } + if in.Passphrase == nil { + return nil + } + cfg.PassphraseEnc = "" + if *in.Passphrase != "" { + enc, err := s.cipher.EncryptString(*in.Passphrase) + if err != nil { + return fmt.Errorf("encrypt passphrase: %w", err) + } + cfg.PassphraseEnc = enc + } + return nil +} + +// ConfigSummary 是列表接口的瘦身视图:只含列表页与全局作用域选择器 +// 消费的字段,订阅 / 促销 / 凭据元数据等详情字段走 Get 单查。 +type ConfigSummary struct { + ID uint `json:"id"` + Alias string `json:"alias"` + Group string `json:"group"` + TenancyOCID string `json:"tenancyOcid"` + TenancyName string `json:"tenancyName"` + Region string `json:"region"` + AccountType string `json:"accountType"` + AliveStatus string `json:"aliveStatus"` + LastError string `json:"lastError"` + LastVerifiedAt *time.Time `json:"lastVerifiedAt"` + MultiRegion bool `json:"multiRegion"` + MultiCompartment bool `json:"multiCompartment"` + ProxyID *uint `json:"proxyId"` + // ProxyName 由 List 按关联填充,供表格代理列 hover 展示 + ProxyName string `json:"proxyName"` + CreatedAt time.Time `json:"createdAt"` +} + +func toConfigSummary(cfg model.OciConfig) ConfigSummary { + return ConfigSummary{ + ID: cfg.ID, + Alias: cfg.Alias, + Group: cfg.Group, + TenancyOCID: cfg.TenancyOCID, + TenancyName: cfg.TenancyName, + Region: cfg.Region, + AccountType: cfg.AccountType, + AliveStatus: cfg.AliveStatus, + LastError: cfg.LastError, + LastVerifiedAt: cfg.LastVerifiedAt, + MultiRegion: cfg.MultiRegion, + MultiCompartment: cfg.MultiCompartment, + ProxyID: cfg.ProxyID, + CreatedAt: cfg.CreatedAt, + } +} + +// List 返回全部配置的列表摘要。 +func (s *OciConfigService) List(ctx context.Context) ([]ConfigSummary, error) { + items := make([]model.OciConfig, 0) + if err := s.db.WithContext(ctx).Order("id").Find(&items).Error; err != nil { + return nil, fmt.Errorf("list oci configs: %w", err) + } + names, err := s.proxyNames(ctx) + if err != nil { + return nil, err + } + summaries := make([]ConfigSummary, len(items)) + for i, cfg := range items { + summaries[i] = toConfigSummary(cfg) + if cfg.ProxyID != nil { + summaries[i].ProxyName = names[*cfg.ProxyID] + } + } + return summaries, nil +} + +// proxyNames 一次取回代理 id → 名称映射,避免列表逐行查询。 +func (s *OciConfigService) proxyNames(ctx context.Context) (map[uint]string, error) { + rows := []model.Proxy{} + if err := s.db.WithContext(ctx).Select("id", "name").Find(&rows).Error; err != nil { + return nil, fmt.Errorf("list proxy names: %w", err) + } + out := make(map[uint]string, len(rows)) + for _, r := range rows { + out[r.ID] = r.Name + } + return out, nil +} + +// Get 返回单个配置快照。 +func (s *OciConfigService) Get(ctx context.Context, id uint) (*model.OciConfig, error) { + var cfg model.OciConfig + if err := s.db.WithContext(ctx).First(&cfg, id).Error; err != nil { + return nil, fmt.Errorf("find oci config %d: %w", id, err) + } + return &cfg, nil +} + +// Delete 删除配置及其区域 / 区间缓存。 +func (s *OciConfigService) Delete(ctx context.Context, id uint) error { + res := s.db.WithContext(ctx).Delete(&model.OciConfig{}, id) + if res.Error != nil { + return fmt.Errorf("delete oci config %d: %w", id, res.Error) + } + if res.RowsAffected == 0 { + return fmt.Errorf("delete oci config %d: %w", id, gorm.ErrRecordNotFound) + } + s.db.WithContext(ctx).Where("oci_config_id = ?", id).Delete(&model.RegionCache{}) + s.db.WithContext(ctx).Where("oci_config_id = ?", id).Delete(&model.CompartmentCache{}) + return nil +} + +// refresh 执行测活与账户信息拉取,并把最新云端状态同步进快照。 +// OCI 调用失败体现在快照状态字段,只有本地存储失败才返回 error。 +func (s *OciConfigService) refresh(ctx context.Context, cfg *model.OciConfig, cred oci.Credentials) error { + now := time.Now() + cfg.LastVerifiedAt = &now + info, err := s.client.ValidateKey(ctx, cred) + if err != nil { + cfg.AliveStatus = model.AliveStatusDead + cfg.LastError = oci.CompactError(err) + } else { + cfg.AliveStatus = model.AliveStatusAlive + cfg.LastError = "" + cfg.TenancyName = info.Name + cfg.HomeRegionKey = info.HomeRegionKey + s.applyProfile(ctx, cfg, cred) + s.syncScopeCaches(ctx, cfg, cred) + } + if err := s.db.WithContext(ctx).Save(cfg).Error; err != nil { + return fmt.Errorf("save oci config snapshot: %w", err) + } + return nil +} + +// applyProfile 拉取订阅信息;失败只把类别降级为 unknown,不影响测活结论。 +func (s *OciConfigService) applyProfile(ctx context.Context, cfg *model.OciConfig, cred oci.Credentials) { + profile, err := s.client.FetchAccountProfile(ctx, cred) + if err != nil { + cfg.AccountType = model.AccountTypeUnknown + cfg.LastError = fmt.Sprintf("fetch account profile: %s", oci.CompactError(err)) + return + } + cfg.AccountType = profile.AccountType + cfg.SubscriptionID = profile.SubscriptionID + cfg.PaymentModel = profile.PaymentModel + cfg.SubscriptionTier = profile.SubscriptionTier + cfg.PromotionStatus = profile.PromotionStatus + cfg.PromotionAmount = profile.PromotionAmount + cfg.PromotionExpires = profile.PromotionExpires +} + +func (s *OciConfigService) buildCredentials(in ImportInput) (oci.Credentials, error) { + cred := oci.Credentials{ + TenancyOCID: in.TenancyOCID, + UserOCID: in.UserOCID, + Region: in.Region, + Fingerprint: in.Fingerprint, + } + if in.ConfigINI != "" { + parsed, err := oci.ParseConfigINI(in.ConfigINI) + if err != nil { + return oci.Credentials{}, err + } + cred = parsed + } + cred.PrivateKey = in.PrivateKey + if cred.Passphrase == "" { + cred.Passphrase = in.Passphrase + } + if err := cred.Validate(); err != nil { + return oci.Credentials{}, err + } + return cred, nil +} + +func (s *OciConfigService) storeConfig(alias string, cred oci.Credentials) (*model.OciConfig, error) { + keyEnc, err := s.cipher.EncryptString(cred.PrivateKey) + if err != nil { + return nil, fmt.Errorf("encrypt private key: %w", err) + } + passEnc := "" + if cred.Passphrase != "" { + if passEnc, err = s.cipher.EncryptString(cred.Passphrase); err != nil { + return nil, fmt.Errorf("encrypt passphrase: %w", err) + } + } + cfg := &model.OciConfig{ + Alias: alias, + UserOCID: cred.UserOCID, + TenancyOCID: cred.TenancyOCID, + Fingerprint: cred.Fingerprint, + Region: cred.Region, + PrivateKeyEnc: keyEnc, + PassphraseEnc: passEnc, + AccountType: model.AccountTypeUnknown, + AliveStatus: model.AliveStatusUnknown, + } + if err := s.db.Create(cfg).Error; err != nil { + return nil, fmt.Errorf("create oci config: %w", err) + } + return cfg, nil +} + +// credentialsOf 从快照解密出完整凭据。 +func (s *OciConfigService) credentialsOf(cfg *model.OciConfig) (oci.Credentials, error) { + key, err := s.cipher.DecryptString(cfg.PrivateKeyEnc) + if err != nil { + return oci.Credentials{}, fmt.Errorf("decrypt private key of config %d: %w", cfg.ID, err) + } + pass := "" + if cfg.PassphraseEnc != "" { + if pass, err = s.cipher.DecryptString(cfg.PassphraseEnc); err != nil { + return oci.Credentials{}, fmt.Errorf("decrypt passphrase of config %d: %w", cfg.ID, err) + } + } + spec, err := s.proxySpecOf(cfg) + if err != nil { + return oci.Credentials{}, err + } + return oci.Credentials{ + TenancyOCID: cfg.TenancyOCID, + UserOCID: cfg.UserOCID, + Region: cfg.Region, + Fingerprint: cfg.Fingerprint, + PrivateKey: key, + Passphrase: pass, + Proxy: spec, + }, nil +} + +// proxySpecOf 加载租户关联的出站代理;未关联返回 nil(直连)。 +// 代理行缺失或解密失败时报错而非静默直连,避免期望走代理的流量泄露真实出口。 +func (s *OciConfigService) proxySpecOf(cfg *model.OciConfig) (*oci.ProxySpec, error) { + if cfg.ProxyID == nil { + return nil, nil + } + var row model.Proxy + if err := s.db.First(&row, *cfg.ProxyID).Error; err != nil { + return nil, fmt.Errorf("find proxy %d of config %d: %w", *cfg.ProxyID, cfg.ID, err) + } + password := "" + if row.PasswordEnc != "" { + p, err := s.cipher.DecryptString(row.PasswordEnc) + if err != nil { + return nil, fmt.Errorf("decrypt proxy password: %w", err) + } + password = p + } + return &oci.ProxySpec{ + Type: row.Type, Host: row.Host, Port: row.Port, + Username: row.Username, Password: password, + }, nil +} + +// diffSnapshots 找出两次快照间云端信息字段的变化。 +func diffSnapshots(before, after model.OciConfig) Changes { + fields := map[string][2]string{ + "aliveStatus": {before.AliveStatus, after.AliveStatus}, + "tenancyName": {before.TenancyName, after.TenancyName}, + "homeRegionKey": {before.HomeRegionKey, after.HomeRegionKey}, + "accountType": {before.AccountType, after.AccountType}, + "subscriptionId": {before.SubscriptionID, after.SubscriptionID}, + "paymentModel": {before.PaymentModel, after.PaymentModel}, + "subscriptionTier": {before.SubscriptionTier, after.SubscriptionTier}, + "promotionStatus": {before.PromotionStatus, after.PromotionStatus}, + } + changes := make(Changes) + for name, pair := range fields { + if pair[0] != pair[1] { + changes[name] = pair + } + } + return changes +} diff --git a/internal/service/ociconfig_test.go b/internal/service/ociconfig_test.go new file mode 100644 index 0000000..d88f2ea --- /dev/null +++ b/internal/service/ociconfig_test.go @@ -0,0 +1,460 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "oci-portal/internal/crypto" + "oci-portal/internal/model" + "oci-portal/internal/oci" +) + +const testPEM = "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----" + +// fakeClient 是 oci.Client 的测试替身,普通测试不访问真实 OCI。 +// 内嵌接口以自动满足 oci.Client,未覆写的方法不会被这些测试调用。 +type fakeClient struct { + oci.Client + + tenancy oci.TenancyInfo + tenancyErr error + profile oci.AccountProfile + profileErr error + + regionSubs []oci.RegionSubscription + regionSubsErr error + regionSubsCalls int + compartments []oci.Compartment + compartmentsErr error + compartmentsCalls int + subscribeErr error + limitValues []oci.LimitValue + limitsErr error + limitServices []oci.LimitService + subscriptions []oci.SubscriptionInfo + subDetail oci.SubscriptionDetail + subDetailErr error + instances []oci.Instance + instancesErr error + costItems []oci.CostItem + costErr error + + subscribedHomeRegion string + subscribedKey string + limitsQuery oci.LimitsQuery + subDetailID string + launched []oci.CreateInstanceInput +} + +func (f *fakeClient) ValidateKey(ctx context.Context, cred oci.Credentials) (oci.TenancyInfo, error) { + return f.tenancy, f.tenancyErr +} + +func (f *fakeClient) FetchAccountProfile(ctx context.Context, cred oci.Credentials) (oci.AccountProfile, error) { + return f.profile, f.profileErr +} + +func (f *fakeClient) ListRegionSubscriptions(ctx context.Context, cred oci.Credentials) ([]oci.RegionSubscription, error) { + f.regionSubsCalls++ + return f.regionSubs, f.regionSubsErr +} + +func (f *fakeClient) ListCompartments(ctx context.Context, cred oci.Credentials) ([]oci.Compartment, error) { + f.compartmentsCalls++ + return f.compartments, f.compartmentsErr +} + +func (f *fakeClient) SubscribeRegion(ctx context.Context, cred oci.Credentials, homeRegion, regionKey string) error { + f.subscribedHomeRegion = homeRegion + f.subscribedKey = regionKey + return f.subscribeErr +} + +func (f *fakeClient) ListLimits(ctx context.Context, cred oci.Credentials, q oci.LimitsQuery) ([]oci.LimitValue, error) { + f.limitsQuery = q + return f.limitValues, f.limitsErr +} + +func (f *fakeClient) ListLimitServices(ctx context.Context, cred oci.Credentials, region string) ([]oci.LimitService, error) { + return f.limitServices, nil +} + +func (f *fakeClient) ListSubscriptions(ctx context.Context, cred oci.Credentials) ([]oci.SubscriptionInfo, error) { + return f.subscriptions, nil +} + +func (f *fakeClient) GetSubscription(ctx context.Context, cred oci.Credentials, subscriptionID string) (oci.SubscriptionDetail, error) { + f.subDetailID = subscriptionID + return f.subDetail, f.subDetailErr +} + +func (f *fakeClient) LaunchInstance(ctx context.Context, cred oci.Credentials, in oci.CreateInstanceInput) (oci.Instance, error) { + f.launched = append(f.launched, in) + return oci.Instance{DisplayName: in.DisplayName, LifecycleState: "PROVISIONING", FreeformTags: in.FreeformTags}, nil +} + +// ListAvailabilityDomains 默认单可用域,抢机自动轮询退化为固定 ad-1。 +func (f *fakeClient) ListAvailabilityDomains(ctx context.Context, cred oci.Credentials, region string) ([]string, error) { + return []string{"fake-ad-1"}, nil +} + +func (f *fakeClient) ListInstances(ctx context.Context, cred oci.Credentials, region string) ([]oci.Instance, error) { + return f.instances, f.instancesErr +} + +func (f *fakeClient) SummarizeCosts(ctx context.Context, cred oci.Credentials, q oci.CostQuery) ([]oci.CostItem, error) { + return f.costItems, f.costErr +} + +func newTestService(t *testing.T, client oci.Client) *OciConfigService { + t.Helper() + 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) + } + if err := db.AutoMigrate( + &model.OciConfig{}, &model.RegionCache{}, &model.CompartmentCache{}, &model.Proxy{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + cipher, err := crypto.NewCipher("test-data-key") + if err != nil { + t.Fatalf("new cipher: %v", err) + } + return NewOciConfigService(db, cipher, client) +} + +func trialImportInput() ImportInput { + return ImportInput{ + Alias: "试用期", + Group: "教育", + TenancyOCID: "ocid1.tenancy.oc1..t", + UserOCID: "ocid1.user.oc1..u", + Region: "eu-frankfurt-1", + Fingerprint: "aa:bb", + PrivateKey: testPEM, + } +} + +func TestImportAutoVerifiesAndClassifies(t *testing.T) { + client := &fakeClient{ + tenancy: oci.TenancyInfo{Name: "mytenancy", HomeRegionKey: "FRA"}, + profile: oci.AccountProfile{ + AccountType: model.AccountTypeTrial, + SubscriptionID: "ocid1.organizationssubscription.oc1..s", + PaymentModel: "FREE_TRIAL", + SubscriptionTier: "FREE_AND_TRIAL", + PromotionStatus: "ACTIVE", + PromotionAmount: 300, + }, + } + svc := newTestService(t, client) + + cfg, err := svc.Import(context.Background(), trialImportInput()) + if err != nil { + t.Fatalf("Import: %v", err) + } + if got, want := cfg.AliveStatus, model.AliveStatusAlive; got != want { + t.Errorf("AliveStatus = %q, want %q", got, want) + } + if got, want := cfg.AccountType, model.AccountTypeTrial; got != want { + t.Errorf("AccountType = %q, want %q", got, want) + } + if got, want := cfg.TenancyName, "mytenancy"; got != want { + t.Errorf("TenancyName = %q, want %q", got, want) + } + if got, want := cfg.Group, "教育"; got != want { + t.Errorf("Group = %q, want %q", got, want) + } + if cfg.LastVerifiedAt == nil { + t.Error("LastVerifiedAt = nil, want set") + } + if cfg.PrivateKeyEnc == testPEM || cfg.PrivateKeyEnc == "" { + t.Error("PrivateKeyEnc: private key stored without encryption") + } +} + +func TestImportDeadKeyStillSaved(t *testing.T) { + client := &fakeClient{tenancyErr: errors.New("NotAuthenticated")} + svc := newTestService(t, client) + + cfg, err := svc.Import(context.Background(), trialImportInput()) + if err != nil { + t.Fatalf("Import: %v", err) + } + if got, want := cfg.AliveStatus, model.AliveStatusDead; got != want { + t.Errorf("AliveStatus = %q, want %q", got, want) + } + if cfg.LastError == "" { + t.Error("LastError is empty, want failure message") + } +} + +func TestImportParsesConfigINI(t *testing.T) { + client := &fakeClient{profileErr: errors.New("skip profile")} + svc := newTestService(t, client) + + cfg, err := svc.Import(context.Background(), ImportInput{ + Alias: "ini 导入", + ConfigINI: "[DEFAULT]\nuser=ocid1.user.oc1..u\ntenancy=ocid1.tenancy.oc1..t\nfingerprint=ff:ee\nregion=ap-tokyo-1\n", + PrivateKey: testPEM, + }) + if err != nil { + t.Fatalf("Import: %v", err) + } + if got, want := cfg.Region, "ap-tokyo-1"; got != want { + t.Errorf("Region = %q, want %q", got, want) + } + if got, want := cfg.UserOCID, "ocid1.user.oc1..u"; got != want { + t.Errorf("UserOCID = %q, want %q", got, want) + } +} + +func TestImportProfileFailureDegradesToUnknown(t *testing.T) { + client := &fakeClient{ + tenancy: oci.TenancyInfo{Name: "mytenancy"}, + profileErr: errors.New("subscription api forbidden"), + } + svc := newTestService(t, client) + + cfg, err := svc.Import(context.Background(), trialImportInput()) + if err != nil { + t.Fatalf("Import: %v", err) + } + if got, want := cfg.AliveStatus, model.AliveStatusAlive; got != want { + t.Errorf("AliveStatus = %q, want %q", got, want) + } + if got, want := cfg.AccountType, model.AccountTypeUnknown; got != want { + t.Errorf("AccountType = %q, want %q", got, want) + } + if cfg.LastError == "" { + t.Error("LastError is empty, want profile failure message") + } +} + +func TestImportRejectsInvalidInput(t *testing.T) { + svc := newTestService(t, &fakeClient{}) + in := trialImportInput() + in.PrivateKey = "not a pem" + if _, err := svc.Import(context.Background(), in); err == nil { + t.Error("Import with bad key: got nil error, want failure") + } +} + +func TestVerifyDetectsChanges(t *testing.T) { + client := &fakeClient{ + tenancy: oci.TenancyInfo{Name: "mytenancy", HomeRegionKey: "FRA"}, + profile: oci.AccountProfile{ + AccountType: model.AccountTypeTrial, + PaymentModel: "FREE_TRIAL", + PromotionStatus: "ACTIVE", + }, + } + svc := newTestService(t, client) + cfg, err := svc.Import(context.Background(), trialImportInput()) + if err != nil { + t.Fatalf("Import: %v", err) + } + + // 模拟云端状态变化:试用到期变免费 + client.profile = oci.AccountProfile{ + AccountType: model.AccountTypeFree, + PaymentModel: "FREE_TRIAL", + PromotionStatus: "EXPIRED", + } + updated, changes, err := svc.Verify(context.Background(), cfg.ID) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if got, want := updated.AccountType, model.AccountTypeFree; got != want { + t.Errorf("AccountType = %q, want %q", got, want) + } + if got, want := changes["accountType"], [2]string{model.AccountTypeTrial, model.AccountTypeFree}; got != want { + t.Errorf("changes[accountType] = %v, want %v", got, want) + } + if got, want := changes["promotionStatus"], [2]string{"ACTIVE", "EXPIRED"}; got != want { + t.Errorf("changes[promotionStatus] = %v, want %v", got, want) + } +} + +func TestVerifyNoChanges(t *testing.T) { + client := &fakeClient{ + tenancy: oci.TenancyInfo{Name: "mytenancy", HomeRegionKey: "FRA"}, + profile: oci.AccountProfile{AccountType: model.AccountTypeTrial, PaymentModel: "FREE_TRIAL"}, + } + svc := newTestService(t, client) + cfg, err := svc.Import(context.Background(), trialImportInput()) + if err != nil { + t.Fatalf("Import: %v", err) + } + _, changes, err := svc.Verify(context.Background(), cfg.ID) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if len(changes) != 0 { + t.Errorf("changes = %v, want empty", changes) + } +} + +func TestVerifyMissingConfig(t *testing.T) { + svc := newTestService(t, &fakeClient{}) + if _, _, err := svc.Verify(context.Background(), 999); err == nil { + t.Error("Verify(999): got nil error, want not found") + } +} + +func TestDelete(t *testing.T) { + svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}) + cfg, err := svc.Import(context.Background(), trialImportInput()) + if err != nil { + t.Fatalf("Import: %v", err) + } + if err := svc.Delete(context.Background(), cfg.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + if err := svc.Delete(context.Background(), cfg.ID); err == nil { + t.Error("Delete twice: got nil error, want not found") + } +} + +func TestUpdateFields(t *testing.T) { + const newPEM = "-----BEGIN PRIVATE KEY-----\nrotated\n-----END PRIVATE KEY-----" + empty := "" + prod := "生产" + tests := []struct { + name string + in UpdateInput + check func(t *testing.T, svc *OciConfigService, cfg *model.OciConfig) + }{ + { + name: "改别名与默认区域", + in: UpdateInput{Alias: "改名后", Region: "ap-tokyo-1"}, + check: func(t *testing.T, svc *OciConfigService, cfg *model.OciConfig) { + if cfg.Alias != "改名后" || cfg.Region != "ap-tokyo-1" { + t.Errorf("alias/region = %q/%q, want 改名后/ap-tokyo-1", cfg.Alias, cfg.Region) + } + if cred, err := svc.credentialsOf(cfg); err != nil || cred.PrivateKey != testPEM { + t.Errorf("private key changed unexpectedly: %v", err) + } + }, + }, + { + name: "改分组", + in: UpdateInput{Group: &prod}, + check: func(t *testing.T, _ *OciConfigService, cfg *model.OciConfig) { + if cfg.Group != "生产" { + t.Errorf("Group = %q, want 生产", cfg.Group) + } + }, + }, + { + name: "清空分组", + in: UpdateInput{Group: &empty}, + check: func(t *testing.T, _ *OciConfigService, cfg *model.OciConfig) { + if cfg.Group != "" { + t.Errorf("Group = %q, want empty", cfg.Group) + } + }, + }, + { + name: "缺省分组保持不变", + in: UpdateInput{Alias: "只改名"}, + check: func(t *testing.T, _ *OciConfigService, cfg *model.OciConfig) { + if cfg.Group != "教育" { + t.Errorf("Group = %q, want 教育 (unchanged)", cfg.Group) + } + }, + }, + { + name: "换私钥与指纹", + in: UpdateInput{PrivateKey: newPEM, Fingerprint: "cc:dd"}, + check: func(t *testing.T, svc *OciConfigService, cfg *model.OciConfig) { + if cfg.Fingerprint != "cc:dd" { + t.Errorf("fingerprint = %q, want cc:dd", cfg.Fingerprint) + } + if cred, err := svc.credentialsOf(cfg); err != nil || cred.PrivateKey != newPEM { + t.Errorf("private key not rotated: %v", err) + } + }, + }, + { + name: "清除口令", + in: UpdateInput{Passphrase: &empty}, + check: func(t *testing.T, _ *OciConfigService, cfg *model.OciConfig) { + if cfg.PassphraseEnc != "" { + t.Errorf("PassphraseEnc = %q, want empty", cfg.PassphraseEnc) + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}) + cfg, err := svc.Import(context.Background(), trialImportInput()) + if err != nil { + t.Fatalf("Import: %v", err) + } + updated, _, err := svc.Update(context.Background(), cfg.ID, tt.in) + if err != nil { + t.Fatalf("Update: %v", err) + } + if updated.LastVerifiedAt == nil || !updated.LastVerifiedAt.After(*cfg.LastVerifiedAt) { + t.Error("Update should re-verify: LastVerifiedAt not advanced") + } + tt.check(t, svc, updated) + }) + } +} + +func TestUpdateKeyRequiresFingerprint(t *testing.T) { + svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}) + cfg, err := svc.Import(context.Background(), trialImportInput()) + if err != nil { + t.Fatalf("Import: %v", err) + } + if _, _, err := svc.Update(context.Background(), cfg.ID, UpdateInput{PrivateKey: "x"}); err == nil { + t.Error("Update with key but no fingerprint: got nil error, want rejection") + } +} + +func TestListReturnsSummaries(t *testing.T) { + svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "mytenancy"}}) + first, err := svc.Import(context.Background(), trialImportInput()) + if err != nil { + t.Fatalf("Import first: %v", err) + } + second := trialImportInput() + second.Alias = "生产号" + second.Group = "" + if _, err := svc.Import(context.Background(), second); err != nil { + t.Fatalf("Import second: %v", err) + } + + items, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(items) != 2 { + t.Fatalf("len(items) = %d, want 2", len(items)) + } + got := items[0] + want := ConfigSummary{ + ID: first.ID, Alias: "试用期", Group: "教育", + TenancyOCID: first.TenancyOCID, TenancyName: "mytenancy", Region: first.Region, + AccountType: first.AccountType, AliveStatus: model.AliveStatusAlive, + LastVerifiedAt: got.LastVerifiedAt, CreatedAt: got.CreatedAt, + } + if got != want { + t.Errorf("items[0] = %+v, want %+v", got, want) + } + if items[1].Group != "" || items[1].Alias != "生产号" { + t.Errorf("items[1] alias/group = %q/%q, want 生产号/empty", items[1].Alias, items[1].Group) + } +} diff --git a/internal/service/overview.go b/internal/service/overview.go new file mode 100644 index 0000000..0d82916 --- /dev/null +++ b/internal/service/overview.go @@ -0,0 +1,196 @@ +package service + +import ( + "context" + "fmt" + "time" + + "oci-portal/internal/model" +) + +// OverviewTenants 是租户数量与类别分布。 +type OverviewTenants struct { + Total int `json:"total"` + Alive int `json:"alive"` + Dead int `json:"dead"` + ByType map[string]int `json:"byType"` +} + +// OverviewCheck 是测活快照聚合;实例数为各配置默认区域之和, +// CoveredConfigs/TotalConfigs 用于前端标注覆盖度。 +type OverviewCheck struct { + HasActiveTask bool `json:"hasActiveTask"` + CoveredConfigs int `json:"coveredConfigs"` + TotalConfigs int `json:"totalConfigs"` + InstanceCount int `json:"instanceCount"` + LastCheckedAt *time.Time `json:"lastCheckedAt"` +} + +// OverviewCostDay 是一天的跨租户成本合计。 +type OverviewCostDay struct { + Day string `json:"day"` + Amount float64 `json:"amount"` +} + +// OverviewCost 是近 7 日成本快照聚合;金额跨配置直接相加, +// Currency 取快照中出现的第一个币种(混合币种时以此为准展示)。 +type OverviewCost struct { + HasActiveTask bool `json:"hasActiveTask"` + CoveredConfigs int `json:"coveredConfigs"` + Currency string `json:"currency"` + Total float64 `json:"total"` + Days []OverviewCostDay `json:"days"` +} + +// OverviewTasks 是后台任务数量统计。 +type OverviewTasks struct { + Total int `json:"total"` + Active int `json:"active"` + HealthCheck int `json:"healthCheck"` + Cost int `json:"cost"` + Snatch int `json:"snatch"` +} + +// Overview 是总览页聚合数据。 +type Overview struct { + Tenants OverviewTenants `json:"tenants"` + Check OverviewCheck `json:"check"` + Cost OverviewCost `json:"cost"` + Tasks OverviewTasks `json:"tasks"` +} + +// Overview 聚合总览数据:租户分布、测活/成本快照与任务统计,全部读本地库不发云端请求。 +func (s *OciConfigService) Overview(ctx context.Context) (*Overview, error) { + var out Overview + if err := s.overviewTenants(ctx, &out); err != nil { + return nil, err + } + if err := s.overviewCheck(ctx, &out); err != nil { + return nil, err + } + if err := s.overviewCost(ctx, &out); err != nil { + return nil, err + } + if err := s.overviewTasks(ctx, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (s *OciConfigService) overviewTenants(ctx context.Context, out *Overview) error { + configs, err := s.List(ctx) + if err != nil { + return err + } + out.Tenants.Total = len(configs) + out.Tenants.ByType = map[string]int{} + for _, cfg := range configs { + out.Tenants.ByType[cfg.AccountType]++ + switch cfg.AliveStatus { + case model.AliveStatusAlive: + out.Tenants.Alive++ + case model.AliveStatusDead: + out.Tenants.Dead++ + } + } + return nil +} + +// hasActiveTask 判断是否存在指定类型且调度中的任务。 +func (s *OciConfigService) hasActiveTask(ctx context.Context, taskType string) (bool, error) { + var n int64 + err := s.db.WithContext(ctx).Model(&model.Task{}). + Where("type = ? AND status = ?", taskType, model.TaskStatusActive).Count(&n).Error + if err != nil { + return false, fmt.Errorf("count %s tasks: %w", taskType, err) + } + return n > 0, nil +} + +func (s *OciConfigService) overviewCheck(ctx context.Context, out *Overview) error { + has, err := s.hasActiveTask(ctx, model.TaskTypeHealthCheck) + if err != nil { + return err + } + out.Check.HasActiveTask = has + out.Check.TotalConfigs = out.Tenants.Total + var snaps []model.CheckSnapshot + err = s.db.WithContext(ctx). + Where("oci_config_id IN (?)", s.db.Model(&model.OciConfig{}).Select("id")). + Find(&snaps).Error + if err != nil { + return fmt.Errorf("load check snapshots: %w", err) + } + out.Check.CoveredConfigs = len(snaps) + for _, snap := range snaps { + out.Check.InstanceCount += snap.InstanceCount + if out.Check.LastCheckedAt == nil || snap.CheckedAt.After(*out.Check.LastCheckedAt) { + t := snap.CheckedAt + out.Check.LastCheckedAt = &t + } + } + return nil +} + +func (s *OciConfigService) overviewCost(ctx context.Context, out *Overview) error { + has, err := s.hasActiveTask(ctx, model.TaskTypeCost) + if err != nil { + return err + } + out.Cost.HasActiveTask = has + since := time.Now().UTC().AddDate(0, 0, -7).Format("2006-01-02") + var snaps []model.CostSnapshot + err = s.db.WithContext(ctx). + Where("day >= ? AND oci_config_id IN (?)", since, s.db.Model(&model.OciConfig{}).Select("id")). + Order("day").Find(&snaps).Error + if err != nil { + return fmt.Errorf("load cost snapshots: %w", err) + } + aggregateCostDays(&out.Cost, snaps) + return nil +} + +// aggregateCostDays 把逐配置逐日的快照聚合为跨租户的每日序列(snaps 须按 day 升序)。 +func aggregateCostDays(cost *OverviewCost, snaps []model.CostSnapshot) { + byDay := map[string]float64{} + covered := map[uint]bool{} + var order []string + for _, snap := range snaps { + if _, ok := byDay[snap.Day]; !ok { + order = append(order, snap.Day) + } + byDay[snap.Day] += snap.Amount + covered[snap.OciConfigID] = true + if cost.Currency == "" { + cost.Currency = snap.Currency + } + } + cost.CoveredConfigs = len(covered) + cost.Days = make([]OverviewCostDay, 0, len(order)) + for _, day := range order { + cost.Days = append(cost.Days, OverviewCostDay{Day: day, Amount: byDay[day]}) + cost.Total += byDay[day] + } +} + +func (s *OciConfigService) overviewTasks(ctx context.Context, out *Overview) error { + var tasks []model.Task + if err := s.db.WithContext(ctx).Find(&tasks).Error; err != nil { + return fmt.Errorf("list tasks: %w", err) + } + out.Tasks.Total = len(tasks) + for _, t := range tasks { + if t.Status == model.TaskStatusActive { + out.Tasks.Active++ + } + switch t.Type { + case model.TaskTypeHealthCheck: + out.Tasks.HealthCheck++ + case model.TaskTypeCost: + out.Tasks.Cost++ + case model.TaskTypeSnatch: + out.Tasks.Snatch++ + } + } + return nil +} diff --git a/internal/service/overview_test.go b/internal/service/overview_test.go new file mode 100644 index 0000000..43912f8 --- /dev/null +++ b/internal/service/overview_test.go @@ -0,0 +1,169 @@ +package service + +import ( + "context" + "math" + "testing" + "time" + + "gorm.io/gorm" + + "oci-portal/internal/model" + "oci-portal/internal/oci" +) + +func costItem(day string, amount float32, currency string) oci.CostItem { + t, _ := time.Parse("2006-01-02", day) + return oci.CostItem{TimeStart: &t, ComputedAmount: amount, Currency: currency} +} + +func TestRunCostTaskSkipsFreeAndUpserts(t *testing.T) { + client := &fakeClient{ + tenancy: oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"}, + costItems: []oci.CostItem{ + costItem("2026-07-01", 5.2, "USD"), + costItem("2026-07-01", 1.4, "USD"), + costItem("2026-07-02", 6.3, "USD"), + }, + } + tasks, configs, db := newTaskEnv(t, client) + ctx := context.Background() + if _, err := configs.Import(ctx, trialImportInput()); err != nil { + t.Fatalf("import: %v", err) + } + free := trialImportInput() + free.Alias = "免费号" + if _, err := configs.Import(ctx, free); err != nil { + t.Fatalf("import free: %v", err) + } + if err := db.Model(&model.OciConfig{}).Where("id = ?", 2). + Update("account_type", model.AccountTypeFree).Error; err != nil { + t.Fatalf("mark free: %v", err) + } + + task, err := tasks.CreateTask(ctx, CreateTaskInput{ + Name: "成本", Type: model.TaskTypeCost, CronExpr: "30 3 * * *", + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + entry, err := tasks.RunTaskNow(ctx, task.ID) + if err != nil { + t.Fatalf("RunTaskNow: %v", err) + } + if !entry.Success || entry.Message != "synced usage for 1 tenants, skipped 1 free" { + t.Errorf("log = %+v, want synced 1 skipped 1", entry) + } + + assertCostSnapshots(t, db, map[string]float64{"2026-07-01": 6.6, "2026-07-02": 6.3}) + + // 再次执行为覆盖更新,不产生重复行 + if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil { + t.Fatalf("RunTaskNow again: %v", err) + } + assertCostSnapshots(t, db, map[string]float64{"2026-07-01": 6.6, "2026-07-02": 6.3}) +} + +// assertCostSnapshots 断言快照表恰好为 want 中的日期与金额(配置 #1)。 +func assertCostSnapshots(t *testing.T, db *gorm.DB, want map[string]float64) { + t.Helper() + var snaps []model.CostSnapshot + if err := db.Where("oci_config_id = ?", 1).Find(&snaps).Error; err != nil { + t.Fatalf("load cost snapshots: %v", err) + } + if len(snaps) != len(want) { + t.Fatalf("snapshots = %d rows, want %d", len(snaps), len(want)) + } + for _, snap := range snaps { + amount, ok := want[snap.Day] + // 金额自 float32 累加而来,按容差比较 + if !ok || math.Abs(snap.Amount-amount) > 1e-4 { + t.Errorf("day %s amount = %v, want %v", snap.Day, snap.Amount, want[snap.Day]) + } + } +} + +func TestRunHealthCheckWritesSnapshot(t *testing.T) { + client := &fakeClient{ + tenancy: oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"}, + instances: []oci.Instance{{ID: "i1"}, {ID: "i2"}}, + } + tasks, configs, db := newTaskEnv(t, client) + ctx := context.Background() + if _, err := configs.Import(ctx, trialImportInput()); err != nil { + t.Fatalf("import: %v", err) + } + task, err := tasks.CreateTask(ctx, CreateTaskInput{ + Name: "测活", Type: model.TaskTypeHealthCheck, CronExpr: "*/5 * * * *", + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil { + t.Fatalf("RunTaskNow: %v", err) + } + var snap model.CheckSnapshot + if err := db.Where("oci_config_id = ?", 1).First(&snap).Error; err != nil { + t.Fatalf("load snapshot: %v", err) + } + if snap.AliveStatus != model.AliveStatusAlive || snap.InstanceCount != 2 { + t.Errorf("snapshot = %+v, want alive with 2 instances", snap) + } +} + +func TestOverviewAggregates(t *testing.T) { + client := &fakeClient{ + tenancy: oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"}, + instances: []oci.Instance{{ID: "i1"}}, + costItems: []oci.CostItem{costItem(time.Now().UTC().Format("2006-01-02"), 3.5, "USD")}, + } + tasks, configs, _ := newTaskEnv(t, client) + ctx := context.Background() + if _, err := configs.Import(ctx, trialImportInput()); err != nil { + t.Fatalf("import: %v", err) + } + for _, in := range []CreateTaskInput{ + {Name: "测活", Type: model.TaskTypeHealthCheck, CronExpr: "*/5 * * * *"}, + {Name: "成本", Type: model.TaskTypeCost, CronExpr: "30 3 * * *"}, + } { + task, err := tasks.CreateTask(ctx, in) + if err != nil { + t.Fatalf("CreateTask %s: %v", in.Name, err) + } + if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil { + t.Fatalf("RunTaskNow %s: %v", in.Name, err) + } + } + + out, err := configs.Overview(ctx) + if err != nil { + t.Fatalf("Overview: %v", err) + } + tests := []struct { + name string + got interface{} + want interface{} + }{ + {"租户总数", out.Tenants.Total, 1}, + {"存活数", out.Tenants.Alive, 1}, + {"测活任务存在", out.Check.HasActiveTask, true}, + {"测活覆盖", out.Check.CoveredConfigs, 1}, + {"实例数", out.Check.InstanceCount, 1}, + {"成本任务存在", out.Cost.HasActiveTask, true}, + {"成本覆盖", out.Cost.CoveredConfigs, 1}, + {"成本合计", out.Cost.Total, 3.5}, + {"币种", out.Cost.Currency, "USD"}, + {"任务总数", out.Tasks.Total, 2}, + {"active 任务", out.Tasks.Active, 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Errorf("got %v, want %v", tt.got, tt.want) + } + }) + } + if len(out.Cost.Days) != 1 { + t.Errorf("cost days = %d, want 1", len(out.Cost.Days)) + } +} diff --git a/internal/service/proxy.go b/internal/service/proxy.go new file mode 100644 index 0000000..ff2e91f --- /dev/null +++ b/internal/service/proxy.go @@ -0,0 +1,253 @@ +package service + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "gorm.io/gorm" + + "oci-portal/internal/crypto" + "oci-portal/internal/model" + "oci-portal/internal/oci" +) + +// 代理配置错误;api 层映射 400 / 409。 +var ( + // ErrProxyInvalid 表示字段校验失败。 + ErrProxyInvalid = errors.New("代理配置非法") + // ErrProxyInUse 表示代理仍被租户引用,拒绝删除。 + ErrProxyInUse = errors.New("代理仍被租户关联,请先解除关联") +) + +// ProxyService 管理出站代理配置;密码 AES-GCM 加密落库。 +type ProxyService struct { + db *gorm.DB + cipher *crypto.Cipher + // wg 追踪创建 / 更新后的异步出口地理探测,Wait 供优雅关停与测试收敛 + wg sync.WaitGroup + // geoClientFor 构造经代理出站的探测 client;测试注入替身避免真实外呼 + geoClientFor func(*oci.ProxySpec) *http.Client +} + +// NewProxyService 组装依赖。 +func NewProxyService(db *gorm.DB, cipher *crypto.Cipher) *ProxyService { + return &ProxyService{db: db, cipher: cipher, geoClientFor: oci.HTTPClientFor} +} + +// Wait 阻塞至在途的地理探测全部完成;进程退出前调用。 +func (s *ProxyService) Wait() { + s.wg.Wait() +} + +// ProxyView 是代理的脱敏视图,绝不含密码明文。 +type ProxyView struct { + ID uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + PasswordSet bool `json:"passwordSet"` + // Country / City 为出口实测地区;GeoAt 空串表示尚未探测(前端显示「检测中」) + Country string `json:"country"` + City string `json:"city"` + GeoAt string `json:"geoAt"` + // UsedBy 为关联此代理的租户数,前端据此提示删除约束 + UsedBy int64 `json:"usedBy"` + CreatedAt string `json:"createdAt"` +} + +// ProxyInput 是创建 / 更新请求体;Name 缺省自动生成, +// Password 为 nil 表示沿用已存值,空串表示清除。 +type ProxyInput struct { + Name string `json:"name"` + Type string `json:"type" binding:"required"` + Host string `json:"host" binding:"required"` + Port int `json:"port" binding:"required"` + Username string `json:"username"` + Password *string `json:"password"` +} + +// validateProxyInput 校验类型 / 端口 / 主机;错误信息用户可读。 +func validateProxyInput(in ProxyInput) error { + if in.Type != "socks5" && in.Type != "http" && in.Type != "https" { + return fmt.Errorf("类型仅支持 socks5 / http / https: %w", ErrProxyInvalid) + } + if in.Port < 1 || in.Port > 65535 { + return fmt.Errorf("端口须在 1-65535 之间: %w", ErrProxyInvalid) + } + if strings.TrimSpace(in.Host) == "" { + return fmt.Errorf("主机不能为空: %w", ErrProxyInvalid) + } + return nil +} + +// List 返回全部代理的脱敏视图,附带引用计数。 +func (s *ProxyService) List(ctx context.Context) ([]ProxyView, error) { + rows := []model.Proxy{} + if err := s.db.WithContext(ctx).Order("id").Find(&rows).Error; err != nil { + return nil, fmt.Errorf("list proxies: %w", err) + } + out := make([]ProxyView, 0, len(rows)) + for i := range rows { + v, err := s.viewOf(ctx, &rows[i]) + if err != nil { + return nil, err + } + out = append(out, v) + } + return out, nil +} + +// viewOf 组装单条脱敏视图。 +func (s *ProxyService) viewOf(ctx context.Context, p *model.Proxy) (ProxyView, error) { + var used int64 + err := s.db.WithContext(ctx).Model(&model.OciConfig{}).Where("proxy_id = ?", p.ID).Count(&used).Error + if err != nil { + return ProxyView{}, fmt.Errorf("count proxy refs: %w", err) + } + return ProxyView{ + ID: p.ID, Name: p.Name, Type: p.Type, Host: p.Host, Port: p.Port, + Username: p.Username, PasswordSet: p.PasswordEnc != "", UsedBy: used, + Country: p.Country, City: p.City, GeoAt: formatTimePtr(p.GeoAt), + CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + }, nil +} + +// formatTimePtr 把可空时间格式化为 RFC3339,nil 返回空串。 +func formatTimePtr(t *time.Time) string { + if t == nil { + return "" + } + return t.Format("2006-01-02T15:04:05Z07:00") +} + +// autoProxyName 名称缺省时生成 `{type}-{host}:{port}`,与现有代理重名时追加序号后缀。 +func (s *ProxyService) autoProxyName(ctx context.Context, in ProxyInput, selfID uint) string { + base := fmt.Sprintf("%s-%s:%d", in.Type, strings.TrimSpace(in.Host), in.Port) + if len(base) > 56 { + base = base[:56] + } + name := base + for i := 2; i <= 999; i++ { + var n int64 + s.db.WithContext(ctx).Model(&model.Proxy{}). + Where("name = ? AND id <> ?", name, selfID).Count(&n) + if n == 0 { + return name + } + name = fmt.Sprintf("%s-%d", base, i) + } + return name +} + +// Create 新建代理;名称唯一冲突返回可读错误,成功后异步探测出口地区。 +func (s *ProxyService) Create(ctx context.Context, in ProxyInput) (ProxyView, error) { + if err := validateProxyInput(in); err != nil { + return ProxyView{}, err + } + if strings.TrimSpace(in.Name) == "" { + in.Name = s.autoProxyName(ctx, in, 0) + } + row := model.Proxy{ + Name: strings.TrimSpace(in.Name), Type: in.Type, + Host: strings.TrimSpace(in.Host), Port: in.Port, Username: in.Username, + } + if err := s.fillPassword(&row, in.Password); err != nil { + return ProxyView{}, err + } + if err := s.db.WithContext(ctx).Create(&row).Error; err != nil { + return ProxyView{}, fmt.Errorf("create proxy: %w", err) + } + s.probeGeoAsync(row.ID) + return s.viewOf(ctx, &row) +} + +// Update 更新代理;密码缺省沿用、空串清除,名称留空重新自动生成,成功后重测地区。 +func (s *ProxyService) Update(ctx context.Context, id uint, in ProxyInput) (ProxyView, error) { + if err := validateProxyInput(in); err != nil { + return ProxyView{}, err + } + if strings.TrimSpace(in.Name) == "" { + in.Name = s.autoProxyName(ctx, in, id) + } + var row model.Proxy + if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil { + return ProxyView{}, fmt.Errorf("find proxy %d: %w", id, err) + } + row.Name, row.Type = strings.TrimSpace(in.Name), in.Type + row.Host, row.Port, row.Username = strings.TrimSpace(in.Host), in.Port, in.Username + if err := s.fillPassword(&row, in.Password); err != nil { + return ProxyView{}, err + } + if err := s.db.WithContext(ctx).Save(&row).Error; err != nil { + return ProxyView{}, fmt.Errorf("update proxy: %w", err) + } + s.probeGeoAsync(row.ID) + return s.viewOf(ctx, &row) +} + +// fillPassword 按输入语义写密文:nil 沿用、空串清除、非空加密覆盖。 +func (s *ProxyService) fillPassword(row *model.Proxy, password *string) error { + if password == nil { + return nil + } + if *password == "" { + row.PasswordEnc = "" + return nil + } + enc, err := s.cipher.EncryptString(*password) + if err != nil { + return fmt.Errorf("encrypt proxy password: %w", err) + } + row.PasswordEnc = enc + return nil +} + +// Delete 删除代理;仍被租户引用时拒绝。 +func (s *ProxyService) Delete(ctx context.Context, id uint) error { + var used int64 + err := s.db.WithContext(ctx).Model(&model.OciConfig{}).Where("proxy_id = ?", id).Count(&used).Error + if err != nil { + return fmt.Errorf("count proxy refs: %w", err) + } + if used > 0 { + return ErrProxyInUse + } + res := s.db.WithContext(ctx).Delete(&model.Proxy{}, id) + if res.Error != nil { + return fmt.Errorf("delete proxy: %w", res.Error) + } + if res.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +// SpecOf 解密并组装 SDK 用的代理参数;id 为 nil 返回 nil(直连)。 +func (s *ProxyService) SpecOf(ctx context.Context, id *uint) (*oci.ProxySpec, error) { + if id == nil { + return nil, nil + } + var row model.Proxy + if err := s.db.WithContext(ctx).First(&row, *id).Error; err != nil { + return nil, fmt.Errorf("find proxy %d: %w", *id, err) + } + password := "" + if row.PasswordEnc != "" { + p, err := s.cipher.DecryptString(row.PasswordEnc) + if err != nil { + return nil, fmt.Errorf("decrypt proxy password: %w", err) + } + password = p + } + return &oci.ProxySpec{ + Type: row.Type, Host: row.Host, Port: row.Port, + Username: row.Username, Password: password, + }, nil +} diff --git a/internal/service/proxy_test.go b/internal/service/proxy_test.go new file mode 100644 index 0000000..ab9df7d --- /dev/null +++ b/internal/service/proxy_test.go @@ -0,0 +1,229 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "oci-portal/internal/crypto" + "oci-portal/internal/model" + "oci-portal/internal/oci" +) + +func newProxyEnv(t *testing.T) (*ProxyService, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlDB, _ := db.DB() + sqlDB.SetMaxOpenConns(1) + if err := db.AutoMigrate(&model.Proxy{}, &model.OciConfig{}); err != nil { + t.Fatalf("migrate: %v", err) + } + cipher, err := crypto.NewCipher("test-key") + if err != nil { + t.Fatalf("cipher: %v", err) + } + svc := NewProxyService(db, cipher) + // 默认注入空 client:异步探测直接跳过,避免测试外呼与写库竞态 + svc.geoClientFor = func(*oci.ProxySpec) *http.Client { return nil } + t.Cleanup(svc.Wait) + return svc, db +} + +func strp(s string) *string { return &s } + +func TestProxyCRUDAndPasswordSemantics(t *testing.T) { + svc, db := newProxyEnv(t) + ctx := context.Background() + + created, err := svc.Create(ctx, ProxyInput{Name: "jp-socks", Type: "socks5", Host: "1.2.3.4", Port: 1080, Username: "u", Password: strp("secret")}) + if err != nil { + t.Fatalf("create: %v", err) + } + if !created.PasswordSet { + t.Fatalf("PasswordSet = false, want true") + } + var row model.Proxy + if err := db.First(&row, created.ID).Error; err != nil { + t.Fatalf("load row: %v", err) + } + if row.PasswordEnc == "" || row.PasswordEnc == "secret" { + t.Fatalf("password must be stored encrypted, got %q", row.PasswordEnc) + } + + // 密码缺省沿用 + updated, err := svc.Update(ctx, created.ID, ProxyInput{Name: "jp-socks", Type: "socks5", Host: "1.2.3.4", Port: 1081}) + if err != nil { + t.Fatalf("update: %v", err) + } + if !updated.PasswordSet || updated.Port != 1081 { + t.Fatalf("update view = %+v, want password kept and port 1081", updated) + } + // 空串清除 + cleared, err := svc.Update(ctx, created.ID, ProxyInput{Name: "jp-socks", Type: "socks5", Host: "1.2.3.4", Port: 1081, Password: strp("")}) + if err != nil { + t.Fatalf("update clear: %v", err) + } + if cleared.PasswordSet { + t.Fatalf("PasswordSet = true after clear, want false") + } + + spec, err := svc.SpecOf(ctx, &created.ID) + if err != nil { + t.Fatalf("SpecOf: %v", err) + } + if spec == nil || spec.Host != "1.2.3.4" || spec.Port != 1081 { + t.Fatalf("spec = %+v", spec) + } + if spec2, err := svc.SpecOf(ctx, nil); err != nil || spec2 != nil { + t.Fatalf("SpecOf(nil) = %+v, %v; want nil, nil", spec2, err) + } + + if err := svc.Delete(ctx, created.ID); err != nil { + t.Fatalf("delete: %v", err) + } +} + +func TestProxyValidateAndDeleteInUse(t *testing.T) { + svc, db := newProxyEnv(t) + ctx := context.Background() + + for _, in := range []ProxyInput{ + {Name: "x", Type: "ss", Host: "h", Port: 1080}, + {Name: "x", Type: "http", Host: "h", Port: 0}, + {Name: "x", Type: "http", Host: " ", Port: 8080}, + } { + if _, err := svc.Create(ctx, in); err == nil { + t.Fatalf("Create(%+v) accepted invalid input", in) + } + } + + created, err := svc.Create(ctx, ProxyInput{Name: "used", Type: "http", Host: "h", Port: 8080}) + if err != nil { + t.Fatalf("create: %v", err) + } + cfg := model.OciConfig{Alias: "t1", ProxyID: &created.ID} + if err := db.Create(&cfg).Error; err != nil { + t.Fatalf("create cfg: %v", err) + } + if err := svc.Delete(ctx, created.ID); err == nil { + t.Fatalf("Delete allowed while in use, want ErrProxyInUse") + } + views, err := svc.List(ctx) + if err != nil || len(views) != 1 || views[0].UsedBy != 1 { + t.Fatalf("List = %+v, %v; want one proxy with UsedBy=1", views, err) + } +} + +func TestProxyAutoNameAndImport(t *testing.T) { + svc, _ := newProxyEnv(t) + ctx := context.Background() + + // 名称缺省自动生成;同 host:port 再建追加序号 + v1, err := svc.Create(ctx, ProxyInput{Type: "socks5", Host: "1.2.3.4", Port: 1080}) + if err != nil || v1.Name != "socks5-1.2.3.4:1080" { + t.Fatalf("auto name = %q, %v; want socks5-1.2.3.4:1080", v1.Name, err) + } + v2, err := svc.Create(ctx, ProxyInput{Type: "socks5", Host: "1.2.3.4", Port: 1080}) + if err != nil || v2.Name != "socks5-1.2.3.4:1080-2" { + t.Fatalf("auto name #2 = %q, %v; want socks5-1.2.3.4:1080-2", v2.Name, err) + } + + text := "# 注释与空行跳过\n\n" + + "socks5://u1:p1@9.9.9.9:1080\n" + + "https://8.8.8.8:8443\n" + + "7.7.7.7:1080:u2:p2\n" + + "badline\n" + res, err := svc.Import(ctx, text) + if err != nil { + t.Fatalf("import: %v", err) + } + if len(res.Created) != 3 || len(res.Failed) != 1 { + t.Fatalf("import = %d created / %d failed, want 3 / 1: %+v", len(res.Created), len(res.Failed), res) + } + if res.Created[0].Type != "socks5" || !res.Created[0].PasswordSet || res.Created[0].Username != "u1" { + t.Fatalf("URI 行解析 = %+v, want socks5 带凭据", res.Created[0]) + } + if res.Created[1].Type != "https" || res.Created[1].Port != 8443 { + t.Fatalf("https 行解析 = %+v", res.Created[1]) + } + if res.Created[2].Type != "socks5" || res.Created[2].Username != "u2" || !res.Created[2].PasswordSet { + t.Fatalf("colon 行解析 = %+v, want socks5 带凭据", res.Created[2]) + } + if res.Failed[0].LineNo != 6 || res.Failed[0].Reason == "" { + t.Fatalf("failed 行 = %+v, want lineNo=6 带原因", res.Failed[0]) + } +} + +func TestProxyImportMasksCredentials(t *testing.T) { + svc, _ := newProxyEnv(t) + ctx := context.Background() + + // 端口非法的行会失败并回显:凭据段必须已脱敏 + res, err := svc.Import(ctx, "socks5://user:topsecret@1.2.3.4:notaport\n1.2.3.4:notaport:user:topsecret") + if err != nil || len(res.Failed) != 2 { + t.Fatalf("import = %+v, %v; want 2 failed", res, err) + } + for _, f := range res.Failed { + if strings.Contains(f.Line, "topsecret") { + t.Fatalf("failed line %q 泄露密码", f.Line) + } + if !strings.Contains(f.Line, "***") { + t.Fatalf("failed line %q 未打码", f.Line) + } + } +} + +func TestProxyGeoProbe(t *testing.T) { + svc, db := newProxyEnv(t) + ctx := context.Background() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ok" { + _, _ = w.Write([]byte(`{"status":"success","country":"日本","city":"大阪市"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + orig := geoEndpoints + defer func() { geoEndpoints = orig }() + // 首个端点失败,验证 fallback 到第二个 + geoEndpoints = []geoEndpoint{ + {url: srv.URL + "/fail", parse: parseIPAPI}, + {url: srv.URL + "/ok", parse: parseIPAPI}, + } + svc.geoClientFor = func(*oci.ProxySpec) *http.Client { return srv.Client() } + + created, err := svc.Create(ctx, ProxyInput{Type: "socks5", Host: "1.2.3.4", Port: 1080}) + if err != nil { + t.Fatalf("create: %v", err) + } + svc.Wait() + var row model.Proxy + if err := db.First(&row, created.ID).Error; err != nil { + t.Fatalf("load row: %v", err) + } + if row.Country != "日本" || row.City != "大阪市" || row.GeoAt == nil { + t.Fatalf("geo = %q/%q geoAt=%v, want 日本/大阪市 带时间", row.Country, row.City, row.GeoAt) + } + + // 全部端点失败:Probe 写空值+时间(未知),不残留旧地区 + geoEndpoints = []geoEndpoint{{url: srv.URL + "/fail", parse: parseIPAPI}} + view, err := svc.Probe(ctx, created.ID) + if err != nil { + t.Fatalf("probe: %v", err) + } + if view.Country != "" || view.City != "" || view.GeoAt == "" { + t.Fatalf("probe view = %+v, want 空地区带探测时间", view) + } +} diff --git a/internal/service/proxygeo.go b/internal/service/proxygeo.go new file mode 100644 index 0000000..b9d8c6c --- /dev/null +++ b/internal/service/proxygeo.go @@ -0,0 +1,133 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "oci-portal/internal/model" +) + +// geoProbeTimeout 覆盖单次探测(含 fallback)的总时长。 +const geoProbeTimeout = 20 * time.Second + +// geoResult 是代理出口地理探测结果;空值表示探测失败(前端显示「未知」)。 +type geoResult struct { + Country string + City string +} + +// geoEndpoint 是单个出口地理 API 的地址与解析器。 +type geoEndpoint struct { + url string + parse func([]byte) (geoResult, bool) +} + +// geoEndpoints 依次尝试的免费出口地理 API,第一个成功即返回。 +// 主选 ip-api.com:免费 45 req/min(按出口 IP 计)、直接返回中文,但仅提供 http; +// 备选 ipwho.is:https、无显式限频,仅英文。均不带 IP 参数,即查代理出口本身。 +var geoEndpoints = []geoEndpoint{ + {url: "http://ip-api.com/json/?fields=status,country,city&lang=zh-CN", parse: parseIPAPI}, + {url: "https://ipwho.is/?fields=success,country,city", parse: parseIPWhois}, +} + +// parseIPAPI 解析 ip-api.com 响应。 +func parseIPAPI(b []byte) (geoResult, bool) { + var r struct { + Status string `json:"status"` + Country string `json:"country"` + City string `json:"city"` + } + if json.Unmarshal(b, &r) != nil || r.Status != "success" || r.Country == "" { + return geoResult{}, false + } + return geoResult{Country: r.Country, City: r.City}, true +} + +// parseIPWhois 解析 ipwho.is 响应。 +func parseIPWhois(b []byte) (geoResult, bool) { + var r struct { + Success bool `json:"success"` + Country string `json:"country"` + City string `json:"city"` + } + if json.Unmarshal(b, &r) != nil || !r.Success || r.Country == "" { + return geoResult{}, false + } + return geoResult{Country: r.Country, City: r.City}, true +} + +// probeGeoOnce 经指定 client 请求单个 geo API;任何失败返回 false。 +func probeGeoOnce(ctx context.Context, hc *http.Client, url string, parse func([]byte) (geoResult, bool)) (geoResult, bool) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return geoResult{}, false + } + resp, err := hc.Do(req) + if err != nil { + return geoResult{}, false + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) + if err != nil || resp.StatusCode != http.StatusOK { + return geoResult{}, false + } + return parse(body) +} + +// probeGeoAsync 后台探测出口地理并回填;创建 / 更新后调用,Wait 收敛。 +func (s *ProxyService) probeGeoAsync(id uint) { + s.wg.Add(1) + go func() { + defer s.wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), geoProbeTimeout) + defer cancel() + s.probeGeo(ctx, id) + }() +} + +// probeGeo 经该代理链路依次尝试 geo API 并写回地区字段; +// 请求全部失败也写空值+探测时间(前端显示「未知」),避免主机变更后残留旧地区; +// client 构造失败(spec 非法)则不动数据。 +func (s *ProxyService) probeGeo(ctx context.Context, id uint) { + spec, err := s.SpecOf(ctx, &id) + if err != nil { + return + } + hc := s.geoClientFor(spec) + if hc == nil { + return + } + res := geoResult{} + for _, ep := range geoEndpoints { + if r, ok := probeGeoOnce(ctx, hc, ep.url, ep.parse); ok { + res = r + break + } + } + var row model.Proxy + if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil { + return + } + now := time.Now() + row.Country, row.City, row.GeoAt = res.Country, res.City, &now + s.db.WithContext(ctx).Save(&row) +} + +// Probe 同步重测代理出口地理并返回最新视图;供手动「重测」入口调用。 +func (s *ProxyService) Probe(ctx context.Context, id uint) (ProxyView, error) { + var row model.Proxy + if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil { + return ProxyView{}, fmt.Errorf("find proxy %d: %w", id, err) + } + pctx, cancel := context.WithTimeout(ctx, geoProbeTimeout) + defer cancel() + s.probeGeo(pctx, id) + if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil { + return ProxyView{}, fmt.Errorf("find proxy %d: %w", id, err) + } + return s.viewOf(ctx, &row) +} diff --git a/internal/service/proxyimport.go b/internal/service/proxyimport.go new file mode 100644 index 0000000..7d164c3 --- /dev/null +++ b/internal/service/proxyimport.go @@ -0,0 +1,103 @@ +package service + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" +) + +// ImportResult 是批量导入的汇总结果。 +type ImportResult struct { + Created []ProxyView `json:"created"` + Failed []ImportFail `json:"failed"` +} + +// ImportFail 记录解析或入库失败的行;Line 已脱敏,凭据段以 *** 代替。 +type ImportFail struct { + LineNo int `json:"lineNo"` + Line string `json:"line"` + Reason string `json:"reason"` +} + +// Import 逐行解析并创建代理;空行与 # 注释跳过,失败行不阻断后续。 +func (s *ProxyService) Import(ctx context.Context, text string) (ImportResult, error) { + res := ImportResult{Created: []ProxyView{}, Failed: []ImportFail{}} + for i, raw := range strings.Split(text, "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + in, err := parseProxyLine(line) + if err == nil { + var view ProxyView + if view, err = s.Create(ctx, in); err == nil { + res.Created = append(res.Created, view) + continue + } + } + res.Failed = append(res.Failed, ImportFail{ + LineNo: i + 1, Line: maskProxyLine(line), Reason: err.Error(), + }) + } + return res, nil +} + +// parseProxyLine 解析一行代理,支持两种格式: +// +// scheme://[user:pass@]host:port(scheme 为 socks5 / http / https) +// host:port[:user:pass](缺省按 socks5) +func parseProxyLine(line string) (ProxyInput, error) { + if strings.Contains(line, "://") { + return parseProxyURI(line) + } + parts := strings.Split(line, ":") + if len(parts) != 2 && len(parts) != 4 { + return ProxyInput{}, fmt.Errorf("格式应为 host:port 或 host:port:user:pass: %w", ErrProxyInvalid) + } + port, err := strconv.Atoi(parts[1]) + if err != nil { + return ProxyInput{}, fmt.Errorf("端口非数字: %w", ErrProxyInvalid) + } + in := ProxyInput{Type: "socks5", Host: parts[0], Port: port} + if len(parts) == 4 { + in.Username = parts[2] + in.Password = &parts[3] + } + return in, nil +} + +// parseProxyURI 解析 scheme://[user:pass@]host:port。 +func parseProxyURI(line string) (ProxyInput, error) { + u, err := url.Parse(line) + if err != nil { + return ProxyInput{}, fmt.Errorf("URI 解析失败: %w", ErrProxyInvalid) + } + port, err := strconv.Atoi(u.Port()) + if err != nil { + return ProxyInput{}, fmt.Errorf("缺少或非法端口: %w", ErrProxyInvalid) + } + in := ProxyInput{Type: u.Scheme, Host: u.Hostname(), Port: port} + if u.User != nil { + in.Username = u.User.Username() + if pw, ok := u.User.Password(); ok { + in.Password = &pw + } + } + return in, nil +} + +// maskProxyLine 把行内可能的凭据段替换为 ***,避免密码回显到响应。 +func maskProxyLine(line string) string { + if i := strings.Index(line, "://"); i >= 0 { + if j := strings.LastIndex(line, "@"); j > i { + return line[:i+3] + "***@" + line[j+1:] + } + return line + } + if parts := strings.Split(line, ":"); len(parts) > 2 { + return strings.Join(parts[:2], ":") + ":***" + } + return line +} diff --git a/internal/service/region.go b/internal/service/region.go new file mode 100644 index 0000000..65afe3f --- /dev/null +++ b/internal/service/region.go @@ -0,0 +1,94 @@ +package service + +import ( + "context" + "fmt" + "strings" + + "oci-portal/internal/oci" +) + +// RegionSubscriptionView 在云端订阅信息之上补充本地维护的友好名称。 +type RegionSubscriptionView struct { + oci.RegionSubscription + Alias string `json:"alias"` +} + +// RegionSubscriptions 返回租户已订阅的区域列表(订阅 Tab 使用 SDK 实时数据); +// 开启多区域支持时与缓存比对,有差异即入库,保持缓存跟随真实状态。 +func (s *OciConfigService) RegionSubscriptions(ctx context.Context, id uint) ([]RegionSubscriptionView, error) { + cfg, err := s.Get(ctx, id) + if err != nil { + return nil, err + } + cred, err := s.credentialsOf(cfg) + if err != nil { + return nil, err + } + subs, err := s.client.ListRegionSubscriptions(ctx, cred) + if err != nil { + return nil, err + } + if cfg.MultiRegion { + s.reconcileRegionCache(ctx, cfg.ID, subs) + } + return enrichRegionAlias(subs), nil +} + +// SubscribeRegion 为租户订阅新区域并返回最新订阅列表。 +// OCI 区域订阅一经创建不可取消。 +func (s *OciConfigService) SubscribeRegion(ctx context.Context, id uint, regionKey string) ([]RegionSubscriptionView, error) { + regionKey = strings.ToUpper(strings.TrimSpace(regionKey)) + if regionKey == "" { + return nil, fmt.Errorf("subscribe region: region key is empty") + } + cfg, err := s.Get(ctx, id) + if err != nil { + return nil, err + } + cred, err := s.credentialsOf(cfg) + if err != nil { + return nil, err + } + // 订阅请求必须发往 home region;本地表查不到时退回配置默认区域 + if err := s.client.SubscribeRegion(ctx, cred, homeRegionOf(cfg), regionKey); err != nil { + return nil, err + } + subs, err := s.client.ListRegionSubscriptions(ctx, cred) + if err != nil { + return nil, err + } + // 订阅成功入库(新区域 IN_PROGRESS 状态随缓存读取持续刷新直到 READY) + if cfg.MultiRegion { + _ = s.saveRegionCache(ctx, cfg.ID, subs) + } + return enrichRegionAlias(subs), nil +} + +// Limits 查询租户在指定区域、服务下的配额。 +func (s *OciConfigService) Limits(ctx context.Context, id uint, q oci.LimitsQuery) ([]oci.LimitValue, error) { + if q.Service == "" { + return nil, fmt.Errorf("list limits: service is empty") + } + cfg, err := s.Get(ctx, id) + if err != nil { + return nil, err + } + cred, err := s.credentialsOf(cfg) + if err != nil { + return nil, err + } + return s.client.ListLimits(ctx, cred, q) +} + +func enrichRegionAlias(subs []oci.RegionSubscription) []RegionSubscriptionView { + views := make([]RegionSubscriptionView, 0, len(subs)) + for _, sub := range subs { + view := RegionSubscriptionView{RegionSubscription: sub} + if info, ok := oci.RegionByKey(sub.Key); ok { + view.Alias = info.Alias + } + views = append(views, view) + } + return views +} diff --git a/internal/service/region_test.go b/internal/service/region_test.go new file mode 100644 index 0000000..c1b4df2 --- /dev/null +++ b/internal/service/region_test.go @@ -0,0 +1,123 @@ +package service + +import ( + "context" + "errors" + "testing" + + "oci-portal/internal/model" + "oci-portal/internal/oci" +) + +// importAliveConfig 导入一份测活成功、home region 为 FRA 的配置。 +func importAliveConfig(t *testing.T, svc *OciConfigService) *model.OciConfig { + t.Helper() + cfg, err := svc.Import(context.Background(), trialImportInput()) + if err != nil { + t.Fatalf("Import: %v", err) + } + return cfg +} + +func TestRegionSubscriptionsEnrichesAlias(t *testing.T) { + client := &fakeClient{ + tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"}, + regionSubs: []oci.RegionSubscription{ + {Key: "FRA", Name: "eu-frankfurt-1", Status: "READY", IsHomeRegion: true}, + {Key: "XXX", Name: "not-in-local-table", Status: "READY"}, + }, + } + svc := newTestService(t, client) + cfg := importAliveConfig(t, svc) + + subs, err := svc.RegionSubscriptions(context.Background(), cfg.ID) + if err != nil { + t.Fatalf("RegionSubscriptions: %v", err) + } + if len(subs) != 2 { + t.Fatalf("len(subs) = %d, want 2", len(subs)) + } + if got, want := subs[0].Alias, "Germany Central (Frankfurt)"; got != want { + t.Errorf("subs[0].Alias = %q, want %q", got, want) + } + if got, want := subs[1].Alias, ""; got != want { + t.Errorf("subs[1].Alias = %q, want %q (本地表未收录时留空)", got, want) + } +} + +func TestSubscribeRegionUsesHomeRegion(t *testing.T) { + client := &fakeClient{ + tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"}, + regionSubs: []oci.RegionSubscription{ + {Key: "FRA", Name: "eu-frankfurt-1", Status: "READY", IsHomeRegion: true}, + {Key: "AMS", Name: "eu-amsterdam-1", Status: "IN_PROGRESS"}, + }, + } + svc := newTestService(t, client) + cfg := importAliveConfig(t, svc) + + subs, err := svc.SubscribeRegion(context.Background(), cfg.ID, " ams ") + if err != nil { + t.Fatalf("SubscribeRegion: %v", err) + } + if got, want := client.subscribedKey, "AMS"; got != want { + t.Errorf("subscribed key = %q, want %q (应去空格并大写)", got, want) + } + if got, want := client.subscribedHomeRegion, "eu-frankfurt-1"; got != want { + t.Errorf("home region = %q, want %q (应由 HomeRegionKey 映射)", got, want) + } + if len(subs) != 2 { + t.Errorf("len(subs) = %d, want 2 (订阅后返回最新列表)", len(subs)) + } +} + +func TestSubscribeRegionRejectsEmptyKey(t *testing.T) { + svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}) + cfg := importAliveConfig(t, svc) + if _, err := svc.SubscribeRegion(context.Background(), cfg.ID, " "); err == nil { + t.Error("SubscribeRegion(empty): got nil error, want failure") + } +} + +func TestSubscribeRegionPropagatesError(t *testing.T) { + client := &fakeClient{ + tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"}, + subscribeErr: errors.New("NotAuthorizedOrNotFound"), + } + svc := newTestService(t, client) + cfg := importAliveConfig(t, svc) + if _, err := svc.SubscribeRegion(context.Background(), cfg.ID, "AMS"); err == nil { + t.Error("SubscribeRegion with cloud error: got nil error, want failure") + } +} + +func TestLimitsPassesQuery(t *testing.T) { + client := &fakeClient{ + tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"}, + limitValues: []oci.LimitValue{ + {Name: "standard-a1-core-count", ScopeType: "AD", AvailabilityDomain: "AD-1", Value: 6}, + }, + } + svc := newTestService(t, client) + cfg := importAliveConfig(t, svc) + + q := oci.LimitsQuery{Region: "eu-frankfurt-1", Service: "compute", Name: "standard-a1-core-count", WithAvailability: true} + values, err := svc.Limits(context.Background(), cfg.ID, q) + if err != nil { + t.Fatalf("Limits: %v", err) + } + if len(values) != 1 { + t.Fatalf("len(values) = %d, want 1", len(values)) + } + if got, want := client.limitsQuery, q; got != want { + t.Errorf("query passed to client = %+v, want %+v", got, want) + } +} + +func TestLimitsRequiresService(t *testing.T) { + svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}) + cfg := importAliveConfig(t, svc) + if _, err := svc.Limits(context.Background(), cfg.ID, oci.LimitsQuery{}); err == nil { + t.Error("Limits without service: got nil error, want failure") + } +} diff --git a/internal/service/scopecache.go b/internal/service/scopecache.go new file mode 100644 index 0000000..af7869e --- /dev/null +++ b/internal/service/scopecache.go @@ -0,0 +1,212 @@ +package service + +import ( + "context" + "fmt" + + "gorm.io/gorm" + + "oci-portal/internal/model" + "oci-portal/internal/oci" +) + +// syncScopeCaches 按配置开关同步区域 / 区间缓存:开启则整组刷新,关闭则清空。 +// 随测活(导入 / 修改 / 测活按钮 / 定时任务)触发;OCI 调用失败不影响测活主流程。 +func (s *OciConfigService) syncScopeCaches(ctx context.Context, cfg *model.OciConfig, cred oci.Credentials) { + if cfg.MultiRegion { + if subs, err := s.client.ListRegionSubscriptions(ctx, cred); err == nil { + _ = s.saveRegionCache(ctx, cfg.ID, subs) + } + } else { + s.db.WithContext(ctx).Where("oci_config_id = ?", cfg.ID).Delete(&model.RegionCache{}) + } + if cfg.MultiCompartment { + if comps, err := s.client.ListCompartments(ctx, cred); err == nil { + _ = s.saveCompartmentCache(ctx, cfg.ID, comps) + } + } else { + s.db.WithContext(ctx).Where("oci_config_id = ?", cfg.ID).Delete(&model.CompartmentCache{}) + } +} + +// saveRegionCache 整组覆盖写入订阅区域缓存。 +func (s *OciConfigService) saveRegionCache(ctx context.Context, cfgID uint, subs []oci.RegionSubscription) error { + rows := make([]model.RegionCache, 0, len(subs)) + for _, sub := range subs { + rows = append(rows, model.RegionCache{ + OciConfigID: cfgID, Key: sub.Key, Name: sub.Name, + Status: sub.Status, IsHomeRegion: sub.IsHomeRegion, + }) + } + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("oci_config_id = ?", cfgID).Delete(&model.RegionCache{}).Error; err != nil { + return err + } + if len(rows) == 0 { + return nil + } + return tx.Create(&rows).Error + }) +} + +// saveCompartmentCache 整组覆盖写入 compartment 缓存。 +func (s *OciConfigService) saveCompartmentCache(ctx context.Context, cfgID uint, comps []oci.Compartment) error { + rows := make([]model.CompartmentCache, 0, len(comps)) + for _, c := range comps { + rows = append(rows, model.CompartmentCache{ + OciConfigID: cfgID, OCID: c.ID, Name: c.Name, State: c.LifecycleState, + }) + } + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("oci_config_id = ?", cfgID).Delete(&model.CompartmentCache{}).Error; err != nil { + return err + } + if len(rows) == 0 { + return nil + } + return tx.Create(&rows).Error + }) +} + +// CachedRegions 返回筛选器用的区域列表:未开启多区域支持时锁定为配置默认区域; +// 缓存缺失或存在非 READY 状态(订阅进行中)时实时刷新,直到全部 READY。 +func (s *OciConfigService) CachedRegions(ctx context.Context, id uint) ([]RegionSubscriptionView, error) { + cfg, err := s.Get(ctx, id) + if err != nil { + return nil, err + } + if !cfg.MultiRegion { + return defaultOnlyRegion(cfg), nil + } + var rows []model.RegionCache + if err := s.db.WithContext(ctx).Where("oci_config_id = ?", id).Find(&rows).Error; err != nil { + return nil, fmt.Errorf("load region cache: %w", err) + } + if len(rows) == 0 || hasPendingRegion(rows) { + if fresh, err := s.refreshRegionCache(ctx, cfg); err == nil { + return fresh, nil + } + // 实时刷新失败时退回现有缓存,避免筛选器整体不可用 + } + return regionCacheViews(rows), nil +} + +// refreshRegionCache 实时拉取订阅区域并覆盖缓存。 +func (s *OciConfigService) refreshRegionCache(ctx context.Context, cfg *model.OciConfig) ([]RegionSubscriptionView, error) { + cred, err := s.credentialsOf(cfg) + if err != nil { + return nil, err + } + subs, err := s.client.ListRegionSubscriptions(ctx, cred) + if err != nil { + return nil, err + } + if err := s.saveRegionCache(ctx, cfg.ID, subs); err != nil { + return nil, err + } + return enrichRegionAlias(subs), nil +} + +// reconcileRegionCache 把 SDK 实时数据与缓存比较,有差异时覆盖缓存。 +func (s *OciConfigService) reconcileRegionCache(ctx context.Context, cfgID uint, subs []oci.RegionSubscription) { + var rows []model.RegionCache + if err := s.db.WithContext(ctx).Where("oci_config_id = ?", cfgID).Find(&rows).Error; err != nil { + return + } + if regionCacheDiffers(rows, subs) { + _ = s.saveRegionCache(ctx, cfgID, subs) + } +} + +func regionCacheDiffers(rows []model.RegionCache, subs []oci.RegionSubscription) bool { + if len(rows) != len(subs) { + return true + } + byKey := make(map[string]model.RegionCache, len(rows)) + for _, r := range rows { + byKey[r.Key] = r + } + for _, sub := range subs { + r, ok := byKey[sub.Key] + if !ok || r.Status != sub.Status || r.Name != sub.Name || r.IsHomeRegion != sub.IsHomeRegion { + return true + } + } + return false +} + +func hasPendingRegion(rows []model.RegionCache) bool { + for _, r := range rows { + if r.Status != regionStatusReady { + return true + } + } + return false +} + +// regionStatusReady 是 OCI 区域订阅完成态。 +const regionStatusReady = "READY" + +// defaultOnlyRegion 为未开启多区域支持的配置合成单元素列表(配置默认区域)。 +func defaultOnlyRegion(cfg *model.OciConfig) []RegionSubscriptionView { + sub := oci.RegionSubscription{ + Key: cfg.HomeRegionKey, Name: cfg.Region, Status: regionStatusReady, + } + if info, ok := oci.RegionByKey(cfg.HomeRegionKey); ok { + sub.IsHomeRegion = info.Name == cfg.Region + } + return enrichRegionAlias([]oci.RegionSubscription{sub}) +} + +func regionCacheViews(rows []model.RegionCache) []RegionSubscriptionView { + subs := make([]oci.RegionSubscription, 0, len(rows)) + for _, r := range rows { + subs = append(subs, oci.RegionSubscription{ + Key: r.Key, Name: r.Name, Status: r.Status, IsHomeRegion: r.IsHomeRegion, + }) + } + // 与实时接口一致:主区域置顶,其余按 alias 字典序 + oci.SortRegionSubscriptions(subs) + return enrichRegionAlias(subs) +} + +// CachedCompartments 返回筛选器用的区间列表:未开启多区间支持返回空(前端锁定根); +// 已开启但缓存为空时实时拉取一次入库。 +func (s *OciConfigService) CachedCompartments(ctx context.Context, id uint) ([]oci.Compartment, error) { + cfg, err := s.Get(ctx, id) + if err != nil { + return nil, err + } + if !cfg.MultiCompartment { + return []oci.Compartment{}, nil + } + var rows []model.CompartmentCache + if err := s.db.WithContext(ctx).Where("oci_config_id = ?", id). + Order("name").Find(&rows).Error; err != nil { + return nil, fmt.Errorf("load compartment cache: %w", err) + } + if len(rows) == 0 { + return s.refreshCompartmentCache(ctx, cfg) + } + comps := make([]oci.Compartment, 0, len(rows)) + for _, r := range rows { + comps = append(comps, oci.Compartment{ID: r.OCID, Name: r.Name, LifecycleState: r.State}) + } + return comps, nil +} + +// refreshCompartmentCache 实时拉取 compartment 并覆盖缓存。 +func (s *OciConfigService) refreshCompartmentCache(ctx context.Context, cfg *model.OciConfig) ([]oci.Compartment, error) { + cred, err := s.credentialsOf(cfg) + if err != nil { + return nil, err + } + comps, err := s.client.ListCompartments(ctx, cred) + if err != nil { + return nil, err + } + if err := s.saveCompartmentCache(ctx, cfg.ID, comps); err != nil { + return nil, err + } + return comps, nil +} diff --git a/internal/service/scopecache_test.go b/internal/service/scopecache_test.go new file mode 100644 index 0000000..178a178 --- /dev/null +++ b/internal/service/scopecache_test.go @@ -0,0 +1,200 @@ +package service + +import ( + "context" + "testing" + + "oci-portal/internal/model" + "oci-portal/internal/oci" +) + +func multiScopeClient() *fakeClient { + return &fakeClient{ + tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"}, + regionSubs: []oci.RegionSubscription{ + {Key: "FRA", Name: "eu-frankfurt-1", Status: "READY", IsHomeRegion: true}, + {Key: "AMS", Name: "eu-amsterdam-1", Status: "READY"}, + }, + compartments: []oci.Compartment{ + {ID: "ocid1.compartment..a", Name: "instances", LifecycleState: "ACTIVE"}, + }, + } +} + +func importMultiConfig(t *testing.T, svc *OciConfigService) *model.OciConfig { + t.Helper() + in := trialImportInput() + in.MultiRegion = true + in.MultiCompartment = true + cfg, err := svc.Import(context.Background(), in) + if err != nil { + t.Fatalf("Import: %v", err) + } + return cfg +} + +func TestImportSyncsScopeCaches(t *testing.T) { + client := multiScopeClient() + svc := newTestService(t, client) + cfg := importMultiConfig(t, svc) + + if !cfg.MultiRegion || !cfg.MultiCompartment { + t.Fatalf("cfg switches = %v/%v, want true/true", cfg.MultiRegion, cfg.MultiCompartment) + } + var regions []model.RegionCache + svc.db.Where("oci_config_id = ?", cfg.ID).Find(®ions) + if len(regions) != 2 { + t.Errorf("region cache rows = %d, want 2", len(regions)) + } + var comps []model.CompartmentCache + svc.db.Where("oci_config_id = ?", cfg.ID).Find(&comps) + if len(comps) != 1 || comps[0].OCID != "ocid1.compartment..a" { + t.Errorf("compartment cache = %+v, want 1 row ocid1.compartment..a", comps) + } +} + +func TestImportWithoutSwitchesSkipsCaches(t *testing.T) { + client := multiScopeClient() + svc := newTestService(t, client) + cfg := importAliveConfig(t, svc) + + var count int64 + svc.db.Model(&model.RegionCache{}).Where("oci_config_id = ?", cfg.ID).Count(&count) + if count != 0 { + t.Errorf("region cache rows = %d, want 0(未开启不入库)", count) + } +} + +func TestCachedRegions(t *testing.T) { + tests := []struct { + name string + prepare func(svc *OciConfigService, cfg *model.OciConfig, client *fakeClient) + wantNames []string + wantCalls int // 缓存读取阶段期望的 SDK 调用次数 + }{ + { + name: "全 READY 直接用缓存", + prepare: func(*OciConfigService, *model.OciConfig, *fakeClient) {}, + wantNames: []string{"eu-frankfurt-1", "eu-amsterdam-1"}, + wantCalls: 0, + }, + { + name: "存在非 READY 强制实时刷新", + prepare: func(svc *OciConfigService, cfg *model.OciConfig, client *fakeClient) { + svc.db.Model(&model.RegionCache{}). + Where("oci_config_id = ? AND key = ?", cfg.ID, "AMS"). + Update("status", "IN_PROGRESS") + client.regionSubs[1].Status = "READY" // SDK 已完成 + }, + wantNames: []string{"eu-frankfurt-1", "eu-amsterdam-1"}, + wantCalls: 1, + }, + { + name: "缓存为空实时拉取入库", + prepare: func(svc *OciConfigService, cfg *model.OciConfig, client *fakeClient) { + svc.db.Where("oci_config_id = ?", cfg.ID).Delete(&model.RegionCache{}) + }, + wantNames: []string{"eu-frankfurt-1", "eu-amsterdam-1"}, + wantCalls: 1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := multiScopeClient() + svc := newTestService(t, client) + cfg := importMultiConfig(t, svc) + tt.prepare(svc, cfg, client) + client.regionSubsCalls = 0 + + views, err := svc.CachedRegions(context.Background(), cfg.ID) + if err != nil { + t.Fatalf("CachedRegions: %v", err) + } + got := make([]string, 0, len(views)) + for _, v := range views { + got = append(got, v.Name) + } + if len(got) != len(tt.wantNames) { + t.Fatalf("regions = %v, want %v", got, tt.wantNames) + } + if client.regionSubsCalls != tt.wantCalls { + t.Errorf("SDK calls = %d, want %d", client.regionSubsCalls, tt.wantCalls) + } + }) + } +} + +func TestCachedRegionsWithoutMultiRegion(t *testing.T) { + client := multiScopeClient() + svc := newTestService(t, client) + cfg := importAliveConfig(t, svc) + client.regionSubsCalls = 0 + + views, err := svc.CachedRegions(context.Background(), cfg.ID) + if err != nil { + t.Fatalf("CachedRegions: %v", err) + } + if len(views) != 1 || views[0].Name != "eu-frankfurt-1" || !views[0].IsHomeRegion { + t.Errorf("views = %+v, want 仅默认区域且标记主区域", views) + } + if client.regionSubsCalls != 0 { + t.Errorf("SDK calls = %d, want 0(未开启不实时请求)", client.regionSubsCalls) + } +} + +func TestCachedCompartments(t *testing.T) { + client := multiScopeClient() + svc := newTestService(t, client) + multiCfg := importMultiConfig(t, svc) + + comps, err := svc.CachedCompartments(context.Background(), multiCfg.ID) + if err != nil { + t.Fatalf("CachedCompartments: %v", err) + } + if len(comps) != 1 || comps[0].ID != "ocid1.compartment..a" { + t.Errorf("comps = %+v, want 缓存中的 1 项", comps) + } + + in := trialImportInput() + in.Alias = "未开启" + plainCfg, err := svc.Import(context.Background(), in) + if err != nil { + t.Fatalf("Import: %v", err) + } + comps, err = svc.CachedCompartments(context.Background(), plainCfg.ID) + if err != nil { + t.Fatalf("CachedCompartments: %v", err) + } + if len(comps) != 0 { + t.Errorf("comps = %+v, want 空(未开启多区间)", comps) + } +} + +func TestRegionSubscriptionsReconcilesCache(t *testing.T) { + client := multiScopeClient() + svc := newTestService(t, client) + cfg := importMultiConfig(t, svc) + + // SDK 侧新订阅一个区域,订阅 Tab 实时读取后缓存应跟进 + client.regionSubs = append(client.regionSubs, + oci.RegionSubscription{Key: "ICN", Name: "ap-seoul-1", Status: "IN_PROGRESS"}) + if _, err := svc.RegionSubscriptions(context.Background(), cfg.ID); err != nil { + t.Fatalf("RegionSubscriptions: %v", err) + } + var rows []model.RegionCache + svc.db.Where("oci_config_id = ?", cfg.ID).Find(&rows) + if len(rows) != 3 { + t.Fatalf("cache rows = %d, want 3(diff 后跟进)", len(rows)) + } + + // 关闭开关的配置不应回写缓存 + off := false + if _, _, err := svc.Update(context.Background(), cfg.ID, UpdateInput{MultiRegion: &off}); err != nil { + t.Fatalf("Update: %v", err) + } + var count int64 + svc.db.Model(&model.RegionCache{}).Where("oci_config_id = ?", cfg.ID).Count(&count) + if count != 0 { + t.Errorf("cache rows = %d, want 0(关闭开关清空缓存)", count) + } +} diff --git a/internal/service/security.go b/internal/service/security.go new file mode 100644 index 0000000..a5a0a1b --- /dev/null +++ b/internal/service/security.go @@ -0,0 +1,190 @@ +package service + +import ( + "context" + "fmt" + "regexp" + "strconv" + "strings" +) + +// 安全设置键:WAF 参数 / 真实IP请求头 / 面板地址(app_url)。 +const ( + settingSecLoginFailLimit = "security_login_fail_limit" + settingSecLoginLockMin = "security_login_lock_minutes" + settingSecIPRateRPS = "security_ip_rate_rps" + settingSecIPRateBurst = "security_ip_rate_burst" + settingSecRealIPHeader = "security_real_ip_header" + settingSecAppURL = "security_app_url" + // 密码登录禁用开关("1" 为禁用);启停校验在 AuthService.SetPasswordLoginDisabled + settingSecPasswordLoginOff = "security_password_login_off" +) + +// SecuritySettings 是安全设置的读写视图;零值无意义,一律经 Security/SecurityCached 构造。 +type SecuritySettings struct { + // 登录守卫:LockMinutes 分钟窗口内失败 FailLimit 次锁 LockMinutes 分钟 + LoginFailLimit int `json:"loginFailLimit"` + LoginLockMinutes int `json:"loginLockMinutes"` + // 全局 IP 限速令牌桶 + IPRateRPS int `json:"ipRateRps"` + IPRateBurst int `json:"ipRateBurst"` + // 真实IP请求头:空 = 直连(RemoteAddr);反代后必须选择与反代匹配的头 + RealIPHeader string `json:"realIpHeader"` + // 面板公网基址;优先于 PUBLIC_URL 环境变量,回传链路与 OAuth 回调用 + AppURL string `json:"appUrl"` +} + +// securityDefaults 与探索文档主题三的推荐参数一致。 +var securityDefaults = SecuritySettings{ + LoginFailLimit: 5, + LoginLockMinutes: 15, + IPRateRPS: 10, + IPRateBurst: 30, + RealIPHeader: "", + AppURL: "", +} + +// realIPHeaderPattern 校验真实IP请求头名:空为直连,非空仅限字母数字与连字符 +// (RFC 7230 token 的常用子集),支持 X-Forwarded-For 等预设外的自定义头。 +var realIPHeaderPattern = regexp.MustCompile(`^[A-Za-z0-9-]{1,64}$`) + +func realIPHeaderValid(h string) bool { + return h == "" || realIPHeaderPattern.MatchString(h) +} + +// ErrInvalidSecurity 标记安全设置越界或非法,api 层据此返回 400。 +var ErrInvalidSecurity = fmt.Errorf("安全设置非法") + +// validateSecurity 校验范围;AppURL 规范化(去尾斜杠)。 +func validateSecurity(in *SecuritySettings) error { + switch { + case in.LoginFailLimit < 1 || in.LoginFailLimit > 20: + return fmt.Errorf("登录失败阈值须在 1-20 之间: %w", ErrInvalidSecurity) + case in.LoginLockMinutes < 1 || in.LoginLockMinutes > 1440: + return fmt.Errorf("锁定时长须在 1-1440 分钟之间: %w", ErrInvalidSecurity) + case in.IPRateRPS < 1 || in.IPRateRPS > 100: + return fmt.Errorf("IP 限速须在 1-100 req/s 之间: %w", ErrInvalidSecurity) + case in.IPRateBurst < 1 || in.IPRateBurst > 500: + return fmt.Errorf("突发额度须在 1-500 之间: %w", ErrInvalidSecurity) + case !realIPHeaderValid(in.RealIPHeader): + return fmt.Errorf("真实IP请求头仅限字母数字与连字符: %w", ErrInvalidSecurity) + } + in.AppURL = strings.TrimRight(strings.TrimSpace(in.AppURL), "/") + if in.AppURL != "" && !strings.HasPrefix(in.AppURL, "http://") && !strings.HasPrefix(in.AppURL, "https://") { + return fmt.Errorf("面板地址须以 http(s):// 开头: %w", ErrInvalidSecurity) + } + return nil +} + +// Security 从库读安全设置;键缺省或脏数据按字段回退默认值。 +func (s *SettingService) Security(ctx context.Context) (SecuritySettings, error) { + out := securityDefaults + ints := []struct { + key string + dst *int + lo int + hi int + }{ + {settingSecLoginFailLimit, &out.LoginFailLimit, 1, 20}, + {settingSecLoginLockMin, &out.LoginLockMinutes, 1, 1440}, + {settingSecIPRateRPS, &out.IPRateRPS, 1, 100}, + {settingSecIPRateBurst, &out.IPRateBurst, 1, 500}, + } + for _, f := range ints { + val, err := s.get(ctx, f.key) + if err != nil { + return SecuritySettings{}, err + } + if n, err := strconv.Atoi(val); err == nil && n >= f.lo && n <= f.hi { + *f.dst = n + } + } + header, err := s.get(ctx, settingSecRealIPHeader) + if err != nil { + return SecuritySettings{}, err + } + if realIPHeaderValid(header) { + out.RealIPHeader = header + } + if out.AppURL, err = s.get(ctx, settingSecAppURL); err != nil { + return SecuritySettings{}, err + } + return out, nil +} + +// UpdateSecurity 校验并保存安全设置,随后刷新内存快照立即生效。 +func (s *SettingService) UpdateSecurity(ctx context.Context, in SecuritySettings) error { + if err := validateSecurity(&in); err != nil { + return err + } + kv := map[string]string{ + settingSecLoginFailLimit: strconv.Itoa(in.LoginFailLimit), + settingSecLoginLockMin: strconv.Itoa(in.LoginLockMinutes), + settingSecIPRateRPS: strconv.Itoa(in.IPRateRPS), + settingSecIPRateBurst: strconv.Itoa(in.IPRateBurst), + settingSecRealIPHeader: in.RealIPHeader, + settingSecAppURL: in.AppURL, + } + for key, value := range kv { + if err := s.set(ctx, key, value); err != nil { + return err + } + } + s.security.Store(in) + return nil +} + +// ReloadSecurity 从库加载安全设置到内存快照;进程启动时调用一次。 +func (s *SettingService) ReloadSecurity(ctx context.Context) error { + sec, err := s.Security(ctx) + if err != nil { + return err + } + s.security.Store(sec) + return nil +} + +// SecurityCached 返回安全设置内存快照,供每请求热路径零 IO 读取; +// 快照未初始化(启动未 Reload 或测试环境)时返回默认值。 +func (s *SettingService) SecurityCached() SecuritySettings { + if v, ok := s.security.Load().(SecuritySettings); ok { + return v + } + return securityDefaults +} + +// securityOf 是 nil 容忍的快照读取,供未注入 settings 的调用方(测试)回退默认。 +func securityOf(s *SettingService) SecuritySettings { + if s == nil { + return securityDefaults + } + return s.SecurityCached() +} + +// SetEnvPublicURL 记录 PUBLIC_URL 环境变量,作为 app_url 未配置时的回退。 +func (s *SettingService) SetEnvPublicURL(url string) { + s.envPublicURL = strings.TrimRight(url, "/") +} + +// PasswordLoginDisabled 读密码登录禁用开关;键缺省视为未禁用。 +func (s *SettingService) PasswordLoginDisabled(ctx context.Context) (bool, error) { + v, err := s.get(ctx, settingSecPasswordLoginOff) + return v == "1", err +} + +// SetPasswordLoginDisabled 保存密码登录禁用开关。 +func (s *SettingService) SetPasswordLoginDisabled(ctx context.Context, disabled bool) error { + v := "" + if disabled { + v = "1" + } + return s.set(ctx, settingSecPasswordLoginOff, v) +} + +// EffectiveAppURL 返回生效的面板公网基址:设置里的 app_url 优先,回退环境变量。 +func (s *SettingService) EffectiveAppURL() string { + if u := s.SecurityCached().AppURL; u != "" { + return u + } + return s.envPublicURL +} diff --git a/internal/service/security_test.go b/internal/service/security_test.go new file mode 100644 index 0000000..2b58834 --- /dev/null +++ b/internal/service/security_test.go @@ -0,0 +1,121 @@ +package service + +import ( + "context" + "errors" + "testing" +) + +func TestSecurityReadWrite(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, svc *SettingService) + want SecuritySettings + }{ + { + name: "键全部缺省回退默认", + setup: func(t *testing.T, svc *SettingService) {}, + want: securityDefaults, + }, + { + name: "写入后读回", + setup: func(t *testing.T, svc *SettingService) { + in := SecuritySettings{LoginFailLimit: 10, LoginLockMinutes: 30, IPRateRPS: 20, IPRateBurst: 60, RealIPHeader: "X-Client-IP", AppURL: "https://demo.example.com/"} + if err := svc.UpdateSecurity(context.Background(), in); err != nil { + t.Fatalf("UpdateSecurity: %v", err) + } + }, + want: SecuritySettings{LoginFailLimit: 10, LoginLockMinutes: 30, IPRateRPS: 20, IPRateBurst: 60, RealIPHeader: "X-Client-IP", AppURL: "https://demo.example.com"}, + }, + { + name: "脏数据按字段回退默认", + setup: func(t *testing.T, svc *SettingService) { + for k, v := range map[string]string{ + settingSecLoginFailLimit: "abc", + settingSecIPRateRPS: "9999", + settingSecRealIPHeader: "X Evil Header\r\n", + } { + if err := svc.set(context.Background(), k, v); err != nil { + t.Fatalf("set: %v", err) + } + } + }, + want: securityDefaults, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, _ := newSettingEnv(t) + tt.setup(t, svc) + got, err := svc.Security(context.Background()) + if err != nil { + t.Fatalf("Security: %v", err) + } + if got != tt.want { + t.Errorf("settings = %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestUpdateSecurityRejectsInvalid(t *testing.T) { + base := securityDefaults + tests := []struct { + name string + mutate func(*SecuritySettings) + }{ + {name: "失败阈值越界", mutate: func(s *SecuritySettings) { s.LoginFailLimit = 0 }}, + {name: "锁定时长越界", mutate: func(s *SecuritySettings) { s.LoginLockMinutes = 1441 }}, + {name: "限速越界", mutate: func(s *SecuritySettings) { s.IPRateRPS = 101 }}, + {name: "突发越界", mutate: func(s *SecuritySettings) { s.IPRateBurst = 0 }}, + {name: "请求头含非法字符", mutate: func(s *SecuritySettings) { s.RealIPHeader = "X Custom;" }}, + {name: "面板地址缺协议", mutate: func(s *SecuritySettings) { s.AppURL = "demo.example.com" }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, _ := newSettingEnv(t) + in := base + tt.mutate(&in) + if err := svc.UpdateSecurity(context.Background(), in); !errors.Is(err, ErrInvalidSecurity) { + t.Fatalf("err = %v, want ErrInvalidSecurity", err) + } + }) + } +} + +func TestSecurityCachedSnapshot(t *testing.T) { + svc, _ := newSettingEnv(t) + // 未初始化时返回默认,不触库 + if got := svc.SecurityCached(); got != securityDefaults { + t.Errorf("cold cache = %+v, want defaults", got) + } + in := securityDefaults + in.LoginFailLimit = 8 + if err := svc.UpdateSecurity(context.Background(), in); err != nil { + t.Fatalf("UpdateSecurity: %v", err) + } + // Update 即刷新快照 + if got := svc.SecurityCached(); got.LoginFailLimit != 8 { + t.Errorf("cached limit = %d, want 8", got.LoginFailLimit) + } + // nil 容忍 + if got := securityOf(nil); got != securityDefaults { + t.Errorf("securityOf(nil) = %+v, want defaults", got) + } +} + +func TestEffectiveAppURL(t *testing.T) { + svc, _ := newSettingEnv(t) + svc.SetEnvPublicURL("https://env.example.com/") + if got := svc.EffectiveAppURL(); got != "https://env.example.com" { + t.Errorf("env fallback = %q", got) + } + in := securityDefaults + in.AppURL = "https://app.example.com" + if err := svc.UpdateSecurity(context.Background(), in); err != nil { + t.Fatalf("UpdateSecurity: %v", err) + } + if got := svc.EffectiveAppURL(); got != "https://app.example.com" { + t.Errorf("app_url 应优先, got %q", got) + } +} diff --git a/internal/service/setting.go b/internal/service/setting.go new file mode 100644 index 0000000..4329e75 --- /dev/null +++ b/internal/service/setting.go @@ -0,0 +1,346 @@ +package service + +import ( + "context" + "errors" + "fmt" + "log" + "strconv" + "strings" + "sync/atomic" + "time" + + "gorm.io/gorm" + + "oci-portal/internal/crypto" + "oci-portal/internal/model" +) + +// Telegram 通知相关的配置键。 +const ( + settingTelegramEnabled = "telegram_enabled" // "1" / "0" + settingTelegramBotToken = "telegram_bot_token" // AES-GCM 密文 + settingTelegramChatID = "telegram_chat_id" // 明文 +) + +// 通知事件开关的配置键(notify_event_{kind}),值 "1"/"0"; +// 键缺省(存量部署从未保存过)视为开启,只有显式 "0" 视为关闭。 +// 云端事件按类别细分(旧总开关键 notify_event_log_event 已废弃,库中遗留无害)。 +const ( + settingNotifyEventTaskFail = "notify_event_task_fail" + settingNotifyEventTaskRecover = "notify_event_task_recover" + settingNotifyEventSnatchSuccess = "notify_event_snatch_success" + settingNotifyEventTenantDead = "notify_event_tenant_dead" + settingNotifyEventTaskStop = "notify_event_task_stop" + settingNotifyEventLoginLock = "notify_event_login_lock" + settingNotifyEventModelDeprecated = "notify_event_model_deprecated" + settingNotifyEventLogEventInstance = "notify_event_log_event_instance" + settingNotifyEventLogEventIdentity = "notify_event_log_event_identity" + settingNotifyEventLogEventPolicy = "notify_event_log_event_policy" + settingNotifyEventLogEventRegion = "notify_event_log_event_region" + settingNotifyEventLogEventLogin = "notify_event_log_event_login" +) + +// 任务行为相关的配置键与取值约束(抢机连续 NotAuthenticated 熔断阈值)。 +const ( + settingSnatchAuthFailLimit = "snatch_auth_fail_limit" + defaultSnatchAuthFailLimit = 3 + minSnatchAuthFailLimit = 1 + maxSnatchAuthFailLimit = 10 +) + +// ErrInvalidAuthFailLimit 标记抢机熔断阈值越界,api 层据此返回 400。 +var ErrInvalidAuthFailLimit = fmt.Errorf("熔断阈值须在 %d-%d 之间", minSnatchAuthFailLimit, maxSnatchAuthFailLimit) + +// SettingService 读写系统级键值配置,敏感值经 Cipher 加密落库; +// 安全设置(WAF/真实IP头/app_url)另持内存快照供每请求热路径零 IO 读取。 +type SettingService struct { + db *gorm.DB + cipher *crypto.Cipher + security atomic.Value // SecuritySettings 快照,ReloadSecurity/UpdateSecurity 刷新 + envPublicURL string // PUBLIC_URL 环境变量,app_url 未配置时的回退 +} + +// NewSettingService 组装依赖。 +func NewSettingService(db *gorm.DB, cipher *crypto.Cipher) *SettingService { + return &SettingService{db: db, cipher: cipher} +} + +// get 读取键值;键不存在视为未配置,返回空串。 +func (s *SettingService) get(ctx context.Context, key string) (string, error) { + var st model.Setting + err := s.db.WithContext(ctx).First(&st, "key = ?", key).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("get setting %s: %w", key, err) + } + return st.Value, nil +} + +// set 写入键值(key 为主键,Save 即 upsert)。 +func (s *SettingService) set(ctx context.Context, key, value string) error { + st := model.Setting{Key: key, Value: value, UpdatedAt: time.Now()} + if err := s.db.WithContext(ctx).Save(&st).Error; err != nil { + return fmt.Errorf("set setting %s: %w", key, err) + } + return nil +} + +// getMany 批量读取多个键;任一失败即返回错误。 +func (s *SettingService) getMany(ctx context.Context, keys ...string) (map[string]string, error) { + out := make(map[string]string, len(keys)) + for _, key := range keys { + value, err := s.get(ctx, key) + if err != nil { + return nil, err + } + out[key] = value + } + return out, nil +} + +// TelegramConfig 是通知发送所需的完整配置,token 已解密,仅供服务内部消费。 +type TelegramConfig struct { + Enabled bool + BotToken string + ChatID string +} + +// TelegramConfig 返回解密后的 Telegram 配置。 +func (s *SettingService) TelegramConfig(ctx context.Context) (TelegramConfig, error) { + enabled, err := s.get(ctx, settingTelegramEnabled) + if err != nil { + return TelegramConfig{}, err + } + chatID, err := s.get(ctx, settingTelegramChatID) + if err != nil { + return TelegramConfig{}, err + } + token, err := s.telegramToken(ctx) + if err != nil { + return TelegramConfig{}, err + } + return TelegramConfig{Enabled: enabled == "1", BotToken: token, ChatID: chatID}, nil +} + +// telegramToken 读取并解密 bot token;未配置时返回空串。 +func (s *SettingService) telegramToken(ctx context.Context) (string, error) { + enc, err := s.get(ctx, settingTelegramBotToken) + if err != nil || enc == "" { + return "", err + } + token, err := s.cipher.DecryptString(enc) + if err != nil { + return "", fmt.Errorf("decrypt telegram token: %w", err) + } + return token, nil +} + +// TelegramView 是设置接口的响应视图,绝不包含 token 明文。 +type TelegramView struct { + Enabled bool `json:"enabled"` + ChatID string `json:"chatId"` + TokenSet bool `json:"tokenSet"` + TokenTail string `json:"tokenTail"` +} + +// TelegramView 返回脱敏后的 Telegram 配置视图。 +func (s *SettingService) TelegramView(ctx context.Context) (TelegramView, error) { + cfg, err := s.TelegramConfig(ctx) + if err != nil { + return TelegramView{}, err + } + return TelegramView{ + Enabled: cfg.Enabled, + ChatID: cfg.ChatID, + TokenSet: cfg.BotToken != "", + TokenTail: tokenTail(cfg.BotToken), + }, nil +} + +// tokenTail 取 token 尾 4 位用于展示;过短时不展示,避免变相泄露。 +func tokenTail(token string) string { + if len(token) < 8 { + return "" + } + return token[len(token)-4:] +} + +// UpdateTelegramInput 是更新 Telegram 配置的输入; +// BotToken 为 nil 沿用已存 token,非 nil 时覆盖(空串表示清除)。 +type UpdateTelegramInput struct { + Enabled bool + BotToken *string + ChatID string +} + +// UpdateTelegram 保存 Telegram 配置,token 加密落库。 +func (s *SettingService) UpdateTelegram(ctx context.Context, in UpdateTelegramInput) error { + enabled := "0" + if in.Enabled { + enabled = "1" + } + if err := s.set(ctx, settingTelegramEnabled, enabled); err != nil { + return err + } + if err := s.set(ctx, settingTelegramChatID, in.ChatID); err != nil { + return err + } + if in.BotToken == nil { + return nil + } + return s.saveTelegramToken(ctx, *in.BotToken) +} + +// saveTelegramToken 加密保存 token;空串直接落空值表示清除。 +func (s *SettingService) saveTelegramToken(ctx context.Context, token string) error { + if token == "" { + return s.set(ctx, settingTelegramBotToken, "") + } + enc, err := s.cipher.EncryptString(token) + if err != nil { + return fmt.Errorf("encrypt telegram token: %w", err) + } + return s.set(ctx, settingTelegramBotToken, enc) +} + +// NotifyEventsView 是通知事件开关视图;零值无意义,一律由 NotifyEvents 构造。 +// 云端事件按类别细分:实例生命周期 / 用户与凭据 / 策略 / 区域订阅 / 控制台登录。 +type NotifyEventsView struct { + TaskFail bool `json:"taskFail"` + TaskRecover bool `json:"taskRecover"` + SnatchSuccess bool `json:"snatchSuccess"` + TenantDead bool `json:"tenantDead"` + TaskStop bool `json:"taskStop"` + LoginLock bool `json:"loginLock"` + ModelDeprecated bool `json:"modelDeprecated"` + LogEventInstance bool `json:"logEventInstance"` + LogEventIdentity bool `json:"logEventIdentity"` + LogEventPolicy bool `json:"logEventPolicy"` + LogEventRegion bool `json:"logEventRegion"` + LogEventLogin bool `json:"logEventLogin"` +} + +// notifyEventFields 把视图字段与配置键一一对应,供读写复用。 +func notifyEventFields(v *NotifyEventsView) []struct { + key string + on *bool +} { + return []struct { + key string + on *bool + }{ + {settingNotifyEventTaskFail, &v.TaskFail}, + {settingNotifyEventTaskRecover, &v.TaskRecover}, + {settingNotifyEventSnatchSuccess, &v.SnatchSuccess}, + {settingNotifyEventTenantDead, &v.TenantDead}, + {settingNotifyEventTaskStop, &v.TaskStop}, + {settingNotifyEventLoginLock, &v.LoginLock}, + {settingNotifyEventModelDeprecated, &v.ModelDeprecated}, + {settingNotifyEventLogEventInstance, &v.LogEventInstance}, + {settingNotifyEventLogEventIdentity, &v.LogEventIdentity}, + {settingNotifyEventLogEventPolicy, &v.LogEventPolicy}, + {settingNotifyEventLogEventRegion, &v.LogEventRegion}, + {settingNotifyEventLogEventLogin, &v.LogEventLogin}, + } +} + +// NotifyEvents 返回全部通知事件开关;键缺省视为开启,兼容存量部署。 +func (s *SettingService) NotifyEvents(ctx context.Context) (NotifyEventsView, error) { + var view NotifyEventsView + for _, f := range notifyEventFields(&view) { + val, err := s.get(ctx, f.key) + if err != nil { + return NotifyEventsView{}, err + } + *f.on = val != "0" + } + return view, nil +} + +// UpdateNotifyEvents 全量保存五个通知事件开关。 +func (s *SettingService) UpdateNotifyEvents(ctx context.Context, in NotifyEventsView) error { + for _, f := range notifyEventFields(&in) { + value := "0" + if *f.on { + value = "1" + } + if err := s.set(ctx, f.key, value); err != nil { + return err + } + } + return nil +} + +// NotifyEventEnabled 查询单个通知事件开关,供发送前过滤; +// 只有显式 "0" 视为关闭,键缺省或读取失败一律按开启返回(降级不漏发),错误仅记日志。 +func (s *SettingService) NotifyEventEnabled(ctx context.Context, kind string) bool { + val, err := s.get(ctx, "notify_event_"+kind) + if err != nil { + log.Printf("notify event %s switch: %v", kind, err) + return true + } + return val != "0" +} + +// NotifyTemplate 读取 kind 的自定义模板;空串表示未自定义(按默认模板发送)。 +func (s *SettingService) NotifyTemplate(ctx context.Context, kind string) (string, error) { + return s.get(ctx, notifyTplKey(kind)) +} + +// NotifyTemplates 返回全部通知模板视图(默认模板 + 自定义现值),顺序稳定。 +func (s *SettingService) NotifyTemplates(ctx context.Context) ([]NotifyTemplateView, error) { + out := make([]NotifyTemplateView, 0, len(notifyTplOrder)) + for _, kind := range notifyTplOrder { + def := notifyTplDefs[kind] + custom, err := s.get(ctx, notifyTplKey(kind)) + if err != nil { + return nil, err + } + out = append(out, NotifyTemplateView{ + Kind: kind, Label: def.Label, Vars: def.Vars, + DefaultTemplate: def.Default, Template: custom, + }) + } + return out, nil +} + +// UpdateNotifyTemplate 保存 kind 的自定义模板;空串清除自定义(恢复默认)。 +func (s *SettingService) UpdateNotifyTemplate(ctx context.Context, kind, tpl string) error { + if !notifyTplValid(kind) { + return fmt.Errorf("未知通知类型 %q", kind) + } + tpl = strings.TrimSpace(tpl) + if len([]rune(tpl)) > 2000 { + return fmt.Errorf("模板长度不能超过 2000 字符") + } + return s.set(ctx, notifyTplKey(kind), tpl) +} + +// TaskSettingsView 是任务行为设置的读写视图。 +type TaskSettingsView struct { + SnatchAuthFailLimit int `json:"snatchAuthFailLimit"` +} + +// TaskSettings 返回任务行为设置;阈值缺省或非法(非整数 / 越界脏数据)时回退默认值。 +func (s *SettingService) TaskSettings(ctx context.Context) (TaskSettingsView, error) { + val, err := s.get(ctx, settingSnatchAuthFailLimit) + if err != nil { + return TaskSettingsView{}, err + } + limit, err := strconv.Atoi(val) + if err != nil || limit < minSnatchAuthFailLimit || limit > maxSnatchAuthFailLimit { + limit = defaultSnatchAuthFailLimit + } + return TaskSettingsView{SnatchAuthFailLimit: limit}, nil +} + +// UpdateTaskSettings 保存任务行为设置;阈值越界返回 ErrInvalidAuthFailLimit。 +func (s *SettingService) UpdateTaskSettings(ctx context.Context, in TaskSettingsView) error { + if in.SnatchAuthFailLimit < minSnatchAuthFailLimit || in.SnatchAuthFailLimit > maxSnatchAuthFailLimit { + return fmt.Errorf("update task settings: %w", ErrInvalidAuthFailLimit) + } + return s.set(ctx, settingSnatchAuthFailLimit, strconv.Itoa(in.SnatchAuthFailLimit)) +} diff --git a/internal/service/setting_test.go b/internal/service/setting_test.go new file mode 100644 index 0000000..5d43df9 --- /dev/null +++ b/internal/service/setting_test.go @@ -0,0 +1,298 @@ +package service + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "oci-portal/internal/crypto" + "oci-portal/internal/model" +) + +// newSettingEnv 建一个只含 Setting 表的内存库环境。 +func newSettingEnv(t *testing.T) (*SettingService, *gorm.DB) { + t.Helper() + 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: 库每个连接彼此独立,SendAsync 等后台 goroutine 读库需复用同一连接 + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("db handle: %v", err) + } + sqlDB.SetMaxOpenConns(1) + if err := db.AutoMigrate(&model.Setting{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + cipher, err := crypto.NewCipher("test-data-key") + if err != nil { + t.Fatalf("new cipher: %v", err) + } + return NewSettingService(db, cipher), db +} + +func strPtr(s string) *string { return &s } + +func TestUpdateTelegramTokenHandling(t *testing.T) { + tests := []struct { + name string + initial *string // 先保存一次的 token;nil 表示未配置过 + update *string // 第二次更新携带的 botToken + wantToken string // 最终 TelegramConfig 应解出的 token + }{ + {name: "nil 沿用旧 token", initial: strPtr("tok-1234567890"), update: nil, wantToken: "tok-1234567890"}, + {name: "非 nil 覆盖旧 token", initial: strPtr("tok-1234567890"), update: strPtr("tok-abcdefghij"), wantToken: "tok-abcdefghij"}, + {name: "空串清除已存 token", initial: strPtr("tok-1234567890"), update: strPtr(""), wantToken: ""}, + {name: "首次设置", initial: nil, update: strPtr("tok-first99999"), wantToken: "tok-first99999"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, _ := newSettingEnv(t) + ctx := context.Background() + if tt.initial != nil { + in := UpdateTelegramInput{Enabled: true, BotToken: tt.initial, ChatID: "42"} + if err := svc.UpdateTelegram(ctx, in); err != nil { + t.Fatalf("save initial: %v", err) + } + } + in := UpdateTelegramInput{Enabled: true, BotToken: tt.update, ChatID: "42"} + if err := svc.UpdateTelegram(ctx, in); err != nil { + t.Fatalf("update: %v", err) + } + cfg, err := svc.TelegramConfig(ctx) + if err != nil { + t.Fatalf("TelegramConfig: %v", err) + } + if cfg.BotToken != tt.wantToken { + t.Errorf("token = %q, want %q", cfg.BotToken, tt.wantToken) + } + }) + } +} + +func TestTelegramViewNeverReturnsPlaintext(t *testing.T) { + svc, db := newSettingEnv(t) + ctx := context.Background() + token := "123456789:AAtestTokenValue" + in := UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "-100200"} + if err := svc.UpdateTelegram(ctx, in); err != nil { + t.Fatalf("UpdateTelegram: %v", err) + } + + view, err := svc.TelegramView(ctx) + if err != nil { + t.Fatalf("TelegramView: %v", err) + } + want := TelegramView{Enabled: true, ChatID: "-100200", TokenSet: true, TokenTail: "alue"} + if view != want { + t.Errorf("view = %+v, want %+v", view, want) + } + + // 落库必须是密文:存储值不含 token 原文,但能解密还原 + var st model.Setting + if err := db.First(&st, "key = ?", settingTelegramBotToken).Error; err != nil { + t.Fatalf("load setting row: %v", err) + } + if strings.Contains(st.Value, token) { + t.Errorf("token stored in plaintext: %q", st.Value) + } + cfg, err := svc.TelegramConfig(ctx) + if err != nil || cfg.BotToken != token { + t.Errorf("decrypted token = %q (%v), want %q", cfg.BotToken, err, token) + } +} + +func TestTokenTail(t *testing.T) { + tests := []struct { + name string + token string + want string + }{ + {name: "常规 token 取尾 4 位", token: "123456789:AAtestTokenValue", want: "alue"}, + {name: "过短不展示", token: "abc", want: ""}, + {name: "空串", token: "", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tokenTail(tt.token); got != tt.want { + t.Errorf("tokenTail(%q) = %q, want %q", tt.token, got, tt.want) + } + }) + } +} + +func TestNotifyEventsReadWrite(t *testing.T) { + allOn := NotifyEventsView{ + TaskFail: true, TaskRecover: true, SnatchSuccess: true, TenantDead: true, + TaskStop: true, LoginLock: true, ModelDeprecated: true, + LogEventInstance: true, LogEventIdentity: true, LogEventPolicy: true, + LogEventRegion: true, LogEventLogin: true, + } + offTaskFailStop := allOn + offTaskFailStop.TaskFail, offTaskFailStop.TaskStop = false, false + offTenantDead := allOn + offTenantDead.TenantDead = false + tests := []struct { + name string + setup func(t *testing.T, svc *SettingService) + want NotifyEventsView + }{ + { + name: "键全部缺省视为全开(兼容存量部署)", + setup: func(t *testing.T, svc *SettingService) {}, + want: allOn, + }, + { + name: "写 0 后读回 false", + setup: func(t *testing.T, svc *SettingService) { + in := allOn + in.TaskFail = false + in.TaskStop = false + if err := svc.UpdateNotifyEvents(context.Background(), in); err != nil { + t.Fatalf("UpdateNotifyEvents: %v", err) + } + }, + want: offTaskFailStop, + }, + { + name: "部分键缺失时只关显式为 0 的项", + setup: func(t *testing.T, svc *SettingService) { + if err := svc.set(context.Background(), settingNotifyEventTenantDead, "0"); err != nil { + t.Fatalf("set: %v", err) + } + }, + want: offTenantDead, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, _ := newSettingEnv(t) + tt.setup(t, svc) + got, err := svc.NotifyEvents(context.Background()) + if err != nil { + t.Fatalf("NotifyEvents: %v", err) + } + if got != tt.want { + t.Errorf("view = %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestNotifyEventEnabled(t *testing.T) { + tests := []struct { + name string + value *string // 键值;nil 表示键不存在 + want bool + }{ + {name: "键缺失视为开启", value: nil, want: true}, + {name: "显式 0 关闭", value: strPtr("0"), want: false}, + {name: "显式 1 开启", value: strPtr("1"), want: true}, + {name: "脏数据非 0 视为开启", value: strPtr("maybe"), want: true}, + {name: "空值视为开启", value: strPtr(""), want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, _ := newSettingEnv(t) + ctx := context.Background() + if tt.value != nil { + if err := svc.set(ctx, settingNotifyEventTaskFail, *tt.value); err != nil { + t.Fatalf("set: %v", err) + } + } + if got := svc.NotifyEventEnabled(ctx, "task_fail"); got != tt.want { + t.Errorf("NotifyEventEnabled = %v, want %v", got, tt.want) + } + }) + } +} + +// saveTaskLimit 返回把抢机熔断阈值写入设置的 setup 步骤,供 TestTaskSettings 复用。 +func saveTaskLimit(limit int) func(t *testing.T, svc *SettingService) { + return func(t *testing.T, svc *SettingService) { + t.Helper() + in := TaskSettingsView{SnatchAuthFailLimit: limit} + if err := svc.UpdateTaskSettings(context.Background(), in); err != nil { + t.Fatalf("UpdateTaskSettings(%d): %v", limit, err) + } + } +} + +func TestTaskSettings(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, svc *SettingService) + want int + }{ + {name: "键缺省回退默认 3", setup: func(t *testing.T, svc *SettingService) {}, want: 3}, + {name: "写 5 读回 5", setup: saveTaskLimit(5), want: 5}, + {name: "合法下界写 1 读回 1", setup: saveTaskLimit(1), want: 1}, + {name: "合法上界写 10 读回 10", setup: saveTaskLimit(10), want: 10}, + { + name: "脏数据非整数回退 3", + setup: func(t *testing.T, svc *SettingService) { + if err := svc.set(context.Background(), settingSnatchAuthFailLimit, "abc"); err != nil { + t.Fatalf("set: %v", err) + } + }, + want: 3, + }, + { + name: "脏数据越界回退 3", + setup: func(t *testing.T, svc *SettingService) { + if err := svc.set(context.Background(), settingSnatchAuthFailLimit, "99"); err != nil { + t.Fatalf("set: %v", err) + } + }, + want: 3, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, _ := newSettingEnv(t) + tt.setup(t, svc) + got, err := svc.TaskSettings(context.Background()) + if err != nil { + t.Fatalf("TaskSettings: %v", err) + } + if got.SnatchAuthFailLimit != tt.want { + t.Errorf("limit = %d, want %d", got.SnatchAuthFailLimit, tt.want) + } + }) + } +} + +func TestUpdateTaskSettingsRejectsOutOfRange(t *testing.T) { + tests := []struct { + name string + limit int + }{ + {name: "低于下界 0", limit: 0}, + {name: "高于上界 11", limit: 11}, + {name: "负值", limit: -3}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, _ := newSettingEnv(t) + err := svc.UpdateTaskSettings(context.Background(), TaskSettingsView{SnatchAuthFailLimit: tt.limit}) + if !errors.Is(err, ErrInvalidAuthFailLimit) { + t.Fatalf("err = %v, want ErrInvalidAuthFailLimit", err) + } + got, err := svc.TaskSettings(context.Background()) + if err != nil { + t.Fatalf("TaskSettings: %v", err) + } + if got.SnatchAuthFailLimit != 3 { + t.Errorf("拒绝后读回 = %d, want 保持默认 3", got.SnatchAuthFailLimit) + } + }) + } +} diff --git a/internal/service/subscription.go b/internal/service/subscription.go new file mode 100644 index 0000000..a395c4b --- /dev/null +++ b/internal/service/subscription.go @@ -0,0 +1,47 @@ +package service + +import ( + "context" + "fmt" + + "oci-portal/internal/oci" +) + +// Subscriptions 列出租户全部订阅摘要。 +func (s *OciConfigService) Subscriptions(ctx context.Context, id uint) ([]oci.SubscriptionInfo, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return nil, err + } + return s.client.ListSubscriptions(ctx, cred) +} + +// SubscriptionDetail 查询租户单个订阅的完整信息。 +func (s *OciConfigService) SubscriptionDetail(ctx context.Context, id uint, subscriptionID string) (oci.SubscriptionDetail, error) { + if subscriptionID == "" { + return oci.SubscriptionDetail{}, fmt.Errorf("subscription detail: subscription id is empty") + } + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.SubscriptionDetail{}, err + } + return s.client.GetSubscription(ctx, cred, subscriptionID) +} + +// LimitServices 列出配额体系支持的全部服务。 +func (s *OciConfigService) LimitServices(ctx context.Context, id uint, region string) ([]oci.LimitService, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return nil, err + } + return s.client.ListLimitServices(ctx, cred, region) +} + +// credentialsByID 加载配置快照并解密出完整凭据。 +func (s *OciConfigService) credentialsByID(ctx context.Context, id uint) (oci.Credentials, error) { + cfg, err := s.Get(ctx, id) + if err != nil { + return oci.Credentials{}, err + } + return s.credentialsOf(cfg) +} diff --git a/internal/service/subscription_test.go b/internal/service/subscription_test.go new file mode 100644 index 0000000..c257860 --- /dev/null +++ b/internal/service/subscription_test.go @@ -0,0 +1,87 @@ +package service + +import ( + "context" + "testing" + + "oci-portal/internal/oci" +) + +func TestSubscriptionsPassThrough(t *testing.T) { + client := &fakeClient{ + tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"}, + subscriptions: []oci.SubscriptionInfo{ + {ID: "ocid1.organizationssubscription.oc1..a", ServiceName: "CLOUDCM"}, + {ID: "ocid1.organizationssubscription.oc1..b", ServiceName: "SAAS"}, + }, + } + svc := newTestService(t, client) + cfg := importAliveConfig(t, svc) + + subs, err := svc.Subscriptions(context.Background(), cfg.ID) + if err != nil { + t.Fatalf("Subscriptions: %v", err) + } + if len(subs) != 2 { + t.Fatalf("len(subs) = %d, want 2", len(subs)) + } + if got, want := subs[0].ServiceName, "CLOUDCM"; got != want { + t.Errorf("subs[0].ServiceName = %q, want %q", got, want) + } +} + +func TestSubscriptionDetailPassesID(t *testing.T) { + client := &fakeClient{ + tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"}, + subDetail: oci.SubscriptionDetail{ + ID: "ocid1.organizationssubscription.oc1..a", + ServiceName: "CLOUDCM", + PaymentModel: "FREE_TRIAL", + Promotions: []oci.PromotionInfo{{Status: "ACTIVE", Amount: 300}}, + }, + } + svc := newTestService(t, client) + cfg := importAliveConfig(t, svc) + + detail, err := svc.SubscriptionDetail(context.Background(), cfg.ID, "ocid1.organizationssubscription.oc1..a") + if err != nil { + t.Fatalf("SubscriptionDetail: %v", err) + } + if got, want := client.subDetailID, "ocid1.organizationssubscription.oc1..a"; got != want { + t.Errorf("passed subscription id = %q, want %q", got, want) + } + if len(detail.Promotions) != 1 || detail.Promotions[0].Amount != 300 { + t.Errorf("Promotions = %+v, want one ACTIVE amount 300", detail.Promotions) + } +} + +func TestSubscriptionDetailRejectsEmptyID(t *testing.T) { + svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}) + cfg := importAliveConfig(t, svc) + if _, err := svc.SubscriptionDetail(context.Background(), cfg.ID, ""); err == nil { + t.Error("SubscriptionDetail(empty id): got nil error, want failure") + } +} + +func TestLimitServicesPassThrough(t *testing.T) { + client := &fakeClient{ + tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"}, + limitServices: []oci.LimitService{ + {Name: "compute", Description: "Compute"}, + {Name: "vcn", Description: "Virtual Cloud Network"}, + }, + } + svc := newTestService(t, client) + cfg := importAliveConfig(t, svc) + + services, err := svc.LimitServices(context.Background(), cfg.ID, "") + if err != nil { + t.Fatalf("LimitServices: %v", err) + } + if len(services) != 2 { + t.Fatalf("len(services) = %d, want 2", len(services)) + } + if got, want := services[0].Name, "compute"; got != want { + t.Errorf("services[0].Name = %q, want %q", got, want) + } +} diff --git a/internal/service/systemlog.go b/internal/service/systemlog.go new file mode 100644 index 0000000..6b78102 --- /dev/null +++ b/internal/service/systemlog.go @@ -0,0 +1,158 @@ +package service + +import ( + "context" + "fmt" + "log" + "sync" + "time" + + "gorm.io/gorm" + + "oci-portal/internal/model" +) + +// 系统日志保留策略与异步写入超时。 +const ( + systemLogRetention = 90 * 24 * time.Hour // 过期删除阈值 + systemLogMaxRows = 50000 // 总量兜底上限,超出删最旧 + systemLogCleanupTick = 24 * time.Hour // 周期清理间隔 + systemLogWriteWait = 3 * time.Second // 单条异步写入超时 +) + +// 系统日志查询分页默认值与上限。 +const ( + systemLogDefaultPageSize = 20 + systemLogMaxPageSize = 100 +) + +// SystemLogService 记录并查询面板自身的操作日志(区别于 OCI 云端审计事件)。 +type SystemLogService struct { + db *gorm.DB + wg sync.WaitGroup +} + +// NewSystemLogService 组装依赖;调用 StartCleanup 后开始周期清理。 +func NewSystemLogService(db *gorm.DB) *SystemLogService { + return &SystemLogService{db: db} +} + +// Record 异步落一条日志,不阻塞业务请求;写入失败只记日志。 +func (s *SystemLogService) Record(entry model.SystemLog) { + s.wg.Add(1) + go func() { + defer s.wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), systemLogWriteWait) + defer cancel() + if err := s.record(ctx, entry); err != nil { + log.Printf("system log record: %v", err) + } + }() +} + +// record 同步落库,供 Record 内部与测试直调。 +func (s *SystemLogService) record(ctx context.Context, entry model.SystemLog) error { + if entry.CreatedAt.IsZero() { + entry.CreatedAt = time.Now() + } + if err := s.db.WithContext(ctx).Create(&entry).Error; err != nil { + return fmt.Errorf("create system log: %w", err) + } + return nil +} + +// Wait 等待在途异步写入与清理 goroutine 退出,供进程收尾与测试同步。 +func (s *SystemLogService) Wait() { + s.wg.Wait() +} + +// SystemLogQuery 是日志查询参数;Keyword 对路径与用户名模糊匹配。 +type SystemLogQuery struct { + Page int + PageSize int + Keyword string +} + +// normalize 补分页默认值并钳制上限。 +func (q SystemLogQuery) normalize() SystemLogQuery { + if q.Page < 1 { + q.Page = 1 + } + if q.PageSize < 1 { + q.PageSize = systemLogDefaultPageSize + } + if q.PageSize > systemLogMaxPageSize { + q.PageSize = systemLogMaxPageSize + } + return q +} + +// List 按时间倒序分页查询日志,返回当前页与总数。 +func (s *SystemLogService) List(ctx context.Context, q SystemLogQuery) ([]model.SystemLog, int64, error) { + q = q.normalize() + tx := s.db.WithContext(ctx).Model(&model.SystemLog{}) + if q.Keyword != "" { + kw := "%" + q.Keyword + "%" + tx = tx.Where("path LIKE ? OR username LIKE ?", kw, kw) + } + var total int64 + if err := tx.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("count system logs: %w", err) + } + items := make([]model.SystemLog, 0, q.PageSize) + err := tx.Order("created_at DESC, id DESC"). + Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize). + Find(&items).Error + if err != nil { + return nil, 0, fmt.Errorf("list system logs: %w", err) + } + return items, total, nil +} + +// StartCleanup 启动周期清理:启动即清一次,之后每 24h 一次,随 ctx 取消退出。 +func (s *SystemLogService) StartCleanup(ctx context.Context) { + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.cleanupOnce(ctx) + ticker := time.NewTicker(systemLogCleanupTick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.cleanupOnce(ctx) + } + } + }() +} + +// cleanupOnce 执行一轮清理,失败只记日志、不中断周期调度。 +func (s *SystemLogService) cleanupOnce(ctx context.Context) { + if err := s.cleanup(ctx, systemLogRetention, systemLogMaxRows); err != nil { + log.Printf("system log cleanup: %v", err) + } +} + +// cleanup 先删过期记录,再对超量部分删最旧;阈值参数化便于测试。 +func (s *SystemLogService) cleanup(ctx context.Context, retention time.Duration, maxRows int) error { + cutoff := time.Now().Add(-retention) + if err := s.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(&model.SystemLog{}).Error; err != nil { + return fmt.Errorf("delete expired system logs: %w", err) + } + var total int64 + if err := s.db.WithContext(ctx).Model(&model.SystemLog{}).Count(&total).Error; err != nil { + return fmt.Errorf("count system logs: %w", err) + } + overflow := int(total) - maxRows + if overflow <= 0 { + return nil + } + oldest := s.db.Model(&model.SystemLog{}).Select("id"). + Order("created_at ASC, id ASC").Limit(overflow) + if err := s.db.WithContext(ctx).Where("id IN (?)", oldest).Delete(&model.SystemLog{}).Error; err != nil { + return fmt.Errorf("trim system logs over cap: %w", err) + } + return nil +} diff --git a/internal/service/systemlog_test.go b/internal/service/systemlog_test.go new file mode 100644 index 0000000..a25db8c --- /dev/null +++ b/internal/service/systemlog_test.go @@ -0,0 +1,217 @@ +package service + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "oci-portal/internal/model" +) + +// newSystemLogEnv 建一个只含 SystemLog 表的内存库环境。 +func newSystemLogEnv(t *testing.T) (*SystemLogService, *gorm.DB) { + t.Helper() + 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: 库每个连接彼此独立,Record 的异步 goroutine 可能拿到新连接而丢表,锁单连接 + 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) + } + return NewSystemLogService(db), db +} + +// seedLogs 由旧到新插入 n 条日志,时间逐分钟递增,路径 / 用户名带序号便于断言。 +func seedLogs(t *testing.T, db *gorm.DB, n int) { + t.Helper() + base := time.Now().Add(-time.Duration(n) * time.Minute) + for i := 0; i < n; i++ { + row := model.SystemLog{ + Username: fmt.Sprintf("user-%02d", i), + Method: "POST", + Path: fmt.Sprintf("/api/v1/res-%02d", i), + Status: 200, + CreatedAt: base.Add(time.Duration(i) * time.Minute), + } + if err := db.Create(&row).Error; err != nil { + t.Fatalf("seed log %d: %v", i, err) + } + } +} + +func TestSystemLogList(t *testing.T) { + tests := []struct { + name string + q SystemLogQuery + wantLen int + wantTotal int64 + wantFirstPath string // 空串表示不校验首条 + }{ + {name: "零值分页取默认 20 条最新在前", q: SystemLogQuery{}, wantLen: 20, wantTotal: 25, wantFirstPath: "/api/v1/res-24"}, + {name: "第二页取余下 5 条", q: SystemLogQuery{Page: 2, PageSize: 20}, wantLen: 5, wantTotal: 25, wantFirstPath: "/api/v1/res-04"}, + {name: "pageSize 超上限被钳制仍能取全量", q: SystemLogQuery{PageSize: 200}, wantLen: 25, wantTotal: 25}, + {name: "关键字模糊匹配路径", q: SystemLogQuery{Keyword: "res-1"}, wantLen: 10, wantTotal: 10, wantFirstPath: "/api/v1/res-19"}, + {name: "关键字模糊匹配用户名", q: SystemLogQuery{Keyword: "user-05"}, wantLen: 1, wantTotal: 1, wantFirstPath: "/api/v1/res-05"}, + {name: "关键字无匹配返回空页", q: SystemLogQuery{Keyword: "no-such"}, wantLen: 0, wantTotal: 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, db := newSystemLogEnv(t) + seedLogs(t, db, 25) + items, total, err := svc.List(context.Background(), tt.q) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(items) != tt.wantLen || total != tt.wantTotal { + t.Errorf("got %d items total %d, want %d items total %d", len(items), total, tt.wantLen, tt.wantTotal) + } + if tt.wantFirstPath != "" && items[0].Path != tt.wantFirstPath { + t.Errorf("first path = %q, want %q", items[0].Path, tt.wantFirstPath) + } + }) + } +} + +func TestSystemLogListStableOrder(t *testing.T) { + svc, db := newSystemLogEnv(t) + // 同一时间戳的两条记录按 id 倒序,保证翻页顺序稳定 + ts := time.Now().Truncate(time.Second) + for i := 0; i < 2; i++ { + row := model.SystemLog{Path: fmt.Sprintf("/p%d", i), CreatedAt: ts} + if err := db.Create(&row).Error; err != nil { + t.Fatalf("seed: %v", err) + } + } + items, _, err := svc.List(context.Background(), SystemLogQuery{}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(items) != 2 || items[0].ID <= items[1].ID { + t.Errorf("order = %+v, want id desc within same created_at", items) + } +} + +func TestSystemLogRecord(t *testing.T) { + tests := []struct { + name string + async bool + }{ + {name: "record 同步落库", async: false}, + {name: "Record 异步落库", async: true}, + } + entry := model.SystemLog{ + Username: "admin", Method: "PUT", Path: "/api/v1/oci-configs/:id", + Status: 200, DurationMs: 18, ClientIP: "203.0.113.7", + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, db := newSystemLogEnv(t) + if tt.async { + svc.Record(entry) + svc.Wait() + } else if err := svc.record(context.Background(), entry); err != nil { + t.Fatalf("record: %v", err) + } + var got model.SystemLog + if err := db.First(&got).Error; err != nil { + t.Fatalf("load row: %v", err) + } + got.ID, got.CreatedAt = 0, entry.CreatedAt // 自动填充字段不参与对比 + if got != entry { + t.Errorf("row = %+v, want %+v", got, entry) + } + }) + } +} + +func TestSystemLogRecordFillsCreatedAt(t *testing.T) { + svc, db := newSystemLogEnv(t) + if err := svc.record(context.Background(), model.SystemLog{Path: "/p"}); err != nil { + t.Fatalf("record: %v", err) + } + var got model.SystemLog + if err := db.First(&got).Error; err != nil { + t.Fatalf("load row: %v", err) + } + if got.CreatedAt.IsZero() { + t.Error("createdAt is zero, want auto filled") + } +} + +func TestSystemLogCleanup(t *testing.T) { + tests := []struct { + name string + ages []time.Duration // 每条记录距今的时长,由旧到新 + retention time.Duration + maxRows int + wantPaths []string // 清理后应保留的路径(按时间升序编号 p0..pN) + }{ + { + name: "只删过期", ages: []time.Duration{100 * 24 * time.Hour, time.Hour}, + retention: 90 * 24 * time.Hour, maxRows: 10, wantPaths: []string{"/p1"}, + }, + { + name: "超上限删最旧", ages: []time.Duration{5 * time.Hour, 4 * time.Hour, 3 * time.Hour, 2 * time.Hour, time.Hour}, + retention: 90 * 24 * time.Hour, maxRows: 3, wantPaths: []string{"/p2", "/p3", "/p4"}, + }, + { + name: "都未触发保持原样", ages: []time.Duration{2 * time.Hour, time.Hour}, + retention: 90 * 24 * time.Hour, maxRows: 10, wantPaths: []string{"/p0", "/p1"}, + }, + { + name: "过期与超量叠加", ages: []time.Duration{100 * 24 * time.Hour, 4 * time.Hour, 3 * time.Hour, 2 * time.Hour, time.Hour}, + retention: 90 * 24 * time.Hour, maxRows: 2, wantPaths: []string{"/p3", "/p4"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, db := newSystemLogEnv(t) + for i, age := range tt.ages { + row := model.SystemLog{Path: fmt.Sprintf("/p%d", i), CreatedAt: time.Now().Add(-age)} + if err := db.Create(&row).Error; err != nil { + t.Fatalf("seed %d: %v", i, err) + } + } + if err := svc.cleanup(context.Background(), tt.retention, tt.maxRows); err != nil { + t.Fatalf("cleanup: %v", err) + } + var paths []string + if err := db.Model(&model.SystemLog{}).Order("created_at ASC").Pluck("path", &paths).Error; err != nil { + t.Fatalf("load rows: %v", err) + } + if fmt.Sprint(paths) != fmt.Sprint(tt.wantPaths) { + t.Errorf("kept = %v, want %v", paths, tt.wantPaths) + } + }) + } +} + +func TestSystemLogStartCleanupStopsOnCancel(t *testing.T) { + svc, _ := newSystemLogEnv(t) + ctx, cancel := context.WithCancel(context.Background()) + svc.StartCleanup(ctx) + cancel() + done := make(chan struct{}) + go func() { + svc.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("cleanup goroutine did not exit after context cancel") + } +} diff --git a/internal/service/task.go b/internal/service/task.go new file mode 100644 index 0000000..a422f8e --- /dev/null +++ b/internal/service/task.go @@ -0,0 +1,857 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "github.com/robfig/cron/v3" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "oci-portal/internal/model" + "oci-portal/internal/oci" +) + +// taskRunTimeout 是单次任务执行的超时时间。 +const taskRunTimeout = 10 * time.Minute + +// taskLogKeep 是每个任务保留的执行日志条数。 +const taskLogKeep = 100 + +// TaskService 管理后台任务的存储、cron 调度与执行。 +type TaskService struct { + db *gorm.DB + configs *OciConfigService + notifier *Notifier + settings *SettingService + cron *cron.Cron + // aiGateway 供 AI 探测任务执行渠道探测,由 main 装配(可为 nil) + aiGateway *AiGatewayService + + mu sync.Mutex + entries map[uint]cron.EntryID +} + +// NewTaskService 组装依赖;notifier 传 nil 表示整体关闭通知, +// settings 供发送前按事件类型过滤(nil 视为全开)。调用 Start 后开始调度。 +func NewTaskService(db *gorm.DB, configs *OciConfigService, notifier *Notifier, settings *SettingService) *TaskService { + return &TaskService{ + db: db, + configs: configs, + notifier: notifier, + settings: settings, + cron: cron.New(), + entries: map[uint]cron.EntryID{}, + } +} + +// AttachAiGateway 注入 AI 网关服务,启用 AI 探测任务的执行与自动同步。 +func (s *TaskService) AttachAiGateway(gw *AiGatewayService) { s.aiGateway = gw } + +// Start 加载全部 active 任务注册调度并启动 cron。 +func (s *TaskService) Start() error { + var tasks []model.Task + if err := s.db.Where("status = ?", model.TaskStatusActive).Find(&tasks).Error; err != nil { + return fmt.Errorf("load active tasks: %w", err) + } + for i := range tasks { + if err := s.schedule(&tasks[i]); err != nil { + return err + } + } + s.cron.Start() + return nil +} + +// Stop 停止调度并等待执行中的任务收尾, +// 保证任务产生的异步通知都已进入 Notifier 的等待队列。 +func (s *TaskService) Stop() { + <-s.cron.Stop().Done() +} + +// healthCheckPayload 是测活任务参数;ociConfigIds 为空表示全部配置。 +type healthCheckPayload struct { + OciConfigIDs []uint `json:"ociConfigIds"` +} + +// costPayload 是成本同步任务参数;ociConfigIds 为空表示全部配置, +// 免费类别的配置在执行时跳过,不发起 Usage API 请求。 +type costPayload struct { + OciConfigIDs []uint `json:"ociConfigIds"` +} + +// snatchPayload 是抢机任务参数;count 为剩余台数(创建时即目标台数, +// 每次执行把剩余写回,直到抢满),totalCount 固定为创建时的目标台数, +// 供前端计算进度(旧任务缺省时前端回退用 count)。 +// authFailCount 为连续 NotAuthenticated 失败计数,达阈值任务熔断停止。 +type snatchPayload struct { + OciConfigID uint `json:"ociConfigId"` + Count int `json:"count"` + TotalCount int `json:"totalCount,omitempty"` + AuthFailCount int `json:"authFailCount,omitempty"` + Instance oci.CreateInstanceInput `json:"instance"` +} + +// CreateTaskInput 是创建任务的输入。 +type CreateTaskInput struct { + Name string + Type string + CronExpr string + Payload json.RawMessage +} + +// CreateTask 校验并保存任务,立即进入调度;AI 探测任务全局唯一(系统自动管理)。 +func (s *TaskService) CreateTask(ctx context.Context, in CreateTaskInput) (*model.Task, error) { + if in.Name == "" { + return nil, fmt.Errorf("create task: name is required") + } + if _, err := cron.ParseStandard(in.CronExpr); err != nil { + return nil, fmt.Errorf("create task: invalid cron %q: %w", in.CronExpr, err) + } + if err := validateTaskPayload(in.Type, in.Payload); err != nil { + return nil, err + } + if err := s.ensureAiProbeUnique(ctx, in.Type); err != nil { + return nil, err + } + task := &model.Task{ + Name: in.Name, + Type: in.Type, + CronExpr: in.CronExpr, + Payload: string(normalizeSnatchPayload(in.Type, in.Payload)), + Status: model.TaskStatusActive, + } + if err := s.db.WithContext(ctx).Create(task).Error; err != nil { + return nil, fmt.Errorf("create task: %w", err) + } + if err := s.schedule(task); err != nil { + return nil, err + } + return task, nil +} + +// ensureAiProbeUnique 拒绝重复创建 AI 探测任务(该类型随渠道数量自动管理)。 +func (s *TaskService) ensureAiProbeUnique(ctx context.Context, taskType string) error { + if taskType != model.TaskTypeAiProbe { + return nil + } + var n int64 + if err := s.db.WithContext(ctx).Model(&model.Task{}). + Where("type = ?", model.TaskTypeAiProbe).Count(&n).Error; err != nil { + return err + } + if n > 0 { + return fmt.Errorf("create task: AI 探测任务已存在,由系统自动管理") + } + return nil +} + +// normalizeSnatchPayload 给抢机 payload 补全目标台数:count 默认 1、 +// totalCount 缺省时固定为创建时的 count,供进度展示;解析失败原样返回 +// (validateTaskPayload 已在前面拦截非法 JSON)。 +func normalizeSnatchPayload(taskType string, payload json.RawMessage) json.RawMessage { + if taskType != model.TaskTypeSnatch { + return payload + } + var p snatchPayload + if err := json.Unmarshal(payload, &p); err != nil { + return payload + } + if p.Count <= 0 { + p.Count = 1 + } + if p.TotalCount <= 0 { + p.TotalCount = p.Count + } + out, err := json.Marshal(p) + if err != nil { + return payload + } + return out +} + +// validateTaskPayload 按任务类型校验参数 JSON。 +func validateTaskPayload(taskType string, payload json.RawMessage) error { + switch taskType { + case model.TaskTypeHealthCheck: + var p healthCheckPayload + if len(payload) > 0 { + if err := json.Unmarshal(payload, &p); err != nil { + return fmt.Errorf("create task: invalid payload: %w", err) + } + } + return nil + case model.TaskTypeCost: + var p costPayload + if len(payload) > 0 { + if err := json.Unmarshal(payload, &p); err != nil { + return fmt.Errorf("create task: invalid payload: %w", err) + } + } + return nil + case model.TaskTypeSnatch: + var p snatchPayload + if err := json.Unmarshal(payload, &p); err != nil { + return fmt.Errorf("create task: invalid payload: %w", err) + } + if p.OciConfigID == 0 { + return fmt.Errorf("create task: snatch payload requires ociConfigId") + } + return validateCreateInstance(p.Instance) + case model.TaskTypeAiProbe: + return nil // 无参数:探测全部渠道 + default: + return fmt.Errorf("create task: unsupported type %q", taskType) + } +} + +// UpdateTaskInput 是更新任务的输入;nil 字段不修改。 +type UpdateTaskInput struct { + Name *string + CronExpr *string + Payload json.RawMessage + Status *string +} + +// UpdateTask 修改任务并重新调度。 +func (s *TaskService) UpdateTask(ctx context.Context, id uint, in UpdateTaskInput) (*model.Task, error) { + task, err := s.GetTask(ctx, id) + if err != nil { + return nil, err + } + if err := applyTaskUpdate(task, in); err != nil { + return nil, err + } + if err := s.db.WithContext(ctx).Save(task).Error; err != nil { + return nil, fmt.Errorf("update task %d: %w", id, err) + } + s.unschedule(task.ID) + if task.Status == model.TaskStatusActive { + if err := s.schedule(task); err != nil { + return nil, err + } + } + return task, nil +} + +func applyTaskUpdate(task *model.Task, in UpdateTaskInput) error { + if in.Name != nil { + task.Name = *in.Name + } + if in.CronExpr != nil { + if _, err := cron.ParseStandard(*in.CronExpr); err != nil { + return fmt.Errorf("update task: invalid cron %q: %w", *in.CronExpr, err) + } + task.CronExpr = *in.CronExpr + } + if len(in.Payload) > 0 { + if err := validateTaskPayload(task.Type, in.Payload); err != nil { + return err + } + task.Payload = string(in.Payload) + } + if in.Status != nil { + if *in.Status != model.TaskStatusActive && *in.Status != model.TaskStatusPaused { + return fmt.Errorf("update task: status must be active or paused") + } + if task.Status == model.TaskStatusFailed && *in.Status == model.TaskStatusActive { + resetSnatchAuthFail(task) + } + task.Status = *in.Status + } + return nil +} + +// resetSnatchAuthFail 清零抢机 payload 的连续鉴权失败计数, +// 供 failed 任务重新启用时调用,避免一恢复调度就再次熔断;非抢机任务不处理。 +func resetSnatchAuthFail(task *model.Task) { + if task.Type != model.TaskTypeSnatch { + return + } + var p snatchPayload + if err := json.Unmarshal([]byte(task.Payload), &p); err != nil { + return + } + p.AuthFailCount = 0 + writeSnatchPayload(task, &p) +} + +// ListTasks 返回全部任务。 +func (s *TaskService) ListTasks(ctx context.Context) ([]model.Task, error) { + tasks := make([]model.Task, 0) + if err := s.db.WithContext(ctx).Order("id").Find(&tasks).Error; err != nil { + return nil, fmt.Errorf("list tasks: %w", err) + } + return tasks, nil +} + +// GetTask 返回单个任务。 +func (s *TaskService) GetTask(ctx context.Context, id uint) (*model.Task, error) { + var task model.Task + if err := s.db.WithContext(ctx).First(&task, id).Error; err != nil { + return nil, fmt.Errorf("find task %d: %w", id, err) + } + return &task, nil +} + +// DeleteTask 注销调度并删除任务与其日志;AI 探测任务由系统按渠道数量 +// 自动创建/删除,拒绝手动删除。 +func (s *TaskService) DeleteTask(ctx context.Context, id uint) error { + task, err := s.GetTask(ctx, id) + if err != nil { + return err + } + if task.Type == model.TaskTypeAiProbe { + return fmt.Errorf("delete task: AI 探测任务由系统自动管理,删除最后一个渠道时自动移除") + } + return s.removeTask(ctx, id) +} + +// removeTask 注销调度并删除任务与其日志(内部路径,不做类型限制)。 +func (s *TaskService) removeTask(ctx context.Context, id uint) error { + s.unschedule(id) + if err := s.db.WithContext(ctx).Delete(&model.Task{}, id).Error; err != nil { + return fmt.Errorf("delete task %d: %w", id, err) + } + if err := s.db.WithContext(ctx).Where("task_id = ?", id).Delete(&model.TaskLog{}).Error; err != nil { + return fmt.Errorf("delete task %d logs: %w", id, err) + } + return nil +} + +// TaskLogs 返回任务最近的执行日志(时间倒序)。 +func (s *TaskService) TaskLogs(ctx context.Context, id uint, limit int) ([]model.TaskLog, error) { + if limit <= 0 || limit > taskLogKeep { + limit = 50 + } + logs := make([]model.TaskLog, 0) + err := s.db.WithContext(ctx).Where("task_id = ?", id). + Order("id desc").Limit(limit).Find(&logs).Error + if err != nil { + return nil, fmt.Errorf("list task %d logs: %w", id, err) + } + return logs, nil +} + +// RunTaskNow 立即执行一次任务并返回本次日志。 +func (s *TaskService) RunTaskNow(ctx context.Context, id uint) (*model.TaskLog, error) { + if _, err := s.GetTask(ctx, id); err != nil { + return nil, err + } + return s.execute(id), nil +} + +// schedule 把任务注册进 cron 调度。 +func (s *TaskService) schedule(task *model.Task) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.entries[task.ID]; ok { + return nil + } + taskID := task.ID + entry, err := s.cron.AddFunc(task.CronExpr, func() { s.execute(taskID) }) + if err != nil { + return fmt.Errorf("schedule task %d: %w", task.ID, err) + } + s.entries[task.ID] = entry + return nil +} + +// unschedule 把任务移出 cron 调度。 +func (s *TaskService) unschedule(taskID uint) { + s.mu.Lock() + defer s.mu.Unlock() + if entry, ok := s.entries[taskID]; ok { + s.cron.Remove(entry) + delete(s.entries, taskID) + } +} + +// execute 执行一次任务:加载 → 分派 → 更新任务状态并写日志, +// 前后状态交给通知判定,只在状态变化时推送。 +func (s *TaskService) execute(taskID uint) *model.TaskLog { + ctx, cancel := context.WithTimeout(context.Background(), taskRunTimeout) + defer cancel() + task, err := s.GetTask(ctx, taskID) + if err != nil { + return nil + } + prev := taskSnapshot{Name: task.Name, Status: task.Status, LastError: task.LastError} + start := time.Now() + message, runErr := s.run(ctx, task) + now := time.Now() + task.LastRunAt = &now + task.RunCount++ + task.LastError = "" + if runErr != nil { + task.LastError = oci.CompactError(runErr) + message = task.LastError + } + s.db.Save(task) + cur := taskSnapshot{Name: task.Name, Status: task.Status, LastError: task.LastError, Message: message} + s.notify(notifyEvents(prev, cur)) + return s.appendLog(task.ID, runErr == nil, message, time.Since(start)) +} + +// run 按类型分派任务执行。 +func (s *TaskService) run(ctx context.Context, task *model.Task) (string, error) { + switch task.Type { + case model.TaskTypeHealthCheck: + return s.runHealthCheck(ctx, task) + case model.TaskTypeCost: + return s.runCost(ctx, task) + case model.TaskTypeSnatch: + return s.runSnatch(ctx, task) + case model.TaskTypeAiProbe: + return s.runAiProbe(ctx) + default: + return "", fmt.Errorf("unsupported task type %q", task.Type) + } +} + +// runAiProbe 逐渠道探测 AI 网关号池;网关未装配时报错。 +func (s *TaskService) runAiProbe(ctx context.Context) (string, error) { + if s.aiGateway == nil { + return "", fmt.Errorf("ai gateway not attached") + } + msg, err := s.aiGateway.ProbeAll(ctx) + if err == nil { + s.warnDeprecatingModels(ctx) + } + return msg, err +} + +// warnDeprecatingModels 对 30 天内即将退役或弃用的在池模型发 Telegram 提醒; +// 随每日探测执行,模型退役被同步剔除后自动停止,受通知管理 model_deprecated 开关控制。 +func (s *TaskService) warnDeprecatingModels(ctx context.Context) { + if s.notifier == nil { + return + } + if s.settings != nil && !s.settings.NotifyEventEnabled(ctx, "model_deprecated") { + return + } + names, err := s.aiGateway.DeprecatingModels(ctx, 30*24*time.Hour) + if err != nil || len(names) == 0 { + return + } + s.notifier.SendTemplateAsync("model_deprecated", map[string]string{"models": strings.Join(names, "\n")}) +} + +// runHealthCheck 对范围内的配置逐个测活,汇总结果并触发失联通知。 +func (s *TaskService) runHealthCheck(ctx context.Context, task *model.Task) (string, error) { + var p healthCheckPayload + if task.Payload != "" { + if err := json.Unmarshal([]byte(task.Payload), &p); err != nil { + return "", fmt.Errorf("parse payload: %w", err) + } + } + ids, err := s.targetConfigIDs(ctx, p.OciConfigIDs) + if err != nil { + return "", err + } + alive := 0 + var deadAliases, failures []string + for _, id := range ids { + cfg, _, err := s.configs.Verify(ctx, id) + ok := err == nil && cfg.AliveStatus == model.AliveStatusAlive + s.saveCheckSnapshot(ctx, id, ok) + if !ok { + deadAliases = append(deadAliases, configAlias(cfg, id)) + failures = append(failures, fmt.Sprintf("#%d %s", id, verifyFailReason(cfg, err))) + continue + } + alive++ + } + s.notifyDeadAliases(ctx, task.ID, deadAliases) + msg := fmt.Sprintf("checked %d: %d alive, %d dead", len(ids), alive, len(deadAliases)) + if len(failures) > 0 { + msg += "; " + strings.Join(failures, "; ") + } + return msg, nil +} + +// configAlias 返回配置别名,配置加载失败时退回 #ID 表示。 +func configAlias(cfg *model.OciConfig, id uint) string { + if cfg != nil && cfg.Alias != "" { + return cfg.Alias + } + return fmt.Sprintf("#%d", id) +} + +func verifyFailReason(cfg *model.OciConfig, err error) string { + if err != nil { + return oci.CompactError(err) + } + return cfg.LastError +} + +// saveCheckSnapshot 覆盖写入测活快照;仅存活时刷新实例数(默认区域口径), +// 失联时保留上次实例数,避免总览 KPI 因 key 失效而抖动。 +func (s *TaskService) saveCheckSnapshot(ctx context.Context, cfgID uint, alive bool) { + status := model.AliveStatusDead + if alive { + status = model.AliveStatusAlive + } + snap := model.CheckSnapshot{OciConfigID: cfgID, AliveStatus: status, CheckedAt: time.Now()} + cols := []string{"alive_status", "checked_at"} + if alive { + if instances, err := s.configs.Instances(ctx, cfgID, "", ""); err == nil { + snap.InstanceCount = len(instances) + cols = append(cols, "instance_count") + } + } + s.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "oci_config_id"}}, + DoUpdates: clause.AssignmentColumns(cols), + }).Create(&snap) +} + +// runCost 对范围内配置同步近 7 天每日成本快照,免费类别跳过。 +func (s *TaskService) runCost(ctx context.Context, task *model.Task) (string, error) { + var p costPayload + if task.Payload != "" { + if err := json.Unmarshal([]byte(task.Payload), &p); err != nil { + return "", fmt.Errorf("parse payload: %w", err) + } + } + ids, err := s.targetConfigIDs(ctx, p.OciConfigIDs) + if err != nil { + return "", err + } + synced, skipped := 0, 0 + var failures []string + for _, id := range ids { + switch err := s.syncCostSnapshot(ctx, id); { + case err == errFreeAccountSkipped: + skipped++ + case err != nil: + failures = append(failures, fmt.Sprintf("#%d %v", id, err)) + default: + synced++ + } + } + msg := fmt.Sprintf("synced usage for %d tenants, skipped %d free", synced, skipped) + if len(failures) > 0 { + msg += "; " + strings.Join(failures, "; ") + } + return msg, nil +} + +// errFreeAccountSkipped 标记成本同步因免费类别被跳过。 +var errFreeAccountSkipped = fmt.Errorf("free account skipped") + +// syncCostSnapshot 拉取单配置近 7 天每日成本并按天覆盖写入快照。 +func (s *TaskService) syncCostSnapshot(ctx context.Context, cfgID uint) error { + cfg, err := s.configs.Get(ctx, cfgID) + if err != nil { + return err + } + if cfg.AccountType == model.AccountTypeFree { + return errFreeAccountSkipped + } + end := time.Now().UTC() + items, err := s.configs.Costs(ctx, cfgID, oci.CostQuery{ + StartTime: end.AddDate(0, 0, -7), + EndTime: end, + }) + if err != nil { + return err + } + return s.saveCostSnapshots(ctx, cfgID, items) +} + +// saveCostSnapshots 把成本条目按 UTC 日聚合后逐日 upsert。 +func (s *TaskService) saveCostSnapshots(ctx context.Context, cfgID uint, items []oci.CostItem) error { + type bucket struct { + amount float64 + currency string + } + byDay := map[string]*bucket{} + for _, item := range items { + if item.TimeStart == nil { + continue + } + day := item.TimeStart.UTC().Format("2006-01-02") + b, ok := byDay[day] + if !ok { + b = &bucket{currency: item.Currency} + byDay[day] = b + } + b.amount += float64(item.ComputedAmount) + } + now := time.Now() + for day, b := range byDay { + snap := model.CostSnapshot{ + OciConfigID: cfgID, Day: day, + Amount: b.amount, Currency: b.currency, SyncedAt: now, + } + err := s.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "oci_config_id"}, {Name: "day"}}, + DoUpdates: clause.AssignmentColumns([]string{"amount", "currency", "synced_at"}), + }).Create(&snap).Error + if err != nil { + return fmt.Errorf("save cost snapshot %s: %w", day, err) + } + } + return nil +} + +// targetConfigIDs 解析任务作用范围;未指定时返回全部配置 ID。 +func (s *TaskService) targetConfigIDs(ctx context.Context, ids []uint) ([]uint, error) { + if len(ids) > 0 { + return ids, nil + } + configs, err := s.configs.List(ctx) + if err != nil { + return nil, err + } + all := make([]uint, 0, len(configs)) + for _, cfg := range configs { + all = append(all, cfg.ID) + } + return all, nil +} + +// runSnatch 尝试创建实例;抢到目标台数后任务标记 succeeded 并停止调度, +// 部分成功把剩余台数写回 payload 下次继续;成功路径一并清零并写回连续 +// 鉴权失败计数,失败路径交给 snatchFailure 做连续 NotAuthenticated 熔断 +// 判定。字段落库由 execute 统一 Save。 +func (s *TaskService) runSnatch(ctx context.Context, task *model.Task) (string, error) { + var p snatchPayload + if err := json.Unmarshal([]byte(task.Payload), &p); err != nil { + return "", fmt.Errorf("parse payload: %w", err) + } + if p.Count < 1 { + p.Count = 1 + } + in, adNote, err := s.snatchInstanceInput(ctx, task, &p) + if err != nil { + return "", s.snatchFailure(ctx, task, &p, err) + } + instances, failures, err := s.configs.CreateInstances(ctx, p.OciConfigID, in, p.Count) + if err == nil && len(instances) == 0 { + err = fmt.Errorf("no instance created%s: %s", adNote, strings.Join(failures, "; ")) + } + if err != nil { + return "", s.snatchFailure(ctx, task, &p, err) + } + p.AuthFailCount = 0 + ids := make([]string, 0, len(instances)) + for _, in := range instances { + ids = append(ids, in.ID) + } + remaining := p.Count - len(instances) + if remaining > 0 { + p.Count = remaining + writeSnatchPayload(task, &p) + return fmt.Sprintf("created %d (%s)%s, %d remaining", len(instances), strings.Join(ids, ","), adNote, remaining), nil + } + task.Status = model.TaskStatusSucceeded + writeSnatchPayload(task, &p) + s.unschedule(task.ID) + return fmt.Sprintf("created %d: %s%s", len(instances), strings.Join(ids, ","), adNote), nil +} + +// snatchInstanceInput 组装本次创建参数:可用域显式指定时原样使用; +// 留空(自动)时按执行序号轮询区域全部可用域——ad-1、ad-2、ad-3 依次循环, +// 分摊单可用域容量不足。附加说明串供执行日志展示本次所用可用域。 +func (s *TaskService) snatchInstanceInput(ctx context.Context, task *model.Task, p *snatchPayload) (oci.CreateInstanceInput, string, error) { + in := p.Instance + if in.AvailabilityDomain != "" { + return in, "", nil + } + ads, err := s.configs.AvailabilityDomains(ctx, p.OciConfigID, in.Region) + if err != nil { + return in, "", fmt.Errorf("list availability domains: %w", err) + } + if len(ads) == 0 { + return in, "", fmt.Errorf("region has no availability domain") + } + // execute 在 run 之后才递增 RunCount,此处即 0 起的本次执行序号 + in.AvailabilityDomain = ads[task.RunCount%len(ads)] + return in, " @ " + in.AvailabilityDomain, nil +} + +// snatchFailure 处理抢机单次失败:错误含 NotAuthenticated 时累计连续计数, +// 达阈值把任务置 failed 并移出调度(熔断);其他错误清零计数。计数写回 payload。 +func (s *TaskService) snatchFailure(ctx context.Context, task *model.Task, p *snatchPayload, cause error) error { + if !strings.Contains(cause.Error(), "NotAuthenticated") { + p.AuthFailCount = 0 + writeSnatchPayload(task, p) + return cause + } + p.AuthFailCount++ + writeSnatchPayload(task, p) + if p.AuthFailCount < s.snatchAuthFailLimit(ctx) { + return cause + } + task.Status = model.TaskStatusFailed + s.unschedule(task.ID) + return fmt.Errorf("连续 %d 次 NotAuthenticated,任务已熔断停止: %w", p.AuthFailCount, cause) +} + +// snatchAuthFailLimit 读取抢机熔断阈值;settings 未注入或读取失败按默认值。 +func (s *TaskService) snatchAuthFailLimit(ctx context.Context) int { + if s.settings == nil { + return defaultSnatchAuthFailLimit + } + view, err := s.settings.TaskSettings(ctx) + if err != nil { + return defaultSnatchAuthFailLimit + } + return view.SnatchAuthFailLimit +} + +// writeSnatchPayload 把最新抢机参数序列化写回任务(execute 统一落库)。 +func writeSnatchPayload(task *model.Task, p *snatchPayload) { + if raw, err := json.Marshal(p); err == nil { + task.Payload = string(raw) + } +} + +// appendLog 写入执行日志并裁剪超出保留数量的旧日志。 +func (s *TaskService) appendLog(taskID uint, success bool, message string, elapsed time.Duration) *model.TaskLog { + entry := &model.TaskLog{ + TaskID: taskID, + Success: success, + Message: message, + DurationMs: elapsed.Milliseconds(), + } + s.db.Create(entry) + s.db.Where("task_id = ? AND id NOT IN (?)", taskID, + s.db.Model(&model.TaskLog{}).Select("id").Where("task_id = ?", taskID). + Order("id desc").Limit(taskLogKeep), + ).Delete(&model.TaskLog{}) + return entry +} + +// taskSnapshot 是通知判定所需的任务状态切片(执行前后各取一份)。 +type taskSnapshot struct { + Name string + Status string + LastError string + Message string // 本次执行结果摘要,仅执行后快照填写 +} + +// notifyKind 是通知事件类型,与设置页「通知管理」开关一一对应。 +type notifyKind string + +const ( + notifyTaskFail notifyKind = "task_fail" + notifyTaskRecover notifyKind = "task_recover" + notifySnatchSuccess notifyKind = "snatch_success" + notifyTenantDead notifyKind = "tenant_dead" + notifyTaskStop notifyKind = "task_stop" // 任务熔断停止(抢机连续鉴权失败达阈值) +) + +// notifyEvent 是一条待发送的通知:类型供开关过滤与模板选择,Vars 为模板变量。 +type notifyEvent struct { + Kind notifyKind + Vars map[string]string +} + +// notifyEvents 比较执行前后的任务状态,返回需要推送的通知事件。 +// 只在状态发生变化时产出:连续失败或持续正常都不重复发,防轰炸。 +// 熔断翻转(置 failed)优先判定,该次只发任务停止、不叠加任务失败。 +func notifyEvents(prev, cur taskSnapshot) []notifyEvent { + var events []notifyEvent + if prev.Status != model.TaskStatusSucceeded && cur.Status == model.TaskStatusSucceeded { + events = append(events, notifyEvent{notifySnatchSuccess, map[string]string{"task_name": cur.Name, "message": cur.Message}}) + } + switch { + case prev.Status != model.TaskStatusFailed && cur.Status == model.TaskStatusFailed: + events = append(events, notifyEvent{notifyTaskStop, map[string]string{"task_name": cur.Name, "error": cur.LastError}}) + case prev.LastError == "" && cur.LastError != "": + events = append(events, notifyEvent{notifyTaskFail, map[string]string{"task_name": cur.Name, "error": cur.LastError}}) + case prev.LastError != "" && cur.LastError == "" && cur.Status != model.TaskStatusSucceeded: + // 恢复即成功收尾(抢机达成目标)时已有抢机成功通知,不再叠加恢复通知 + events = append(events, notifyEvent{notifyTaskRecover, map[string]string{"task_name": cur.Name}}) + } + return events +} + +// notifyFilterTimeout 是发送前查询事件开关的超时时间(本地 SQLite,查询极快)。 +const notifyFilterTimeout = 5 * time.Second + +// notify 逐条按事件开关过滤后异步发送;notifier 未注入(nil)时整体关闭。 +// 开关读取失败按开启降级(NotifyEventEnabled 内部兜底),不因设置异常漏发。 +func (s *TaskService) notify(events []notifyEvent) { + if s.notifier == nil || len(events) == 0 { + return + } + ctx, cancel := context.WithTimeout(context.Background(), notifyFilterTimeout) + defer cancel() + for _, ev := range events { + if s.settings != nil && !s.settings.NotifyEventEnabled(ctx, string(ev.Kind)) { + continue + } + s.notifier.SendTemplateAsync(string(ev.Kind), ev.Vars) + } +} + +// deadAliasKey 是测活任务留存上次失联别名集合的 Setting 键。 +func deadAliasKey(taskID uint) string { + return fmt.Sprintf("health_dead_alias:%d", taskID) +} + +// notifyDeadAliases 只在失联集合发生变化时推送失联通知,并留存本次集合; +// 集合不变(含持续失联)不重复发。notifier 未注入时整体跳过。 +func (s *TaskService) notifyDeadAliases(ctx context.Context, taskID uint, aliases []string) { + if s.notifier == nil { + return + } + if sameStringSet(s.loadDeadAliases(ctx, taskID), aliases) { + return + } + s.saveDeadAliases(ctx, taskID, aliases) + if len(aliases) > 0 { + s.notify([]notifyEvent{{notifyTenantDead, map[string]string{"tenants": strings.Join(aliases, "、")}}}) + } +} + +// loadDeadAliases 读取任务上次记录的失联别名集合;无记录视为空集。 +func (s *TaskService) loadDeadAliases(ctx context.Context, taskID uint) []string { + var st model.Setting + err := s.db.WithContext(ctx).First(&st, "key = ?", deadAliasKey(taskID)).Error + if err != nil || st.Value == "" { + return nil + } + var aliases []string + if err := json.Unmarshal([]byte(st.Value), &aliases); err != nil { + return nil + } + return aliases +} + +// saveDeadAliases 覆盖保存本次失联别名集合;留存失败不影响任务执行。 +func (s *TaskService) saveDeadAliases(ctx context.Context, taskID uint, aliases []string) { + raw, err := json.Marshal(aliases) + if err != nil { + return + } + s.db.WithContext(ctx).Save(&model.Setting{ + Key: deadAliasKey(taskID), Value: string(raw), UpdatedAt: time.Now(), + }) +} + +// sameStringSet 判断两个字符串切片内容是否相同(忽略顺序,重复元素按次数计)。 +func sameStringSet(a, b []string) bool { + if len(a) != len(b) { + return false + } + count := make(map[string]int, len(a)) + for _, v := range a { + count[v]++ + } + for _, v := range b { + count[v]-- + if count[v] < 0 { + return false + } + } + return true +} diff --git a/internal/service/task_aiprobe.go b/internal/service/task_aiprobe.go new file mode 100644 index 0000000..4ca81cd --- /dev/null +++ b/internal/service/task_aiprobe.go @@ -0,0 +1,74 @@ +package service + +import ( + "context" + "errors" + "log" + + "gorm.io/gorm" + + "oci-portal/internal/model" +) + +// AI 探测任务由系统按渠道数量自动管理:每天 00:00 探测一次全部号池渠道 +// (逐渠道使用该渠道自己的租户凭据,与号池一一对应)。 +const ( + aiProbeTaskName = "AI渠道探测" + aiProbeCron = "0 0 * * *" +) + +// SyncAiProbeTask 按渠道数量同步 AI 探测任务: +// 渠道数 > 0 时确保任务存在且激活,归零时连同日志自动删除; +// 由渠道增删钩子与启动时调用。cron 与代码基线不一致时一并对齐(升级自动迁移)。 +func (s *TaskService) SyncAiProbeTask(ctx context.Context) { + var count int64 + if err := s.db.WithContext(ctx).Model(&model.AiChannel{}).Count(&count).Error; err != nil { + return + } + var task model.Task + err := s.db.WithContext(ctx).Where("type = ?", model.TaskTypeAiProbe).First(&task).Error + notFound := errors.Is(err, gorm.ErrRecordNotFound) + if err != nil && !notFound { + return + } + if !notFound && task.CronExpr != aiProbeCron { + s.alignAiProbeCron(ctx, &task) + } + if count > 0 { + s.activateAiProbe(ctx, &task, notFound) + return + } + if !notFound { + if err := s.removeTask(ctx, task.ID); err != nil { + log.Printf("sync ai probe task: %v", err) + } + } +} + +// alignAiProbeCron 把存量任务的执行计划对齐到当前基线。 +func (s *TaskService) alignAiProbeCron(ctx context.Context, task *model.Task) { + cron := aiProbeCron + if _, err := s.UpdateTask(ctx, task.ID, UpdateTaskInput{CronExpr: &cron}); err != nil { + log.Printf("align ai probe cron: %v", err) + return + } + task.CronExpr = aiProbeCron +} + +// activateAiProbe 创建或激活 AI 探测任务。 +func (s *TaskService) activateAiProbe(ctx context.Context, task *model.Task, notFound bool) { + if notFound { + in := CreateTaskInput{Name: aiProbeTaskName, Type: model.TaskTypeAiProbe, CronExpr: aiProbeCron} + if _, err := s.CreateTask(ctx, in); err != nil { + log.Printf("sync ai probe task: %v", err) + } + return + } + if task.Status == model.TaskStatusActive { + return + } + active := model.TaskStatusActive + if _, err := s.UpdateTask(ctx, task.ID, UpdateTaskInput{Status: &active}); err != nil { + log.Printf("sync ai probe task: %v", err) + } +} diff --git a/internal/service/task_test.go b/internal/service/task_test.go new file mode 100644 index 0000000..ee73d1e --- /dev/null +++ b/internal/service/task_test.go @@ -0,0 +1,701 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "oci-portal/internal/crypto" + "oci-portal/internal/model" + "oci-portal/internal/oci" +) + +// snatchFake 可控 LaunchInstance 结果:前 failFirst 次失败模拟容量不足。 +type snatchFake struct { + fakeClient + launches int + failFirst int +} + +func (f *snatchFake) LaunchInstance(ctx context.Context, cred oci.Credentials, in oci.CreateInstanceInput) (oci.Instance, error) { + f.launches++ + if f.launches <= f.failFirst { + return oci.Instance{}, fmt.Errorf("Out of host capacity") + } + return oci.Instance{ID: fmt.Sprintf("ocid1.instance.%d", f.launches), DisplayName: in.DisplayName}, nil +} + +// scriptedSnatchFake 按脚本逐次控制 LaunchInstance 结果:nil 表示成功; +// 脚本耗尽后一律成功。 +type scriptedSnatchFake struct { + fakeClient + mu sync.Mutex + script []error + launches int +} + +func (f *scriptedSnatchFake) LaunchInstance(ctx context.Context, cred oci.Credentials, in oci.CreateInstanceInput) (oci.Instance, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.launches++ + if len(f.script) > 0 { + err := f.script[0] + f.script = f.script[1:] + if err != nil { + return oci.Instance{}, err + } + } + return oci.Instance{ID: fmt.Sprintf("ocid1.instance.%d", f.launches), DisplayName: in.DisplayName}, nil +} + +func newTaskEnv(t *testing.T, client oci.Client) (*TaskService, *OciConfigService, *gorm.DB) { + t.Helper() + 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.OciConfig{}, &model.Task{}, &model.TaskLog{}, + &model.CheckSnapshot{}, &model.CostSnapshot{}, &model.Setting{}, &model.Proxy{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + cipher, err := crypto.NewCipher("test-data-key") + if err != nil { + t.Fatalf("new cipher: %v", err) + } + configs := NewOciConfigService(db, cipher, client) + return NewTaskService(db, configs, nil, NewSettingService(db, cipher)), configs, db +} + +func TestCreateTaskValidation(t *testing.T) { + tasks, _, _ := newTaskEnv(t, &fakeClient{}) + tests := []struct { + name string + in CreateTaskInput + }{ + {name: "非法 cron", in: CreateTaskInput{Name: "t", Type: model.TaskTypeHealthCheck, CronExpr: "not-cron"}}, + {name: "未知类型", in: CreateTaskInput{Name: "t", Type: "nope", CronExpr: "* * * * *"}}, + {name: "缺名称", in: CreateTaskInput{Type: model.TaskTypeHealthCheck, CronExpr: "* * * * *"}}, + { + name: "抢机缺配置ID", + in: CreateTaskInput{Name: "t", Type: model.TaskTypeSnatch, CronExpr: "* * * * *", + Payload: json.RawMessage(`{"instance":{"displayName":"a","shape":"s","imageId":"i"}}`)}, + }, + { + name: "抢机实例参数不全", + in: CreateTaskInput{Name: "t", Type: model.TaskTypeSnatch, CronExpr: "* * * * *", + Payload: json.RawMessage(`{"ociConfigId":1,"instance":{"shape":"s"}}`)}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := tasks.CreateTask(context.Background(), tt.in); err == nil { + t.Error("CreateTask succeeded, want error") + } + }) + } +} + +func TestRunHealthCheckTask(t *testing.T) { + client := &fakeClient{tenancy: oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"}} + tasks, configs, _ := newTaskEnv(t, client) + ctx := context.Background() + if _, err := configs.Import(ctx, trialImportInput()); err != nil { + t.Fatalf("import: %v", err) + } + second := trialImportInput() + second.Alias = "第二个" + if _, err := configs.Import(ctx, second); err != nil { + t.Fatalf("import second: %v", err) + } + + task, err := tasks.CreateTask(ctx, CreateTaskInput{ + Name: "测活", Type: model.TaskTypeHealthCheck, CronExpr: "*/5 * * * *", + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + entry, err := tasks.RunTaskNow(ctx, task.ID) + if err != nil { + t.Fatalf("RunTaskNow: %v", err) + } + if !entry.Success || entry.Message != "checked 2: 2 alive, 0 dead" { + t.Errorf("log = %+v, want success checked 2", entry) + } + + scoped, err := tasks.CreateTask(ctx, CreateTaskInput{ + Name: "范围测活", Type: model.TaskTypeHealthCheck, CronExpr: "*/5 * * * *", + Payload: json.RawMessage(`{"ociConfigIds":[1]}`), + }) + if err != nil { + t.Fatalf("CreateTask scoped: %v", err) + } + entry, err = tasks.RunTaskNow(ctx, scoped.ID) + if err != nil { + t.Fatalf("RunTaskNow scoped: %v", err) + } + if entry.Message != "checked 1: 1 alive, 0 dead" { + t.Errorf("scoped message = %q, want checked 1", entry.Message) + } +} + +func TestRunSnatchTaskUntilSuccess(t *testing.T) { + client := &snatchFake{failFirst: 2} + client.tenancy = oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"} + tasks, configs, _ := newTaskEnv(t, client) + ctx := context.Background() + if _, err := configs.Import(ctx, trialImportInput()); err != nil { + t.Fatalf("import: %v", err) + } + task, err := tasks.CreateTask(ctx, CreateTaskInput{ + Name: "抢机", Type: model.TaskTypeSnatch, CronExpr: "* * * * *", + Payload: json.RawMessage(`{"ociConfigId":1,"instance":{"displayName":"snatch-vm","shape":"VM.Standard.A1.Flex","ocpus":4,"memoryInGBs":24,"imageId":"ocid1.image.test"}}`), + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + + // 前两次容量不足:任务保持 active 并记录失败 + for i := 1; i <= 2; i++ { + entry, err := tasks.RunTaskNow(ctx, task.ID) + if err != nil { + t.Fatalf("run %d: %v", i, err) + } + if entry.Success { + t.Fatalf("run %d succeeded, want capacity failure", i) + } + } + got, _ := tasks.GetTask(ctx, task.ID) + if got.Status != model.TaskStatusActive || got.RunCount != 2 || got.LastError == "" { + t.Errorf("after failures: status=%s runs=%d lastError=%q", got.Status, got.RunCount, got.LastError) + } + + // 第三次成功:任务自动标记 succeeded + entry, err := tasks.RunTaskNow(ctx, task.ID) + if err != nil { + t.Fatalf("run 3: %v", err) + } + if !entry.Success { + t.Fatalf("run 3 = %+v, want success", entry) + } + got, _ = tasks.GetTask(ctx, task.ID) + if got.Status != model.TaskStatusSucceeded || got.LastError != "" { + t.Errorf("after success: status=%s lastError=%q, want succeeded", got.Status, got.LastError) + } + + logs, err := tasks.TaskLogs(ctx, task.ID, 10) + if err != nil { + t.Fatalf("TaskLogs: %v", err) + } + if len(logs) != 3 || !logs[0].Success { + t.Errorf("logs = %d entries, newest success=%v; want 3 with newest success", len(logs), logs[0].Success) + } +} + +// adRotationFake 记录每次 LaunchInstance 收到的可用域;创建一律按容量不足 +// 失败,任务保持 active 以便连续执行验证轮询顺序。 +type adRotationFake struct { + fakeClient + ads []string + got []string +} + +func (f *adRotationFake) ListAvailabilityDomains(ctx context.Context, cred oci.Credentials, region string) ([]string, error) { + return f.ads, nil +} + +func (f *adRotationFake) LaunchInstance(ctx context.Context, cred oci.Credentials, in oci.CreateInstanceInput) (oci.Instance, error) { + f.got = append(f.got, in.AvailabilityDomain) + return oci.Instance{}, fmt.Errorf("Out of host capacity") +} + +// TestSnatchAdRotation 可用域留空(自动)时按执行序号轮询区域全部可用域, +// 显式指定时不轮询。 +func TestSnatchAdRotation(t *testing.T) { + client := &adRotationFake{ads: []string{"ad-1", "ad-2", "ad-3"}} + client.tenancy = oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"} + tasks, configs, _ := newTaskEnv(t, client) + ctx := context.Background() + if _, err := configs.Import(ctx, trialImportInput()); err != nil { + t.Fatalf("import: %v", err) + } + task, err := tasks.CreateTask(ctx, CreateTaskInput{ + Name: "抢机", Type: model.TaskTypeSnatch, CronExpr: "* * * * *", + Payload: json.RawMessage(`{"ociConfigId":1,"instance":{"displayName":"vm","shape":"s","imageId":"i"}}`), + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + for i := 0; i < 4; i++ { + if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil { + t.Fatalf("run %d: %v", i, err) + } + } + if got := strings.Join(client.got, ","); got != "ad-1,ad-2,ad-3,ad-1" { + t.Errorf("自动轮询序列 = %s, want ad-1,ad-2,ad-3,ad-1", got) + } + client.got = nil + fixed, err := tasks.CreateTask(ctx, CreateTaskInput{ + Name: "定向", Type: model.TaskTypeSnatch, CronExpr: "* * * * *", + Payload: json.RawMessage(`{"ociConfigId":1,"instance":{"displayName":"vm","shape":"s","imageId":"i","availabilityDomain":"ad-2"}}`), + }) + if err != nil { + t.Fatalf("CreateTask fixed: %v", err) + } + for i := 0; i < 2; i++ { + if _, err := tasks.RunTaskNow(ctx, fixed.ID); err != nil { + t.Fatalf("run fixed %d: %v", i, err) + } + } + if got := strings.Join(client.got, ","); got != "ad-2,ad-2" { + t.Errorf("显式指定序列 = %s, want ad-2,ad-2", got) + } +} + +func TestSnatchPartialSuccessKeepsRemaining(t *testing.T) { + client := &snatchFake{failFirst: 1} // 第 1 台失败、第 2 台成功 + client.tenancy = oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"} + tasks, configs, _ := newTaskEnv(t, client) + ctx := context.Background() + if _, err := configs.Import(ctx, trialImportInput()); err != nil { + t.Fatalf("import: %v", err) + } + task, err := tasks.CreateTask(ctx, CreateTaskInput{ + Name: "抢两台", Type: model.TaskTypeSnatch, CronExpr: "* * * * *", + Payload: json.RawMessage(`{"ociConfigId":1,"count":2,"instance":{"displayName":"vm","shape":"s","imageId":"i"}}`), + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + entry, err := tasks.RunTaskNow(ctx, task.ID) + if err != nil { + t.Fatalf("RunTaskNow: %v", err) + } + if !entry.Success { + t.Fatalf("log = %+v, want partial success", entry) + } + got, _ := tasks.GetTask(ctx, task.ID) + if got.Status != model.TaskStatusActive { + t.Errorf("status = %s, want active(还差 1 台)", got.Status) + } + var p snatchPayload + if err := json.Unmarshal([]byte(got.Payload), &p); err != nil || p.Count != 1 { + t.Errorf("payload count = %d (%v), want 1", p.Count, err) + } +} + +func TestUpdateTaskPauseResume(t *testing.T) { + tasks, _, _ := newTaskEnv(t, &fakeClient{}) + ctx := context.Background() + task, err := tasks.CreateTask(ctx, CreateTaskInput{ + Name: "测活", Type: model.TaskTypeHealthCheck, CronExpr: "*/10 * * * *", + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + paused := model.TaskStatusPaused + updated, err := tasks.UpdateTask(ctx, task.ID, UpdateTaskInput{Status: &paused}) + if err != nil { + t.Fatalf("pause: %v", err) + } + if updated.Status != model.TaskStatusPaused { + t.Errorf("status = %s, want paused", updated.Status) + } + if _, ok := tasks.entries[task.ID]; ok { + t.Error("paused task still scheduled") + } + active := model.TaskStatusActive + cronExpr := "*/30 * * * *" + updated, err = tasks.UpdateTask(ctx, task.ID, UpdateTaskInput{Status: &active, CronExpr: &cronExpr}) + if err != nil { + t.Fatalf("resume: %v", err) + } + if updated.CronExpr != cronExpr || updated.Status != model.TaskStatusActive { + t.Errorf("updated = %+v, want active with new cron", updated) + } + if _, ok := tasks.entries[task.ID]; !ok { + t.Error("resumed task not scheduled") + } +} + +// enabledTelegramInput 返回启用态的 Telegram 配置输入,供通知测试复用。 +func enabledTelegramInput() *UpdateTelegramInput { + token := "123456:AAfake" + return &UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"} +} + +func TestSnatchRepeatedFailureNotifiesOnce(t *testing.T) { + client := &snatchFake{failFirst: 3} + client.tenancy = oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"} + tasks, configs, _ := newTaskEnv(t, client) + srv, rec := newFakeTelegram(t, `{"ok":true}`) + tasks.notifier = newTestNotifier(t, srv.URL, enabledTelegramInput()) + ctx := context.Background() + if _, err := configs.Import(ctx, trialImportInput()); err != nil { + t.Fatalf("import: %v", err) + } + task, err := tasks.CreateTask(ctx, CreateTaskInput{ + Name: "抢机", Type: model.TaskTypeSnatch, CronExpr: "* * * * *", + Payload: json.RawMessage(`{"ociConfigId":1,"instance":{"displayName":"vm","shape":"s","imageId":"i"}}`), + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + + // 连续失败 3 次:只发 1 条失败通知 + for i := 1; i <= 3; i++ { + if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil { + t.Fatalf("run %d: %v", i, err) + } + } + tasks.notifier.Wait() + if texts := rec.snapshot(); len(texts) != 1 || !strings.Contains(texts[0], "任务失败") { + t.Fatalf("失败阶段通知 = %v, want 仅 1 条任务失败", texts) + } + + // 第 4 次抢满:追加 1 条抢机成功,不叠加恢复通知 + if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil { + t.Fatalf("run 4: %v", err) + } + tasks.notifier.Wait() + texts := rec.snapshot() + if len(texts) != 2 || !strings.Contains(texts[1], "抢机成功") { + t.Fatalf("成功后通知 = %v, want 追加 1 条抢机成功", texts) + } +} + +// TestNotifyFiltersDisabledKinds 验证按事件类型过滤:关闭「任务失败」后 +// 失败不再推送,任务恢复与抢机成功不受影响仍推送。 +func TestNotifyFiltersDisabledKinds(t *testing.T) { + client := &snatchFake{failFirst: 3} + client.tenancy = oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"} + tasks, configs, _ := newTaskEnv(t, client) + srv, rec := newFakeTelegram(t, `{"ok":true}`) + tasks.notifier = newTestNotifier(t, srv.URL, enabledTelegramInput()) + ctx := context.Background() + off := NotifyEventsView{TaskRecover: true, SnatchSuccess: true, TenantDead: true, TaskStop: true} + if err := tasks.settings.UpdateNotifyEvents(ctx, off); err != nil { + t.Fatalf("UpdateNotifyEvents: %v", err) + } + if _, err := configs.Import(ctx, trialImportInput()); err != nil { + t.Fatalf("import: %v", err) + } + task, err := tasks.CreateTask(ctx, CreateTaskInput{ + Name: "抢两台", Type: model.TaskTypeSnatch, CronExpr: "* * * * *", + Payload: json.RawMessage(`{"ociConfigId":1,"count":2,"instance":{"displayName":"vm","shape":"s","imageId":"i"}}`), + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + + // 三次执行依次触发:task_fail(已关闭)→ task_recover → snatch_success + steps := []struct { + name string + wantLen int // 执行后累计推送条数 + wantKw string // 最新一条需包含的关键词;空表示本步无新增 + }{ + {name: "两台全失败:任务失败已关闭不发", wantLen: 0}, + {name: "抢到 1 台剩 1:任务恢复仍发", wantLen: 1, wantKw: "任务恢复"}, + {name: "抢满收尾:抢机成功仍发", wantLen: 2, wantKw: "抢机成功"}, + } + for _, step := range steps { + if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil { + t.Fatalf("%s: %v", step.name, err) + } + tasks.notifier.Wait() + texts := rec.snapshot() + if len(texts) != step.wantLen { + t.Fatalf("%s: 通知 = %v (%d 条), want %d 条", step.name, texts, len(texts), step.wantLen) + } + if step.wantKw != "" && !strings.Contains(texts[len(texts)-1], step.wantKw) { + t.Fatalf("%s: 最新通知 = %q, want contains %q", step.name, texts[len(texts)-1], step.wantKw) + } + } +} + +// deadPickFake 按 UserOCID 控制测活结果,模拟部分租户失联。 +type deadPickFake struct { + fakeClient + mu sync.Mutex + dead map[string]bool +} + +func (f *deadPickFake) ValidateKey(ctx context.Context, cred oci.Credentials) (oci.TenancyInfo, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.dead[cred.UserOCID] { + return oci.TenancyInfo{}, fmt.Errorf("NotAuthenticated") + } + return f.tenancy, nil +} + +func (f *deadPickFake) setDead(user string, dead bool) { + f.mu.Lock() + defer f.mu.Unlock() + f.dead[user] = dead +} + +func TestHealthCheckNotifiesDeadAliasOnChange(t *testing.T) { + client := &deadPickFake{dead: map[string]bool{}} + client.tenancy = oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"} + tasks, configs, _ := newTaskEnv(t, client) + srv, rec := newFakeTelegram(t, `{"ok":true}`) + tasks.notifier = newTestNotifier(t, srv.URL, enabledTelegramInput()) + ctx := context.Background() + if _, err := configs.Import(ctx, trialImportInput()); err != nil { + t.Fatalf("import A: %v", err) + } + second := trialImportInput() + second.Alias = "second-tenant" + second.UserOCID = "ocid1.user.oc1..u2" + if _, err := configs.Import(ctx, second); err != nil { + t.Fatalf("import B: %v", err) + } + task, err := tasks.CreateTask(ctx, CreateTaskInput{ + Name: "测活", Type: model.TaskTypeHealthCheck, CronExpr: "*/5 * * * *", + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + run := func(step string) { + t.Helper() + if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil { + t.Fatalf("%s: %v", step, err) + } + tasks.notifier.Wait() + } + + run("全部存活") + client.setDead("ocid1.user.oc1..u2", true) + run("B 失联") // 集合变化:发 1 条 + run("B 持续失联") // 集合不变:不发 + client.setDead("ocid1.user.oc1..u2", false) + run("B 恢复") // 集合清空:不发失联通知 + client.setDead("ocid1.user.oc1..u2", true) + run("B 再失联") // 集合再次变化:再发 1 条 + + texts := rec.snapshot() + if len(texts) != 2 { + t.Fatalf("失联通知 %d 条 %v, want 2 条", len(texts), texts) + } + for i, text := range texts { + if !strings.Contains(text, "租户失联") || !strings.Contains(text, "second-tenant") { + t.Errorf("text[%d] = %q, want 含「租户失联」与失联别名", i, text) + } + } +} + +// snatchAuthCount 解析任务 payload 中的连续鉴权失败计数。 +func snatchAuthCount(t *testing.T, payload string) int { + t.Helper() + var p snatchPayload + if err := json.Unmarshal([]byte(payload), &p); err != nil { + t.Fatalf("parse payload %q: %v", payload, err) + } + return p.AuthFailCount +} + +func TestSnatchAuthFuse(t *testing.T) { + notAuth := fmt.Errorf("NotAuthenticated: 鉴权失败") + capacity := fmt.Errorf("Out of host capacity") + tests := []struct { + name string + limit int // 0 表示不设置,用默认 3 + count int // 目标台数;0 按 1 处理 + script []error + runs int + wantStatus string + wantAuthCnt int + }{ + { + name: "连续 2 次不熔断", script: []error{notAuth, notAuth}, runs: 2, + wantStatus: model.TaskStatusActive, wantAuthCnt: 2, + }, + { + name: "默认阈值第 3 次熔断", script: []error{notAuth, notAuth, notAuth}, runs: 3, + wantStatus: model.TaskStatusFailed, wantAuthCnt: 3, + }, + { + name: "中途成功清零", count: 2, script: []error{notAuth, notAuth, nil, capacity}, runs: 2, + wantStatus: model.TaskStatusActive, wantAuthCnt: 0, + }, + { + name: "中途其他错误清零", script: []error{notAuth, capacity}, runs: 2, + wantStatus: model.TaskStatusActive, wantAuthCnt: 0, + }, + { + name: "抢满收尾清零并写回", script: []error{notAuth, nil}, runs: 2, + wantStatus: model.TaskStatusSucceeded, wantAuthCnt: 0, + }, + { + name: "limit=1 立即熔断", limit: 1, script: []error{notAuth}, runs: 1, + wantStatus: model.TaskStatusFailed, wantAuthCnt: 1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + task, tasks := runAuthFuseCase(t, tt.limit, tt.count, tt.script, tt.runs) + got, err := tasks.GetTask(context.Background(), task.ID) + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if got.Status != tt.wantStatus { + t.Errorf("status = %s, want %s", got.Status, tt.wantStatus) + } + if cnt := snatchAuthCount(t, got.Payload); cnt != tt.wantAuthCnt { + t.Errorf("authFailCount = %d, want %d", cnt, tt.wantAuthCnt) + } + assertAuthFuseSideEffects(t, tasks, got, tt.wantStatus, tt.wantAuthCnt) + }) + } +} + +// runAuthFuseCase 搭好环境并按用例参数执行 runs 次抢机任务。 +func runAuthFuseCase(t *testing.T, limit, count int, script []error, runs int) (*model.Task, *TaskService) { + t.Helper() + client := &scriptedSnatchFake{script: script} + client.tenancy = oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"} + tasks, configs, _ := newTaskEnv(t, client) + ctx := context.Background() + if _, err := configs.Import(ctx, trialImportInput()); err != nil { + t.Fatalf("import: %v", err) + } + if limit > 0 { + if err := tasks.settings.UpdateTaskSettings(ctx, TaskSettingsView{SnatchAuthFailLimit: limit}); err != nil { + t.Fatalf("UpdateTaskSettings: %v", err) + } + } + if count == 0 { + count = 1 + } + task, err := tasks.CreateTask(ctx, CreateTaskInput{ + Name: "抢机", Type: model.TaskTypeSnatch, CronExpr: "* * * * *", + Payload: json.RawMessage(fmt.Sprintf( + `{"ociConfigId":1,"count":%d,"instance":{"displayName":"vm","shape":"s","imageId":"i"}}`, count)), + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + for i := 1; i <= runs; i++ { + if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil { + t.Fatalf("run %d: %v", i, err) + } + } + return task, tasks +} + +// assertAuthFuseSideEffects 断言熔断副作用:仅 active 保持调度(failed 熔断、 +// succeeded 抢满均移出);failed 的 LastError 须带熔断说明,其余不得出现。 +func assertAuthFuseSideEffects(t *testing.T, tasks *TaskService, got *model.Task, wantStatus string, wantCnt int) { + t.Helper() + if _, scheduled := tasks.entries[got.ID]; scheduled != (wantStatus == model.TaskStatusActive) { + t.Errorf("scheduled = %v, want %v", scheduled, wantStatus == model.TaskStatusActive) + } + fused := wantStatus == model.TaskStatusFailed + fuseMsg := fmt.Sprintf("连续 %d 次 NotAuthenticated,任务已熔断停止", wantCnt) + if fused && !strings.Contains(got.LastError, fuseMsg) { + t.Errorf("LastError = %q, want 含 %q", got.LastError, fuseMsg) + } + if !fused && strings.Contains(got.LastError, "任务已熔断停止") { + t.Errorf("LastError = %q, 不应出现熔断说明", got.LastError) + } +} + +// TestNotifyEventsFailedTransitions 锁定熔断相关通知语义:置 failed 当次 +// 只发任务停止不叠加任务失败;重新启用后恢复只发任务恢复。 +func TestNotifyEventsFailedTransitions(t *testing.T) { + fuseErr := "连续 3 次 NotAuthenticated,任务已熔断停止: NotAuthenticated" + tests := []struct { + name string + prev, cur taskSnapshot + wantKinds []notifyKind + wantText string // 首条通知变量值需包含的关键词;空表示不校验 + }{ + { + name: "熔断翻转只发任务停止不叠加任务失败", + prev: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive}, + cur: taskSnapshot{Name: "抢机", Status: model.TaskStatusFailed, LastError: fuseErr}, + wantKinds: []notifyKind{notifyTaskStop}, + wantText: "任务已熔断停止", + }, + { + name: "持续 failed 不重复发", + prev: taskSnapshot{Name: "抢机", Status: model.TaskStatusFailed, LastError: fuseErr}, + cur: taskSnapshot{Name: "抢机", Status: model.TaskStatusFailed, LastError: fuseErr}, + wantKinds: nil, + }, + { + name: "failed 重新启用后成功只发任务恢复", + prev: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive, LastError: fuseErr}, + cur: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive}, + wantKinds: []notifyKind{notifyTaskRecover}, + wantText: "抢机", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + events := notifyEvents(tt.prev, tt.cur) + if len(events) != len(tt.wantKinds) { + t.Fatalf("events = %+v (%d 条), want %v", events, len(events), tt.wantKinds) + } + for i, ev := range events { + if ev.Kind != tt.wantKinds[i] { + t.Errorf("kind[%d] = %s, want %s", i, ev.Kind, tt.wantKinds[i]) + } + } + if tt.wantText != "" { + joined := "" + for _, v := range events[0].Vars { + joined += v + " " + } + if !strings.Contains(joined, tt.wantText) { + t.Errorf("vars = %q, want contains %q", joined, tt.wantText) + } + } + }) + } +} + +func TestUpdateTaskFailedToActiveResetsAuthFail(t *testing.T) { + notAuth := fmt.Errorf("NotAuthenticated: 鉴权失败") + task, tasks := runAuthFuseCase(t, 0, 1, []error{notAuth, notAuth, notAuth}, 3) + ctx := context.Background() + got, err := tasks.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if got.Status != model.TaskStatusFailed { + t.Fatalf("前置熔断未生效: status=%s", got.Status) + } + + active := model.TaskStatusActive + updated, err := tasks.UpdateTask(ctx, task.ID, UpdateTaskInput{Status: &active}) + if err != nil { + t.Fatalf("重新启用: %v", err) + } + if updated.Status != model.TaskStatusActive { + t.Errorf("status = %s, want active", updated.Status) + } + if cnt := snatchAuthCount(t, updated.Payload); cnt != 0 { + t.Errorf("authFailCount = %d, want 0(重新启用应清零)", cnt) + } + if _, ok := tasks.entries[task.ID]; !ok { + t.Error("重新启用后任务未回到调度") + } +} diff --git a/internal/service/tenantadmin.go b/internal/service/tenantadmin.go new file mode 100644 index 0000000..f022094 --- /dev/null +++ b/internal/service/tenantadmin.go @@ -0,0 +1,192 @@ +package service + +import ( + "context" + "fmt" + "strings" + + "oci-portal/internal/model" + "oci-portal/internal/oci" +) + +// homeRegionOf 返回配置的 home region 名称;本地表查不到时退回默认区域。 +// 用户管理等 IAM 写操作必须发往 home region。 +func homeRegionOf(cfg *model.OciConfig) string { + if info, ok := oci.RegionByKey(cfg.HomeRegionKey); ok { + return info.Name + } + return cfg.Region +} + +// credentialsAndHomeRegion 一次取回凭据与 home region。 +func (s *OciConfigService) credentialsAndHomeRegion(ctx context.Context, id uint) (oci.Credentials, string, error) { + cfg, err := s.Get(ctx, id) + if err != nil { + return oci.Credentials{}, "", err + } + cred, err := s.credentialsOf(cfg) + if err != nil { + return oci.Credentials{}, "", err + } + return cred, homeRegionOf(cfg), nil +} + +// TenantUsers 列出租户 IAM 用户。 +func (s *OciConfigService) TenantUsers(ctx context.Context, id uint) ([]oci.TenantUser, error) { + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return nil, err + } + return s.client.ListTenantUsers(ctx, cred) +} + +// TenantUserDetail 查用户的域档案与管理员状态(编辑表单预填充用)。 +func (s *OciConfigService) TenantUserDetail(ctx context.Context, id uint, userID string) (oci.TenantUserDetail, error) { + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return oci.TenantUserDetail{}, err + } + return s.client.GetTenantUserDetail(ctx, cred, homeRegion, userID) +} + +// CreateTenantUser 新增用户;名字与姓氏必填(域用户档案要求), +// 描述缺省用「名 姓」。 +func (s *OciConfigService) CreateTenantUser(ctx context.Context, id uint, in oci.CreateTenantUserInput) (oci.TenantUser, error) { + if strings.TrimSpace(in.Name) == "" { + return oci.TenantUser{}, fmt.Errorf("create tenant user: name is required") + } + if strings.TrimSpace(in.GivenName) == "" || strings.TrimSpace(in.FamilyName) == "" { + return oci.TenantUser{}, fmt.Errorf("create tenant user: givenName and familyName are required") + } + if in.Description == "" { + in.Description = strings.TrimSpace(in.GivenName + " " + in.FamilyName) + } + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return oci.TenantUser{}, err + } + return s.client.CreateTenantUser(ctx, cred, homeRegion, in) +} + +// UpdateTenantUser 编辑用户资料;nil 字段不修改,全 nil 报错。 +func (s *OciConfigService) UpdateTenantUser(ctx context.Context, id uint, userID string, in oci.UpdateTenantUserInput) (oci.TenantUser, error) { + hasField := in.Description != nil || in.Email != nil || in.GivenName != nil || in.FamilyName != nil + hasAdmin := in.GrantDomainAdmin != nil || in.AddToAdminGroup != nil + if !hasField && !hasAdmin { + return oci.TenantUser{}, fmt.Errorf("update tenant user: nothing to update") + } + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return oci.TenantUser{}, err + } + return s.client.UpdateTenantUser(ctx, cred, homeRegion, userID, in) +} + +// IdentitySetting 读取域身份设置(当前只透出主邮箱必填开关)。 +func (s *OciConfigService) IdentitySetting(ctx context.Context, id uint) (oci.IdentitySettingInfo, error) { + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return oci.IdentitySettingInfo{}, err + } + return s.client.GetIdentitySetting(ctx, cred, homeRegion) +} + +// UpdateIdentitySetting 修改「用户需要提供主电子邮件地址」开关。 +func (s *OciConfigService) UpdateIdentitySetting(ctx context.Context, id uint, primaryEmailRequired bool) (oci.IdentitySettingInfo, error) { + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return oci.IdentitySettingInfo{}, err + } + return s.client.UpdateIdentitySetting(ctx, cred, homeRegion, primaryEmailRequired) +} + +// DeleteTenantUser 删除 IAM 用户;拒绝删除当前配置正在使用的用户。 +func (s *OciConfigService) DeleteTenantUser(ctx context.Context, id uint, userID string) error { + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return err + } + if userID == cred.UserOCID { + return fmt.Errorf("delete tenant user: refusing to delete the user this config signs requests with") + } + return s.client.DeleteTenantUser(ctx, cred, homeRegion, userID) +} + +// ResetTenantUserPassword 重置用户控制台密码,返回一次性新密码。 +func (s *OciConfigService) ResetTenantUserPassword(ctx context.Context, id uint, userID string) (string, error) { + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return "", err + } + return s.client.ResetTenantUserPassword(ctx, cred, homeRegion, userID) +} + +// DeleteTenantUserMfa 清除用户全部 MFA,返回删除的经典 TOTP 设备数。 +func (s *OciConfigService) DeleteTenantUserMfa(ctx context.Context, id uint, userID string) (int, error) { + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return 0, err + } + return s.client.DeleteTenantUserMfaDevices(ctx, cred, homeRegion, userID) +} + +// DeleteTenantUserApiKeys 删除用户的 API Key;默认保留当前配置使用的指纹。 +func (s *OciConfigService) DeleteTenantUserApiKeys(ctx context.Context, id uint, userID string, includeCurrent bool) (int, error) { + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return 0, err + } + return s.client.DeleteTenantUserApiKeys(ctx, cred, homeRegion, userID, includeCurrent) +} + +// NotificationRecipients 查询域通知收件人设置。 +func (s *OciConfigService) NotificationRecipients(ctx context.Context, id uint) (oci.NotificationRecipients, error) { + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return oci.NotificationRecipients{}, err + } + return s.client.GetNotificationRecipients(ctx, cred, homeRegion) +} + +// UpdateNotificationRecipients 把域通知改为只发给指定收件人; +// 收件人为空表示关闭 test mode 恢复默认发送。 +func (s *OciConfigService) UpdateNotificationRecipients(ctx context.Context, id uint, emails []string) (oci.NotificationRecipients, error) { + if err := validateEmails(emails); err != nil { + return oci.NotificationRecipients{}, err + } + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return oci.NotificationRecipients{}, err + } + return s.client.UpdateNotificationRecipients(ctx, cred, homeRegion, emails) +} + +func validateEmails(emails []string) error { + for _, email := range emails { + if !strings.Contains(email, "@") || strings.ContainsAny(email, " \t") { + return fmt.Errorf("update notification recipients: invalid email %q", email) + } + } + return nil +} + +// PasswordPolicies 列出域密码策略。 +func (s *OciConfigService) PasswordPolicies(ctx context.Context, id uint) ([]oci.PasswordPolicyInfo, error) { + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return nil, err + } + return s.client.ListPasswordPolicies(ctx, cred, homeRegion) +} + +// UpdatePasswordPolicy 修改域密码策略的过期天数或最小长度。 +func (s *OciConfigService) UpdatePasswordPolicy(ctx context.Context, id uint, policyID string, in oci.UpdatePasswordPolicyInput) (oci.PasswordPolicyInfo, error) { + if in.PasswordExpiresAfter == nil && in.MinLength == nil { + return oci.PasswordPolicyInfo{}, fmt.Errorf("update password policy: nothing to update") + } + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return oci.PasswordPolicyInfo{}, err + } + return s.client.UpdatePasswordPolicy(ctx, cred, homeRegion, policyID, in) +} diff --git a/internal/service/totp.go b/internal/service/totp.go new file mode 100644 index 0000000..05d2e10 --- /dev/null +++ b/internal/service/totp.go @@ -0,0 +1,149 @@ +package service + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/pquerna/otp/totp" + "golang.org/x/crypto/bcrypt" + + "oci-portal/internal/model" +) + +// TOTP(RFC 6238)两步验证相关错误;api 层据此映射状态码。 +var ( + // ErrTotpRequired 表示账号已启用两步验证但登录未携带验证码(密码已通过)。 + ErrTotpRequired = errors.New("需要两步验证码") + // ErrTotpInvalid 表示验证码校验失败(设置流程中)。 + ErrTotpInvalid = errors.New("两步验证码错误") + // ErrTotpNotSetup 表示激活前未执行 setup 或暂存已过期。 + ErrTotpNotSetup = errors.New("请先重新发起两步验证设置") + // ErrTotpAlreadyOn 表示重复启用。 + ErrTotpAlreadyOn = errors.New("两步验证已启用,如需重置请先停用") + // ErrTotpConfirm 表示停用时未提供密码或验证码确认。 + ErrTotpConfirm = errors.New("停用需要当前验证码或登录密码确认") +) + +// totpPendingTTL 是 setup 暂存密钥的有效期;过期须重新 setup。 +const totpPendingTTL = 10 * time.Minute + +// totpIssuer 展示在验证器 App 中的签发方名称。 +const totpIssuer = "oci-portal" + +// pendingTotp 是 setup 后待激活的临时密钥(内存,重启丢弃)。 +type pendingTotp struct { + secret string + expires time.Time +} + +// TotpStatus 返回用户是否已启用两步验证。 +func (s *AuthService) TotpStatus(ctx context.Context, username string) (bool, error) { + user, err := s.findUser(ctx, username) + if err != nil { + return false, err + } + return user.TotpSecretEnc != "", nil +} + +// findUser 按用户名加载账号。 +func (s *AuthService) findUser(ctx context.Context, username string) (*model.User, error) { + var user model.User + if err := s.db.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil { + return nil, fmt.Errorf("find user: %w", err) + } + return &user, nil +} + +// SetupTotp 生成新 TOTP 密钥暂存(10 分钟内待激活),返回手动输入码与 otpauth URI; +// 重复调用覆盖旧暂存,已启用时拒绝(先停用再重置)。 +func (s *AuthService) SetupTotp(ctx context.Context, username string) (secret, uri string, err error) { + if s.cipher == nil { + return "", "", errors.New("totp: cipher not configured") + } + enabled, err := s.TotpStatus(ctx, username) + if err != nil { + return "", "", err + } + if enabled { + return "", "", ErrTotpAlreadyOn + } + key, err := totp.Generate(totp.GenerateOpts{Issuer: totpIssuer, AccountName: username}) + if err != nil { + return "", "", fmt.Errorf("generate totp key: %w", err) + } + s.totpMu.Lock() + s.totpPending[username] = pendingTotp{secret: key.Secret(), expires: time.Now().Add(totpPendingTTL)} + s.totpMu.Unlock() + return key.Secret(), key.URL(), nil +} + +// ActivateTotp 校验暂存密钥的验证码,通过后加密落库启用。 +func (s *AuthService) ActivateTotp(ctx context.Context, username, code string) error { + s.totpMu.Lock() + pending, ok := s.totpPending[username] + s.totpMu.Unlock() + if !ok || time.Now().After(pending.expires) { + return ErrTotpNotSetup + } + if !totp.Validate(code, pending.secret) { + return ErrTotpInvalid + } + enc, err := s.cipher.EncryptString(pending.secret) + if err != nil { + return fmt.Errorf("encrypt totp secret: %w", err) + } + err = s.db.WithContext(ctx).Model(&model.User{}). + Where("username = ?", username).Update("totp_secret_enc", enc).Error + if err != nil { + return fmt.Errorf("save totp secret: %w", err) + } + s.totpMu.Lock() + delete(s.totpPending, username) + s.totpMu.Unlock() + return nil +} + +// DisableTotp 停用两步验证;需当前验证码或登录密码任一确认。 +func (s *AuthService) DisableTotp(ctx context.Context, username, password, code string) error { + user, err := s.findUser(ctx, username) + if err != nil { + return err + } + if user.TotpSecretEnc == "" { + return nil + } + if !s.confirmDisable(user, password, code) { + return ErrTotpConfirm + } + err = s.db.WithContext(ctx).Model(&model.User{}). + Where("username = ?", username).Update("totp_secret_enc", "").Error + if err != nil { + return fmt.Errorf("clear totp secret: %w", err) + } + return nil +} + +// confirmDisable 校验停用凭证:验证码或密码任一通过即可。 +func (s *AuthService) confirmDisable(user *model.User, password, code string) bool { + if code != "" && s.verifyTotp(user, code) { + return true + } + if password != "" && bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) == nil { + return true + } + return false +} + +// verifyTotp 解密密钥并校验验证码(±1 时间步容差)。 +func (s *AuthService) verifyTotp(user *model.User, code string) bool { + if s.cipher == nil || user.TotpSecretEnc == "" { + return false + } + secret, err := s.cipher.DecryptString(user.TotpSecretEnc) + if err != nil { + return false + } + return totp.Validate(code, secret) +} diff --git a/internal/service/totp_oauth_test.go b/internal/service/totp_oauth_test.go new file mode 100644 index 0000000..fdf835a --- /dev/null +++ b/internal/service/totp_oauth_test.go @@ -0,0 +1,311 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/glebarez/sqlite" + "github.com/pquerna/otp/totp" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "oci-portal/internal/crypto" + "oci-portal/internal/model" +) + +// newTotpEnv 建含 User/UserIdentity/Setting 表的环境,预置 admin 并注入 cipher。 +func newTotpEnv(t *testing.T) (*AuthService, *gorm.DB) { + t.Helper() + 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.User{}, &model.UserIdentity{}, &model.Setting{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + auth := NewAuthService(db, "test-secret") + cipher, err := crypto.NewCipher("test-key") + if err != nil { + t.Fatalf("new cipher: %v", err) + } + auth.SetCipher(cipher) + if err := auth.EnsureAdmin("admin", "pass123"); err != nil { + t.Fatalf("ensure admin: %v", err) + } + return auth, db +} + +// enableTotp 走完整 setup→activate 流程,返回明文密钥供测试生成验证码。 +func enableTotp(t *testing.T, auth *AuthService) string { + t.Helper() + ctx := context.Background() + secret, uri, err := auth.SetupTotp(ctx, "admin") + if err != nil { + t.Fatalf("SetupTotp: %v", err) + } + if secret == "" || uri == "" { + t.Fatalf("setup 返回空 secret/uri") + } + code, err := totp.GenerateCode(secret, time.Now()) + if err != nil { + t.Fatalf("generate code: %v", err) + } + if err := auth.ActivateTotp(ctx, "admin", code); err != nil { + t.Fatalf("ActivateTotp: %v", err) + } + return secret +} + +func TestTotpLifecycle(t *testing.T) { + auth, db := newTotpEnv(t) + ctx := context.Background() + + if on, _ := auth.TotpStatus(ctx, "admin"); on { + t.Fatal("初始不应启用") + } + secret := enableTotp(t, auth) + if on, _ := auth.TotpStatus(ctx, "admin"); !on { + t.Fatal("激活后应为启用") + } + // 密钥必须密文落库 + var user model.User + if err := db.Where("username = ?", "admin").First(&user).Error; err != nil { + t.Fatalf("load user: %v", err) + } + if user.TotpSecretEnc == secret || user.TotpSecretEnc == "" { + t.Errorf("totp 密钥未加密落库") + } + // 重复 setup 拒绝 + if _, _, err := auth.SetupTotp(ctx, "admin"); !errors.Is(err, ErrTotpAlreadyOn) { + t.Errorf("重复 setup err = %v, want ErrTotpAlreadyOn", err) + } + // 停用:无凭证拒绝,密码通过 + if err := auth.DisableTotp(ctx, "admin", "", ""); !errors.Is(err, ErrTotpConfirm) { + t.Errorf("空凭证停用 err = %v, want ErrTotpConfirm", err) + } + if err := auth.DisableTotp(ctx, "admin", "pass123", ""); err != nil { + t.Fatalf("密码停用: %v", err) + } + if on, _ := auth.TotpStatus(ctx, "admin"); on { + t.Fatal("停用后应为关闭") + } +} + +func TestActivateTotpRejects(t *testing.T) { + auth, _ := newTotpEnv(t) + ctx := context.Background() + // 未 setup 直接激活 + if err := auth.ActivateTotp(ctx, "admin", "123456"); !errors.Is(err, ErrTotpNotSetup) { + t.Errorf("err = %v, want ErrTotpNotSetup", err) + } + // setup 后错误验证码 + if _, _, err := auth.SetupTotp(ctx, "admin"); err != nil { + t.Fatalf("SetupTotp: %v", err) + } + if err := auth.ActivateTotp(ctx, "admin", "000000"); !errors.Is(err, ErrTotpInvalid) { + t.Errorf("err = %v, want ErrTotpInvalid", err) + } +} + +func TestLoginWithTotp(t *testing.T) { + auth, _ := newTotpEnv(t) + ctx := context.Background() + secret := enableTotp(t, auth) + + // 缺验证码:密码对也返回 ErrTotpRequired(不计失败) + if _, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", ""); !errors.Is(err, ErrTotpRequired) { + t.Fatalf("err = %v, want ErrTotpRequired", err) + } + // 错误验证码:按失败处理 + if _, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", "000000"); !errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("err = %v, want ErrInvalidCredentials", err) + } + // 正确验证码:登录成功 + code, err := totp.GenerateCode(secret, time.Now()) + if err != nil { + t.Fatalf("generate code: %v", err) + } + token, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", code) + if err != nil || token == "" { + t.Fatalf("带验证码登录失败: %v", err) + } +} + +// fakeBindIdentity 直接把外部身份写入待测服务(绕过真实 OAuth flow)。 +func fakeBindIdentity(t *testing.T, o *OAuthService, username, provider, subject, display string) { + t.Helper() + if err := o.bind(context.Background(), username, provider, externalIdentity{Subject: subject, Display: display}); err != nil { + t.Fatalf("bind: %v", err) + } +} + +func newOAuthEnv(t *testing.T) (*OAuthService, *AuthService) { + t.Helper() + auth, db := newTotpEnv(t) + cipher, err := crypto.NewCipher("test-key") + if err != nil { + t.Fatalf("new cipher: %v", err) + } + settings := NewSettingService(db, cipher) + return NewOAuthService(db, settings, auth), auth +} + +func TestOAuthBindLoginUnbind(t *testing.T) { + o, auth := newOAuthEnv(t) + ctx := context.Background() + + fakeBindIdentity(t, o, "admin", "github", "10086", "octocat") + // 重复绑定同一身份拒绝 + if err := o.bind(ctx, "admin", "github", externalIdentity{Subject: "10086", Display: "octocat"}); !errors.Is(err, ErrOAuthBound) { + t.Errorf("重复绑定 err = %v, want ErrOAuthBound", err) + } + // 已绑定身份可登录并拿到有效 JWT + token, display, err := o.loginByIdentity(ctx, "github", externalIdentity{Subject: "10086", Display: "octocat"}) + if err != nil || token == "" { + t.Fatalf("loginByIdentity: %v", err) + } + if display != "octocat" { + t.Errorf("display = %q", display) + } + if username, err := auth.ParseToken(token); err != nil || username != "admin" { + t.Errorf("token 应属 admin, got %q (%v)", username, err) + } + // 未绑定身份拒绝登录 + if _, _, err := o.loginByIdentity(ctx, "github", externalIdentity{Subject: "999"}); !errors.Is(err, ErrOAuthNotBound) { + t.Errorf("未绑定登录 err = %v, want ErrOAuthNotBound", err) + } + // 列表与解绑 + items, err := o.Identities(ctx, "admin") + if err != nil || len(items) != 1 { + t.Fatalf("identities = %d (%v), want 1", len(items), err) + } + if err := o.Unbind(ctx, "admin", items[0].ID); err != nil { + t.Fatalf("Unbind: %v", err) + } + if items, _ = o.Identities(ctx, "admin"); len(items) != 0 { + t.Errorf("解绑后仍有 %d 条", len(items)) + } +} + +func TestOAuthStateOneShot(t *testing.T) { + o, _ := newOAuthEnv(t) + o.mu.Lock() + o.pending["st1"] = oauthPending{provider: "github", mode: "login", expires: time.Now().Add(time.Minute)} + o.pending["st2"] = oauthPending{provider: "github", mode: "login", expires: time.Now().Add(-time.Minute)} + o.mu.Unlock() + + if _, err := o.takeState("github", "st1"); err != nil { + t.Fatalf("首次消费: %v", err) + } + if _, err := o.takeState("github", "st1"); !errors.Is(err, ErrOAuthState) { + t.Errorf("二次消费 err = %v, want ErrOAuthState", err) + } + if _, err := o.takeState("github", "st2"); !errors.Is(err, ErrOAuthState) { + t.Errorf("过期 state err = %v, want ErrOAuthState", err) + } + if _, err := o.takeState("oidc", "no-such"); !errors.Is(err, ErrOAuthState) { + t.Errorf("未知 state err = %v, want ErrOAuthState", err) + } +} + +func TestOAuthProvidersListsConfigured(t *testing.T) { + o, _ := newOAuthEnv(t) + ctx := context.Background() + if got := o.Providers(ctx); len(got) != 0 { + t.Fatalf("未配置时 providers = %v", got) + } + secret := "gh-secret" + err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: "cid", GithubClientSecret: &secret}) + if err != nil { + t.Fatalf("UpdateOAuth: %v", err) + } + got := o.Providers(ctx) + if len(got) != 1 || got[0].Provider != "github" { + t.Errorf("providers = %v, want [github]", got) + } + // 视图不回明文且标记已设置 + view, err := o.settings.OAuthView(ctx) + if err != nil || !view.GithubSecretSet || view.GithubClientID != "cid" { + t.Errorf("view = %+v (%v)", view, err) + } +} + +// 未配置 provider 与未设面板地址是两类错误,文案分别指路。 +func TestOAuthAuthorizeConfigErrors(t *testing.T) { + o, _ := newOAuthEnv(t) + ctx := context.Background() + + // clientID 缺失 + if _, err := o.AuthorizeURL(ctx, "github", "login", ""); !errors.Is(err, ErrOAuthNotConfigured) { + t.Errorf("无 clientID err = %v, want ErrOAuthNotConfigured", err) + } + // clientID 已配但面板地址未设置 + if err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: "Iv1.test"}); err != nil { + t.Fatalf("UpdateOAuth: %v", err) + } + if _, err := o.AuthorizeURL(ctx, "github", "login", ""); !errors.Is(err, ErrOAuthNoAppURL) { + t.Errorf("无面板地址 err = %v, want ErrOAuthNoAppURL", err) + } + // 面板地址就绪后正常返回授权 URL + o.settings.SetEnvPublicURL("https://demo.example.com") + url, err := o.AuthorizeURL(ctx, "github", "login", "") + if err != nil || url == "" { + t.Fatalf("AuthorizeURL: %q, %v", url, err) + } +} + +func TestOAuthProvidersAndDisabled(t *testing.T) { + o, _ := newOAuthEnv(t) + ctx := context.Background() + o.settings.SetEnvPublicURL("https://demo.example.com") + + // 未配置任何 provider → 空列表 + if got := o.Providers(ctx); len(got) != 0 { + t.Fatalf("Providers = %v, want empty", got) + } + // 配置 github(无显示名称)→ 默认名 GitHub + in := UpdateOAuthInput{GithubClientID: "Iv1.test"} + if err := o.settings.UpdateOAuth(ctx, in); err != nil { + t.Fatalf("UpdateOAuth: %v", err) + } + got := o.Providers(ctx) + if len(got) != 1 || got[0].Provider != "github" || got[0].DisplayName != "GitHub" { + t.Fatalf("Providers = %+v, want [github/GitHub]", got) + } + // 自定义显示名称 + in.GithubDisplayName = "公司账号" + if err := o.settings.UpdateOAuth(ctx, in); err != nil { + t.Fatalf("UpdateOAuth: %v", err) + } + if got = o.Providers(ctx); got[0].DisplayName != "公司账号" { + t.Fatalf("DisplayName = %q, want 公司账号", got[0].DisplayName) + } + // 禁用后:列表隐藏,login 模式 409,bind 模式仍可发起 + in.GithubDisabled = true + if err := o.settings.UpdateOAuth(ctx, in); err != nil { + t.Fatalf("UpdateOAuth: %v", err) + } + if got = o.Providers(ctx); len(got) != 0 { + t.Fatalf("禁用后 Providers = %v, want empty", got) + } + if _, err := o.AuthorizeURL(ctx, "github", "login", ""); !errors.Is(err, ErrOAuthDisabled) { + t.Errorf("禁用 login err = %v, want ErrOAuthDisabled", err) + } + if url, err := o.AuthorizeURL(ctx, "github", "bind", "admin"); err != nil || url == "" { + t.Errorf("禁用 bind = %q, %v, want 正常返回", url, err) + } + // view 回读禁用态与显示名称 + view, err := o.settings.OAuthView(ctx) + if err != nil || !view.GithubDisabled || view.GithubDisplayName != "公司账号" { + t.Fatalf("OAuthView = %+v, %v", view, err) + } +} diff --git a/internal/service/usage.go b/internal/service/usage.go new file mode 100644 index 0000000..d51f567 --- /dev/null +++ b/internal/service/usage.go @@ -0,0 +1,91 @@ +package service + +import ( + "context" + "fmt" + "time" + + "oci-portal/internal/oci" +) + +// maxUsageWindowDays 限制流量/成本查询窗口,避免拉取超大区间。 +const maxUsageWindowDays = 366 + +// InstanceTraffic 查询实例近 days 天的进出流量(按天聚合)。 +func (s *OciConfigService) InstanceTraffic(ctx context.Context, id uint, region, instanceID string, days int) (oci.InstanceTraffic, error) { + if days <= 0 { + days = 30 + } + if days > maxUsageWindowDays { + return oci.InstanceTraffic{}, fmt.Errorf("instance traffic: days must be within %d", maxUsageWindowDays) + } + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return oci.InstanceTraffic{}, err + } + end := time.Now().UTC().Truncate(time.Hour).Add(time.Hour) + return s.client.SummarizeInstanceTraffic(ctx, cred, oci.TrafficQuery{ + Region: region, + InstanceID: instanceID, + StartTime: end.AddDate(0, 0, -days), + EndTime: end, + }) +} + +// Costs 汇总租户成本或用量;时间缺省为最近 30 天,起止按粒度对齐。 +func (s *OciConfigService) Costs(ctx context.Context, id uint, q oci.CostQuery) ([]oci.CostItem, error) { + applyCostDefaults(&q) + if q.EndTime.Sub(q.StartTime) <= 0 { + return nil, fmt.Errorf("costs: endTime must be after startTime") + } + if q.EndTime.Sub(q.StartTime) > maxUsageWindowDays*24*time.Hour { + return nil, fmt.Errorf("costs: window must be within %d days", maxUsageWindowDays) + } + cred, err := s.credentialsByID(ctx, id) + if err != nil { + return nil, err + } + return s.client.SummarizeCosts(ctx, cred, q) +} + +// applyCostDefaults 填充缺省值并把时间对齐到粒度边界(Usage API 要求)。 +func applyCostDefaults(q *oci.CostQuery) { + if q.Granularity == "" { + q.Granularity = "DAILY" + } + if q.QueryType == "" { + q.QueryType = "COST" + } + if q.GroupBy == "" { + q.GroupBy = "service" + } + now := time.Now().UTC() + if q.EndTime.IsZero() { + q.EndTime = now + } + if q.StartTime.IsZero() { + q.StartTime = q.EndTime.AddDate(0, 0, -30) + } + q.StartTime = alignDown(q.StartTime, q.Granularity) + // endTime 为开区间,向上对齐到粒度边界以覆盖当天/当月 + q.EndTime = alignUp(q.EndTime, q.Granularity) +} + +func alignDown(t time.Time, granularity string) time.Time { + t = t.UTC() + if granularity == "MONTHLY" { + return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC) + } + return t.Truncate(24 * time.Hour) +} + +func alignUp(t time.Time, granularity string) time.Time { + down := alignDown(t, granularity) + if down.Equal(t.UTC()) { + return down + } + if granularity == "MONTHLY" { + return down.AddDate(0, 1, 0) + } + return down.AddDate(0, 0, 1) +} diff --git a/internal/service/usage_test.go b/internal/service/usage_test.go new file mode 100644 index 0000000..3550b5c --- /dev/null +++ b/internal/service/usage_test.go @@ -0,0 +1,122 @@ +package service + +import ( + "testing" + "time" + + "oci-portal/internal/oci" +) + +func TestAlignDown(t *testing.T) { + tests := []struct { + name string + in time.Time + granularity string + want time.Time + }{ + { + name: "daily 截到当天零点", + in: time.Date(2026, 7, 3, 15, 4, 5, 0, time.UTC), + granularity: "DAILY", + want: time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC), + }, + { + name: "daily 已在边界不变", + in: time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC), + granularity: "DAILY", + want: time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC), + }, + { + name: "monthly 截到当月一号", + in: time.Date(2026, 7, 15, 8, 0, 0, 0, time.UTC), + granularity: "MONTHLY", + want: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := alignDown(tt.in, tt.granularity); !got.Equal(tt.want) { + t.Errorf("alignDown() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAlignUp(t *testing.T) { + tests := []struct { + name string + in time.Time + granularity string + want time.Time + }{ + { + name: "daily 进到明日零点覆盖当天", + in: time.Date(2026, 7, 3, 15, 4, 5, 0, time.UTC), + granularity: "DAILY", + want: time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC), + }, + { + name: "daily 已在边界不进位", + in: time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC), + granularity: "DAILY", + want: time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC), + }, + { + name: "monthly 进到下月一号覆盖当月", + in: time.Date(2026, 7, 15, 8, 0, 0, 0, time.UTC), + granularity: "MONTHLY", + want: time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC), + }, + { + name: "monthly 十二月进位跨年", + in: time.Date(2026, 12, 20, 0, 0, 0, 0, time.UTC), + granularity: "MONTHLY", + want: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := alignUp(tt.in, tt.granularity); !got.Equal(tt.want) { + t.Errorf("alignUp() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestApplyCostDefaults(t *testing.T) { + var q oci.CostQuery + applyCostDefaults(&q) + if q.Granularity != "DAILY" || q.QueryType != "COST" || q.GroupBy != "service" { + t.Errorf("defaults = %s/%s/%s, want DAILY/COST/service", q.Granularity, q.QueryType, q.GroupBy) + } + if q.StartTime.IsZero() || q.EndTime.IsZero() { + t.Fatal("time window not defaulted") + } + window := q.EndTime.Sub(q.StartTime) + if window < 30*24*time.Hour || window > 32*24*time.Hour { + t.Errorf("default window = %v, want ~30d", window) + } + if !q.EndTime.After(time.Now().UTC()) { + t.Errorf("endTime %v should be aligned up past now (open interval)", q.EndTime) + } +} + +func TestValidateEmails(t *testing.T) { + tests := []struct { + name string + emails []string + wantErr bool + }{ + {name: "空列表合法表示关闭 test mode", emails: nil, wantErr: false}, + {name: "正常邮箱", emails: []string{"a@b.com", "c@d.org"}, wantErr: false}, + {name: "缺少@", emails: []string{"not-an-email"}, wantErr: true}, + {name: "含空格", emails: []string{"a b@c.com"}, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateEmails(tt.emails); (err != nil) != tt.wantErr { + t.Errorf("validateEmails(%v) error = %v, wantErr %v", tt.emails, err, tt.wantErr) + } + }) + } +} diff --git a/internal/service/vnic_test.go b/internal/service/vnic_test.go new file mode 100644 index 0000000..c4048fb --- /dev/null +++ b/internal/service/vnic_test.go @@ -0,0 +1,75 @@ +package service + +import ( + "context" + "strings" + "testing" + + "oci-portal/internal/oci" +) + +// vnicStubClient 覆写 VNIC 三方法并记录透传参数,其余行为沿用 fakeClient。 +type vnicStubClient struct { + *fakeClient + + vnics []oci.Vnic + attached oci.AttachVnicInput + attachInst string + detachedID string + ipv6Vnic string +} + +func (f *vnicStubClient) ListInstanceVnics(ctx context.Context, cred oci.Credentials, region, instanceID string) ([]oci.Vnic, error) { + return f.vnics, nil +} + +func (f *vnicStubClient) AttachVnic(ctx context.Context, cred oci.Credentials, region, instanceID string, in oci.AttachVnicInput) (oci.Vnic, error) { + f.attachInst, f.attached = instanceID, in + return oci.Vnic{AttachmentID: "att-1", SubnetID: in.SubnetID, LifecycleState: "ATTACHING"}, nil +} + +func (f *vnicStubClient) DetachVnic(ctx context.Context, cred oci.Credentials, region, attachmentID string) error { + f.detachedID = attachmentID + return nil +} + +func (f *vnicStubClient) AddVnicIpv6(ctx context.Context, cred oci.Credentials, region, vnicID, address string) (string, error) { + f.ipv6Vnic = vnicID + return "2603:c022::1", nil +} + +func TestVnicManagement(t *testing.T) { + client := &vnicStubClient{ + fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, + vnics: []oci.Vnic{{AttachmentID: "att-0", IsPrimary: true, PrivateIP: "10.0.0.2"}}, + } + svc := newTestService(t, client) + cfg := importAliveConfig(t, svc) + ctx := context.Background() + + got, err := svc.InstanceVnics(ctx, cfg.ID, "ap-tokyo-1", "ocid1.instance.oc1..x") + if err != nil || len(got) != 1 || !got[0].IsPrimary { + t.Fatalf("InstanceVnics = %+v, %v", got, err) + } + // subnetId 缺失被拒 + _, err = svc.AttachVnic(ctx, cfg.ID, "ap-tokyo-1", "ocid1.instance.oc1..x", oci.AttachVnicInput{}) + if err == nil || !strings.Contains(err.Error(), "subnetId") { + t.Errorf("AttachVnic 无 subnet err = %v, want subnetId required", err) + } + in := oci.AttachVnicInput{SubnetID: "ocid1.subnet.oc1..s", AssignPublicIP: true, DisplayName: "vnic-2"} + vnic, err := svc.AttachVnic(ctx, cfg.ID, "ap-tokyo-1", "ocid1.instance.oc1..x", in) + if err != nil || vnic.AttachmentID != "att-1" || client.attached != in || client.attachInst != "ocid1.instance.oc1..x" { + t.Fatalf("AttachVnic = %+v, %v (passed %+v)", vnic, err, client.attached) + } + if err := svc.DetachVnic(ctx, cfg.ID, "ap-tokyo-1", "att-1"); err != nil || client.detachedID != "att-1" { + t.Fatalf("DetachVnic err = %v, detached = %q", err, client.detachedID) + } + // per-VNIC IPv6:vnicId 缺失被拒,正常路径透传 + if _, err := svc.AddVnicIpv6(ctx, cfg.ID, "ap-tokyo-1", "", ""); err == nil { + t.Error("AddVnicIpv6 空 vnicId 应报错") + } + addr, err := svc.AddVnicIpv6(ctx, cfg.ID, "ap-tokyo-1", "ocid1.vnic.oc1..v", "") + if err != nil || addr == "" || client.ipv6Vnic != "ocid1.vnic.oc1..v" { + t.Fatalf("AddVnicIpv6 = %q, %v (vnic %q)", addr, err, client.ipv6Vnic) + } +} diff --git a/internal/service/webconsole.go b/internal/service/webconsole.go new file mode 100644 index 0000000..a80a88a --- /dev/null +++ b/internal/service/webconsole.go @@ -0,0 +1,353 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/hex" + "fmt" + "io" + "net" + "regexp" + "strings" + "sync" + "time" + + "golang.org/x/crypto/ssh" +) + +// consoleSessionTTL 是会话从创建起的最长存活时间,超时未在用即回收(含云端连接)。 +// OCI 侧另有 24 小时强制断开兜底。 +const consoleSessionTTL = 15 * time.Minute + +// 网页控制台会话类型:serial 走 PTY 交互式会话,vnc 走 5900 端口转发。 +const ( + ConsoleTypeSerial = "serial" + ConsoleTypeVnc = "vnc" +) + +// ConsoleSession 是一次网页控制台会话(串行 / VNC):私钥只存内存,服务重启即失效。 +type ConsoleSession struct { + ID string + Type string + cfgID uint + instanceID string + region string + connectionID string // console connection OCID,同时是第一跳 SSH 的用户名 + consoleHost string // instance-console..oci.oraclecloud.com + signer ssh.Signer + createdAt time.Time + + mu sync.Mutex + inUse bool +} + +// ConsoleService 管理网页控制台会话:创建 OCI 控制台连接、建立两跳 SSH 隧道。 +type ConsoleService struct { + configs *OciConfigService + pollInterval time.Duration // 清理残留连接的轮询间隔,测试注入缩短 + + mu sync.Mutex + sessions map[string]*ConsoleSession +} + +func NewConsoleService(configs *OciConfigService) *ConsoleService { + return &ConsoleService{ + configs: configs, + pollInterval: 2 * time.Second, + sessions: map[string]*ConsoleSession{}, + } +} + +// CreateSession 生成一次性 RSA 密钥对并创建实例控制台连接(等待 ACTIVE)。 +// OCI 限制每实例一个控制台连接,创建前先清理实例上的残留连接。 +func (s *ConsoleService) CreateSession(ctx context.Context, cfgID uint, instanceID, region, typ string) (*ConsoleSession, error) { + if typ != ConsoleTypeSerial && typ != ConsoleTypeVnc { + return nil, fmt.Errorf("unsupported console session type %q (expect serial or vnc)", typ) + } + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, fmt.Errorf("generate rsa key: %w", err) + } + signer, err := ssh.NewSignerFromKey(key) + if err != nil { + return nil, fmt.Errorf("new ssh signer: %w", err) + } + if err := s.cleanupStaleConnections(ctx, cfgID, region, instanceID); err != nil { + return nil, err + } + pubKey := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey()))) + conn, err := s.configs.CreateConsoleConnection(ctx, cfgID, region, instanceID, pubKey) + if err != nil { + return nil, err + } + if conn.LifecycleState != "ACTIVE" { + return nil, fmt.Errorf("console connection not active yet (state %s), retry later", conn.LifecycleState) + } + host, err := parseConsoleHost(conn.VncConnectionString) + if err != nil { + return nil, err + } + return s.storeSession(cfgID, instanceID, region, typ, conn.ID, host, signer), nil +} + +// cleanupStaleConnections 删除实例上已有的控制台连接(每实例限一个), +// 并等待其真正消失;DELETED 是终态记录,仅需跳过。 +func (s *ConsoleService) cleanupStaleConnections(ctx context.Context, cfgID uint, region, instanceID string) error { + conns, err := s.configs.ConsoleConnections(ctx, cfgID, region, instanceID) + if err != nil { + return fmt.Errorf("list existing console connections: %w", err) + } + pending := false + for _, c := range conns { + switch c.LifecycleState { + case "DELETED": + continue + case "DELETING": + pending = true + default: + if err := s.configs.DeleteConsoleConnection(ctx, cfgID, region, c.ID); err != nil { + return fmt.Errorf("delete stale console connection: %w", err) + } + pending = true + } + } + if !pending { + return nil + } + return s.waitConnectionsGone(ctx, cfgID, region, instanceID) +} + +// waitConnectionsGone 轮询等待实例上的控制台连接全部进入 DELETED,最长约 60 秒。 +func (s *ConsoleService) waitConnectionsGone(ctx context.Context, cfgID uint, region, instanceID string) error { + for i := 0; i < 30; i++ { + select { + case <-ctx.Done(): + return fmt.Errorf("wait console connection cleanup: %w", ctx.Err()) + case <-time.After(s.pollInterval): + } + conns, err := s.configs.ConsoleConnections(ctx, cfgID, region, instanceID) + if err != nil { + return fmt.Errorf("poll console connection cleanup: %w", err) + } + gone := true + for _, c := range conns { + if c.LifecycleState != "DELETED" { + gone = false + break + } + } + if gone { + return nil + } + } + return fmt.Errorf("cleanup previous console connection timed out, retry later") +} + +func (s *ConsoleService) storeSession(cfgID uint, instanceID, region, typ, connID, host string, signer ssh.Signer) *ConsoleSession { + buf := make([]byte, 16) + _, _ = rand.Read(buf) + sess := &ConsoleSession{ + ID: hex.EncodeToString(buf), + Type: typ, + cfgID: cfgID, + instanceID: instanceID, + region: region, + connectionID: connID, + consoleHost: host, + signer: signer, + createdAt: time.Now(), + } + s.mu.Lock() + s.sessions[sess.ID] = sess + s.mu.Unlock() + time.AfterFunc(consoleSessionTTL, func() { s.expire(sess.ID) }) + return sess +} + +// expire TTL 到期回收:正在使用的会话跳过(连接断开后自然停止,无续期)。 +func (s *ConsoleService) expire(id string) { + s.mu.Lock() + sess, ok := s.sessions[id] + if ok { + sess.mu.Lock() + if sess.inUse { + ok = false + } else { + delete(s.sessions, id) + } + sess.mu.Unlock() + } + s.mu.Unlock() + if ok { + s.deleteCloudConnection(sess) + } +} + +// Get 返回会话;不存在或已回收返回 nil。 +func (s *ConsoleService) Get(id string) *ConsoleSession { + s.mu.Lock() + defer s.mu.Unlock() + return s.sessions[id] +} + +// CloseSession 主动结束会话并删除云端控制台连接。 +func (s *ConsoleService) CloseSession(id string) { + s.mu.Lock() + sess, ok := s.sessions[id] + delete(s.sessions, id) + s.mu.Unlock() + if ok { + s.deleteCloudConnection(sess) + } +} + +func (s *ConsoleService) deleteCloudConnection(sess *ConsoleSession) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = s.configs.DeleteConsoleConnection(ctx, sess.cfgID, sess.region, sess.connectionID) +} + +// MarkUse 标记会话进入 / 退出使用(使用中的会话不被 TTL 回收)。 +func (sess *ConsoleSession) MarkUse(v bool) { + sess.mu.Lock() + sess.inUse = v + sess.mu.Unlock() +} + +// consoleHostRe 从 OCI 下发的 VNC 连接串里取代理主机: +// ssh -o ProxyCommand='ssh -W %h:%p -p 443 @' ... +var consoleHostRe = regexp.MustCompile(`-p\s+443\s+\S+@([A-Za-z0-9.-]+)`) + +func parseConsoleHost(vncConnectionString string) (string, error) { + m := consoleHostRe.FindStringSubmatch(vncConnectionString) + if m == nil { + return "", fmt.Errorf("parse console host from vnc connection string failed") + } + return m[1], nil +} + +// sshConfig 构造两跳共用的客户端配置;代理主机名来自 OCI API 响应,无公开指纹可校验。 +func (sess *ConsoleSession) sshConfig(user string) *ssh.ClientConfig { + return &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ssh.PublicKeys(sess.signer)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 20 * time.Second, + } +} + +// dialHops 建立两跳 SSH:第一跳到区域控制台代理(443,用户名为连接 OCID), +// 在其上开通道到实例 22 端口完成第二跳握手,返回第二跳客户端。 +func (sess *ConsoleSession) dialHops() (hop1, hop2 *ssh.Client, err error) { + hop1, err = ssh.Dial("tcp", net.JoinHostPort(sess.consoleHost, "443"), sess.sshConfig(sess.connectionID)) + if err != nil { + return nil, nil, fmt.Errorf("dial console proxy: %w", err) + } + pipe, err := hop1.Dial("tcp", net.JoinHostPort(sess.instanceID, "22")) + if err != nil { + hop1.Close() + return nil, nil, fmt.Errorf("open tunnel to instance console endpoint: %w", err) + } + conn, chans, reqs, err := ssh.NewClientConn(pipe, sess.instanceID+":22", sess.sshConfig(sess.instanceID)) + if err != nil { + pipe.Close() + hop1.Close() + return nil, nil, fmt.Errorf("second hop ssh handshake: %w", err) + } + return hop1, ssh.NewClient(conn, chans, reqs), nil +} + +// DialVNC 经第二跳转发到实例 5900 的 VNC 流。 +func (sess *ConsoleSession) DialVNC(ctx context.Context) (net.Conn, error) { + hop1, hop2, err := sess.dialHops() + if err != nil { + return nil, err + } + vnc, err := hop2.Dial("tcp", net.JoinHostPort(sess.instanceID, "5900")) + if err != nil { + hop2.Close() + hop1.Close() + return nil, fmt.Errorf("open vnc channel: %w", err) + } + return &chainedConn{Conn: vnc, closers: []func() error{hop2.Close, hop1.Close}}, nil +} + +// SerialTerminal 是一路串行控制台终端:读端为串口输出,写端为键入; +// Resize 调整代理侧 PTY 尺寸(guest 内 tty 需另行 stty 同步)。 +type SerialTerminal struct { + io.Reader + io.Writer + session *ssh.Session + closers []func() error +} + +func (t *SerialTerminal) Resize(rows, cols int) error { + return t.session.WindowChange(rows, cols) +} + +func (t *SerialTerminal) Close() error { + err := t.session.Close() + for _, fn := range t.closers { + _ = fn() + } + return err +} + +// OpenSerial 经第二跳打开交互式 PTY 会话,会话的输入输出即串口字节流。 +func (sess *ConsoleSession) OpenSerial(ctx context.Context, rows, cols int) (*SerialTerminal, error) { + hop1, hop2, err := sess.dialHops() + if err != nil { + return nil, err + } + term, err := openSerialSession(hop2, rows, cols) + if err != nil { + hop2.Close() + hop1.Close() + return nil, err + } + term.closers = []func() error{hop2.Close, hop1.Close} + return term, nil +} + +// openSerialSession 在第二跳上申请 PTY 并启动 shell(即串口流)。 +func openSerialSession(hop2 *ssh.Client, rows, cols int) (*SerialTerminal, error) { + session, err := hop2.NewSession() + if err != nil { + return nil, fmt.Errorf("open serial session: %w", err) + } + modes := ssh.TerminalModes{ssh.ECHO: 1, ssh.TTY_OP_ISPEED: 115200, ssh.TTY_OP_OSPEED: 115200} + if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil { + session.Close() + return nil, fmt.Errorf("request pty: %w", err) + } + stdin, err := session.StdinPipe() + if err != nil { + session.Close() + return nil, fmt.Errorf("serial stdin pipe: %w", err) + } + stdout, err := session.StdoutPipe() + if err != nil { + session.Close() + return nil, fmt.Errorf("serial stdout pipe: %w", err) + } + if err := session.Shell(); err != nil { + session.Close() + return nil, fmt.Errorf("start serial shell: %w", err) + } + return &SerialTerminal{Reader: stdout, Writer: stdin, session: session}, nil +} + +// chainedConn 关闭数据流时连带关闭底层 SSH 连接,避免隧道泄漏。 +type chainedConn struct { + net.Conn + closers []func() error +} + +func (c *chainedConn) Close() error { + err := c.Conn.Close() + for _, fn := range c.closers { + _ = fn() + } + return err +} diff --git a/internal/service/webconsole_test.go b/internal/service/webconsole_test.go new file mode 100644 index 0000000..a0e01fc --- /dev/null +++ b/internal/service/webconsole_test.go @@ -0,0 +1,118 @@ +package service + +import ( + "context" + "testing" + "time" + + "oci-portal/internal/oci" +) + +// consoleFakeClient 在 fakeClient 上覆写控制台连接三方法: +// 首次 List 返回预置连接,删除后的后续 List 全部报告 DELETED。 +type consoleFakeClient struct { + fakeClient + + conns []oci.ConsoleConnection + listed int + deleted []string + created int +} + +func (f *consoleFakeClient) ListConsoleConnections(ctx context.Context, cred oci.Credentials, region, instanceID string) ([]oci.ConsoleConnection, error) { + f.listed++ + if f.listed == 1 { + return f.conns, nil + } + gone := make([]oci.ConsoleConnection, len(f.conns)) + for i, c := range f.conns { + c.LifecycleState = "DELETED" + gone[i] = c + } + return gone, nil +} + +func (f *consoleFakeClient) DeleteConsoleConnection(ctx context.Context, cred oci.Credentials, region, connectionID string) error { + f.deleted = append(f.deleted, connectionID) + return nil +} + +func (f *consoleFakeClient) CreateConsoleConnection(ctx context.Context, cred oci.Credentials, region, instanceID, sshPublicKey string) (oci.ConsoleConnection, error) { + f.created++ + return oci.ConsoleConnection{ + ID: "ocid1.icc.new", + InstanceID: instanceID, + LifecycleState: "ACTIVE", + VncConnectionString: "ssh -o ProxyCommand='ssh -W %h:%p -p 443 " + + "ocid1.icc.new@instance-console.eu-frankfurt-1.oci.oraclecloud.com' -N -L ...", + }, nil +} + +func newConsoleTestService(t *testing.T, client *consoleFakeClient) (*ConsoleService, uint) { + t.Helper() + client.tenancy = oci.TenancyInfo{Name: "t"} + svc := newTestService(t, client) + cfg, err := svc.Import(context.Background(), trialImportInput()) + if err != nil { + t.Fatalf("import config: %v", err) + } + console := NewConsoleService(svc) + console.pollInterval = time.Millisecond + return console, cfg.ID +} + +func TestCreateConsoleSession(t *testing.T) { + tests := []struct { + name string + typ string + conns []oci.ConsoleConnection + wantErr bool + wantDeleted int + }{ + {name: "非法类型拒绝", typ: "rdp", wantErr: true}, + {name: "无残留直接创建", typ: ConsoleTypeSerial}, + { + name: "ACTIVE 残留先删再建", + typ: ConsoleTypeVnc, + conns: []oci.ConsoleConnection{ + {ID: "ocid1.icc.stale", LifecycleState: "ACTIVE"}, + }, + wantDeleted: 1, + }, + { + name: "DELETED 终态记录跳过", + typ: ConsoleTypeSerial, + conns: []oci.ConsoleConnection{ + {ID: "ocid1.icc.gone", LifecycleState: "DELETED"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := &consoleFakeClient{conns: tt.conns} + console, cfgID := newConsoleTestService(t, client) + sess, err := console.CreateSession(context.Background(), cfgID, "ocid1.instance.i", "", tt.typ) + if tt.wantErr { + if err == nil { + t.Fatal("expect error, got nil") + } + return + } + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + if sess.Type != tt.typ { + t.Errorf("session type = %s, want %s", sess.Type, tt.typ) + } + if len(client.deleted) != tt.wantDeleted { + t.Errorf("deleted %d stale connections, want %d", len(client.deleted), tt.wantDeleted) + } + if client.created != 1 { + t.Errorf("created %d connections, want 1", client.created) + } + if console.Get(sess.ID) == nil { + t.Error("session not retrievable after create") + } + }) + } +}