![[OSSCA] Terraform Provider 개발(4) - Custom Provider 만들기](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdna%2FFQmeV%2FbtsIPXAomZn%2FAAAAAAAAAAAAAAAAAAAAAORSlaWd3UWu8YjwAEaos_RwduGi0qtWsaHmeUpmLWjY%2Fimg.webp%3Fcredential%3DyqXZFxpELC7KVnFOS48ylbz2pIh7yKj8%26expires%3D1756652399%26allow_ip%3D%26allow_referer%3D%26signature%3DOQYZ9Qu0Ny97GxpEFKueb8fvW30%253D)
드디어 마지막 단계, 기존까지 개발한 코드들을 모두 집합하여 커스텀 프로바이더를 개발할 차례이다. Go 언어 문법도 모르는 채로 시작해서 큰 난항을 계속 마주했지만 Terraform 코드를 작성하려니 더 어려웠던 점이 많아, 어떤 흐름으로 개발했는지 간단하게 작성하면서 학습해보고자 한다.
기본 설정
var (
_ resource.Resource = &cafeResource{}
_ resource.ResourceWithConfigure = &cafeResource{}
)
func NewCafeResource() resource.Resource {
return &cafeResource{}
}
type cafeResource struct {
client *hashicups.Client
}
해당 부분의 코드는 인터페이스 구현을 검증하는 Go언어의 관용적 방법을 사용하고 있다. 이는 cafeResource에서 resource.Resource와 resource.ResourceWithConfigure를 구현했는지 확인하는 방식으로 사용된다.
resource.Resource
type Resource interface {
// Metadata should return the full name of the resource, such as
// examplecloud_thing.
Metadata(context.Context, MetadataRequest, *MetadataResponse)
// Schema should return the schema for this resource.
Schema(context.Context, SchemaRequest, *SchemaResponse)
// Create is called when the provider must create a new resource. Config
// and planned state values should be read from the
// CreateRequest and new state values set on the CreateResponse.
Create(context.Context, CreateRequest, *CreateResponse)
// Read is called when the provider must read resource values in order
// to update state. Planned state values should be read from the
// ReadRequest and new state values set on the ReadResponse.
Read(context.Context, ReadRequest, *ReadResponse)
// Update is called to update the state of the resource. Config, planned
// state, and prior state values should be read from the
// UpdateRequest and new state values set on the UpdateResponse.
Update(context.Context, UpdateRequest, *UpdateResponse)
// Delete is called when the provider must delete the resource. Config
// values may be read from the DeleteRequest.
//
// If execution completes without error, the framework will automatically
// call DeleteResponse.State.RemoveResource(), so it can be omitted
// from provider logic.
Delete(context.Context, DeleteRequest, *DeleteResponse)
}
해당 Resource의 구현 내용을 보면 위와 같다. 위와 같은 형식의 인터페이스가 있고 해당 인터페이스의 내용을 충족할 수 있도록 함수를 모두 구현해 주어야 한다(Create, Read, Update, Delete의 함수 이름을 통일해야 한다)
resource.ResourceWithConfigure
type ResourceWithConfigure interface {
Resource
// Configure enables provider-level data or clients to be set in the
// provider-defined Resource type. It is separately executed for each
// ReadResource RPC.
Configure(context.Context, ConfigureRequest, *ConfigureResponse)
}
Configure의 경우 provider-level에서 Resource type을 정의해 주는 용도로 사용된다. 즉, 리소스가 프로바이더 설정의 데이터를 받을 수 있도록 하며 cafeResource 구조체가 해당 인터페이스를 구현함으로써, 프로바이더에서 설정된 클라이언트나 기타 설정 데이터를 받아 초기화하는 것이 가능해진다.
cafeResource
NewCafeResource
func NewCafeResource() resource.Resource {
return &cafeResource{}
}
CafeResource를 생성하는 함수를 만들어준다. 해당 함수의 경우 provider.go에서 아래와 같이 Resource를 등록하는데 사용된다.
func (p *hashicupsProvider) Resources(_ context.Context) []func() resource.Resource {
return []func() resource.Resource{
NewOrderResource,
NewCafeResource,
}
}
아래에 CafeResource를 추가하여 등록해주자. 이때, DataSource까지 제작했다면, DataSource에 추가해주어야 한다.
type CafeResource
type cafeResource struct {
client *hashicups.Client
}
해당 리소스는 HashiCups API와 상호작용 하기 위한 리소스의 정의를 포함한다. 즉, client 필드가 HashiCupes API와 통신하기 위한 클라이언트를 저장하게 된다. 이후 client의 사용 방식에 대해 유심히 알아보도록 하자.
모델 정의
type cafeResourceModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
Address types.String `tfsdk:"address"`
Description types.String `tfsdk:"description"`
Image types.String `tfsdk:"image"`
}
API에서 만든 cafeResource를 받아주기 위한 Model을 정의한다. 위와 같은 방식으로 기존의 Model을 참고해 타입을 정의해주었다.
메타데이터 및 스키마 정의
메타데이터
func (r *cafeResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_cafe"
}
리소스의 메타데이터를 정의한다. 이는, 추후 terraform 파일을 만들 때 _cafe가 있으면 cafe 리소스임을 식별할 수 있도록 정의해주는 것이다. 해당 리소스는 cafe 리소스 이므로 "_cafe"가 있다면 식별할 수 있도록 코드를 작성해준다.
스키마
func (r *cafeResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"name": schema.StringAttribute{
Optional: true,
},
"address": schema.StringAttribute{
Optional: true,
},
"description": schema.StringAttribute{
Optional: true,
},
"image": schema.StringAttribute{
Optional: true,
},
},
}
}
스키마는 리소스의 구조를 정의하고, 리소스의 속성을 설명하는 것이다. 즉, 이를 통해서 Terraform은 사용자가 제공한 입력을 검증하고 리소스의 상태를 저장하고 관리하는 것이 가능하다.
스키마의 주요 역할
- 속성 정의 및 검증:
- 각 속성(attribute)의 이름, 유형(type), 요구사항(required, optional), 기본값(default), 및 기타 특성을 정의한다.
- Terraform은 이 정보를 사용하여 사용자가 제공한 입력을 검증하고, 잘못된 입력을 사전에 차단할 수 있다.
- 상태 저장 및 관리:
- Terraform은 리소스의 상태(state)를 관리합니다. 스키마는 이 상태가 어떻게 구성되어야 하는지를 정의한다.
- 리소스가 생성, 읽기, 업데이트, 삭제되는 동안, 스키마는 Terraform이 올바른 데이터를 유지하도록 보장한다.
- 계획 및 변경 관리:
- 스키마는 Terraform이 계획(plan)을 세우고, 실행(apply)할 때, 어떤 속성이 변경되었는지를 파악하는 데 도움을 준다.
- 이를 통해 사용자에게 리소스의 변경 사항을 명확히 전달할 수 있다.
스키마의 주요 구성 요소
- Attributes:
- 스키마의 주요 구성 요소로, 리소스의 각 속성을 정의한다. 각 속성은 이름과 타입을 포함하며, 추가적으로 다양한 특성을 가질 수 있다.
- Computed:
- 속성이 사용자 입력에 의존하지 않고, Terraform이 계산하여 설정하는 경우 사용된다.
- Required/Optional:
- 속성이 필수인지(required), 선택적인지(optional)를 정의합니다. 필수 속성은 사용자가 반드시 제공해야 하며, 선택적 속성은 제공하지 않아도 된다..
- Default:
- 사용자가 값을 제공하지 않았을 때 사용되는 기본값을 정의한다.
- Description:
- 속성에 대한 설명을 제공하여 문서화한다. 이는 사용자가 리소스를 이해하고 사용할 수 있도록 돕는다.
- PlanModifiers:
- 계획 단계에서 속성의 값을 수정하는 데 사용된다. 예를 들어, UseStateForUnknown는 계획 단계에서 속성의 현재 상태를 사용하도록 한다.
Create
func (r *cafeResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan cafeResourceModel
diags := req.Plan.Get(ctx, &plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
cafeResourceModel에 request로 들어온 내용을 받아 값을 적는다. 이는 req를 보게 되면, 리소스가 json의 형식으로 구성되지 않는데, 초반에 보았던 gRPC의 기술이 해당 부분에서 사용된다.
type CreateRequest struct {
// Config is the configuration the user supplied for the resource.
//
// This configuration may contain unknown values if a user uses
// interpolation or other functionality that would prevent Terraform
// from knowing the value at request time.
Config tfsdk.Config
// Plan is the planned state for the resource.
Plan tfsdk.Plan
// ProviderMeta is metadata from the provider_meta block of the module.
ProviderMeta tfsdk.Config
}
위와같이 구성되어, ModelResource의 body로 들어온 내용을 형식대로 지정해주지 않았음에도 불구하고, Plan을 가져오면 ModelResource에 값을 할당할 수 있다.
cafe := hashicups.Cafe{
Name: plan.Name.ValueString(),
Address: plan.Address.ValueString(),
Description: plan.Description.ValueString(),
Image: plan.Image.ValueString(),
}
createdCafe, err := r.client.CreateCafe([]hashicups.Cafe{cafe})
if err != nil {
resp.Diagnostics.AddError(
"Error creating cafe",
"Could not create cafe, unexpected error: "+err.Error(),
)
return
}
plan에 할당한 내용을 이용하여 hashicups.Cafe 객체를 만든다. ValueString()을 이용하여 값을 가져와 할당한다.
plan.ID = types.StringValue(strconv.Itoa(createdCafe.ID))
plan.Name = types.StringValue(createdCafe.Name)
plan.Address = types.StringValue(createdCafe.Address)
plan.Description = types.StringValue(createdCafe.Description)
plan.Image = types.StringValue(createdCafe.Image)
diags = resp.State.Set(ctx, plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
이후 State를 set 하기 위해 plan에 대한 내용을 createdCafe에서 가져올 수 있도록 설계하였다.
Read
func (r *cafeResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state cafeResourceModel
diags := req.State.Get(ctx, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
cafeID, err := strconv.Atoi(state.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError(
"Error Converting Cafe ID",
"Could not convert cafe ID to integer: "+err.Error(),
)
return
}
// Assume GetCafe now returns a list of cafes
cafes, err := r.client.GetCafe(strconv.Itoa(cafeID))
if err != nil {
resp.Diagnostics.AddError(
"Unable to Read HashiCups Cafe",
err.Error(),
)
return
}
create와 마찬가지로 cafeResourceModel을 만든 뒤, state에 값을 할당한다. 또한, state의 ID값을 이용하여 cafeID를 찾고, ID값을 이용하여 getCafe 클라이언트(이전에 만든 라이브러리)를 이용해 Cafe 객체를 가져오도록 설계하였다.
if len(cafes) == 0 {
resp.Diagnostics.AddError(
"Cafe Not Found",
"No cafe found with the given ID",
)
return
}
cafe := cafes[0]
// Map response body to model
state.ID = types.StringValue(strconv.Itoa(cafe.ID))
state.Address = types.StringValue(cafe.Address)
state.Image = types.StringValue(cafe.Image)
state.Name = types.StringValue(cafe.Name)
state.Description = types.StringValue(cafe.Description)
// Set state
diags = resp.State.Set(ctx, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
이후 가져온 cafes를 이용하여 단일 객체의 경우 첫번째 cafe를 가져오고, 이를 state에 할당하여 반환하는 방식으로 설계하였다.
Update
func (r *cafeResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan cafeResourceModel
diags := req.Plan.Get(ctx, &plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Convert the ID from string to int
cafeID, err := strconv.Atoi(plan.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError(
"Error Converting Cafe ID",
"Could not convert cafe ID to integer: "+err.Error(),
)
return
}
// Create a cafe object
cafe := hashicups.Cafe{
ID: cafeID, // ID is an int
Name: plan.Name.ValueString(),
Address: plan.Address.ValueString(),
Description: plan.Description.ValueString(),
Image: plan.Image.ValueString(),
}
// Update the existing cafe
updatedCafe, err := r.client.UpdateCafe(plan.ID.ValueString(), []hashicups.Cafe{cafe})
if err != nil {
resp.Diagnostics.AddError(
"Error Updating HashiCups Cafe",
"Could not update cafe, unexpected error: "+err.Error(),
)
return
}
// Update resource state with updated items
plan.ID = types.StringValue(strconv.Itoa(updatedCafe.ID))
plan.Name = types.StringValue(updatedCafe.Name)
plan.Address = types.StringValue(updatedCafe.Address)
plan.Description = types.StringValue(updatedCafe.Description)
plan.Image = types.StringValue(updatedCafe.Image)
diags = resp.State.Set(ctx, plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
Create와 유사하게 plan을 가져온뒤 해당 내용을 client의 UpdateCafe를 호출해 업데이트 한 뒤, 응답값의 경우 plan에 마찬가지로 값을 할당해 반환하는 방식으로 구성한다.
Delete
func (r *cafeResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state cafeResourceModel
diags := req.State.Get(ctx, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
cafeID, err := strconv.Atoi(state.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError(
"Error Deleting HashiCups Cafe",
"Could not convert cafe ID to integer: "+err.Error(),
)
return
}
err = r.client.DeleteCafe(strconv.Itoa(cafeID))
if err != nil {
resp.Diagnostics.AddError(
"Error Deleting HashiCups Cafe",
"Could not delete cafe, unexpected error: "+err.Error(),
)
return
}
}
Delete또한 마찬가지로, 요청 값을 가져온 뒤 cafeID를 이용해 client를 호출하고 삭제하는 방식으로 구성된다.
Configure
func (r *cafeResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*hashicups.Client)
if !ok {
resp.Diagnostics.AddError(
"Unexpected Data Source Configure Type",
fmt.Sprintf("Expected *hashicups.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData),
)
return
}
r.client = client
}
설정 내용은 위와 같이 구성하였다.
테스트 방식
.terraformrc 파일을 만들어 다음과 같이 수정해 주었다.
provider_installation{
dev_overrides{
"registry.terraform.io/study/inpyu-ossca" = "/home/dayeon620/go/bin/"
}
direc{}
}
위와 같이 만든 레지스트리 주소를 참조할 수 있도록 terraformrc 파일을 꼭 만들어 주어야 한다.
테라폼 코드 작성
terraform {
required_providers {
inpyu = {
source = "registry.terraform.io/study/inpyu-ossca"
}
}
}
provider "inpyu" {
username = "inpyu"
password = "test123"
host = "http://localhost:19090"
}
resource "inpyu_cafe" "cafe" {
name = "Sample Cafe"
address = "123 Coffee St"
description = "A cozy place to enjoy coffee and pastries"
image = "http://example.com/image.jpg"
}
이후 위와 같이 만든 것을 작성하여 테스트가 가능하다.
{
"version": 4,
"terraform_version": "1.8.1",
"serial": 6,
"lineage": "149b9f2b-0db9-e7ee-5c17-f41d6c1359e8",
"outputs": {},
"resources": [
{
"mode": "managed",
"type": "inpyu_cafe",
"name": "cafe",
"provider": "provider[\"registry.terraform.io/study/inpyu-ossca\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"address": "123 Coffee St",
"description": "A cozy place to enjoy coffee and pastries",
"id": "8",
"image": "http://example.com/image.jpg",
"name": "Sample Cafe"
},
"sensitive_attributes": []
}
]
}
],
"check_results": null
}
이렇게 test를 하면 terraformstate 파일이 생성되어, 제대로 된 것을 파악하는 것이 가능하다!
'Infra > Terraform' 카테고리의 다른 글
[OSSCA] Terraform Provider 개발(3) - Go API 패키지 만들기 (0) | 2024.07.30 |
---|---|
[OSSCA] Terraform Provider 개발(2) - Go API 제작하기 (0) | 2024.07.30 |
[OSSCA] Terraform Provider 개발(1) - Go API 분석하기 (0) | 2024.07.29 |
[OSSCA] Terraform Provider SDK 와 Framework 버전 차이를 알아보자 (1) | 2024.07.24 |
[OSSCA] Terraform Provider 살펴보기 (2) | 2024.07.23 |
보안 전공 개발자지만 대학로에서 살고 싶어요
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!