Skip to content

Commit

Permalink
Add terraform mapping to k8s provider
Browse files Browse the repository at this point in the history
This returns a basic mapping description for the terraform converter to
map terraform programs using
https://registry.terraform.io/providers/hashicorp/kubernetes to our
kubernetes provider, even though it's not bridged.

I grabbed a list of all terraform kubernetes resources by running
`terraform providers schema -json` in a tiny terraform program that just
defined a kubernetes provider. I've then hooked that up to
gen-kubernetes to do a best guess of mapping everything (well just
resources so far) from terraform to our provider. Most things look to
map pretty well by convention, but we'll probably need some manual
fixups to cover everything (already got one for the "container" to
"containers" rename).
  • Loading branch information
Frassle committed Jun 16, 2023
1 parent 891740e commit ee9f9f4
Show file tree
Hide file tree
Showing 9 changed files with 99,058 additions and 7 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Pulumi.*.yaml
/provider/pkg/gen/openapi-specs
/provider/cmd/pulumi-resource-kubernetes/schema.go
/provider/cmd/pulumi-resource-kubernetes/schema-embed.json
/provider/cmd/pulumi-resource-kubernetes/terraform-mapping-embed.json
yarn.lock
ci-scripts
/nuget/
Expand All @@ -21,3 +22,6 @@ sdk/java/.gradle
sdk/java/gradle
sdk/java/gradlew
sdk/java/gradlew.bat

go.work
go.work.sum
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,11 @@ lint::
pushd $$DIR && golangci-lint run -c ../.golangci.yml --timeout 10m && popd ; \
done

install:: install_nodejs_sdk install_dotnet_sdk
install_provider::
cp $(WORKING_DIR)/bin/${PROVIDER} ${GOPATH}/bin

install:: install_nodejs_sdk install_dotnet_sdk install_provider

GO_TEST_FAST := go test -short -v -count=1 -cover -timeout 2h -parallel ${TESTPARALLELISM}
GO_TEST := go test -v -count=1 -cover -timeout 2h -parallel ${TESTPARALLELISM}

Expand Down
216 changes: 216 additions & 0 deletions provider/cmd/pulumi-gen-kubernetes/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"sort"
"strings"
"text/template"
"unicode"

"github.com/pkg/errors"
"github.com/pulumi/pulumi-kubernetes/provider/v3/pkg/gen"
Expand All @@ -37,7 +38,10 @@ import (
nodejsgen "github.com/pulumi/pulumi/pkg/v3/codegen/nodejs"
pythongen "github.com/pulumi/pulumi/pkg/v3/codegen/python"
"github.com/pulumi/pulumi/pkg/v3/codegen/schema"
"github.com/pulumi/pulumi/sdk/v3/go/common/tokens"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)

// TemplateDir is the path to the base directory for code generator templates.
Expand Down Expand Up @@ -101,6 +105,7 @@ func main() {
case Schema:
pkgSpec := generateSchema(inputFile)
mustWritePulumiSchema(pkgSpec, version)
mustWriteTerraformMapping(pkgSpec)
default:
panic(fmt.Sprintf("Unrecognized language '%s'", language))
}
Expand Down Expand Up @@ -524,3 +529,214 @@ func mustWritePulumiSchema(pkgSpec schema.PackageSpec, version string) {
}
mustWriteFile(BaseDir, filepath.Join("sdk", "schema", "schema.json"), versionedSchemaJSON)
}

// Minimal types for reading the terraform schema file
type TerraformAttributeSchema struct {
}

type TerraformBlockTypeSchema struct {
Block *TerraformBlockSchema `json:"block"`
MaxItems int `json:"max_items"`
}

type TerraformBlockSchema struct {
Attributes map[string]*TerraformAttributeSchema `json:"attributes"`
BlockTypes map[string]*TerraformBlockTypeSchema `json:"block_types"`
}

type TerraformResourceSchema struct {
Block *TerraformBlockSchema `json:"block"`
}

type TerraformSchema struct {
Provider *TerraformResourceSchema `json:"provider"`
ResourceSchemas map[string]*TerraformResourceSchema `json:"resource_schemas"`
DataSourceSchemas map[string]*TerraformResourceSchema `json:"data_source_schemas"`
}

