Merge pull request #34988 from hashicorp/nolegacysdk-kubernetes

backend/kubernetes: Remove legacy helper/schema dependency
pull/35699/head
James Bardin 2 years ago committed by GitHub
commit f844ba0be6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -5,16 +5,13 @@ package kubernetes
import (
"bytes"
"context"
"fmt"
"log"
"os"
"path/filepath"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/legacy/helper/schema"
"github.com/hashicorp/terraform/version"
"github.com/mitchellh/go-homedir"
"github.com/zclconf/go-cty/cty"
k8sSchema "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
@ -22,6 +19,12 @@ import (
restclient "k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/backend/backendbase"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/hashicorp/terraform/version"
)
// Modified from github.com/terraform-providers/terraform-provider-kubernetes
@ -44,158 +47,191 @@ var (
// New creates a new backend for kubernetes remote state.
func New() backend.Backend {
s := &schema.Backend{
Schema: map[string]*schema.Schema{
"secret_suffix": {
Type: schema.TypeString,
Required: true,
Description: "Suffix used when creating the secret. The secret will be named in the format: `tfstate-{workspace}-{secret_suffix}`.",
},
"labels": {
Type: schema.TypeMap,
Optional: true,
Description: "Map of additional labels to be applied to the secret.",
Elem: &schema.Schema{Type: schema.TypeString},
},
"namespace": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("KUBE_NAMESPACE", "default"),
Description: "Namespace to store the secret in.",
},
"in_cluster_config": {
Type: schema.TypeBool,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("KUBE_IN_CLUSTER_CONFIG", false),
Description: "Used to authenticate to the cluster from inside a pod.",
},
"load_config_file": {
Type: schema.TypeBool,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("KUBE_LOAD_CONFIG_FILE", true),
Description: "Load local kubeconfig.",
},
"host": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("KUBE_HOST", ""),
Description: "The hostname (in form of URI) of Kubernetes master.",
},
"username": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("KUBE_USER", ""),
Description: "The username to use for HTTP basic authentication when accessing the Kubernetes master endpoint.",
},
"password": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("KUBE_PASSWORD", ""),
Description: "The password to use for HTTP basic authentication when accessing the Kubernetes master endpoint.",
},
"insecure": {
Type: schema.TypeBool,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("KUBE_INSECURE", false),
Description: "Whether server should be accessed without verifying the TLS certificate.",
},
"client_certificate": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("KUBE_CLIENT_CERT_DATA", ""),
Description: "PEM-encoded client certificate for TLS authentication.",
},
"client_key": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("KUBE_CLIENT_KEY_DATA", ""),
Description: "PEM-encoded client certificate key for TLS authentication.",
},
"cluster_ca_certificate": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("KUBE_CLUSTER_CA_CERT_DATA", ""),
Description: "PEM-encoded root certificates bundle for TLS authentication.",
},
"config_paths": {
Type: schema.TypeList,
Elem: &schema.Schema{Type: schema.TypeString},
Optional: true,
Description: "A list of paths to kube config files. Can be set with KUBE_CONFIG_PATHS environment variable.",
},
"config_path": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("KUBE_CONFIG_PATH", ""),
Description: "Path to the kube config file. Can be set with KUBE_CONFIG_PATH environment variable.",
},
"config_context": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("KUBE_CTX", ""),
},
"config_context_auth_info": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("KUBE_CTX_AUTH_INFO", ""),
Description: "",
},
"config_context_cluster": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("KUBE_CTX_CLUSTER", ""),
Description: "",
},
"token": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("KUBE_TOKEN", ""),
Description: "Token to authentifcate a service account.",
},
"proxy_url": {
Type: schema.TypeString,
Optional: true,
Description: "URL to the proxy to be used for all API requests",
DefaultFunc: schema.EnvDefaultFunc("KUBE_PROXY_URL", ""),
},
"exec": {
Type: schema.TypeList,
Optional: true,
MaxItems: 1,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"api_version": {
Type: schema.TypeString,
Required: true,
},
"command": {
Type: schema.TypeString,
Required: true,
},
"env": {
Type: schema.TypeMap,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"args": {
Type: schema.TypeList,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
return &Backend{
Base: backendbase.Base{
Schema: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"secret_suffix": {
Type: cty.String,
Required: true,
Description: "Suffix used when creating the secret. The secret will be named in the format: `tfstate-{workspace}-{secret_suffix}`.",
},
"labels": {
Type: cty.Map(cty.String),
Optional: true,
Description: "Map of additional labels to be applied to the secret.",
},
"namespace": {
Type: cty.String,
Optional: true,
Description: "Namespace to store the secret in.",
},
"in_cluster_config": {
Type: cty.Bool,
Optional: true,
Description: "Used to authenticate to the cluster from inside a pod.",
},
"load_config_file": {
Type: cty.Bool,
Optional: true,
Description: "Load local kubeconfig.",
},
"host": {
Type: cty.String,
Optional: true,
Description: "The hostname (in form of URI) of Kubernetes master.",
},
"username": {
Type: cty.String,
Optional: true,
Description: "The username to use for HTTP basic authentication when accessing the Kubernetes master endpoint.",
},
"password": {
Type: cty.String,
Optional: true,
Description: "The password to use for HTTP basic authentication when accessing the Kubernetes master endpoint.",
},
"insecure": {
Type: cty.Bool,
Optional: true,
Description: "Whether server should be accessed without verifying the TLS certificate.",
},
"client_certificate": {
Type: cty.String,
Optional: true,
Description: "PEM-encoded client certificate for TLS authentication.",
},
"client_key": {
Type: cty.String,
Optional: true,
Description: "PEM-encoded client certificate key for TLS authentication.",
},
"cluster_ca_certificate": {
Type: cty.String,
Optional: true,
Description: "PEM-encoded root certificates bundle for TLS authentication.",
},
"config_paths": {
Type: cty.List(cty.String),
Optional: true,
Description: "A list of paths to kube config files. Can be set with KUBE_CONFIG_PATHS environment variable.",
},
"config_path": {
Type: cty.String,
Optional: true,
Description: "Path to the kube config file. Can be set with KUBE_CONFIG_PATH environment variable.",
},
"config_context": {
Type: cty.String,
Optional: true,
},
"config_context_auth_info": {
Type: cty.String,
Optional: true,
Description: "",
},
"config_context_cluster": {
Type: cty.String,
Optional: true,
Description: "",
},
"token": {
Type: cty.String,
Optional: true,
Description: "Token to authentifcate a service account.",
},
"proxy_url": {
Type: cty.String,
Optional: true,
Description: "URL to the proxy to be used for all API requests",
},
},
BlockTypes: map[string]*configschema.NestedBlock{
"exec": {
Nesting: configschema.NestingSingle,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"api_version": {
Type: cty.String,
Required: true,
},
"command": {
Type: cty.String,
Required: true,
},
"env": {
Type: cty.Map(cty.String),
Optional: true,
},
"args": {
Type: cty.List(cty.String),
Optional: true,
},
},
},
},
},
Description: "Use a credential plugin to authenticate.",
},
SDKLikeDefaults: backendbase.SDKLikeDefaults{
"namespace": {
EnvVars: []string{"KUBE_NAMESPACE"},
Fallback: "default",
},
"in_cluster_config": {
EnvVars: []string{"KUBE_IN_CLUSTER_CONFIG"},
Fallback: "false",
},
"load_config_file": {
EnvVars: []string{"KUBE_LOAD_CONFIG_FILE"},
Fallback: "true",
},
"host": {
EnvVars: []string{"KUBE_HOST"},
},
"username": {
EnvVars: []string{"KUBE_USER"},
},
"password": {
EnvVars: []string{"KUBE_PASSWORD"},
},
"insecure": {
EnvVars: []string{"KUBE_INSECURE"},
Fallback: "false",
},
"client_certificate": {
EnvVars: []string{"KUBE_CLIENT_CERT_DATA"},
},
"client_key": {
EnvVars: []string{"KUBE_CLIENT_KEY_DATA"},
},
"cluster_ca_certificate": {
EnvVars: []string{"KUBE_CLUSTER_CA_CERT_DATA"},
},
"config_path": {
EnvVars: []string{"KUBE_CONFIG_PATH"},
},
"config_context": {
EnvVars: []string{"KUBE_CTX"},
},
"config_context_auth_info": {
EnvVars: []string{"KUBE_CTX_AUTH_INFO"},
},
"config_context_cluster": {
EnvVars: []string{"KUBE_CTX_CLUSTER"},
},
"token": {
EnvVars: []string{"KUBE_TOKEN"},
},
"proxy_url": {
EnvVars: []string{"KUBE_PROXY_URL"},
},
},
},
}
result := &Backend{Backend: s}
result.Backend.ConfigureFunc = result.configure
return result
}
type Backend struct {
*schema.Backend
backendbase.Base
// The fields below are set from configure
kubernetesSecretClient dynamic.ResourceInterface
@ -234,68 +270,69 @@ func (b Backend) KubernetesLeaseClient() (coordinationv1.LeaseInterface, error)
return b.kubernetesLeaseClient, nil
}
func (b *Backend) configure(ctx context.Context) error {
func (b *Backend) Configure(configVal cty.Value) tfdiags.Diagnostics {
if b.config != nil {
return nil
}
// Grab the resource data
data := schema.FromContextBackendConfig(ctx)
data := backendbase.NewSDKLikeData(configVal)
cfg, err := getInitialConfig(data)
if err != nil {
return err
return backendbase.ErrorAsDiagnostics(err)
}
// Overriding with static configuration
cfg.UserAgent = fmt.Sprintf("HashiCorp/1.0 Terraform/%s", version.String())
if v, ok := data.GetOk("host"); ok {
cfg.Host = v.(string)
}
if v, ok := data.GetOk("username"); ok {
cfg.Username = v.(string)
if v := data.String("host"); v != "" {
cfg.Host = v
}
if v, ok := data.GetOk("password"); ok {
cfg.Password = v.(string)
if v := data.String("username"); v != "" {
cfg.Username = v
}
if v, ok := data.GetOk("insecure"); ok {
cfg.Insecure = v.(bool)
if v := data.String("password"); v != "" {
cfg.Password = v
}
if v, ok := data.GetOk("cluster_ca_certificate"); ok {
cfg.CAData = bytes.NewBufferString(v.(string)).Bytes()
cfg.Insecure = data.Bool("insecure")
if v := data.String("cluster_ca_certificate"); v != "" {
cfg.CAData = bytes.NewBufferString(v).Bytes()
}
if v, ok := data.GetOk("client_certificate"); ok {
cfg.CertData = bytes.NewBufferString(v.(string)).Bytes()
if v := data.String("client_certificate"); v != "" {
cfg.CertData = bytes.NewBufferString(v).Bytes()
}
if v, ok := data.GetOk("client_key"); ok {
cfg.KeyData = bytes.NewBufferString(v.(string)).Bytes()
if v := data.String("client_key"); v != "" {
cfg.KeyData = bytes.NewBufferString(v).Bytes()
}
if v, ok := data.GetOk("token"); ok {
cfg.BearerToken = v.(string)
if v := data.String("token"); v != "" {
cfg.BearerToken = v
}
if v, ok := data.GetOk("labels"); ok {
if v := data.GetAttr("labels", cty.Map(cty.String)); !v.IsNull() {
labels := map[string]string{}
for k, vv := range v.(map[string]interface{}) {
labels[k] = vv.(string)
for it := v.ElementIterator(); it.Next(); {
kV, vV := it.Element()
if vV.IsNull() {
vV = cty.StringVal("")
}
labels[kV.AsString()] = vV.AsString()
}
b.labels = labels
}
ns := data.Get("namespace").(string)
ns := data.String("namespace")
b.namespace = ns
b.nameSuffix = data.Get("secret_suffix").(string)
b.nameSuffix = data.String("secret_suffix")
b.config = cfg
return nil
}
func getInitialConfig(data *schema.ResourceData) (*restclient.Config, error) {
func getInitialConfig(data backendbase.SDKLikeData) (*restclient.Config, error) {
var cfg *restclient.Config
var err error
inCluster := data.Get("in_cluster_config").(bool)
inCluster := data.Bool("in_cluster_config")
if inCluster {
cfg, err = restclient.InClusterConfig()
if err != nil {
@ -314,16 +351,14 @@ func getInitialConfig(data *schema.ResourceData) (*restclient.Config, error) {
return cfg, err
}
func tryLoadingConfigFile(d *schema.ResourceData) (*restclient.Config, error) {
func tryLoadingConfigFile(d backendbase.SDKLikeData) (*restclient.Config, error) {
loader := &clientcmd.ClientConfigLoadingRules{}
configPaths := []string{}
if v, ok := d.Get("config_path").(string); ok && v != "" {
if v := d.String("config_path"); v != "" {
configPaths = []string{v}
} else if v, ok := d.Get("config_paths").([]interface{}); ok && len(v) > 0 {
for _, p := range v {
configPaths = append(configPaths, p.(string))
}
} else if v := d.GetAttr("config_paths", cty.List(cty.String)); !v.IsNull() {
configPaths = append(configPaths, decodeListOfString(v)...)
} else if v := os.Getenv("KUBE_CONFIG_PATHS"); v != "" {
configPaths = filepath.SplitList(v)
}
@ -348,46 +383,56 @@ func tryLoadingConfigFile(d *schema.ResourceData) (*restclient.Config, error) {
overrides := &clientcmd.ConfigOverrides{}
ctxSuffix := "; default context"
ctx, ctxOk := d.GetOk("config_context")
authInfo, authInfoOk := d.GetOk("config_context_auth_info")
cluster, clusterOk := d.GetOk("config_context_cluster")
if ctxOk || authInfoOk || clusterOk {
configCtx := d.String("config_context")
authInfo := d.String("config_context_auth_info")
cluster := d.String("config_context_cluster")
if configCtx != "" || authInfo != "" || cluster != "" {
ctxSuffix = "; overriden context"
if ctxOk {
overrides.CurrentContext = ctx.(string)
if configCtx != "" {
overrides.CurrentContext = configCtx
ctxSuffix += fmt.Sprintf("; config ctx: %s", overrides.CurrentContext)
log.Printf("[DEBUG] Using custom current context: %q", overrides.CurrentContext)
}
overrides.Context = clientcmdapi.Context{}
if authInfoOk {
overrides.Context.AuthInfo = authInfo.(string)
if authInfo != "" {
overrides.Context.AuthInfo = authInfo
ctxSuffix += fmt.Sprintf("; auth_info: %s", overrides.Context.AuthInfo)
}
if clusterOk {
overrides.Context.Cluster = cluster.(string)
if cluster != "" {
overrides.Context.Cluster = cluster
ctxSuffix += fmt.Sprintf("; cluster: %s", overrides.Context.Cluster)
}
log.Printf("[DEBUG] Using overidden context: %#v", overrides.Context)
}
if v, ok := d.GetOk("exec"); ok {
exec := &clientcmdapi.ExecConfig{}
if spec, ok := v.([]interface{})[0].(map[string]interface{}); ok {
exec.APIVersion = spec["api_version"].(string)
exec.Command = spec["command"].(string)
exec.Args = expandStringSlice(spec["args"].([]interface{}))
for kk, vv := range spec["env"].(map[string]interface{}) {
exec.Env = append(exec.Env, clientcmdapi.ExecEnvVar{Name: kk, Value: vv.(string)})
// exec is a nested block with nesting mode NestingSingle, so GetAttr
// will return a value of an object type that will be null if the block
// isn't present at all.
if v := d.GetAttr("exec", cty.DynamicPseudoType); !v.IsNull() {
spec := backendbase.NewSDKLikeData(v)
exec := &clientcmdapi.ExecConfig{
APIVersion: spec.String("api_version"),
Command: spec.String("command"),
Args: decodeListOfString(spec.GetAttr("args", cty.List(cty.String))),
}
if envMap := spec.GetAttr("env", cty.Map(cty.String)); !envMap.IsNull() {
for it := envMap.ElementIterator(); it.Next(); {
kV, vV := it.Element()
if vV.IsNull() {
vV = cty.StringVal("")
}
exec.Env = append(exec.Env, clientcmdapi.ExecEnvVar{
Name: kV.AsString(),
Value: vV.AsString(),
})
}
} else {
return nil, fmt.Errorf("Failed to parse exec")
}
overrides.AuthInfo.Exec = exec
}
if v, ok := d.GetOk("proxy_url"); ok {
overrides.ClusterDefaults.ProxyURL = v.(string)
if v := d.String("proxy_url"); v != "" {
overrides.ClusterDefaults.ProxyURL = v
}
cc := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loader, overrides)
@ -404,15 +449,18 @@ func tryLoadingConfigFile(d *schema.ResourceData) (*restclient.Config, error) {
return cfg, nil
}
func expandStringSlice(s []interface{}) []string {
result := make([]string, len(s), len(s))
for k, v := range s {
// Handle the Terraform parser bug which turns empty strings in lists to nil.
if v == nil {
result[k] = ""
func decodeListOfString(v cty.Value) []string {
if v.IsNull() {
return nil
}
ret := make([]string, 0, v.LengthInt())
for it := v.ElementIterator(); it.Next(); {
_, vV := it.Element()
if vV.IsNull() {
ret = append(ret, "")
} else {
result[k] = v.(string)
ret = append(ret, vV.AsString())
}
}
return result
return ret
}

@ -4,8 +4,8 @@ go 1.22.5
require (
github.com/hashicorp/terraform v0.0.0-00010101000000-000000000000
github.com/hashicorp/terraform/internal/legacy v0.0.0-00010101000000-000000000000
github.com/mitchellh/go-homedir v1.1.0
github.com/zclconf/go-cty v1.15.0
k8s.io/api v0.25.5
k8s.io/apimachinery v0.25.5
k8s.io/client-go v0.25.5
@ -49,10 +49,7 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
@ -60,7 +57,6 @@ require (
github.com/spf13/afero v1.9.3 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/testify v1.8.4 // indirect
github.com/zclconf/go-cty v1.15.0 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/net v0.23.0 // indirect
@ -99,6 +95,4 @@ replace github.com/hashicorp/terraform/internal/backend/remote-state/pg => ../pg
replace github.com/hashicorp/terraform/internal/backend/remote-state/s3 => ../s3
replace github.com/hashicorp/terraform/internal/legacy => ../../../legacy
replace github.com/hashicorp/terraform => ../../../..

@ -261,18 +261,12 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=

Loading…
Cancel
Save