1. 客户端代码执行流程

1. GIT拉取客户端代码

https://wwwin-github.cisco.com/netascode/terraform-aac.git

2. tf配置文件结构

2.1 backend.tf 配置terraform 状态文件存储在哪 (local AWS S3...)

terraform {
  backend "http" {}
}

2.2 main.tf terraform入口文件

module "aci" {
  # 调用 netascode/nac-aci/aci:0.7.0 terraform repositry中的源码 并向源码中提交 (yaml_directories, manage_access_policies, manage_fabric_policies, write_default_values_file等变量)
  source  = "netascode/nac-aci/aci"
  version = "0.7.0"

  yaml_directories = ["data"]

  manage_access_policies    = false
  manage_fabric_policies    = false
  manage_pod_policies       = false
  manage_node_policies      = false
  manage_interface_policies = false
  manage_tenants            = true

  write_default_values_file = "defaults.yaml"
}

2.3 provider.tf 配置terraform供应商

terraform {
  required_providers {
    aci = {
      source  = "CiscoDevNet/aci"
      version = ">= 2.1.0"
    }
    utils = {
      source  = "cloudposse/utils"
      version = ">= 0.15.0"
    }
  }
}

provider "aci" {
  # 调用CiscoDevNet/aci:2.10.0时像源码中提交的变量这里设置了 APIC URL, 用户名,密码 insecure, 以及尝试次数
  url      = var.apic_url
  username = var.apic_user
  password = var.apic_pwd
  insecure = true
  retries  = 4
}

2.4 terraform.tfvars 以及 variables.tf 配置变量

对于terraform.tfvars文件,它是用来存储变量值的外部文件。在运行Terraform时,可以使用该文件来提供变量的值。它可以包含覆盖variables.tf中定义的默认值的变量值

# terraform.tfvars
apic_url  = "http://f1apic1.aci.pub"
apic_user = "apic:tacacs\\\\aac-gitlab"
apic_pwd  = "aac-gitlab"
# variables.tf
variable "apic_user" {
  description = "APIC user"
  type        = string
}

variable "apic_pwd" {
  description = "APIC password"
  type        = string
}

variable "apic_url" {
  description = "APIC url"
  type        = string
}

2.5 data文件夹以及下面的yaml文件

# tenant_aac-linxu3-new.yaml
---
apic:
  tenants:
    - name: 'xiawang3_aci_jenkins_team'
      vrfs:
        - name: 'test'

      bridge_domains:
        - name: BD_VLAN100
          vrf: PROD-linus-aac-terraform

        - name: BD_VLAN101
          vrf: PROD-linus-aac-terraform

        - name: BD_VLAN102
          vrf: PROD-linus-aac-terraform

      application_profiles:
        - name: PROD-linus-aac-terraform
          endpoint_groups:
            - name: EPG_VLAN100
              bridge_domain: BD_VLAN100
              physical_domains:
                - PHYSICAL1
              static_ports:
                - node_id: 101
                  port: 1
                  vlan: 100

            - name: EPG_VLAN101
              bridge_domain: BD_VLAN101
              physical_domains:
                - PHYSICAL1

2.6 总结

netascode /terraform-aac 项目中
main.tf 文件会调用terraform仓库中的 netascode/nac-aci/aci:0.7.0源码, 源码中的variables.tf接受变量
provider.tf 文件会调用terraform仓库中的CiscoDevNet/aci:2.10.0源码并向其中传递, APIC用户名,密码,url等参数

文件执行顺序

  1. versions.tf
  2. provider.tf
  3. variables.tf
  4. main.tf
  5. merge.tf
  6. backend.tf
  7. outputs.tf

3. 查看上面提到netascode/nac-aci/aci:0.7.0的源码信息

terraform 仓库地址:
https://registry.terraform.io/modules/netascode/nac-aci/aci/latest
github 源代码地址:
https://github.com/netascode/terraform-aci-nac-aci
github 源码文件结构:

3.1 通过源码的tf文件,查看terraform程序执行过程

3.1.1 源码中包含的tf文件


以apic开头的tf文件为AAC可实现的User Case,先查看其他tf文件
包含tf 文件以及加载顺序如下

  1. versions.tf
  2. variables.tf
  3. merge.tf
  4. main.tf
  5. outputs.tf

3.1.1.1 version.tf