func findPulumiTokenFromTerraformToken(pkgSpec schema.PackageSpec, token string) tokens.Token {
// strip off the leading "kubernetes_"
token = strings.TrimPrefix(token, "kubernetes_")
// split of any _v1, _v2, etc. suffix
tokenParts := strings.Split(token, "_")
maybeVersion := tokenParts[len(tokenParts)-1]
versions := []string{
"v1alpha1",
"v1beta1",
"v1beta2",
"v1",
"v2alpha1",
"v2beta1",
"v2beta2",
"v2",
}
versionIndex := -1
if maybeVersion[0] == 'v' && unicode.IsDigit(rune(maybeVersion[1])) {
for i, v := range versions {
if maybeVersion == v {
versionIndex = i
break
}
}
if versionIndex == -1 {
panic(fmt.Sprintf("unexpected version suffix %q in token %q", maybeVersion, token))
}
}

// Get the full token as a pulumi camel case name
length := len(tokenParts) - 1
if versionIndex == -1 {
// If the last item wasn't a version add it back to the token and clear maybeVersion
length++
maybeVersion = ""
}
caser := cases.Title(language.English)
for i := 0; i < length; i++ {
tokenParts[i] = caser.String(tokenParts[i])
}
searchToken := strings.Join(tokenParts[:length], "")

foundTokens := make([]tokens.Token, 0)
for t := range pkgSpec.Resources {
pulumiToken := tokens.Token(t)
member := pulumiToken.ModuleMember()
memberName := member.Name()
if string(memberName) == searchToken {
foundTokens = append(foundTokens, pulumiToken)
}
}

// If we didn't find any tokens, return an empty string
if len(foundTokens) == 0 {
return ""
}

// Else try and workout the right version to use. If we have a version suffix, try to use that, otherwise use the _oldest_ version.
// e.g. kubernetes_thing should map to kubernetes:core/v1:Thing, not kubernetes:core/v2:Thing.
if versionIndex != -1 {
for _, t := range foundTokens {
module := t.Module()
if strings.HasSuffix(string(module), maybeVersion) {
// Exact match, just return it
return t
}
}
}

// If we didn't find an exact match, return the _oldest_ one. `versions` is sorted for this purpose.
lowestIndex := len(versions)
var foundToken tokens.Token
for _, t := range foundTokens {
module := t.Module()
modulesVersionIndex := -1
for i, v := range versions {
if strings.HasSuffix(string(module), v) {
modulesVersionIndex = i
break
}
}

if modulesVersionIndex == -1 {
panic(fmt.Sprintf("unexpected module version %q in token %q", module, t))
}

if lowestIndex > modulesVersionIndex {
lowestIndex = modulesVersionIndex
foundToken = t
}
}

return foundToken
}

func buildPulumiFieldsFromTerraform(path string, block *TerraformBlockSchema) map[string]any {
// Recursively build up the fields for this resource
fields := make(map[string]any)

// Attributes _might_ need to be renamed
for attrName := range block.Attributes {
field := map[string]any{}

// Manual fixups for the schema

// Only add this field if it says something meaningful
if len(field) > 0 {
fields[attrName] = field
}
}

for blockName, blockType := range block.BlockTypes {
field := map[string]any{}

// Recurse to see if the block needs to return any fields
elem := buildPulumiFieldsFromTerraform(path+"."+blockName, blockType.Block)
if len(elem) > 0 {
field["fields"] = elem
}

// If the block has a max_items of 1, then we need to tell the converter that
if blockType.MaxItems == 1 {
field["maxItemsOne"] = true
}

// Manual fixups for the schema
// 1. kubernetes_deployment has a field "container" which is a list, but we call it "containers"
if path == "kubernetes_deployment.spec.template.spec" && blockName == "container" {
field["name"] = "containers"
}
// 2. kubernetes_deployment has a field "port" which is a list, but we call it "ports"
if path == "kubernetes_deployment.spec.template.spec.container" && blockName == "port" {
field["name"] = "ports"
}

// Only add this field if it says something meaningful
if len(field) > 0 {
fields[blockName] = field
}
}
return fields
}

func mustWriteTerraformMapping(pkgSpec schema.PackageSpec) {
// The terraform converter expects the mapping to be the JSON serialization of it's ProviderInfo
// structure. We can get away with returning a _very_ limited subset of the information here, since the
// converter only cares about a few fields and is tolerant of missing fields. We get the terraform
// kubernetes schema by running `terraform providers schema -json` in a minimal terraform project that
// defines a kubernetes provider.
rawTerraformSchema := mustLoadFile(filepath.Join(BaseDir, "provider", "cmd", "pulumi-gen-kubernetes", "terraform.json"))

var terraformSchema TerraformSchema
err := json.Unmarshal(rawTerraformSchema, &terraformSchema)
if err != nil {
panic(err)
}

resources := make(map[string]any)
for tftok, resource := range terraformSchema.ResourceSchemas {
putok := findPulumiTokenFromTerraformToken(pkgSpec, tftok)
// Skip if the token is empty.
if putok == "" {
continue
}

// Need to fill in just enough fields so that MaxItemsOne is set correctly for things.
resources[tftok] = map[string]any{
"tok": putok,
"fields": buildPulumiFieldsFromTerraform(tftok, resource.Block),
}
}

info := map[string]any{
"name": "kubernetes",
"provider": map[string]any{},
"resources": resources,
"dataSources": map[string]any{},
}

data, err := makeJSONString(info)
if err != nil {
panic(err)
}

mustWriteFile(BaseDir, filepath.Join("provider", "cmd", "pulumi-resource-kubernetes", "terraform-mapping.json"), data)
}
Loading

0 comments on commit ee9f9f4

Please sign in to comment.