2023-05-30-用OO的思维写Terraform

#domain/LandingZone #domain/terraform #twitter #blog

分类:: 折腾
时间:: 2023.05.30
状态:: done

Terraform :HashiCorp 公司开发的开源工具,用于自动化管理基础设施(Infrastructure as Code)

CloudSSO :阿里云推出的身份认证与访问控制服务,基于多账号场景下的企业级单点登录解决方案,类似产品有:AWS SSO、Azure AD、Google Cloud Identity、Okta等

OO : 面向对象(Object-Oriented)是一种编程范式,将程序中的数据和方法组织在一起,核心特性为:封装、继承和多态

1 这是一件什么样的事情?
...

使用面向对象的风格(主要是抽象和封装的思路)写了一版拉起 cloudsso 的自动化模版,亲测100%可用,并且抽成了 module,发布到 github上,repo 地址: https://github.com/EasterFan/alicloud-module-cloudsso

2 新手是怎么做Terraform开发的?
...

众所周知,Terraform 是一门声明式语言,开发者只需要关注问题的声明和规范,而非具体的实现细节,类似的语言还有 SQL,只需要指定你需要的数据结构,而不需要关心如何执行增删改查来实现这个数据结构,这件事情数据库会自动完成;terraform 也是这样,开发者只需要告诉terraform 需要创建的资源名称 + 参数,terraform 将会调用该资源对应的多个 sdk,将资源创建成功,terraform 的开发者,是不需要关注创建资源的细节,比如该调用sdk的哪些接口,调用的顺序等细节步骤。

和大多数 Terraform 新手一样,在开始用 Terraform 写阿里云产品的第一周,我的开发过程大概是这样的步骤:

  1. 第一步,打开 Terraform 官网 https://registry.terraform.io/providers/aliyun/alicloud/latest/docs/data-sources/resource_manager_resource_directories
  2. 第二步,找到产品对应的示例代码,⌘ + C ,并在项目中 ⌘ + V
  3. 第三步,测试代码是否正常创建,以及创建后是否能正常销毁(PS: 千万不要觉得销毁这一步多余,alicloud provider 的TF接口覆盖率并不是 100%,比如资源目录创建后,因为账号删除保护,默认是不能自动销毁的,在我反复手动删除几十次和产研强调后,目前已修复这个问题)

如此这般开发一段时间后,你会发现 main.tf 中出现大量重复的 resource 代码,并且 variables.tf 文件中定义了大量平铺的变量和用户数据(因为最先的代码已经被重构掉了,但是如果如果是 terraform 新手,应该可以很快不经意的写出这样的代码,你会明白我的意思~此处意会一下)

3 如何进行对象化改造?
...

经过一周的开发,已经有了一套完成度90%的 cloudsso 模版,接下来将根据现有的逻辑用面向对象的方式进行重构,根据Martin Fowler 在 《重构-改善既有代码的设计》一书中提到的两个观点:

  • 重构之前,确定重构的范围
  • 重构之前,准备一套可靠的测试数据

3.1 重构之前-确定重构范围
...

首先对 cloudsso 全部的操作,画出一份时序图,时序图中包括每个操作步骤涉及的实体,这次改动的范围包括:创建用户、创建访问配置、多账号配置权限,这三个部分。
image-20230524130140832.png

3.2 重构之前-准备测试数据
...

在重构前准备可靠的测试数据,有点类似 TDD(Test Drive Development)的思想,测试数据非常重要,用来验证你的对象参数是否正确,因为如果对产品模型理解错误,那么你构建的对象,测试数据一定是创建失败的

从哪里获取可靠的测试数据?自己随便造一些数据吗?

大部分情况下,我们自己随意造的数据,往往只能覆盖到比较小的功能点,类似单元测试,测试的意义是不大的;推荐根据云产品的最佳实践,根据产品文档提供的功能,将一个完整的 user story 涉及的数据作为测试数据,类似端到端测试,覆盖度更广