查看terraform版本是否大于等于 1.3.0
供应商版本是否满足要求

terraform {
  required_version = ">= 1.3.0"

  required_providers {
    aci = {
      source  = "CiscoDevNet/aci"
      version = ">= 2.6.1"
    }
    utils = {
      source  = "netascode/utils"
      version = ">= 0.2.5"
    }
    local = {
      source  = "hashicorp/local"
      version = ">= 2.3.0"
    }
  }
}

3.1.1.2 variables.tf

在 2.2中定义的变量会被传入并接收, 并覆盖默认值

variable "yaml_directories" {
  description = "List of paths to YAML directories."
  type        = list(string)
  default     = []
}

variable "yaml_files" {
  description = "List of paths to YAML files."
  type        = list(string)
  default     = []
}

variable "model" {
  description = "As an alternative to YAML files, a native Terraform data structure can be provided as well."
  type        = map(any)
  default     = {}
}

variable "manage_access_policies" {
  description = "Flag to indicate if access policies should be managed."
  type        = bool
  default     = false
}

variable "manage_fabric_policies" {
  description = "Flag to indicate if fabric policies should be managed."
  type        = bool
  default     = false
}

variable "manage_pod_policies" {
  description = "Flag to indicate if pod policies should be managed."
  type        = bool
  default     = false
}

variable "manage_node_policies" {
  description = "Flag to indicate if node policies should be managed."
  type        = bool
  default     = false
}

variable "manage_interface_policies" {
  description = "Flag to indicate if interface policies should be managed."
  type        = bool
  default     = false
}

variable "managed_interface_policies_nodes" {
  description = "List of node IDs for which interface policies should be managed. By default interface policies for all nodes will be managed."
  type        = list(number)
  default     = []
}

variable "manage_tenants" {
  description = "Flag to indicate if tenants should be managed."
  type        = bool
  default     = false
}

variable "managed_tenants" {
  description = "List of tenant names to be managed. By default all tenants will be managed."
  type        = list(string)
  default     = []
}

variable "write_default_values_file" {
  description = "Write all default values to a YAML file. Value is a path pointing to the file to be created."
  type        = string
  default     = ""
}

3.1.1.3 merge.tf 定义 locals{里面参数的keyword为可用的变量名称} 和 data类新的个实例(data.utils_yaml_merge.model/defaults/modules)

locals {
  # yaml_strings_directories 使用了一个for循环来遍历var.yaml_directories列表中的每个目录,并使用fileset函数获取目录中的所有.yaml和.yml文件。 2.2中定义
  yaml_strings_directories = flatten([
    for dir in var.yaml_directories : [
      for file in fileset(".", "${dir}/*.{yml,yaml}") : file(file)
    ]
  ])
  # yaml_strings_files 为yaml所在的yaml file名称 可在2.2中定义
  yaml_strings_files = [
    for file in var.yaml_files : file(file)
  ]
  # model_strings 使用了一个条件表达式判断var.model中是否有值,如果有值则将其转为一个列表,否则赋值一个空列表。
  model_strings = length(keys(var.model)) != 0 ? [yamlencode(var.model)] : []
  # user_defaults 使用了try函数来解析data.utils_yaml_merge.model.output中的defaults值,如果解析失败则返回一个空字典
  user_defaults = { "defaults" : try(lookup(yamldecode(data.utils_yaml_merge.model.output), "defaults"), {}) }
  defaults      = lookup(yamldecode(data.utils_yaml_merge.defaults.output), "defaults")
  user_modules  = { "modules" : try(lookup(yamldecode(data.utils_yaml_merge.model.output), "modules"), {}) }
  modules       = lookup(yamldecode(data.utils_yaml_merge.modules.output), "modules")
  model         = yamldecode(data.utils_yaml_merge.model.output)
}

# 这里的 data.utils_yaml_merge.model 返回值就是data文件夹的yaml文件里定义的所有模块
data "utils_yaml_merge" "model" {
  input = concat(local.yaml_strings_directories, local.yaml_strings_files, local.model_strings)

  # 当规定了data文件夹有值,文件夹里有yaml文件, yaml文件不是空文件时才会返回model 要不会报错 "Either `yaml_directories`,`yaml_files` or a non-empty `model` value must be provided."
  lifecycle {
    precondition {
      condition     = length(var.yaml_directories) != 0 || length(var.yaml_files) != 0 || length(keys(var.model)) != 0
      error_message = "Either `yaml_directories`,`yaml_files` or a non-empty `model` value must be provided."
    }
  }
}

