Gemini Enterprise 群组许可自动化管理实战:基于 Cloud Run Job 的批量许可证生命周期编排
【免费下载链接】generative-aiSample code and notebooks for Generative AI on Google Cloud, with Gemini Enterprise Agent Platform项目地址: https://gitcode.com/GitHub_Trending/ge/generative-ai
导读
在 Gemini Enterprise(Gemini Enterprise Agent Platform)的规模化落地中,许可证(license)的授予与回收往往依赖人工操作,随着组织成员变动、群组结构调整,很容易出现"该有权限的没开通、已离职/停用的仍占用席位"的错配问题。本项目 group-licensing 以Cloud Run Job + Cloud Scheduler为载体,实现了一个自动化的 Gemini Enterprise 许可证生命周期管理方案:通过定时批量对账(reconciliation),在 Google Cloud Identity 群组成员身份与 Discovery Engine 许可证分配之间建立桥梁,提供基于群组的 SKU 映射、自动开通(provisioning)与过期许可证清理(cleanup)能力。阅读本文后,你将掌握该方案的整体架构、配置格式、两个核心工作流的源码级原理,以及构建、测试、部署的完整命令。
方案概览:一个镜像,两个 Job,各自调度
整个方案的核心是一个 Go 语言编写的 Cloud Run Job 程序,从同一个容器镜像部署出两个 Job 定义,分别由 Cloud Scheduler 按各自的时间表触发:
| Job | JOB_TYPE | 调度频率 | 职责 |
|---|---|---|---|
gemini-box-office-joiner | joiner | 每日(24 小时) | 分页遍历所有配置的群组成员,为缺失许可的用户授予许可证;若用户同时属于多个映射了 SKU 的群组,则按最高优先级 SKU 授予 |
gemini-box-office-gc | garbage_collection | 每 6 小时 | 分页遍历所有已授权用户,回收"过期"(超过配置阈值未登录)或已不属于任何有权限群组的用户的许可证 |
这种"一镜像双 Job"的设计让两类对账逻辑共享同一套配置解析、适配器、日志与领域模型,同时又能独立调度、独立设置环境变量、独立扩缩容(通过--tasks N并行分片)。
环境变量:两个工作流共享的运行时开关
Job 的行为通过环境变量控制,完整清单如下(对应源码 job_settings.go 中的LoadJobSettings解析逻辑):
| 变量 | 说明 | 默认值 |
|---|---|---|
JOB_TYPE | 选择要运行的工作流(joiner或garbage_collection),必填 | — |
DRY_RUN | 为true时完整执行评估逻辑但不发起任何写 API 调用;可通过 Cloud Scheduler 请求体按次覆盖 | false |
GC_SKIP_GROUP_EVAL | 为true时,垃圾回收 Job 跳过对已授权用户的群组成员身份评估,仅依据许可过期情况回收,仅适用于 garbage collection 工作流 | false |
CLOUD_RUN_TASK_INDEX | Cloud Run 注入,当前任务实例的 0 基索引 | 0 |
CLOUD_RUN_TASK_COUNT | Cloud Run 注入,并发任务实例总数 | 1 |
从源码看,LoadJobSettings会对每个变量做严格校验:JOB_TYPE必须是joiner或garbage_collection(对应 enums.go 中的WorkflowJoiner/WorkflowGarbageCollection);三个布尔变量经strconv.ParseBool解析,非法值直接报错;CLOUD_RUN_TASK_INDEX必须是非负整数、CLOUD_RUN_TASK_COUNT必须是 ≥1 的整数,且强制TASK_INDEX < TASK_COUNT,防止任务索引越界。
SKU 名称与优先级:多群组重叠时的裁决规则
当用户同时符合多个 SKU 的资格时,系统为其分配排名最高的那个 SKU。该优先级硬编码在 enums.go 的skuPrecedence映射中(数值越大优先级越高),自上而下依次为:
SUBSCRIPTION_TIER_SEARCH_AND_ASSISTANT(Gemini Enterprise Plus)SUBSCRIPTION_TIER_ENTERPRISE(Gemini Enterprise Standard)SUBSCRIPTION_TIER_SEARCH(Search + NotebookLM)SUBSCRIPTION_TIER_NOTEBOOK_LM(仅 NotebookLM)SUBSCRIPTION_TIER_AGENTSPACE_BUSINESS(Gemini Business)SUBSCRIPTION_TIER_AGENTSPACE_STARTER(Gemini Business Starter)SUBSCRIPTION_TIER_FRONTLINE_WORKERSUBSCRIPTION_TIER_FRONTLINE_STARTERSUBSCRIPTION_TIER_ENTERPRISE_EMERGING(新兴市场 Enterprise Standard)SUBSCRIPTION_TIER_EDU_PROSUBSCRIPTION_TIER_EDUSUBSCRIPTION_TIER_EDU_PRO_EMERGINGSUBSCRIPTION_TIER_EDU_EMERGING
在 joiner 工作流中,collectGroupMembers为每个用户记录当前遍历到的最高优先级条目(HasHigherPrecedenceThan比较),确保同一用户最终只会落在一个最优 SKU/位置的许可配置上;未识别或SUBSCRIPTION_TIER_UNSPECIFIED的值优先级为 0,同时IsValid()校验会在配置加载阶段就拦截非法 SKU。
配置:Secret Manager 中的entitlements.json
配置存放在GCP Secret Manager中,以文件卷的方式挂载到 Job 内的固定路径/run/secrets/entitlements.json(路径常量定义于 constants.go)。
{ "billing_account_id": "ABCDE-12345-FGHIJ", "projects": { "customer-project-alpha": [ { "subscription_tier": "SUBSCRIPTION_TIER_ENTERPRISE", "location": "global", "groups": [ "group-data-scientists@example.com", "group-senior-devs@example.com" ] }, { "subscription_tier": "SUBSCRIPTION_TIER_AGENTSPACE_BUSINESS", "location": "global", "groups": [ "group-marketing@example.com", "group-general-staff@example.com" ] } ] }, "settings": { "staleness_threshold_days": 30 } }字段说明如下:
| 字段 | 说明 |
|---|---|
billing_account_id | 与被管理项目关联的 GCP 结算账号 ID,必填 |
projects | GCP 项目 ID → 授权条目列表(每个 SKU/location 组合一条)的映射 |
projects[].subscription_tier | 该条目的 Gemini SKU,合法值见上文 SKU 优先级列表 |
projects[].location | 许可证管理的地理区域,仅可为global、us、eu之一 |
projects[].groups | 拥有该 SKU 权限的 Google 群组邮箱列表 |
settings.staleness_threshold_days | 超过该天数未活跃的用户将被回收;设为0或省略则完全禁用过期检查,仅评估授权资格 |
单个 Job 可通过在projects下追加条目管理多个项目。对于非常大的项目组合,可在 Job 执行时设置--tasks N,将项目列表分片到 N 个并行任务实例上。
配置校验:加载即拦截错误
配置解析与校验逻辑集中在 config.go 的Load/validate中,任何一项不满足都会返回带哨兵错误的失败信息(哨兵错误定义于 errors.go):
billing_account_id非空,projects非空,否则报ErrConfigNoProjects;- 项目 ID 必须匹配
^[a-z][a-z0-9\-]{4,28}[a-z0-9]$(6–30 字符、小写字母开头、仅含[a-z0-9-]、以字母或数字结尾); - 每个项目至少一个条目、每个条目至少一个群组邮箱,否则报
ErrConfigNoGroups; - SKU 必须是
skuPrecedence中已识别的值(否则ErrInvalidSKU); - location 必须是
global/us/eu(否则ErrInvalidLocation); - 群组邮箱须匹配
^[^@\s]+@[^@\s]+\.[^@\s]+$(恰好一个@、域名含点); staleness_threshold_days必须 ≥ 0。
值得一提的是,配置结构里还预留了可选字段subscription_id:当启用DIRECT_LAW(直连模式)环境变量时,每条授权记录要求显式提供subscription_id,用于直接指定licenseConfigs/{uuid}中的{uuid},而不再依赖按 SKU 自动解析配置路径(见 config.go)。
IAM、OAuth 与 API 要求
Job 的服务账号需要以下权限:
IAM 角色:
roles/discoveryengine.admin— 列出与更新用户许可证roles/cloudidentity.groups.viewer— 列出群组成员并校验成员关系roles/secretmanager.secretAccessor— 读取挂载的配置 Secretroles/billing.viewer— 读取已购买的 Gemini Enterprise 订阅配置roles/run.builder(可选)— 使用 Cloud Build 构建产物时roles/storage.admin(可选)— 使用 Cloud Build 上传源码、暂存构建roles/artifactregistry.createOnPushWriter(可选)— 使用 Cloud Build 推送镜像到 Artifact Registryroles/logging.logWriter(可选)— 使用 Cloud Build 写构建日志
OAuth 范围:
https://www.googleapis.com/auth/cloud-platformhttps://www.googleapis.com/auth/admin.directory.group.member.readonly
API 要求:
- Discovery Engine API(GCP)
- Resource Manager API(GCP)
- Admin SDK API(Cloud Identity / Workspace)
- Cloud Run Admin API(可选)
- Cloud Build API(可选)
- Compute Engine API(可选)
- Secret Manager API(可选)
- Cloud Scheduler API(可选)
Job 使用Application Default Credentials(ADC),无需任何服务账号密钥文件。触发器授权:Cloud Scheduler 的服务账号必须被授予对每个 Cloud Run Job 资源的roles/run.invoker权限。
从 main.go 可以看到实际初始化过程:Admin SDK 客户端使用admin.AdminDirectoryGroupMemberReadonlyScope范围,Resource Manager 客户端使用cloudresourcemanager.CloudPlatformReadOnlyScope范围,随后分别组装为cloudidentity.New(adminSvc)、discoveryengine.New()与resourcemanager.New(crmSvc)三个适配器。
项目结构与构建测试
cmd/job/ # Job 入口(main.go) internal/ adapters/ cloudidentity/ # Cloud Identity Admin API 适配器 discoveryengine/ # Discovery Engine API 适配器 resourcemanager/ # Resource Manager API 适配器(解析项目号) config/ # 配置加载与校验(授权配置 + Job 设置) middleware/ # 结构化日志中间件 models/ # 领域类型、DTO、错误、常量、枚举 ports/ # 接口定义(IdpClient、GeminiClient、ResourceManagerClient) services/ # 业务逻辑(JoinerService、GCService) docs/ PRD.md # 产品需求文档 TDD.md # 技术设计文档 Dockerfile # 多阶段构建 → distroless 运行时镜像构建镜像:
docker build -t gemini-box-office:latest .运行单元测试:
go test ./...所有测试均为单元测试,不依赖任何外部服务或凭据。测试覆盖了配置解析/校验(config_test.go)、SKU 优先级与枚举校验(enums_test.go)、joiner 与 GC 工作流(joiner_test.go、gc_test.go)以及两个适配器(cloudidentity、discoveryengine)。
部署:一条命令部署为 Cloud Run Job
以下示例命令可直接将当前目录源码构建并部署为 Cloud Run Job:
gcloud run jobs deploy [name_of_job] \ --source . \ --region [desired_cloud_run_region] \ --update-secrets=/run/secrets/entitlements.json=[name_of_secret_in_secret_manager]:latest \ --set-env-vars JOB_TYPE=[joiner_or_garbage_collection],DRY_RUN=false,GC_SKIP_GROUP_EVAL=false \ --service-account [desired_service_account_email_address]构建到 Artifact Registry 再部署
如果需要先把容器镜像构建并保存到 Artifact Registry、稍后再部署,可参考以下 Cloud Build 命令:
gcloud builds submit . \ --tag [desired_gcp_region]-docker.pkg.dev/[gcp_project_id]/[artifact_registry_repository]/[artifact_registry_package_name]:latest \ --service-account=projects/[gcp_project_id]/serviceAccounts/[desired_build_service_account_email_address] \ --default-buckets-behavior=regional-user-owned-bucket \ --region=[desired_gcp_region]构建成功后,使用 Artifact Registry 中的镜像部署为 Cloud Run Job:
gcloud run jobs deploy [name_of_job] \ --image [desired_gcp_region]-docker.pkg.dev/[gcp_project_id]/[artifact_registry_repository]/[artifact_registry_package_name]:latest \ --region [desired_cloud_run_region] \ --update-secrets=/run/secrets/entitlements.json=[name_of_secret_in_secret_manager]:latest \ --set-env-vars JOB_TYPE=[joiner_or_garbage_collection],DRY_RUN=false,GC_SKIP_GROUP_EVAL=false \ --service-account [desired_service_account_email_address]Job 输出:结构化 JSON 日志
Job 完成时以退出码0(成功)或1(失败)结束。结果以结构化 JSON 日志条目输出到 stdout,由 Cloud Logging 自动采集:
{"time":"...","level":"INFO","workflow":"joiner","task_index":0,"msg":"joiner workflow complete","duration_ms":4821,"licenses_granted":42,"licenses_soft_failed":0,"groups_processed":5,"dry_run":false}如果某个 SKU 的许可证池在运行中途耗尽,系统会为每个耗尽的池发出一条 WARN 日志并继续执行:
{"time":"...","level":"WARN","workflow":"joiner","task_index":0,"msg":"license pool exhausted, soft-failing remaining users","project_id":"customer-project-alpha","license_config_path":"projects/123/locations/global/licenseConfigs/ent-config","available":3,"soft_failed":17}如果结算账号对同一 SKU 有多个有效订阅,无法在一个池中入座的用户会被自动顺延到下一个池;每个耗尽的池都会触发 WARN,但最终汇总中的licenses_soft_failed只统计所有池尝试后仍未入座的用户数。
{"time":"...","level":"INFO","workflow":"garbage_collection","task_index":0,"msg":"garbage collection workflow complete","duration_ms":9134,"licenses_revoked":12,"users_evaluated":500,"dry_run":false}日志规范
所有日志输出均为结构化 JSON 并写入 stdout,便于 Cloud Logging 自动采集;每条日志都包含workflow和task_index字段。PII(用户邮箱地址)永不写入日志。从 main.go 可见,日志系统将 slog 的level键重写为 Cloud Logging 期望的severity键,并在加载配置后为默认 logger 附加workflow与task_index属性,使服务层通过middleware.LoggerFromContext发出的每一条日志都自动携带这些字段。
源码级原理:joiner 工作流的对账链条
joiner 的完整执行链路在 joiner.go 中清晰可见,可归纳为五步:
- 获取许可证配置索引:通过
FetchLicenseConfigIndex读取结算账号下的全部 licenseConfig 资源,构建(SKU, 项目号, location) → licenseConfig 路径列表的索引(LicenseConfigIndex)。 - 解析项目号:由于 Discovery Engine API 的 licenseConfig 资源路径使用数字项目号(如
projects/415104041262/...)而非人类可读的项目 ID,joiner 会通过 Resource Manager 适配器把每个项目 ID 解析为项目号(见 types.go 的说明)。 - 枚举群组成员并裁决最高优先级:对每个项目的每条配置,逐页调用 Cloud Identity 的
members.list(每页 200 人,最多 500 页,见 constants.go);适配器以includeDerivedMembership=true展平嵌套群组(见 idp.go),只保留USER类型成员,用优先级比较为每个用户记录最优 (SKU, location)。 - 按 key 分组待授权项:默认按
(SKU, 项目号, location)分组;直连(DIRECT_LAW)模式下则按subscription_id分组。 - 批量授予:按
MaxBatchSize = 100分块调用BatchUpdateUserLicenses。若池耗尽,grantBatch会先查询FetchLicenseUsageStats计算剩余席位,用余量重试,超出部分作为 soft-failed 顺延到下一个同 key 的池。
源码级原理:garbage collection 工作流的回收规则
GC 工作流在 gc.go 中实现,核心是shouldRevoke的判定逻辑,用户满足"过期 OR 无资格"即被回收:
- 过期判定(staleness):仅当
staleness_threshold_days > 0时执行。参考时间优先取LastLoginTime;若该时间为零(用户从未登录),则回退到AssignmentTime(许可证创建时间),避免刚开通、尚未登录的用户被立即回收;两者皆为零时视为立即过期。 - 资格判定(entitlement):用户须至少属于项目配置中任意群组(
members.hasMember校验,支持直接/间接成员)。若启用GC_SKIP_GROUP_EVAL=true,则跳过该检查、仅按过期情况回收;过期判定通过时会短路,避免昂贵的成员关系查询。
GC 的processProject按去重后的 location 分别分页(ListUserLicenses),每页内就地分块回收,保证任意时刻内存中只保留一页候选,而不是先累积全量再写。值得注意的是,GC 工作流只依赖 IdP 与 Gemini 两个端口(无需 Resource Manager,见 main.go)。
任务分片:多任务并行处理大项目组合
Cloud Run Job 支持并行任务,本项目据此实现了项目列表的分片机制。在 main.go 中,启动流程会:
- 对
cfg.Projects的键做确定性排序; - 按
i % TaskCount == TaskIndex将项目分配到当前任务; - 若当前任务未分到任何项目,打印
no projects assigned to this task; nothing to do并以0退出。
这样无论是 joiner 还是 GC,都可以通过--tasks N将数千个项目的对账工作横向拆分到 N 个并发实例,且每个实例的分配结果与任务数量无关地保持稳定,便于观察和重放。
总结与适用前提
gemini-box-office以"定时对账"的思路,用极小的运维面(两个 Cloud Run Job + 一份 Secret 配置)覆盖了 Gemini Enterprise 许可证生命周期的三个关键诉求:按群组自动开通、按 SKU 优先级仲裁、按活跃度与资格自动回收。使用前请注意以下前提:
- 本文所述命令、变量与行为均以当前仓库代码为准,SKU 清单以 Google 官方 Gemini Enterprise SubscriptionTier 参考为准;
- 部署需具备文中列出的 IAM 角色与 API 启用条件;
- 首次上线强烈建议保持
DRY_RUN=true,通过结构化日志观察将执行的授予/回收动作后再切换为真实写入; - 产品需求与技术设计细节可进一步阅读 docs/PRD.md 与 docs/TDD.md。
【免费下载链接】generative-aiSample code and notebooks for Generative AI on Google Cloud, with Gemini Enterprise Agent Platform项目地址: https://gitcode.com/GitHub_Trending/ge/generative-ai
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考