以 cloudsso 产品为例,其最佳应用场景是多账号多权限、同步AD,那么测试数据是这样构建的:
image-20230528203459907.png

用户数据:

/* 云sso中的用户 */
variable "sso_users" {
  type = list(object({
    group_name   = string
    user_name    = string
    display_name = string
    comments     = string
  }))
  default = [
    {
      group_name   = "GG-Log"
      user_name    = "fandongfang"
      display_name = "fandongfang"
      comments     = "云sso用户"
    },
    {
      group_name   = "GG-Log"
      user_name    = "fandongfang2"
      display_name = "樊东方2"
      comments     = "云sso用户"
    },
    {
      group_name   = "GG-Security"
      user_name    = "weiliheng"
      display_name = "魏立恒"
      comments     = "云sso用户"
    },
    {
      group_name   = "GG-DO-Master"
      user_name    = "shenbinbin"
      display_name = "沈彬彬"
      comments     = "云sso用户"
  }]
}

权限数据:

/* 云sso中的访问配置 */
variable "sso_access" {
  type = map(object({
    sso_access_name    = string
    description        = string
    system_policy_list = list(string)
    custom_policy      = optional(string)
  }))

  default = {
    Administrator = {
      sso_access_name    = "Administrator"
      description        = "负责账号下的所有权限"
      system_policy_list = ["AdministratorAccess"]
    }
    FinaceAdmin = {
      sso_access_name = "FinaceAdmin"
      description     = "负责管理企业财务 (账单 付款 发票等),由GG-DO-Admin团队中部分核心成员扮演"
      system_policy_list = ["AliyunBSSFullAccess",
        "AliyunFinanceConsoleFullAccess",
      "AliyunCloudCommunicationFullAccess"]
    }
    SecurityAdmin = {
      sso_access_name = "SecurityAdmin"
      description     = "负责管理企业财务 (账单 付款 发票等),由GG-DO-Admin团队中部分核心成员扮演"
      system_policy_list = ["AliyunActionTrailFullAccess",
        "AliyunConfigFullAccess",
        "AliyunLogFullAccess",
        "AliyunOSSFullAccess",
        "ReadOnlyAccess",
      "AliyunBSSFullAccess"]
    }
    LogAdmin = {
      sso_access_name = "LogAdmin"
      description     = "负责组织下所有账号的日志采集分析工作"
      system_policy_list = ["AliyunLogFullAccess",
      "ReadOnlyAccess"]
    }
    BillingAdmin = {
      sso_access_name = "BillingAdmin"
      description     = "为兼容目前的老账号,负责管理企业财务 (账单 付款 发票等),由GG-DO-Admin团队中部分核心成员扮演"
      system_policy_list = ["AliyunBSSFullAccess",
        "AliyunFinanceConsoleFullAccess",
      "AliyunCloudCommunicationFullAccess"]
    }
  }
}

账号-权限-用户关系数据:

/* 云sso中的账号访问关系数据 账号(key)、用户(组)、权限 */
variable "sso_access_relation_name" {
  type = map(object({
    sso_relation_name = string
    description       = string
    deploy_user_group = list(object({
      target_type        = string
      target_name        = string
      deploy_access_list = list(string)
    }))
  }))

  default = {
    GG-Security = {
      sso_relation_name = "GG-Security-Rule"
      description       = "GG-Security 账号下的权限分配"
      deploy_user_group = [
        {
          target_type        = "User"
          target_name        = "fandongfang"
          deploy_access_list = ["SecurityAdmin"]
        },
        {
          target_type        = "Group"
          target_name        = "GG-Security"
          deploy_access_list = ["SecurityAdmin"]
      }]
    }
    GG-DO = {
      sso_relation_name = "GG-DO-Rule"
      description       = "GG-DO 账号下的权限分配"
      deploy_user_group = [
        {
          target_type        = "User"
          target_name        = "fandongfang"
          deploy_access_list = ["Administrator"]
      }]
    }
  }
}

3.3 第一种结构
...