# 从defaults文件夹中拿到defaults.yaml 里面的值设置 data.utils_yaml_merge.defaults
data "utils_yaml_merge" "defaults" {
  input = [file("${path.module}/defaults/defaults.yaml"), yamlencode(local.user_defaults)]
}

# 从defaults文件夹中拿到modules.yaml 里面的值设置 data.utils_yaml_merge.modules
data "utils_yaml_merge" "modules" {
  input = [file("${path.module}/defaults/modules.yaml"), yamlencode(local.user_modules)]
}

resource "local_sensitive_file" "defaults" {
  count    = var.write_default_values_file != "" ? 1 : 0
  content  = data.utils_yaml_merge.defaults.output
  filename = var.write_default_values_file
}

3.1.1.4 main.tf

# tenant_aac-linxu3-new.yaml
---
apic:
  tenants:
    - name: 'xiawang3_aci_jenkins_team'
      vrfs:
        - name: 'test'

      bridge_domains:
        - name: BD_VLAN100
          vrf: PROD-linus-aac-terraform

        - name: BD_VLAN101
          vrf: PROD-linus-aac-terraform

        - name: BD_VLAN102
          vrf: PROD-linus-aac-terraform

      application_profiles:
        - name: PROD-linus-aac-terraform
          endpoint_groups:
            - name: EPG_VLAN100
              bridge_domain: BD_VLAN100
              physical_domains:
                - PHYSICAL1
              static_ports:
                - node_id: 101
                  port: 1
                  vlan: 100

            - name: EPG_VLAN101
              bridge_domain: BD_VLAN101
              physical_domains:
                - PHYSICAL1

以上代码是之前定义在data文件夹下的yaml文件, 里面只有 locals.apic.tenants
所以下面main.tf只需要看tenants部分即可

locals {
  apic               = try(local.model.apic, {})
  access_policies    = try(local.apic.access_policies, {})
  fabric_policies    = try(local.apic.fabric_policies, {})
  pod_policies       = try(local.apic.pod_policies, {})
  node_policies      = try(local.apic.node_policies, {})
  interface_policies = try(local.apic.interface_policies, {})

  nodes = [for node in try(local.apic.interface_policies.nodes, []) : {
    id         = node.id
    name       = try([for n in local.node_policies.nodes : n.name if n.id == node.id][0], "")
    role       = try([for n in local.node_policies.nodes : n.role if n.id == node.id][0], "")
    interfaces = try(node.interfaces, [])
    fexes      = try(node.fexes, [])
  } if length(var.managed_interface_policies_nodes) == 0 || contains(var.managed_interface_policies_nodes, node.id)]

  # locals.tenants
  # 这段代码使用了  Terraform  中的  `for`  表达式和条件语句来生成一个列表  `tenants`。
  # 该表达式的含义是遍历  `local.apic.tenants`  列表中的每个  `tenant`,然后使用条件判断来过滤保留的  `tenant`。
  # -  条件  `length(var.managed_tenants)  ==  0`  表示如果  `var.managed_tenants`  列表为空,则保留所有的  `tenant`。
  # -  条件  `contains(var.managed_tenants,  tenant.name)`  表示如果  `var.managed_tenants`  列表中包含当前  `tenant`  的  `name`  属性,则保留该  `tenant`。
  # 最终生成的  `tenants`  列表中包含符合条件的  `tenant`  对象。
  tenants = [for tenant in try(local.apic.tenants, []) : tenant if length(var.managed_tenants) == 0 || contains(var.managed_tenants, tenant.name)]

  interface_types = flatten([
    for node in try(local.interface_policies.nodes, []) : [
      for interface in try(node.interfaces, []) : {
        key     = "${node.id}/${try(interface.module, local.defaults.apic.interface_policies.nodes.interfaces.module)}/${interface.port}"
        pod_id  = try([for n in try(local.node_policies.nodes, []) : try(n.pod, local.defaults.apic.node_policies.nodes.pod) if n.id == node.id][0], local.defaults.apic.node_policies.nodes.pod)
        node_id = node.id
        module  = try(interface.module, local.defaults.apic.interface_policies.nodes.interfaces.module)
        port    = interface.port
        type    = interface.type
      } if try(interface.type, null) != null
    ]
  ])

  leaf_interface_policy_group_mapping = [
    for pg in try(local.access_policies.leaf_interface_policy_groups, []) : {
      name = pg.name
      type = pg.type
      node_ids = [
        for node in try(local.interface_policies.nodes, []) :
        node.id if length([for int in try(node.interfaces, []) : try(int.policy_group, null) if try(int.policy_group, null) == pg.name]) > 0
      ]
      fex_ids = flatten([
        for node in try(local.interface_policies.nodes, []) : [
          for fex in try(node.fexes, []) :
          fex.id if length([for int in try(fex.interfaces, []) : try(int.policy_group, null) if try(int.policy_group, null) == pg.name]) > 0
        ]
      ])
    }
  ]
}

