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 d68bd83
Show file tree
Hide file tree
Showing 9 changed files with 99,589 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
230 changes: 230 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,228 @@ 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{}

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

// Recurse to see if the block needs to return any fields
elem := buildPulumiFieldsFromTerraform(path+"."+blockName, blockType.Block)
if len(elem) > 0 {
// Based on if we're treating this as a list of not elem should either be added to the "fields"
// field or nested under the "element" field
if field["maxItemsOne"] == true {
field["fields"] = elem
} else {
field["element"] = map[string]any{
"fields": elem,
}
}
}

// Manual fixups for the schema, most of these look like pluralization issues, but not sure if there's
// a safe way to do this automatically.

//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"
}
// 3. kubernetes_service has a field "port" wich is a list, but we call it "ports"
if path == "kubernetes_service.spec" && 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 d68bd83

Please sign in to comment.