将 "用户组" 看做一个实体对象(sso_users),类型是 list ,此list 中的对象拥有(group_name/user_name/display_name/comments)四个属性,从面向对象封装的角度看:

list 中的对象≈ Java 中的 PO(Persistant Object(持久对象)),与数据表中字段一一对应
sso_users ≈ Java 中的 BO(Bussiness Object(业务对象)), 是开发者根据产品的业务关系,在PO 上做的一层组合

variable "sso_users" {
  type = list(object({
    group_name   = string
    user_name    = string
    display_name = string
    comments     = string
  }))

这个结构在 cloudsso 上对应的页面是:
image-20230527235347231.png

3.4 第二种结构
...

将”访问配置“看做一个实体对象(sso_access),类型是 map,其实这里设计为 list 和 map 都是可以的,选 map 是因为 Terraform 现在 for_each 只支持 map,不支持 list;另一个设计点,因为内置策略是可以为空的,所以使用了optional 关键字兼容可能存在的空指针(虽然目前还没有遇到过 terraform 关于空指针的报错,但作为Java开发者,必须得防范于未然)

variable "sso_access" {
  type = map(object({
    sso_access_name    = string
    description        = string
    system_policy_list = list(string)
    custom_policy      = optional(string)
  }))
}

这个结构在 cloudsso 上对应的页面是:
image-20230528001821952.png

3.5 第三种结构
...

这是这次实践中遇到的最大难点了,由于”账号、用户、权限“ 这三个实体间的对应关系是多对多,为了准确的抽象出三者的关系,最终敲定的数据结构嵌套了三层,map 嵌套list(object),object 中嵌套 list(string), 在 Java 中,其实这样的数据结构哪怕再嵌套5 6层,用 stream 处理起来也不过是一行代码的事情,但是由于 terraform 语法不支持stream,虽然也有内置的 flatten() 方法,但是处理起多层嵌套足以让人抓狂,最后如何解决这个嵌套问题的呢?我将在下一节中展开说明。

variable "sso_access_relation_name" {
  type = map(object({
    sso_relation_name = string
    description       = string
    deploy_user_group = list(object({
      target_type        = string
      target_name        = string
      deploy_access_list = list(string)
    }))
  }))
}

这个结构在 cloudsso 上对应的页面是:
image-20230528000135274.png

3.6 重构之后-我改变了开发方式
...

完成这个开源项目后,如果再重新为一个产品写模版,我会按照这样的步骤:

  • 先找测试数据,主要是参考我以往在阿里云 LandingZone 项目上使用的最佳实践数据,能串联起一个完整 user story 的数据最佳,并画出一个涵盖所有操作步骤的时序图
  • 观察产品的设计,主要是思考各个实体间的对应关系(1对多,多对多),其实当你观察到一定程度,自然而然就可以从页面上推测出产品后台的数据表设计
  • 粗读这个产品对应的所有 resource,大部分情况下,一个 resource 可以对应一个实体,目前除了 ACK 的接口是我看过最多的,其他简单的云产品接口会在20个左右,初学者推荐从“资源管理”或者“云SSO”入手
  • 最后一步,才是动手开发(这一步现在已经外包给 ChatGPT 做了)

这种开发方式的转变是很重要的,我以前写CRUD做“CV工程师”的时候,给自己带入的是一个搬砖工的角色,哪里需要就往哪里搬两块砖,不需要的地方也会搬两块砖,很多初级程序员刚入行的时候都是这样,根本不在乎自己的实现是否合理,只要搬来搬去能锻炼到肌肉💪🏻就行,如果恰巧功能也能堆出来,那就更是功德一件了。言至于此,不由的想起自己曾经在一个只有3个人的后端项目里强行引入flyway,在 Java8 刚支持Stream流式的时候没有机会也要创建机会,各种强行流式的种种行为,比较糟的是,后一个项目没有 code review,脑海中浮现出前同事们读我的模块时,脸上的痛苦面具......

当然这都是过去的事了,开发方式改变后,我现在扮演的是一个拿着图纸的包工头角色,更加关注每块砖之间的联系,是否合理,如果他不合理,我该如何修改他,每天在多个账号,几十个云产品,几百个微服务间来回踱步,让云上基础设施按照我手中最佳实践的图纸,稳定运行。

4 改进中遇到的难点?
...

因为实体设计的比较复杂,且Terraform 暂时对 list map 结构数据处理能力较弱,所以做复杂数据类型处理时,比较费力,比如:

4.1 函数式支持的不彻底
...

cloudsso 中创建访问配置,系统策略和自定义策略,使用的 resource 不同,所以在创建前,需要根据策略类型先分组再创建,为了实现分组的效果,使用了 locals 和 dynamic

locals {
  /* 访问控制的自定义策略和系统策略分组 */
  access_with_system = { for key, item in var.sso_access : key => item if null == item.custom_policy }
  access_with_custom = { for key, item in var.sso_access : key => item if null != item.custom_policy }
}


/* 云sso访问控制:系统策略  */
resource "alicloud_cloud_sso_access_configuration" "sso_system_access" {
  depends_on   = [data.alicloud_cloud_sso_service.open]
  directory_id = local.directory_id

  for_each                  = local.access_with_system
  access_configuration_name = each.value.sso_access_name
  description               = each.value.description

  dynamic "permission_policies" {
    for_each = each.value.system_policy_list
    content {
      permission_policy_type = "System"
      permission_policy_name = permission_policies.value
    }
  }
}

我理想中的最佳实现方式, 应该是类似这样的:
没错,引入默认方法,定义传参,一行解决,非常流式

/* 伪代码,仅供参考 */
resource "alicloud_cloud_sso_access_configuration" "sso_system_access" {
  var.sso_access.forEach(item => createAccess(item.name,item.description,item.system_policy_list)) 
}

4.2 对象层级越深,数据处理越复杂
...

上面已经提到,如果你的抽象设计中,将对象层级嵌套得越深,数据处理就越困难,以设计3 为例,因为嵌套了三层,所以在使用时,需要使用3层for循环进行取值

locals {
  sso_relation_configs = flatten([
    for account, relation in var.sso_access_relation_name : [
      for group_or_user in relation.deploy_user_group : [
        for role in group_or_user.deploy_access_list : {
          account_name       = account,
          relation_name      = relation.sso_relation_name,
          description        = relation.description,
          deploy_target_type = group_or_user.target_type,
          deploy_target_name = group_or_user.target_name,
          access_list        = role
        }
      ]
    ]
  ])

  /* 构建username 和 userid 的map, 通过 name 取ID */
  configurationMap = { for user in data.alicloud_cloud_sso_access_configurations.access_configurations.configurations :
  user.access_configuration_name => user.access_configuration_id }
  accountMap = { for user in data.alicloud_resource_manager_accounts.default.accounts :
  user.display_name => user.account_id }
}


/* 将分配的权限配置到指定的账号 */
resource "alicloud_cloud_sso_access_configuration_provisioning" "provisioning" {
  depends_on = [alicloud_cloud_sso_access_configuration.sso_custom_access, alicloud_cloud_sso_access_configuration.sso_system_access, alicloud_cloud_sso_group.sso_user_group]
  for_each = {
    for relation_config in local.sso_relation_configs :
    "${relation_config.account_name}:${relation_config.relation_name}:${relation_config.deploy_target_type}:${relation_config.deploy_target_name}" => relation_config
  }

  directory_id            = local.directory_id
  access_configuration_id = lookup(local.configurationMap, each.value.access_list, "tmp")
  target_id               = lookup(local.accountMap, each.value.account_name, "tmp")
  target_type             = "RD-Account"
}

多级嵌套下,我理想中多层级嵌套场景的最佳实现方式,应该是类似这样的:

resource "alicloud_cloud_sso_access_configuration_provisioning" "provisioning" {
	Optional.ofNullable(sso_access_relation_name)
            .map(List::stream)
            .map(obj -> {
                new relationDTO(getAccessConfigurationIdByName(obj.deploy_access_list(0)), 
                getTargrtIdByName(obj.target_name))
            })
            .collect(Collectors.toList());;
}

虽然这两类问题处理起来很是费劲,但本质是语言特性导致,我相信未来随着terraform语言的更新,都是可以解决的。

5 这么折腾有什么意义?
...

terraform 本身是个简单的声名式语言,你用面向对象的方式封装一下,这不是非要用牛刀来杀鸡吗?我觉得这个问题本质上是在问,面向对象有什么意义?语言的抽象和封装有什么意义?经过这番实践与试错,我认为有以下几点:

  • 结构清晰:抽象化后,variables.tf 文件中代码结构更清晰,更贴近产品数据结构
  • 更易于扩展:数据的增减,只需要在 variables.tf 中维护,不需要更改模板文件中的数据处理逻辑
  • 更易于复用:一次抽象完成,只要产品在数据模型上不出现大变动,可以一直复用,一劳永逸
  • 一阵趋势:首推国外这家专研IaC的公司: https://github.com/cloudposse/, 他们发布了大量高质量工程化的Terraform模块和实践,这也是我重写 cloudsso 和写这篇文章的主要灵感来源,他们的模块中有大量封装的设计,但是大部分针对AWS,缺点是没有阿里云, 阿里云的Terraform模块在: https://github.com/terraform-alicloud-modules ,其中 cloudsso 官方模块中也有对象封装的设计(参见 https://github.com/terraform-alicloud-modules/terraform-alicloud-cloud-sso/blob/main/variables.tf ) ,但官方模版中只有创建用户和访问配置的功能,多账号授权的功能是缺失的,这也是我决定自己重写的一个原因。

6 如何使用此模版?
...

Step1:下载此 module
git clone https://github.com/EasterFan/alicloud-module-cloudsso.git

Step2:将此 module 移动到你的项目 modules 下,并在 main.tf 中引用
image-20230524130727851.png

Step3:执行验证
terraform apply -var "access_key=LTA**************" -var "secret_key=Grjo0**************" -auto-approve

7 最后
...

从idea产生到模版编写结束开源到 github,前后耗时2周,写这篇文章又碎片化耗时一周,也因此,这篇文章更多的是展现一个思考和实践的过程,而不是给一个纯粹的结论。我知道有人看了标题就会质疑,东方你才写了1个月的terraform就下这样的结论,是不是手里只有一把锤子🔨看啥都是钉子啊,我只能说,确实如此。因为编程这件事本身就是对真实世界的抽象,不管你用什么语言,作为一个开发者,抽象,一定是第一把锤子。

当然了,严格意义上说,抽象和封装是面向对象的一个特性,面向对象最核心的特性是多态,是使用 SOLID 方式去做软件的设计,但由于terraform 语言的限制,在肉眼可见的短期内,是不太可能支持到这种程度的。

另外,在实践的过程中(使用的版本是 terraform 1.4.0 + AliCloud 1.201.0),对 Terraform 语法了解越多,越觉得它不仅仅是一个简单的声名式语言,极有可能像 JavaScript 一样,从一个简单的脚本语言,发展到同样支持面向对象的ES6,如今 Terraform 支持 object、list、map 等数据结构,尤其是当看到 optional 关键字时,从一个 Java 开发者的语言敏锐度上,我认为它以后很大概率会支持函数式,支持更多诸如 filter/reduce/map 等函数以提升对 list 数据类型的处理能力,从而给开发者根据产品的模型封装时,提供更大的想象空间。

未来何时到来?

按照terraform 更新的速度,在0.几的版本里,可是连基本的 for_each 都很费劲,所以在2023年5月做个小小预言,Terraform 将在 3.几版本提供更丰富的面向对象语法特性,我已经关注了 HashiCorp 的推特,如果预言实现,到时再来更新。