mian.tf中便定义好了yaml中规定tenants, 使用locals.tenants调用

3.1.1.5 aci_tenants.tf 下面的代码结构就很熟悉了, 定义多种模块,只能aci_tenant模块讲解

这段代码是使用Terraform的ACI模块创建ACI租户。ACI是Cisco的一种数据中心网络架构。

在模块的参数中,使用了netascode/tenant/aci模块的版本0.1.1

for_each语句循环遍历local.tenants列表中的每个租户。使用try函数判断tenant.managed属性是否存在,如果不存在则使用local.defaults.apic.tenants.managed的值来代替。判断local.modules.aci_tenantvar.manage_tenants是否为真来确定是否创建租户。如果满足条件,则以tenant.name作为键值并以tenant对应的值作为租户对象存储。

然后,将每个租户的属性作为参数传递给ACI租户模块。其中namealiasdescriptionsecurity_domains等参数是根据每个租户的属性值来设置的。

locals块中,定义了一个vrfs变量。使用嵌套的for循环遍历local.tenants列表中的每个租户以及租户中的每个VRF(虚拟路由和转发)。通过try函数判断tenant.vrfs属性是否存在,如果不存在则使用空列表代替。然后,将每个VRF的属性作为参数传递给ACI VRF模块。其中key属性用于标识每个VRF,tenant属性设置为租户的名称,name属性根据VRF的名称和local.defaults.apic.tenants.vrfs.name_suffix设置。alias属性根据VRF的alias属性设置。其他属性未提供。

根据提供的信息,这段代码的主要功能是根据给定的租户和VRF信息来创建ACI租户和VRF。

module "aci_tenant" {
  source  = "netascode/tenant/aci"
  version = "0.1.1"

  for_each         = { for tenant in local.tenants : tenant.name => tenant if try(tenant.managed, local.defaults.apic.tenants.managed, true) && local.modules.aci_tenant && var.manage_tenants }
  name             = each.value.name
  alias            = try(each.value.alias, "")
  description      = try(each.value.description, "")
  security_domains = try(each.value.security_domains, [])
}

