diff --git a/kong2tf/builder.go b/kong2tf/builder.go index 1b1447986..7f4666cb9 100644 --- a/kong2tf/builder.go +++ b/kong2tf/builder.go @@ -5,15 +5,16 @@ import ( ) type ITerraformBuilder interface { - buildServices(*file.Content, *string, bool) - buildRoutes(*file.Content, *string, bool) - buildGlobalPlugins(*file.Content, *string, bool) + buildControlPlaneVar(*string) + buildServices(*file.Content, *string) + buildRoutes(*file.Content, *string) + buildGlobalPlugins(*file.Content, *string) buildConsumers(*file.Content, *string, bool) - buildConsumerGroups(*file.Content, *string, bool) - buildUpstreams(*file.Content, *string, bool) - buildCACertificates(*file.Content, *string, bool) - buildCertificates(*file.Content, *string, bool) - buildVaults(*file.Content, *string, bool) + buildConsumerGroups(*file.Content, *string) + buildUpstreams(*file.Content, *string) + buildCACertificates(*file.Content, *string) + buildCertificates(*file.Content, *string) + buildVaults(*file.Content, *string) getContent() string } @@ -31,16 +32,20 @@ func newDirector(builder ITerraformBuilder) *Director { } } -func (d *Director) builTerraformResources(content *file.Content, generateImportsForControlPlaneID *string, ignoreCredentialChanges bool) string { - - d.builder.buildGlobalPlugins(content, generateImportsForControlPlaneID, ignoreCredentialChanges) - d.builder.buildServices(content, generateImportsForControlPlaneID, ignoreCredentialChanges) - d.builder.buildUpstreams(content, generateImportsForControlPlaneID, ignoreCredentialChanges) - d.builder.buildRoutes(content, generateImportsForControlPlaneID, ignoreCredentialChanges) +func (d *Director) builTerraformResources( + content *file.Content, + generateImportsForControlPlaneID *string, + ignoreCredentialChanges bool, +) string { + d.builder.buildControlPlaneVar(generateImportsForControlPlaneID) + d.builder.buildGlobalPlugins(content, generateImportsForControlPlaneID) + d.builder.buildServices(content, generateImportsForControlPlaneID) + d.builder.buildUpstreams(content, generateImportsForControlPlaneID) + d.builder.buildRoutes(content, generateImportsForControlPlaneID) d.builder.buildConsumers(content, generateImportsForControlPlaneID, ignoreCredentialChanges) - d.builder.buildConsumerGroups(content, generateImportsForControlPlaneID, ignoreCredentialChanges) - d.builder.buildCACertificates(content, generateImportsForControlPlaneID, ignoreCredentialChanges) - d.builder.buildCertificates(content, generateImportsForControlPlaneID, ignoreCredentialChanges) - d.builder.buildVaults(content, generateImportsForControlPlaneID, ignoreCredentialChanges) + d.builder.buildConsumerGroups(content, generateImportsForControlPlaneID) + d.builder.buildCACertificates(content, generateImportsForControlPlaneID) + d.builder.buildCertificates(content, generateImportsForControlPlaneID) + d.builder.buildVaults(content, generateImportsForControlPlaneID) return d.builder.getContent() } diff --git a/kong2tf/builder_default_terraform.go b/kong2tf/builder_default_terraform.go index 02c24165a..fb11d791f 100644 --- a/kong2tf/builder_default_terraform.go +++ b/kong2tf/builder_default_terraform.go @@ -1,249 +1,438 @@ package kong2tf import ( - "bytes" - _ "embed" + "crypto/md5" //nolint:gosec "encoding/json" + "fmt" "log" - "regexp" - "text/template" + "strings" - "github.com/kong/go-apiops/logbasics" "github.com/kong/go-database-reconciler/pkg/file" - "github.com/mitchellh/hashstructure" ) -// cleanField removes all characters from the input string that are not letters, digits, underscores, and dashes. -func cleanField(input string) string { - // Regular expression to match disallowed characters and replace them - re := regexp.MustCompile(`[^a-zA-Z0-9_-]`) - return re.ReplaceAllString(input, "") -} - -// dashToUnderscore replaces all dashes in the input string with underscores. -func dashToUnderscore(input string) string { - // Regular expression to match dashes and replace them with underscores - re := regexp.MustCompile(`-`) - return re.ReplaceAllString(input, "_") -} - -var funcs = template.FuncMap{ - "hash": hashstructure.Hash, - "jsonmarshal": json.Marshal, - "cleanField": cleanField, - "dashToUnderscore": dashToUnderscore, -} - type DefaultTerraformBuider struct { content string } -type TemplateObjectWrapper struct { - Content interface{} - GenerateImportsForControlPlaneID *string - IgnoreCredentialChanges bool -} - func newDefaultTerraformBuilder() *DefaultTerraformBuider { return &DefaultTerraformBuider{} } -//go:embed templates/service.go.tmpl -var terraformServiceTemplate string - -func (b *DefaultTerraformBuider) buildServices(content *file.Content, generateImportsForControlPlaneID *string, ignoreCredentialChanges bool) { - tmpl, err := template.New(terraformServiceTemplate).Funcs(funcs).Parse(terraformServiceTemplate) +// Generic function that takes type T and returns map[string]any using JSON marshalling +func toMapAny(resource any) map[string]any { + resourceMap := make(map[string]interface{}) + resourceJSON, err := json.Marshal(resource) if err != nil { - log.Fatal(err, "Failed to parse template") - return // Changed from log.Fatalf to return after logging the error + log.Fatal(err, "Failed to marshal resource") + return resourceMap } - - for index, service := range content.Services { - - var buffer bytes.Buffer - err = tmpl.Execute(&buffer, service) - if err != nil { - log.Fatal(err, "Failed to execute template for service", "serviceIndex", index+1) - } - - b.content += buffer.String() + err = json.Unmarshal(resourceJSON, &resourceMap) + if err != nil { + log.Fatal(err, "Failed to unmarshal resource") + return resourceMap } + return resourceMap } -//go:embed templates/route.go.tmpl -var terraformRouteTemplate string - -func (b *DefaultTerraformBuider) buildRoutes(content *file.Content, generateImportsForControlPlaneID *string, ignoreCredentialChanges bool) { - logbasics.Info("Starting to build routes") - logbasics.Info("Template content before parsing", "template", terraformRouteTemplate) - - tmpl, err := template.New(terraformRouteTemplate).Funcs(funcs).Parse(terraformRouteTemplate) - if err != nil { - log.Fatal(err) +func (b *DefaultTerraformBuider) buildControlPlaneVar(controlPlaneID *string) { + cpID := "YOUR_CONTROL_PLANE_ID" + if controlPlaneID != nil { + cpID = *controlPlaneID } + b.content += fmt.Sprintf(`variable "control_plane_id" { + type = "string" + default = "%s" +}`, cpID) + "\n\n" +} +func (b *DefaultTerraformBuider) buildServices(content *file.Content, controlPlaneID *string) { for _, service := range content.Services { - var buffer bytes.Buffer + parentResourceName := strings.ReplaceAll(*service.Name, "-", "_") + b.content += generateResource( + "gateway_service", + parentResourceName, + toMapAny(service), + map[string]string{}, + importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": service.ID, + }, + }, + []string{}, + ) + + for _, route := range service.Routes { + resourceName := strings.ReplaceAll(*route.Name, "-", "_") + b.content += generateResource("gateway_route", resourceName, toMapAny(route), map[string]string{ + "service": parentResourceName, + }, importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": route.ID, + }, + }, []string{}) + + for _, plugin := range route.Plugins { + pluginName := strings.ReplaceAll(*plugin.Name, "-", "_") + b.content += generateResource("gateway_plugin", pluginName, toMapAny(plugin), map[string]string{ + "route": resourceName, + }, importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": plugin.ID, + }, + }, []string{}) + } + } - err = tmpl.Execute(&buffer, service) - if err != nil { - log.Fatal(err) + for _, plugin := range service.Plugins { + resourceName := strings.ReplaceAll(*plugin.Name, "-", "_") + b.content += generateResource("gateway_plugin", resourceName, toMapAny(plugin), map[string]string{ + "service": parentResourceName, + }, importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": plugin.ID, + }, + }, []string{}) } - b.content += buffer.String() } } -//go:embed templates/global_plugin.go.tmpl -var terraformGlobalPluginTemplate string - -func (b *DefaultTerraformBuider) buildGlobalPlugins(content *file.Content, generateImportsForControlPlaneID *string, ignoreCredentialChanges bool) { - logbasics.Info("Starting to build global plugins") - logbasics.Info("Template content before parsing", "template", terraformGlobalPluginTemplate) - - tmpl, err := template.New(terraformGlobalPluginTemplate).Funcs(funcs).Parse(terraformGlobalPluginTemplate) - if err != nil { - log.Fatal(err) - } - - for _, globalPlugin := range content.Plugins { - var buffer bytes.Buffer - - err = tmpl.Execute(&buffer, globalPlugin) - if err != nil { - log.Fatal(err) +func (b *DefaultTerraformBuider) buildRoutes(content *file.Content, controlPlaneID *string) { + for _, route := range content.Routes { + parentResourceName := strings.ReplaceAll(*route.Name, "-", "_") + parents := map[string]string{} + if route.Service != nil { + parents["service"] = strings.ReplaceAll(*route.Service.Name, "-", "_") + } + b.content += generateResource("gateway_route", parentResourceName, toMapAny(route), parents, importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": route.ID, + }, + }, []string{}) + + for _, plugin := range route.Plugins { + resourceName := strings.ReplaceAll(*plugin.Name, "-", "_") + b.content += generateResource("gateway_plugin", resourceName, toMapAny(plugin), map[string]string{ + "route": parentResourceName, + }, importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": plugin.ID, + }, + }, []string{}) } - b.content += buffer.String() } } -//go:embed templates/consumer.go.tmpl -var terraformConsumerTemplate string - -func (b *DefaultTerraformBuider) buildConsumers(content *file.Content, generateImportsForControlPlaneID *string, ignoreCredentialChanges bool) { - logbasics.Info("Starting to build consumers") - logbasics.Info("Template content before parsing", "template", terraformConsumerTemplate) - - tmpl, err := template.New(terraformConsumerTemplate).Funcs(funcs).Parse(terraformConsumerTemplate) - if err != nil { - log.Fatal(err) +func (b *DefaultTerraformBuider) buildGlobalPlugins(content *file.Content, controlPlaneID *string) { + for _, globalPlugin := range content.Plugins { + resourceName := strings.ReplaceAll(*globalPlugin.Name, "-", "_") + b.content += generateResource( + "gateway_plugin", + resourceName, + toMapAny(globalPlugin), + map[string]string{}, + importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": globalPlugin.ID, + }, + }, + []string{}, + ) } +} +func (b *DefaultTerraformBuider) buildConsumers( + content *file.Content, + controlPlaneID *string, + ignoreCredentialChanges bool, +) { for _, consumer := range content.Consumers { - wrapper := TemplateObjectWrapper{ - Content: consumer, - GenerateImportsForControlPlaneID: generateImportsForControlPlaneID, - IgnoreCredentialChanges: ignoreCredentialChanges, + parentResourceName := strings.ReplaceAll(*consumer.Username, "-", "_") + b.content += generateResource( + "gateway_consumer", + parentResourceName, + toMapAny(consumer), + map[string]string{}, + importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": consumer.ID, + }, + }, + []string{}, + ) + + for _, cg := range consumer.Groups { + resourceName := strings.ReplaceAll(*cg.Name, "-", "_") + + b.content += generateRelationship( + "gateway_consumer_group_member", + resourceName+"_"+parentResourceName, + map[string]string{ + "consumer": parentResourceName, + "consumer_group": resourceName, + }, + toMapAny(consumer), + importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "consumer_id": consumer.ID, + "consumer_group_id": cg.ID, + }, + }, + ) } - var buffer bytes.Buffer - err = tmpl.Execute(&buffer, wrapper) - if err != nil { - log.Fatal(err) + for _, acl := range consumer.ACLGroups { + resourceName := "acl_" + strings.ReplaceAll(*acl.Group, "-", "_") + b.content += generateResource("gateway_acl", resourceName, toMapAny(acl), map[string]string{ + "consumer_id": parentResourceName, + }, importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": acl.ID, + "consumer_id": consumer.ID, + }, + }, []string{}) } - b.content += buffer.String() - } -} -//go:embed templates/consumer_group.go.tmpl -var terraformConsumerGroupTemplate string + for _, basicauth := range consumer.BasicAuths { + lifecycle := []string{} + + if ignoreCredentialChanges { + lifecycle = []string{ + "password", + } + } + + resourceName := "basic_auth_" + strings.ReplaceAll(*basicauth.Username, "-", "_") + b.content += generateResource("gateway_basic_auth", resourceName, toMapAny(basicauth), map[string]string{ + "consumer_id": parentResourceName, + }, importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": basicauth.ID, + "consumer_id": consumer.ID, + }, + }, lifecycle) + } -func (b *DefaultTerraformBuider) buildConsumerGroups(content *file.Content, generateImportsForControlPlaneID *string, ignoreCredentialChanges bool) { - logbasics.Info("Starting to build consumer groups") - logbasics.Info("Template content before parsing", "template", terraformConsumerGroupTemplate) + for _, keyauth := range consumer.KeyAuths { + resourceName := "key_auth_" + strings.ReplaceAll(*keyauth.Key, "-", "_") + b.content += generateResource("gateway_key_auth", resourceName, toMapAny(keyauth), map[string]string{ + "consumer_id": parentResourceName, + }, importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": keyauth.ID, + "consumer_id": consumer.ID, + }, + }, []string{}) + } - tmpl, err := template.New(terraformConsumerGroupTemplate).Funcs(funcs).Parse(terraformConsumerGroupTemplate) - if err != nil { - log.Fatal(err) - } + for _, jwt := range consumer.JWTAuths { + lifecycle := []string{} + + if ignoreCredentialChanges { + lifecycle = []string{ + "secret", "key", + } + } + resourceName := "jwt_" + strings.ReplaceAll(*jwt.Key, "-", "_") + b.content += generateResource("gateway_jwt", resourceName, toMapAny(jwt), map[string]string{ + "consumer_id": parentResourceName, + }, importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": jwt.ID, + "consumer_id": consumer.ID, + }, + }, lifecycle) + } - for _, consumerGroup := range content.ConsumerGroups { - var buffer bytes.Buffer + for _, hmacauth := range consumer.HMACAuths { + resourceName := "hmac_auth_" + strings.ReplaceAll(*hmacauth.Username, "-", "_") + b.content += generateResource("gateway_hmac_auth", resourceName, toMapAny(hmacauth), map[string]string{ + "consumer_id": parentResourceName, + }, importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": hmacauth.ID, + "consumer_id": consumer.ID, + }, + }, []string{}) + } - err = tmpl.Execute(&buffer, consumerGroup) - if err != nil { - log.Fatal(err) + for _, plugin := range consumer.Plugins { + pluginName := strings.ReplaceAll(*plugin.Name, "-", "_") + b.content += generateResource("gateway_plugin", pluginName, toMapAny(plugin), map[string]string{ + "consumer": parentResourceName, + }, importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": plugin.ID, + }, + }, []string{}) } - b.content += buffer.String() + } } -//go:embed templates/upstream.go.tmpl -var terraformUpstreamTemplate string +func (b *DefaultTerraformBuider) buildConsumerGroups(content *file.Content, controlPlaneID *string) { + for _, cg := range content.ConsumerGroups { + parentResourceName := strings.ReplaceAll(*cg.Name, "-", "_") + parents := map[string]string{} + b.content += generateResource("gateway_consumer_group", parentResourceName, toMapAny(cg), parents, importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": cg.ID, + }, + }, []string{}) + + // We intentionally don't generate consumers here. Consumers is a FK reference, not a definition. + for _, consumer := range cg.Consumers { + resourceName := strings.ReplaceAll(*consumer.Username, "-", "_") + + b.content += generateRelationship( + "gateway_consumer_group_member", + parentResourceName+"_"+resourceName, + map[string]string{ + "consumer": resourceName, + "consumer_group": parentResourceName, + }, + toMapAny(consumer), + importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "consumer_id": consumer.ID, + "consumer_group_id": cg.ID, + }, + }, + ) + } -func (b *DefaultTerraformBuider) buildUpstreams(content *file.Content, generateImportsForControlPlaneID *string, ignoreCredentialChanges bool) { - tmpl, err := template.New(terraformUpstreamTemplate).Funcs(funcs).Parse(terraformUpstreamTemplate) - if err != nil { - log.Fatal(err) + for _, plugin := range cg.Plugins { + resourceName := strings.ReplaceAll(*plugin.Name, "-", "_") + b.content += generateResource("gateway_plugin", resourceName, toMapAny(plugin), map[string]string{ + "consumer_group": parentResourceName, + }, importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": plugin.ID, + }, + }, []string{}) + } } +} +func (b *DefaultTerraformBuider) buildUpstreams(content *file.Content, controlPlaneID *string) { for _, upstream := range content.Upstreams { - var buffer bytes.Buffer - - err = tmpl.Execute(&buffer, upstream) - if err != nil { - log.Fatal(err) + parentResourceName := strings.ReplaceAll(*upstream.Name, "-", "_") + parentResourceName = "upstream_" + strings.ReplaceAll(parentResourceName, ".", "_") + parents := map[string]string{} + b.content += generateResource("gateway_upstream", parentResourceName, toMapAny(upstream), parents, importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": upstream.ID, + }, + }, []string{}) + + for _, target := range upstream.Targets { + resourceName := strings.ReplaceAll(*target.Target.Target, ".", "_") + resourceName = "target_" + strings.ReplaceAll(resourceName, ":", "_") + b.content += generateResource("gateway_target", resourceName, toMapAny(target), map[string]string{ + "upstream_id": parentResourceName, + }, importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": target.ID, + "upstream_id": upstream.ID, + }, + }, []string{}) } - b.content += buffer.String() } } -//go:embed templates/ca_certificate.go.tmpl -var terraformCACertificateTemplate string - -func (b *DefaultTerraformBuider) buildCACertificates(content *file.Content, generateImportsForControlPlaneID *string, ignoreCredentialChanges bool) { - tmpl, err := template.New(terraformCACertificateTemplate).Funcs(funcs).Parse(terraformCACertificateTemplate) - if err != nil { - log.Fatal(err) - } - +func (b *DefaultTerraformBuider) buildCACertificates(content *file.Content, controlPlaneID *string) { + idx := 0 for _, caCertificate := range content.CACertificates { - var buffer bytes.Buffer - - err = tmpl.Execute(&buffer, caCertificate) - if err != nil { - log.Fatal(err) - } - b.content += buffer.String() + hashedCert := fmt.Sprintf("%x", md5.Sum([]byte(*caCertificate.Cert))) //nolint:gosec + resourceName := "ca_cert_" + hashedCert + idx++ + b.content += generateResource( + "gateway_ca_certificate", + resourceName, + toMapAny(caCertificate), + map[string]string{}, + importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": caCertificate.ID, + }, + }, + []string{}, + ) } } -//go:embed templates/certificate.go.tmpl -var terraformCertificateTemplate string - -func (b *DefaultTerraformBuider) buildCertificates(content *file.Content, generateImportsForControlPlaneID *string, ignoreCredentialChanges bool) { - tmpl, err := template.New(terraformCertificateTemplate).Funcs(funcs).Parse(terraformCertificateTemplate) - if err != nil { - log.Fatal(err) - } - +func (b *DefaultTerraformBuider) buildCertificates(content *file.Content, controlPlaneID *string) { for _, certificate := range content.Certificates { - var buffer bytes.Buffer - - err = tmpl.Execute(&buffer, certificate) - if err != nil { - log.Fatal(err) + hashedCert := fmt.Sprintf("%x", md5.Sum([]byte(*certificate.Cert))) //nolint:gosec + resourceName := "cert_" + hashedCert + b.content += generateResource( + "gateway_certificate", + resourceName, + toMapAny(certificate), + map[string]string{}, + importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": certificate.ID, + }, + }, + []string{}, + ) + + for _, sni := range certificate.SNIs { + resourceName := "sni_" + strings.ReplaceAll(*sni.Name, ".", "_") + b.content += generateResource("gateway_sni", resourceName, toMapAny(sni), map[string]string{ + "certificate": "cert_" + hashedCert, + }, importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": sni.ID, + }, + }, []string{}) } - b.content += buffer.String() } } -//go:embed templates/vault.go.tmpl -var terraformVaultTemplate string - -func (b *DefaultTerraformBuider) buildVaults(content *file.Content, generateImportsForControlPlaneID *string, ignoreCredentialChanges bool) { - tmpl, err := template.New(terraformVaultTemplate).Funcs(funcs).Parse(terraformVaultTemplate) - if err != nil { - log.Fatal(err) - } - +func (b *DefaultTerraformBuider) buildVaults(content *file.Content, controlPlaneID *string) { for _, vault := range content.Vaults { - var buffer bytes.Buffer - - err = tmpl.Execute(&buffer, vault) - if err != nil { - log.Fatal(err) - } - b.content += buffer.String() + parentResourceName := strings.ReplaceAll(*vault.Name, "-", "_") + parents := map[string]string{} + b.content += generateResourceWithCustomizations( + "gateway_vault", + parentResourceName, + toMapAny(vault), + parents, + map[string]string{ + "config": "jsonencode", + }, + importConfig{ + controlPlaneID: controlPlaneID, + importValues: map[string]*string{ + "id": vault.ID, + }, + }, + []string{}, + ) } } diff --git a/kong2tf/generate_resource.go b/kong2tf/generate_resource.go new file mode 100644 index 000000000..07295ee57 --- /dev/null +++ b/kong2tf/generate_resource.go @@ -0,0 +1,393 @@ +package kong2tf + +import ( + "fmt" + "sort" + "strings" +) + +type importConfig struct { + controlPlaneID *string + importValues map[string]*string +} + +func generateResource( + entityType, + name string, + entity map[string]any, + parents map[string]string, + imports importConfig, + lifecycle []string, +) string { + return generateResourceWithCustomizations(entityType, name, entity, parents, map[string]string{}, imports, lifecycle) +} + +func generateResourceWithCustomizations( + entityType, + name string, + entity map[string]any, + parents map[string]string, + customizations map[string]string, + imports importConfig, + lifecycle []string, +) string { + // Cache ID in case we need to use it for imports + entityID := "" + if entity["id"] != nil { + entityID = entity["id"].(string) + } + + // Populate parents with foreign keys as needed + parentKeys := []string{"service", "route", "consumer", "upstream", "certificate", "consumer_group"} + + // Populate parents with foreign keys as needed + for _, key := range parentKeys { + if entity[key] != nil { + // Switch on type of parent + switch entity[key].(type) { + case string: + parents[key] = entity[key].(string) + case map[string]interface{}: + parents[key] = entity[key].(map[string]interface{})["name"].(string) + default: + panic(fmt.Sprintf("Unknown type for parent %s", key)) + } + } + } + + // List of keys to remove + removeKeys := []string{ + "id", + } + + // Build a map of entity types to keys + entityTypeToKeys := map[string][]string{ + "gateway_service": {"routes", "plugins"}, + "gateway_route": {"plugins", "service"}, + "gateway_plugin": {"service", "route", "consumer"}, + "gateway_consumer": { + "groups", "acls", "basicauth_credentials", "keyauth_credentials", + "jwt_secrets", "hmacauth_credentials", "basicauth_credentials", "plugins", + }, + "gateway_upstream": {"targets"}, + "gateway_consumer_group": {"consumers", "plugins"}, + "gateway_certificate": {"snis"}, + } + + if additionalKeys := entityTypeToKeys[entityType]; additionalKeys != nil { + removeKeys = append(removeKeys, additionalKeys...) + } + + // Remove keys that are not needed + for _, k := range removeKeys { + delete(entity, k) + } + + if entityType == "gateway_plugin" { + entityType = fmt.Sprintf("%s_%s", entityType, name) + delete(entity, "name") + } + + // We don't need to prefix SNIs with the Cert name + // Or routes with the service name + if entityType != "gateway_sni" && entityType != "gateway_route" { + for k := range parents { + name = fmt.Sprintf("%s_%s", strings.ReplaceAll(parents[k], "-", "_"), name) + } + } + + s := fmt.Sprintf(` +resource "konnect_%s" "%s" { +%s + +%s control_plane_id = var.control_plane_id%s +} +`, + entityType, name, + strings.TrimRight(output(entityType, entity, 1, true, "\n", customizations), "\n"), + generateParents(parents), + generateLifecycle(lifecycle)) + + // Generate imports + if imports.controlPlaneID != nil && entityID != "" { + entity["id"] = entityID + s += generateImports(entityType, name, imports.importValues, imports.controlPlaneID) + } + + return strings.TrimSpace(s) + "\n\n" +} + +func generateRelationship( + entityType string, + name string, + relations map[string]string, + _ map[string]any, // 'entity' when TODO is resolved + _ importConfig, // 'imports' when TODO is resolved +) string { + // TODO: We don't support relationship importing in the provider yet + // entityID := entity["id"].(string) + + s := fmt.Sprintf(`resource "konnect_%s" "%s" {`, entityType, name) + + // Extract keys to iterate in a deterministic order + keys := make([]string, 0) + for k := range relations { + keys = append(keys, k) + } + + sort.Strings(keys) + + // Output each item in the relationship + for _, k := range keys { + s += fmt.Sprintf("\n"+` %s_id = konnect_gateway_%s.%s.id`, k, k, relations[k]) + } + s += "\n control_plane_id = var.control_plane_id" + s += "\n}\n\n" + + // TODO: We don't support relationship importing in the provider yet + /* + │ Error: Not Implemented + │ + │ No available import state operation is available for resource gateway_consumer_group_member. + */ + //if imports.controlPlaneID != nil { + // entity["id"] = entityID + // s += generateImports(entityType, name, entity, imports.importValues, imports.controlPlaneID) + "\n\n" + //} + + return s +} + +func generateImports( + entityType string, + name string, + keysFromEntity map[string]*string, + cpID *string, +) string { + if len(keysFromEntity) == 0 { + return "" + } + + return fmt.Sprintf("\n"+`import { + to = konnect_%s.%s + id = "%s" +}`, entityType, name, generateImportKeys(keysFromEntity, cpID)) +} + +func generateImportKeys(keys map[string]*string, cpID *string) string { + if len(keys) == 0 { + return "" + } + + s := "{" + for k, val := range keys { + s += fmt.Sprintf(`\"%s\": \"%s\", `, k, *val) + } + + s += fmt.Sprintf(`\"control_plane_id\": \"%s\", `, *cpID) + + s = strings.TrimRight(s, ", ") + + s += "}" + + return s +} + +func generateLifecycle(lifecycle []string) string { + if len(lifecycle) == 0 { + return "" + } + + s := ` + lifecycle { + ignore_changes = [` + for _, l := range lifecycle { + s += "\n " + l + "," + } + s = strings.TrimRight(s, ",") + + s += ` + ] + } +` + + return s +} + +func generateParents(parents map[string]string) string { + if len(parents) == 0 { + return "" + } + + var result []string + for k, v := range parents { + v = strings.ReplaceAll(v, "-", "_") + // if parent ends with _id, use it as-is + if strings.HasSuffix(k, "_id") { + result = append(result, fmt.Sprintf(` %s = konnect_gateway_%s.%s.id`, k, strings.TrimSuffix(k, "_id"), v)+"\n") + continue + } + result = append(result, fmt.Sprintf(` %s = { + id = konnect_gateway_%s.%s.id + }`+"\n", k, k, v)) + } + + return strings.Join(result, "\n") + "\n" +} + +// Output function that handles the dynamic data +func output( + entityType string, + object map[string]interface{}, + depth int, + isRoot bool, + eol string, + customizations map[string]string, +) string { + var result []string + + // Loop through object in order of keys + keys := make([]string, 0) + for k := range object { + keys = append(keys, k) + } + + sort.Strings(keys) + + // Move the most common keys to the front + var prioritizedKeys []string + for _, k := range []string{"enabled", "name", "username"} { + if _, exists := object[k]; exists { + prioritizedKeys = append(prioritizedKeys, k) + } + } + + // Append the rest of the keys + for _, k := range keys { + if contains(prioritizedKeys, k) { + continue + } + if k != "name" && k != "enabled" { + prioritizedKeys = append(prioritizedKeys, k) + } + } + keys = prioritizedKeys + + for _, k := range keys { + v := object[k] + + // TODO: Remove this once deck dump doesn't export nil values + if v == nil { + continue + } + + switch v := v.(type) { + case map[string]interface{}: + result = append(result, outputHash(entityType, k, v, depth, isRoot, eol, customizations)) + case []interface{}: + result = append(result, outputList(entityType, k, v, depth)) + default: + result = append(result, line(fmt.Sprintf("%s = %s", k, quote(v)), depth, eol)) + } + } + return strings.Join(result, "") +} + +// Handles rendering a map (hash) in Go +func outputHash( + entityType string, + key string, + input map[string]interface{}, + depth int, + isRoot bool, + eol string, + customizations map[string]string, +) string { + s := "" + if !isRoot { + s += "\n" + } + + custom := customizations[key] + + if custom != "" { + s += line(fmt.Sprintf("%s = %s({", key, custom), depth, eol) + } else { + s += line(fmt.Sprintf("%s = {", key), depth, eol) + } + + s += output(entityType, input, depth+1, true, eol, customizations) + + if custom != "" { + s += line("})", depth, eol) + } else { + s += line("}", depth, eol) + } + return s +} + +// Handles rendering a map within a list in Go +func outputHashInList(entityType string, input map[string]interface{}, depth int) string { + s := "\n" + s += line("{", depth+1, "\n") + s += output(entityType, input, depth+2, false, "\n", map[string]string{}) + s += line("},", depth+1, "\n") + return s +} + +// Handles rendering a list (array) in Go +func outputList(entityType string, key string, input []interface{}, depth int) string { + s := line(fmt.Sprintf("%s = [", key), depth, "") + for _, v := range input { + switch v := v.(type) { + case map[string]interface{}: + s += outputHashInList(entityType, v, depth) + default: + s += fmt.Sprintf("%s, ", quote(v)) + } + } + s = strings.TrimRight(s, ", ") + s += endList(input, depth) + return s +} + +// Ends a list rendering in Go +func endList(input []interface{}, depth int) string { + lastLine := line("]", depth, "\n") + if _, ok := input[len(input)-1].(map[string]interface{}); ok { + return lastLine + } + return strings.TrimLeft(lastLine, " ") +} + +// Formats a line with proper indentation and end-of-line characters +func line(input string, depth int, eol string) string { + return strings.Repeat(" ", depth) + input + eol +} + +// Properly quotes a value based on its type +func quote(input interface{}) string { + switch v := input.(type) { + case nil: + return "" + case bool, int, float64: + return fmt.Sprintf("%v", v) + case string: + if strings.Contains(v, "\n") { + return fmt.Sprintf("<" - client_secret: - - "" - session_secret: "" - response_mode: form_post \ No newline at end of file diff --git a/kong2tf/testdata/global-plugin-oidc-input.yaml b/kong2tf/testdata/global-plugin-oidc-input.yaml new file mode 100644 index 000000000..de3a90503 --- /dev/null +++ b/kong2tf/testdata/global-plugin-oidc-input.yaml @@ -0,0 +1,14 @@ +plugins: + - name: openid-connect + enabled: true + config: + auth_methods: + - authorization_code + - session + issuer: http://example.org + client_id: + - "" + client_secret: + - "" + session_secret: "" + response_mode: form_post diff --git a/kong2tf/testdata/global-plugin-oidc-output-expected.tf b/kong2tf/testdata/global-plugin-oidc-output-expected.tf new file mode 100644 index 000000000..0b402b1f4 --- /dev/null +++ b/kong2tf/testdata/global-plugin-oidc-output-expected.tf @@ -0,0 +1,19 @@ +variable "control_plane_id" { + type = "string" + default = "YOUR_CONTROL_PLANE_ID" +} + +resource "konnect_gateway_plugin_openid_connect" "openid_connect" { + enabled = true + config = { + auth_methods = ["authorization_code", "session"] + client_id = [""] + client_secret = [""] + issuer = "http://example.org" + response_mode = "form_post" + session_secret = "" + } + + control_plane_id = var.control_plane_id +} + diff --git a/kong2tf/testdata/global-plugin-output-expected.tf b/kong2tf/testdata/global-plugin-output-expected.tf deleted file mode 100644 index 80a2915a6..000000000 --- a/kong2tf/testdata/global-plugin-output-expected.tf +++ /dev/null @@ -1,4 +0,0 @@ -resource "konnect_gateway_plugin_openid_connect" "openid-connect" { - config = {"auth_methods":["authorization_code","session"],"client_id":["\u003cclient-id\u003e"],"client_secret":["\u003cclient-secret\u003e"],"issuer":"http://example.org","response_mode":"form_post","session_secret":"\u003csession-secret\u003e"} - control_plane_id = var.control_plane_id -} diff --git a/kong2tf/testdata/global-plugin-rate-limiting-input.yaml b/kong2tf/testdata/global-plugin-rate-limiting-input.yaml new file mode 100644 index 000000000..121a06a1e --- /dev/null +++ b/kong2tf/testdata/global-plugin-rate-limiting-input.yaml @@ -0,0 +1,7 @@ +plugins: + - name: rate-limiting + enabled: false + config: + second: 5 + hour: 10000 + policy: local diff --git a/kong2tf/testdata/global-plugin-rate-limiting-output-expected.tf b/kong2tf/testdata/global-plugin-rate-limiting-output-expected.tf new file mode 100644 index 000000000..5a19a1acb --- /dev/null +++ b/kong2tf/testdata/global-plugin-rate-limiting-output-expected.tf @@ -0,0 +1,16 @@ +variable "control_plane_id" { + type = "string" + default = "YOUR_CONTROL_PLANE_ID" +} + +resource "konnect_gateway_plugin_rate_limiting" "rate_limiting" { + enabled = false + config = { + hour = 10000 + policy = "local" + second = 5 + } + + control_plane_id = var.control_plane_id +} + diff --git a/kong2tf/testdata/route-input.yaml b/kong2tf/testdata/route-input.yaml index e739ffb7d..b4c32430c 100644 --- a/kong2tf/testdata/route-input.yaml +++ b/kong2tf/testdata/route-input.yaml @@ -35,4 +35,14 @@ services: - ip: 192.168.0.1 destinations: - ip: 10.10.10.10 - port: 8080 \ No newline at end of file + port: 8080 + +routes: + - name: top-level-route + hosts: + - top-level.example.com + - name: top-level-with-service-route + service: + name: example-service + hosts: + - top-level-with-service.example.com diff --git a/kong2tf/testdata/route-output-expected.tf b/kong2tf/testdata/route-output-expected.tf index 30f2bc8bf..bc879c61d 100644 --- a/kong2tf/testdata/route-output-expected.tf +++ b/kong2tf/testdata/route-output-expected.tf @@ -1,41 +1,67 @@ -resource "konnect_gateway_service" "example-service" { - name = "example-service" - protocol = "http" - host = "example-api.com" - port = 80 +variable "control_plane_id" { + type = "string" + default = "YOUR_CONTROL_PLANE_ID" +} + +resource "konnect_gateway_service" "example_service" { + name = "example-service" + host = "example-api.com" + port = 80 + protocol = "http" + control_plane_id = var.control_plane_id } - -resource "konnect_gateway_route" "example-route" { +resource "konnect_gateway_route" "example_route" { name = "example-route" - hosts = ["example.com", "another-example.com", "yet-another-example.com"] + destinations = [ + { + ip = "10.10.10.10" + port = 8080 + }, + ] headers = { - "x-another-header" = jsonencode(["first-header-value","second-header-value"]) - "x-my-header" = jsonencode(["~*foos?bar$"]) + x-another-header = ["first-header-value", "second-header-value"] + x-my-header = ["~*foos?bar$"] } + hosts = ["example.com", "another-example.com", "yet-another-example.com"] + https_redirect_status_code = 302 methods = ["GET", "POST"] paths = ["~/v1/example/?$", "/v1/another-example", "/v1/yet-another-example"] preserve_host = true protocols = ["http", "https"] regex_priority = 1 - strip_path = false snis = ["example.com"] sources = [ { - ip = "192.168.0.1" - } - ] - destinations = [ - { - ip = "10.10.10.10" - port = 8080 - } + ip = "192.168.0.1" + }, ] + strip_path = false tags = ["version:v1"] - https_redirect_status_code = 302 + + service = { + id = konnect_gateway_service.example_service.id + } + + control_plane_id = var.control_plane_id +} + +resource "konnect_gateway_route" "top_level_route" { + name = "top-level-route" + hosts = ["top-level.example.com"] + + control_plane_id = var.control_plane_id +} + +resource "konnect_gateway_route" "top_level_with_service_route" { + name = "top-level-with-service-route" + hosts = ["top-level-with-service.example.com"] + service = { - id = konnect_gateway_service.example-service.id - } + id = konnect_gateway_service.example_service.id + } + control_plane_id = var.control_plane_id } + diff --git a/kong2tf/testdata/route-plugin-output-expected.tf b/kong2tf/testdata/route-plugin-output-expected.tf index 71d06815e..662d8b4ea 100644 --- a/kong2tf/testdata/route-plugin-output-expected.tf +++ b/kong2tf/testdata/route-plugin-output-expected.tf @@ -1,24 +1,42 @@ -resource "konnect_gateway_service" "example-service" { - name = "example-service" - protocol = "http" - host = "example-api.com" - port = 80 +variable "control_plane_id" { + type = "string" + default = "YOUR_CONTROL_PLANE_ID" +} + +resource "konnect_gateway_service" "example_service" { + name = "example-service" + host = "example-api.com" + port = 80 + protocol = "http" + control_plane_id = var.control_plane_id } - -resource "konnect_gateway_route" "example-route" { +resource "konnect_gateway_route" "example_route" { name = "example-route" paths = ["~/v1/example/?$"] + service = { - id = konnect_gateway_service.example-service.id - } + id = konnect_gateway_service.example_service.id + } + control_plane_id = var.control_plane_id } -resource "konnect_gateway_plugin_cors" "example-service_example-route_cors" { - config = {"credentials":true,"exposed_headers":["X-My-Header"],"headers":["Authorization"],"max_age":3600,"methods":["GET","POST"],"origins":["example.com"]} + +resource "konnect_gateway_plugin_cors" "example_route_cors" { + config = { + credentials = true + exposed_headers = ["X-My-Header"] + headers = ["Authorization"] + max_age = 3600 + methods = ["GET", "POST"] + origins = ["example.com"] + } + route = { - id = konnect_gateway_route.example-route.id + id = konnect_gateway_route.example_route.id } + control_plane_id = var.control_plane_id } + diff --git a/kong2tf/testdata/service-output-expected.tf b/kong2tf/testdata/service-output-expected.tf index b8a317591..59128051e 100644 --- a/kong2tf/testdata/service-output-expected.tf +++ b/kong2tf/testdata/service-output-expected.tf @@ -1,16 +1,24 @@ -resource "konnect_gateway_service" "example-service" { - name = "example-service" - protocol = "http" - host = "example-api.com" - port = 80 - path = "/v1" - connect_timeout = 5000 - read_timeout = 60000 - write_timeout = 60000 - retries = 5 - tls_verify = true +variable "control_plane_id" { + type = "string" + default = "YOUR_CONTROL_PLANE_ID" +} + +resource "konnect_gateway_service" "example_service" { + enabled = true + name = "example-service" + client_certificate = "4e3ad2e4-0bc4-4638-8e34-c84a417ba39b" + connect_timeout = 5000 + host = "example-api.com" + path = "/v1" + port = 80 + protocol = "http" + read_timeout = 60000 + retries = 5 + tags = ["example", "api"] + tls_verify = true tls_verify_depth = 1 - tags = ["example", "api"] + write_timeout = 60000 + control_plane_id = var.control_plane_id } diff --git a/kong2tf/testdata/service-plugin-output-expected.tf b/kong2tf/testdata/service-plugin-output-expected.tf index 232b724af..ea871a4f1 100644 --- a/kong2tf/testdata/service-plugin-output-expected.tf +++ b/kong2tf/testdata/service-plugin-output-expected.tf @@ -1,15 +1,40 @@ -resource "konnect_gateway_service" "example-service" { - name = "example-service" - protocol = "http" - host = "example-api.com" - port = 80 +variable "control_plane_id" { + type = "string" + default = "YOUR_CONTROL_PLANE_ID" +} + +resource "konnect_gateway_service" "example_service" { + name = "example-service" + host = "example-api.com" + port = 80 + protocol = "http" + control_plane_id = var.control_plane_id } -resource "konnect_gateway_plugin_rate_limiting_advanced" "example-service_rate-limiting-advanced" { - config = {"hide_client_headers":false,"identifier":"consumer","limit":[5],"namespace":"example_namespace","strategy":"local","sync_rate":-1,"window_size":[30]} + +resource "konnect_gateway_plugin_rate_limiting_advanced" "example_service_rate_limiting_advanced" { + config = { + hide_client_headers = false + identifier = "consumer" + limit = [5] + namespace = "example_namespace" + strategy = "local" + sync_rate = -1 + window_size = [30] + } + ordering = { + after = { + access = ["yet-another-plugin"] + } + before = { + access = ["another-plugin"] + } + } + service = { - id = konnect_gateway_service.example-service.id + id = konnect_gateway_service.example_service.id } + control_plane_id = var.control_plane_id } diff --git a/kong2tf/testdata/upstream-target-input.yaml b/kong2tf/testdata/upstream-target-input.yaml index 16dc59b34..285ca28f8 100644 --- a/kong2tf/testdata/upstream-target-input.yaml +++ b/kong2tf/testdata/upstream-target-input.yaml @@ -81,4 +81,4 @@ upstreams: - target: 10.10.10.10:8000 weight: 100 - target: 10.10.10.11:8000 - weight: 100 \ No newline at end of file + weight: 200 \ No newline at end of file diff --git a/kong2tf/testdata/upstream-target-output-expected.tf b/kong2tf/testdata/upstream-target-output-expected.tf index 3f8d23615..fdb9eb0a1 100644 --- a/kong2tf/testdata/upstream-target-output-expected.tf +++ b/kong2tf/testdata/upstream-target-output-expected.tf @@ -1,63 +1,77 @@ -resource "konnect_gateway_upstream" "example-apicom" { - name = "example-api.com" - slots = 10000 - host_header = "example.com" - algorithm = "round-robin" - hash_on = "none" - hash_fallback = "none" +variable "control_plane_id" { + type = "string" + default = "YOUR_CONTROL_PLANE_ID" +} + +resource "konnect_gateway_upstream" "upstream_example_api_com" { + name = "example-api.com" + algorithm = "round-robin" + hash_fallback = "none" + hash_on = "none" hash_on_cookie_path = "/" - use_srv_name = false - tags = ["user-level", "low-priority"] healthchecks = { active = { - concurrency = 10 - http_path = "/" - https_sni = "example.com" - https_verify_certificate = true - timeout = 1 - type = "http" - headers = { - "x-another-header" = jsonencode(["bla"]) - "x-my-header" = jsonencode(["foo","bar"]) - } + concurrency = 10 + headers = { + x-another-header = ["bla"] + x-my-header = ["foo", "bar"] + } healthy = { http_statuses = [200, 302] interval = 0 successes = 0 } + http_path = "/" + https_sni = "example.com" + https_verify_certificate = true + timeout = 1 + type = "http" unhealthy = { http_failures = 0 http_statuses = [429, 404, 500, 501, 502, 503, 504, 505] + interval = 0 tcp_failures = 0 timeouts = 0 - interval = 0 } } passive = { - healthy = { - http_statuses = [200, 201, 202, 203, 204, 205, 206, 207, 208, 226, 300, 301, 302, 303, 304, 305, 306, 307, 308] - successes = 0 - } - unhealthy = { - http_failures = 0 - http_statuses = [429, 500, 503] - tcp_failures = 0 - timeouts = 0 - } - type = "http" + healthy = { + http_statuses = [200, 201, 202, 203, 204, 205, 206, 207, 208, 226, 300, 301, 302, 303, 304, 305, 306, 307, 308] + successes = 0 + } + type = "http" + unhealthy = { + http_failures = 0 + http_statuses = [429, 500, 503] + tcp_failures = 0 + timeouts = 0 + } } + threshold = 0 } + host_header = "example.com" + slots = 10000 + tags = ["user-level", "low-priority"] + use_srv_name = false + control_plane_id = var.control_plane_id } -resource "konnect_gateway_target" "example-apicom_101010108000" { - target = "10.10.10.10:8000" - weight = 100 - upstream_id = konnect_gateway_upstream.example-apicom.id + +resource "konnect_gateway_target" "upstream_example_api_com_target_10_10_10_10_8000" { + target = "10.10.10.10:8000" + weight = 100 + + upstream_id = konnect_gateway_upstream.upstream_example_api_com.id + control_plane_id = var.control_plane_id } -resource "konnect_gateway_target" "example-apicom_101010118000" { - target = "10.10.10.11:8000" - weight = 100 - upstream_id = konnect_gateway_upstream.example-apicom.id + +resource "konnect_gateway_target" "upstream_example_api_com_target_10_10_10_11_8000" { + target = "10.10.10.11:8000" + weight = 200 + + upstream_id = konnect_gateway_upstream.upstream_example_api_com.id + control_plane_id = var.control_plane_id } + diff --git a/kong2tf/testdata/vault-output-expected.tf b/kong2tf/testdata/vault-output-expected.tf index 434453062..1c1af8d8b 100644 --- a/kong2tf/testdata/vault-output-expected.tf +++ b/kong2tf/testdata/vault-output-expected.tf @@ -1,6 +1,17 @@ +variable "control_plane_id" { + type = "string" + default = "YOUR_CONTROL_PLANE_ID" +} + resource "konnect_gateway_vault" "env" { - name = "env" + name = "env" + config = jsonencode({ + prefix = "MY_SECRET_" + }) + description = "ENV vault for secrets" prefix = "my-env-vault" - config = jsonencode({"prefix":"MY_SECRET_"}) + tags = ["env-vault"] + control_plane_id = var.control_plane_id -} \ No newline at end of file +} +