locals {
  vrfs = flatten([
    for tenant in local.tenants : [
      for vrf in try(tenant.vrfs, []) : {
        key                                     = format("%s/%s", tenant.name, vrf.name)
        tenant                                  = tenant.name
        name                                    = "${vrf.name}${local.defaults.apic.tenants.vrfs.name_suffix}"
        alias                                   = try(vrf.alias, "")
        description                             = try(vrf.description, "")
        enforcement_direction                   = try(vrf.enforcement_direction, local.defaults.apic.tenants.vrfs.enforcement_direction)
        enforcement_preference                  = try(vrf.enforcement_preference, local.defaults.apic.tenants.vrfs.enforcement_preference)
        data_plane_learning                     = try(vrf.data_plane_learning, local.defaults.apic.tenants.vrfs.data_plane_learning)
        contract_consumers                      = try([for contract in vrf.contracts.consumers : "${contract}${local.defaults.apic.tenants.contracts.name_suffix}"], [])
        contract_providers                      = try([for contract in vrf.contracts.providers : "${contract}${local.defaults.apic.tenants.contracts.name_suffix}"], [])
        contract_imported_consumers             = try([for contract in vrf.contracts.imported_consumers : "${contract}${local.defaults.apic.tenants.imported_contracts.name_suffix}"], [])
        preferred_group                         = try(vrf.preferred_group, local.defaults.apic.tenants.vrfs.preferred_group)
        transit_route_tag_policy                = try(vrf.transit_route_tag_policy, null) != null ? "${vrf.transit_route_tag_policy}${local.defaults.apic.tenants.policies.route_tag_policies.name_suffix}" : ""
        bgp_timer_policy                        = try("${vrf.bgp.timer_policy}${local.defaults.apic.tenants.policies.bgp_timer_policies.name_suffix}", "")
        bgp_ipv4_address_family_context_policy  = try("${vrf.bgp.ipv4_address_family_context_policy}${local.defaults.apic.tenants.policies.bgp_address_family_context_policies.name_suffix}", "")
        bgp_ipv6_address_family_context_policy  = try("${vrf.bgp.ipv6_address_family_context_policy}${local.defaults.apic.tenants.policies.bgp_address_family_context_policies.name_suffix}", "")
        bgp_ipv4_import_route_target            = try(vrf.bgp.ipv4_import_route_target, "")
        bgp_ipv4_export_route_target            = try(vrf.bgp.ipv4_export_route_target, "")
        bgp_ipv6_import_route_target            = try(vrf.bgp.ipv6_import_route_target, "")
        bgp_ipv6_export_route_target            = try(vrf.bgp.ipv6_export_route_target, "")
        dns_labels                              = try(vrf.dns_labels, [])
        pim_enabled                             = try(vrf.pim, null) != null ? true : false
        pim_mtu                                 = try(vrf.pim.mtu, local.defaults.apic.tenants.vrfs.pim.mtu)
        pim_fast_convergence                    = try(vrf.pim.fast_convergence, local.defaults.apic.tenants.vrfs.pim.fast_convergence)
        pim_strict_rfc                          = try(vrf.pim.strict_rfc, local.defaults.apic.tenants.vrfs.pim.strict_rfc)
        pim_max_multicast_entries               = try(vrf.pim.max_multicast_entries, local.defaults.apic.tenants.vrfs.pim.max_multicast_entries)
        pim_reserved_multicast_entries          = try(vrf.pim.reserved_multicast_entries, local.defaults.apic.tenants.vrfs.pim.reserved_multicast_entries)
        pim_resource_policy_multicast_route_map = try(vrf.pim.resource_policy_multicast_route_map, null) != null ? "${vrf.pim.resource_policy_multicast_route_map}${local.defaults.apic.tenants.policies.multicast_route_maps.name_suffix}" : ""
        pim_static_rps = [for rp in try(vrf.pim.static_rps, []) : {
          ip                  = rp.ip
          multicast_route_map = try(rp.multicast_route_map, null) != null ? "${rp.multicast_route_map}${local.defaults.apic.tenants.policies.multicast_route_maps.name_suffix}" : ""
        }]
        pim_fabric_rps = [for rp in try(vrf.pim.fabric_rps, []) : {
          ip                  = rp.ip
          multicast_route_map = try(rp.multicast_route_map, null) != null ? "${rp.multicast_route_map}${local.defaults.apic.tenants.policies.multicast_route_maps.name_suffix}" : ""
        }]
        pim_bsr_listen_updates                   = try(vrf.pim.bsr_listen_updates, local.defaults.apic.tenants.vrfs.pim.bsr_listen_updates)
        pim_bsr_forward_updates                  = try(vrf.pim.bsr_forward_updates, local.defaults.apic.tenants.vrfs.pim.bsr_forward_updates)
        pim_bsr_filter_multicast_route_map       = try(vrf.pim.bsr_filter_multicast_route_map, null) != null ? "${vrf.pim.bsr_filter_multicast_route_map}${local.defaults.apic.tenants.policies.multicast_route_maps.name_suffix}" : ""
        pim_auto_rp_listen_updates               = try(vrf.pim.auto_rp_listen_updates, local.defaults.apic.tenants.vrfs.pim.auto_rp_listen_updates)
        pim_auto_rp_forward_updates              = try(vrf.pim.auto_rp_forward_updates, local.defaults.apic.tenants.vrfs.pim.auto_rp_forward_updates)
        pim_auto_rp_filter_multicast_route_map   = try(vrf.pim.auto_rp_filter_multicast_route_map, null) != null ? "${vrf.pim.auto_rp_filter_multicast_route_map}${local.defaults.apic.tenants.policies.multicast_route_maps.name_suffix}" : ""
        pim_asm_shared_range_multicast_route_map = try(vrf.pim.asm_shared_range_multicast_route_map, null) != null ? "${vrf.pim.asm_shared_range_multicast_route_map}${local.defaults.apic.tenants.policies.multicast_route_maps.name_suffix}" : ""
        pim_asm_sg_expiry                        = try(vrf.pim.asm_sg_expiry, local.defaults.apic.tenants.vrfs.pim.asm_sg_expiry)
        pim_asm_sg_expiry_multicast_route_map    = try(vrf.pim.asm_sg_expiry_multicast_route_map, null) != null ? "${vrf.pim.asm_sg_expiry_multicast_route_map}${local.defaults.apic.tenants.policies.multicast_route_maps.name_suffix}" : ""
        pim_asm_traffic_registry_max_rate        = try(vrf.pim.asm_traffic_registry_max_rate, local.defaults.apic.tenants.vrfs.pim.asm_traffic_registry_max_rate)
        pim_asm_traffic_registry_source_ip       = try(vrf.pim.asm_traffic_registry_source_ip, local.defaults.apic.tenants.vrfs.pim.asm_traffic_registry_source_ip)
        pim_ssm_group_range_multicast_route_map  = try(vrf.pim.ssm_group_range_multicast_route_map, null) != null ? "${vrf.pim.ssm_group_range_multicast_route_map}${local.defaults.apic.tenants.policies.multicast_route_maps.name_suffix}" : ""
        pim_inter_vrf_policies = [for pol in try(vrf.pim.inter_vrf_policies, []) : {
          tenant              = pol.tenant
          vrf                 = "${pol.vrf}${local.defaults.apic.tenants.vrfs.name_suffix}"
          multicast_route_map = try(pol.multicast_route_map, null) != null ? "${pol.multicast_route_map}${local.defaults.apic.tenants.policies.multicast_route_maps.name_suffix}" : ""
        }]
        pim_igmp_ssm_translate_policies = [for pol in try(vrf.pim.igmp_context_ssm_translate_policies, []) : {
          group_prefix   = pol.group_prefix
          source_address = pol.source_address
        }]
        leaked_internal_prefixes = [for prefix in try(vrf.leaked_internal_prefixes, []) : {
          prefix = prefix.prefix
          public = try(prefix.public, local.defaults.apic.tenants.vrfs.leaked_internal_prefixes.public)
          destinations = [for dest in try(prefix.destinations, []) : {
            description = try(dest.description, "")
            tenant      = dest.tenant
            vrf         = dest.vrf
            public      = try(dest.public, null)
          }]
        }]
        leaked_external_prefixes = [for prefix in try(vrf.leaked_external_prefixes, []) : {
          prefix             = prefix.prefix
          from_prefix_length = try(prefix.from_prefix_length, null)
          to_prefix_length   = try(prefix.to_prefix_length, null)
          destinations = [for dest in try(prefix.destinations, []) : {
            description = try(dest.description, "")
            tenant      = dest.tenant
            vrf         = dest.vrf
          }]
        }]
      }
    ]
  ])
}

4.查看terraform仓库中netascode/tenant/aci:0.1.1源码

terraform仓库路径:
https://registry.terraform.io/modules/netascode/tenant/aci/latest
github url:
https://github.com/netascode/terraform-aci-tenant
目录结构:

4.1 直接查看main.tf

终于找到了resource资源,我们知道resource资源由provider提供,那么需要进入provider源码查看 资源类型为aci_rest_managed的配置

resource "aci_rest_managed" "fvTenant" {
  dn         = "uni/tn-${var.name}"
  class_name = "fvTenant"
  content = {
    name      = var.name
    nameAlias = var.alias
    descr     = var.description
  }
}

resource "aci_rest_managed" "aaaDomainRef" {
  for_each   = toset(var.security_domains)
  dn         = "${aci_rest_managed.fvTenant.dn}/domain-${each.value}"
  class_name = "aaaDomainRef"
  content = {
    name = each.value
  }
}

4.2 创建tenant时

5 查看provider源码

https://github.com/CiscoDevNet/terraform-provider-aci/blob/master/aci/data_source_aci_rest_managed.go
路径在这里,看的很吃力, 慢慢思索中

posted @ 2023-11-05 17:35  khalil12138  阅读(49)  评论(0编辑  收藏  举报