Merge pull request #240 from apelisse/apelisse-droot-seans3
kinflate: Manifest type improvements
This commit is contained in:
commit
1f4c1efbdf
|
|
@ -30,7 +30,7 @@ func main() {
|
|||
cmd.AddCommand(commands.NewCmdInflate(os.Stdout, os.Stderr))
|
||||
cmd.AddCommand(commands.NewCmdAddResource(os.Stdout, os.Stderr, fs.MakeRealFS()))
|
||||
cmd.AddCommand(commands.NewCmdInit(os.Stdout, os.Stderr, fs.MakeRealFS()))
|
||||
cmd.AddCommand(commands.NewCmdAddConfigMap(os.Stderr))
|
||||
cmd.AddCommand(commands.NewCmdAddConfigMap(os.Stderr, fs.MakeRealFS()))
|
||||
cmd.AddCommand(commands.NewCmdAddSecret(os.Stderr))
|
||||
|
||||
err := cmd.Execute()
|
||||
|
|
|
|||
|
|
@ -100,11 +100,17 @@ type Manifest struct {
|
|||
// and Overlays fields.
|
||||
Configmaps []ConfigMap `json:"configmaps,omitempty" yaml:"configmaps,omitempty"`
|
||||
|
||||
// List of secrets to generate from secret sources.
|
||||
// List of generic secrets to generate from secret sources.
|
||||
// Base/overlay concept doesn't apply to this field.
|
||||
// If a secret want to have a base and an overlay, it should go to Bases and
|
||||
// Overlays fields.
|
||||
Secrets []Secret `json:"secrets,omitempty" yaml:"secrets,omitempty"`
|
||||
GenericSecrets []GenericSecret `json:"genericSecrets,omitempty" yaml:"genericSecrets,omitempty"`
|
||||
|
||||
// List of TLS secrets to generate from secret sources.
|
||||
// Base/overlay concept doesn't apply to this field.
|
||||
// If a secret want to have a base and an overlay, it should go to Bases and
|
||||
// Overlays fields.
|
||||
TLSSecrets []TLSSecret `json:"tlsSecrets,omitempty" yaml:"tlsSecrets,omitempty"`
|
||||
|
||||
// Whether prune resources not defined in Kube-manifest.yaml, similar to
|
||||
// `kubectl apply --prune` behavior.
|
||||
|
|
@ -122,41 +128,43 @@ type Manifest struct {
|
|||
|
||||
// ConfigMap contains the metadata of how to generate a configmap.
|
||||
type ConfigMap struct {
|
||||
// The type of the configmap. e.g. `env`, `file`, `literal`.
|
||||
Type string `json:"type,omitempty" yaml:"type,omitempty"`
|
||||
|
||||
// Name prefix of the configmap.
|
||||
// The full name should be Manifest.NamePrefix + Configmap.NamePrefix +
|
||||
// Name of the configmap.
|
||||
// The full name should be Manifest.NamePrefix + Configmap.Name +
|
||||
// hash(content of configmap).
|
||||
NamePrefix string `json:"namePrefix,omitempty" yaml:"namePrefix,omitempty"`
|
||||
Name string `json:"name,omitempty" yaml:"name,omitempty"`
|
||||
|
||||
// Generic source for configmap, it could of one of `env`, `file`, `literal`
|
||||
Generic `json:",inline,omitempty" yaml:",inline,omitempty"`
|
||||
// DataSources for configmap.
|
||||
DataSources `json:",inline,omitempty" yaml:",inline,omitempty"`
|
||||
}
|
||||
|
||||
// Secret contains the metadata of how to generate a secret.
|
||||
// Only one of source or tls can be set.
|
||||
type Secret struct {
|
||||
// The type of the secret. e.g. `generic` and `tls`.
|
||||
Type string `json:"type,omitempty" yaml:"type,omitempty"`
|
||||
|
||||
// Name prefix of the secret.
|
||||
// The full name should be Manifest.NamePrefix + Secret.NamePrefix +
|
||||
// GenericSecret contains the metadata of how to generate a generic secret.
|
||||
type GenericSecret struct {
|
||||
// Name of the secret.
|
||||
// The full name should be Manifest.NamePrefix + GenericSecret.Name +
|
||||
// hash(content of secret).
|
||||
NamePrefix string `json:"namePrefix,omitempty" yaml:"namePrefix,omitempty"`
|
||||
Name string `json:"name,omitempty" yaml:"name,omitempty"`
|
||||
|
||||
// Generic source for secret, it could of one of `env`, `file`, `literal`
|
||||
Generic `json:",inline,omitempty" yaml:",inline,omitempty"`
|
||||
|
||||
// TLS secret.
|
||||
TLS *TLS `json:"tls,omitempty" yaml:"tls,omitempty"`
|
||||
|
||||
// TODO: support more secret types, e.g. DockerRegistry
|
||||
// DataSources for secret.
|
||||
DataSources `json:",inline,omitempty" yaml:",inline,omitempty"`
|
||||
}
|
||||
|
||||
// Generic contains some generic sources for configmap or secret.
|
||||
// TLSSecret contains the metadata of how to generate a TLS secret.
|
||||
type TLSSecret struct {
|
||||
// Name of the secret
|
||||
// The full name should be Manifest.NamePrefix + TLSSecret.Name +
|
||||
// hash(content of secret).
|
||||
Name string `json:"name,omitempty" yaml:"name,omitempty"`
|
||||
|
||||
// Path to PEM encoded public key certificate.
|
||||
CertFile string `json:"certFile,omitempty" yaml:"certFile,omitempty"`
|
||||
|
||||
// Path to private key associated with given certificate.
|
||||
KeyFile string `json:"keyFile,omitempty" yaml:"keyFile,omitempty"`
|
||||
}
|
||||
|
||||
// DataSources contains some generic sources for configmap or secret.
|
||||
// Only one field can be set.
|
||||
type Generic struct {
|
||||
type DataSources struct {
|
||||
// LiteralSources is a list of literal sources.
|
||||
// Each literal source should be a key and literal value,
|
||||
// e.g. `somekey=somevalue`
|
||||
|
|
@ -177,12 +185,3 @@ type Generic struct {
|
|||
// i.e. a Docker .env file or a .ini file.
|
||||
EnvSource string `json:"env,omitempty" yaml:"env,omitempty"`
|
||||
}
|
||||
|
||||
// TLS contains cert and key paths.
|
||||
type TLS struct {
|
||||
// Path to PEM encoded public key certificate.
|
||||
CertFile string `json:"certFile,omitempty" yaml:"certFile,omitempty"`
|
||||
|
||||
// Path to private key associated with given certificate.
|
||||
KeyFile string `json:"keyFile,omitempty" yaml:"keyFile,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ func adjustPathsForManifest(m *manifest.Manifest, pathToDir []string) {
|
|||
m.Resources = adjustPaths(m.Resources, pathToDir)
|
||||
m.Patches = adjustPaths(m.Patches, pathToDir)
|
||||
m.Configmaps = adjustPathForConfigMaps(m.Configmaps, pathToDir)
|
||||
m.Secrets = adjustPathForSecrets(m.Secrets, pathToDir)
|
||||
|
||||
m.GenericSecrets = adjustPathForGenericSecrets(m.GenericSecrets, pathToDir)
|
||||
m.TLSSecrets = adjustPathForTLSSecrets(m.TLSSecrets, pathToDir)
|
||||
}
|
||||
|
||||
func adjustPathForConfigMaps(cms []manifest.ConfigMap, prefix []string) []manifest.ConfigMap {
|
||||
|
|
@ -44,7 +44,7 @@ func adjustPathForConfigMaps(cms []manifest.ConfigMap, prefix []string) []manife
|
|||
return cms
|
||||
}
|
||||
|
||||
func adjustPathForSecrets(secrets []manifest.Secret, prefix []string) []manifest.Secret {
|
||||
func adjustPathForGenericSecrets(secrets []manifest.GenericSecret, prefix []string) []manifest.GenericSecret {
|
||||
for i, secret := range secrets {
|
||||
if len(secret.FileSources) > 0 {
|
||||
for j, fileSource := range secret.FileSources {
|
||||
|
|
@ -54,10 +54,14 @@ func adjustPathForSecrets(secrets []manifest.Secret, prefix []string) []manifest
|
|||
if len(secret.EnvSource) > 0 {
|
||||
secrets[i].EnvSource = adjustPath(secret.EnvSource, prefix)
|
||||
}
|
||||
if secret.TLS != nil {
|
||||
secrets[i].TLS.CertFile = adjustPath(secret.TLS.CertFile, prefix)
|
||||
secrets[i].TLS.KeyFile = adjustPath(secret.TLS.KeyFile, prefix)
|
||||
}
|
||||
}
|
||||
return secrets
|
||||
}
|
||||
|
||||
func adjustPathForTLSSecrets(secrets []manifest.TLSSecret, prefix []string) []manifest.TLSSecret {
|
||||
for i, secret := range secrets {
|
||||
secrets[i].CertFile = adjustPath(secret.CertFile, prefix)
|
||||
secrets[i].KeyFile = adjustPath(secret.KeyFile, prefix)
|
||||
}
|
||||
return secrets
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,41 +17,15 @@ limitations under the License.
|
|||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"k8s.io/kubectl/pkg/kinflate/util/fs"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type addConfigMap struct {
|
||||
// Name of configMap (required)
|
||||
Name string
|
||||
// FileSources to derive the configMap from (optional)
|
||||
FileSources []string
|
||||
// LiteralSources to derive the configMap from (optional)
|
||||
LiteralSources []string
|
||||
// EnvFileSource to derive the configMap from (optional)
|
||||
EnvFileSource string
|
||||
}
|
||||
|
||||
// validate validates required fields are set to support structured generation.
|
||||
func (a *addConfigMap) Validate(args []string) error {
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("name must be specified once")
|
||||
}
|
||||
a.Name = args[0]
|
||||
if len(a.EnvFileSource) == 0 && len(a.FileSources) == 0 && len(a.LiteralSources) == 0 {
|
||||
return fmt.Errorf("at least from-env-file, or from-file or from-literal must be set")
|
||||
}
|
||||
if len(a.EnvFileSource) > 0 && (len(a.FileSources) > 0 || len(a.LiteralSources) > 0) {
|
||||
return fmt.Errorf("from-env-file cannot be combined with from-file or from-literal")
|
||||
}
|
||||
// TODO: Should we check if the path exists? if it's valid, if it's within the same (sub-)directory?
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewCmdAddConfigMap(errOut io.Writer) *cobra.Command {
|
||||
var config addConfigMap
|
||||
func NewCmdAddConfigMap(errOut io.Writer, fsys fs.FileSystem) *cobra.Command {
|
||||
var config dataConfig
|
||||
cmd := &cobra.Command{
|
||||
Use: "configmap NAME [--from-file=[key=]source] [--from-literal=key1=value1]",
|
||||
Short: "Adds a configmap to your manifest file.",
|
||||
|
|
|
|||
|
|
@ -18,72 +18,12 @@ package commands
|
|||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"k8s.io/kubectl/pkg/kinflate/util/fs"
|
||||
)
|
||||
|
||||
func TestNewAddConfigMapIsNotNil(t *testing.T) {
|
||||
if NewCmdAddConfigMap(nil) == nil {
|
||||
if NewCmdAddConfigMap(nil, fs.MakeFakeFS()) == nil {
|
||||
t.Fatal("NewCmdAddConfigMap shouldn't be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddConfigValidation_NoName(t *testing.T) {
|
||||
config := addConfigMap{}
|
||||
|
||||
if config.Validate([]string{}) == nil {
|
||||
t.Fatal("Validation should fail if no name is specified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddConfigValidation_MoreThanOneName(t *testing.T) {
|
||||
config := addConfigMap{}
|
||||
|
||||
if config.Validate([]string{"name", "othername"}) == nil {
|
||||
t.Fatal("Validation should fail if more than one name is specified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddConfigValidation_Flags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config addConfigMap
|
||||
shouldFail bool
|
||||
}{
|
||||
{
|
||||
name: "env-file-source and literal are both set",
|
||||
config: addConfigMap{
|
||||
LiteralSources: []string{"one", "two"},
|
||||
EnvFileSource: "three",
|
||||
},
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "env-file-source and from-file are both set",
|
||||
config: addConfigMap{
|
||||
FileSources: []string{"one", "two"},
|
||||
EnvFileSource: "three",
|
||||
},
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "we don't have any option set",
|
||||
config: addConfigMap{},
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "we have from-file and literal ",
|
||||
config: addConfigMap{
|
||||
LiteralSources: []string{"one", "two"},
|
||||
FileSources: []string{"three", "four"},
|
||||
},
|
||||
shouldFail: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
if test.config.Validate([]string{"name"}) == nil && test.shouldFail {
|
||||
t.Fatalf("Validation should fail if %s", test.name)
|
||||
} else if test.config.Validate([]string{"name"}) != nil && !test.shouldFail {
|
||||
t.Fatalf("Validation should succeed if %s", test.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
Copyright 2018 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// dataConfig encapsulates the options for add configmap/Secret commands.
|
||||
type dataConfig struct {
|
||||
// Name of configMap/Secret (required)
|
||||
Name string
|
||||
// FileSources to derive the configMap/Secret from (optional)
|
||||
FileSources []string
|
||||
// LiteralSources to derive the configMap/Secret from (optional)
|
||||
LiteralSources []string
|
||||
// EnvFileSource to derive the configMap/Secret from (optional)
|
||||
EnvFileSource string
|
||||
}
|
||||
|
||||
// Validate validates required fields are set to support structured generation.
|
||||
func (a *dataConfig) Validate(args []string) error {
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("name must be specified once")
|
||||
}
|
||||
a.Name = args[0]
|
||||
if len(a.EnvFileSource) == 0 && len(a.FileSources) == 0 && len(a.LiteralSources) == 0 {
|
||||
return fmt.Errorf("at least from-env-file, or from-file or from-literal must be set")
|
||||
}
|
||||
if len(a.EnvFileSource) > 0 && (len(a.FileSources) > 0 || len(a.LiteralSources) > 0) {
|
||||
return fmt.Errorf("from-env-file cannot be combined with from-file or from-literal")
|
||||
}
|
||||
// TODO: Should we check if the path exists? if it's valid, if it's within the same (sub-)directory?
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
Copyright 2018 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package commands
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDataConfigValidation_NoName(t *testing.T) {
|
||||
config := dataConfig{}
|
||||
|
||||
if config.Validate([]string{}) == nil {
|
||||
t.Fatal("Validation should fail if no name is specified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataConfigValidation_MoreThanOneName(t *testing.T) {
|
||||
config := dataConfig{}
|
||||
|
||||
if config.Validate([]string{"name", "othername"}) == nil {
|
||||
t.Fatal("Validation should fail if more than one name is specified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataConfigValidation_Flags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config dataConfig
|
||||
shouldFail bool
|
||||
}{
|
||||
{
|
||||
name: "env-file-source and literal are both set",
|
||||
config: dataConfig{
|
||||
LiteralSources: []string{"one", "two"},
|
||||
EnvFileSource: "three",
|
||||
},
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "env-file-source and from-file are both set",
|
||||
config: dataConfig{
|
||||
FileSources: []string{"one", "two"},
|
||||
EnvFileSource: "three",
|
||||
},
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "we don't have any option set",
|
||||
config: dataConfig{},
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "we have from-file and literal ",
|
||||
config: dataConfig{
|
||||
LiteralSources: []string{"one", "two"},
|
||||
FileSources: []string{"three", "four"},
|
||||
},
|
||||
shouldFail: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
if test.config.Validate([]string{"name"}) == nil && test.shouldFail {
|
||||
t.Fatalf("Validation should fail if %s", test.name)
|
||||
} else if test.config.Validate([]string{"name"}) != nil && !test.shouldFail {
|
||||
t.Fatalf("Validation should succeed if %s", test.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2017 The Kubernetes Authors.
|
||||
Copyright 2018 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
|
@ -23,35 +23,8 @@ import (
|
|||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type addGenericSecret struct {
|
||||
// Name of secret (required)
|
||||
Name string
|
||||
// FileSources to derive the secret from (optional)
|
||||
FileSources []string
|
||||
// LiteralSources to derive the secret from (optional)
|
||||
LiteralSources []string
|
||||
// EnvFileSource to derive the secret from (optional)
|
||||
EnvFileSource string
|
||||
}
|
||||
|
||||
// Validate validates required fields are set to support structured generation.
|
||||
func (a *addGenericSecret) Validate(args []string) error {
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("name must be specified once")
|
||||
}
|
||||
a.Name = args[0]
|
||||
if len(a.EnvFileSource) == 0 && len(a.FileSources) == 0 && len(a.LiteralSources) == 0 {
|
||||
return fmt.Errorf("at least from-env-file, or from-file or from-literal must be set")
|
||||
}
|
||||
if len(a.EnvFileSource) > 0 && (len(a.FileSources) > 0 || len(a.LiteralSources) > 0) {
|
||||
return fmt.Errorf("from-env-file cannot be combined with from-file or from-literal")
|
||||
}
|
||||
// TODO: Should we check if the path exists? if it's valid, if it's within the same (sub-)directory?
|
||||
return nil
|
||||
}
|
||||
|
||||
func newCmdAddSecretGeneric(errOut io.Writer) *cobra.Command {
|
||||
var config addGenericSecret
|
||||
var config dataConfig
|
||||
cmd := &cobra.Command{
|
||||
Use: "generic NAME [--type=string] [--from-file=[key=]source] [--from-literal=key1=value1]",
|
||||
Short: "Adds a secret from a local file, directory or literal value.",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2017 The Kubernetes Authors.
|
||||
Copyright 2018 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
|
@ -25,125 +25,3 @@ func TestNewAddSecretIsNotNil(t *testing.T) {
|
|||
t.Fatal("NewCmdAddSecret shouldn't be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddGenericSecretValidation_NoName(t *testing.T) {
|
||||
config := addGenericSecret{}
|
||||
|
||||
if config.Validate([]string{}) == nil {
|
||||
t.Fatal("Validation should fail if no name is specified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddGenericSecretValidation_MoreThanOneName(t *testing.T) {
|
||||
config := addGenericSecret{}
|
||||
|
||||
if config.Validate([]string{"name", "othername"}) == nil {
|
||||
t.Fatal("Validation should fail if more than one name is specified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddGenericSecretValidation_Flags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config addGenericSecret
|
||||
shouldFail bool
|
||||
}{
|
||||
{
|
||||
name: "env-file-source and literal are both set",
|
||||
config: addGenericSecret{
|
||||
LiteralSources: []string{"one", "two"},
|
||||
EnvFileSource: "three",
|
||||
},
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "env-file-source and from-file are both set",
|
||||
config: addGenericSecret{
|
||||
FileSources: []string{"one", "two"},
|
||||
EnvFileSource: "three",
|
||||
},
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "we don't have any option set",
|
||||
config: addGenericSecret{},
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "we have from-file and literal ",
|
||||
config: addGenericSecret{
|
||||
LiteralSources: []string{"one", "two"},
|
||||
FileSources: []string{"three", "four"},
|
||||
},
|
||||
shouldFail: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
if test.config.Validate([]string{"name"}) == nil && test.shouldFail {
|
||||
t.Fatalf("Validation should fail if %s", test.name)
|
||||
} else if test.config.Validate([]string{"name"}) != nil && !test.shouldFail {
|
||||
t.Fatalf("Validation should succeed if %s", test.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddTLSSecretValidation_NoName(t *testing.T) {
|
||||
config := addTLSSecret{}
|
||||
|
||||
if config.Validate([]string{}) == nil {
|
||||
t.Fatal("Validation should fail if no name is specified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddTLSSecretValidation_MoreThanOneName(t *testing.T) {
|
||||
config := addTLSSecret{}
|
||||
|
||||
if config.Validate([]string{"name", "othername"}) == nil {
|
||||
t.Fatal("Validation should fail if more than one name is specified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddTLSSecretValidation_Flags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config addTLSSecret
|
||||
shouldFail bool
|
||||
}{
|
||||
{
|
||||
name: "cert and key are set",
|
||||
config: addTLSSecret{
|
||||
Cert: "cert",
|
||||
Key: "key",
|
||||
},
|
||||
shouldFail: false,
|
||||
},
|
||||
{
|
||||
name: "cert is set, but not key",
|
||||
config: addTLSSecret{
|
||||
Cert: "cert",
|
||||
},
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "key is set, but not cert",
|
||||
config: addTLSSecret{
|
||||
Key: "key",
|
||||
},
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "neither key nor cert is set",
|
||||
config: addTLSSecret{},
|
||||
shouldFail: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
if test.config.Validate([]string{"name"}) == nil && test.shouldFail {
|
||||
t.Fatalf("Validation should fail if %s", test.name)
|
||||
} else if test.config.Validate([]string{"name"}) != nil && !test.shouldFail {
|
||||
t.Fatalf("Validation should succeed if %s", test.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,18 +45,31 @@ func MakeConfigmapAndGenerateName(cm manifest.ConfigMap) (*unstructured.Unstruct
|
|||
return unstructuredCM, nameWithHash, err
|
||||
}
|
||||
|
||||
// MakeSecretAndGenerateName makes a secret and returns the secret and the name appended with a hash.
|
||||
func MakeSecretAndGenerateName(secret manifest.Secret) (*unstructured.Unstructured, string, error) {
|
||||
corev1Secret, err := makeSecret(secret)
|
||||
// MakeGenericSecretAndGenerateName makes a generic secret and returns the secret and the name appended with a hash.
|
||||
func MakeGenericSecretAndGenerateName(secret manifest.GenericSecret) (*unstructured.Unstructured, string, error) {
|
||||
corev1Secret, err := makeGenericSecret(secret)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
h, err := hash.SecretHash(corev1Secret)
|
||||
return makeSecretAndGenerateName(corev1Secret, secret.Name)
|
||||
}
|
||||
|
||||
// MakeTLSSecretAndGenerateName makes a generic secret and returns the secret and the name appended with a hash.
|
||||
func MakeTLSSecretAndGenerateName(secret manifest.TLSSecret) (*unstructured.Unstructured, string, error) {
|
||||
corev1Secret, err := makeTlsSecret(secret)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
nameWithHash := fmt.Sprintf("%s-%s", corev1Secret.GetName(), h)
|
||||
unstructuredCM, err := objectToUnstructured(corev1Secret)
|
||||
return makeSecretAndGenerateName(corev1Secret, secret.Name)
|
||||
}
|
||||
|
||||
func makeSecretAndGenerateName(secret *corev1.Secret, name string) (*unstructured.Unstructured, string, error) {
|
||||
h, err := hash.SecretHash(secret)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
nameWithHash := fmt.Sprintf("%s-%s", name, h)
|
||||
unstructuredCM, err := objectToUnstructured(secret)
|
||||
return unstructuredCM, nameWithHash, err
|
||||
}
|
||||
|
||||
|
|
@ -74,55 +87,76 @@ func makeConfigMap(cm manifest.ConfigMap) (*corev1.ConfigMap, error) {
|
|||
corev1cm := &corev1.ConfigMap{}
|
||||
corev1cm.APIVersion = "v1"
|
||||
corev1cm.Kind = "ConfigMap"
|
||||
corev1cm.Name = cm.NamePrefix
|
||||
corev1cm.Name = cm.Name
|
||||
corev1cm.Data = map[string]string{}
|
||||
var err error
|
||||
switch cm.Type {
|
||||
case "env":
|
||||
err = cutil.HandleConfigMapFromEnvFileSource(corev1cm, cm.EnvSource)
|
||||
case "file":
|
||||
err = cutil.HandleConfigMapFromFileSources(corev1cm, cm.FileSources)
|
||||
case "literal":
|
||||
err = cutil.HandleConfigMapFromLiteralSources(corev1cm, cm.LiteralSources)
|
||||
default:
|
||||
err = fmt.Errorf("unknown type of configmap: %v", cm.Type)
|
||||
|
||||
if cm.EnvSource != "" {
|
||||
if err := cutil.HandleConfigMapFromEnvFileSource(corev1cm, cm.EnvSource); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return corev1cm, err
|
||||
if cm.FileSources != nil {
|
||||
if err := cutil.HandleConfigMapFromFileSources(corev1cm, cm.FileSources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if cm.LiteralSources != nil {
|
||||
if err := cutil.HandleConfigMapFromLiteralSources(corev1cm, cm.LiteralSources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return corev1cm, nil
|
||||
}
|
||||
|
||||
func makeSecret(secret manifest.Secret) (*corev1.Secret, error) {
|
||||
func makeGenericSecret(secret manifest.GenericSecret) (*corev1.Secret, error) {
|
||||
corev1secret := &corev1.Secret{}
|
||||
corev1secret.APIVersion = "v1"
|
||||
corev1secret.Kind = "Secret"
|
||||
corev1secret.Name = secret.NamePrefix
|
||||
corev1secret.Name = secret.Name
|
||||
corev1secret.Type = corev1.SecretTypeOpaque
|
||||
corev1secret.Data = map[string][]byte{}
|
||||
var err error
|
||||
switch secret.Type {
|
||||
case "tls":
|
||||
if err = validateTLS(secret.TLS.CertFile, secret.TLS.KeyFile); err != nil {
|
||||
|
||||
if secret.EnvSource != "" {
|
||||
if err := cutil.HandleFromEnvFileSource(corev1secret, secret.EnvSource); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsCrt, err := ioutil.ReadFile(secret.TLS.CertFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsKey, err := ioutil.ReadFile(secret.TLS.KeyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
corev1secret.Type = corev1.SecretTypeTLS
|
||||
corev1secret.Data[corev1.TLSCertKey] = []byte(tlsCrt)
|
||||
corev1secret.Data[corev1.TLSPrivateKeyKey] = []byte(tlsKey)
|
||||
case "env":
|
||||
err = cutil.HandleFromEnvFileSource(corev1secret, secret.EnvSource)
|
||||
case "file":
|
||||
err = cutil.HandleFromFileSources(corev1secret, secret.FileSources)
|
||||
case "literal":
|
||||
err = cutil.HandleFromLiteralSources(corev1secret, secret.LiteralSources)
|
||||
default:
|
||||
err = fmt.Errorf("unknown type of secret: %v", secret.Type)
|
||||
}
|
||||
if secret.FileSources != nil {
|
||||
if err := cutil.HandleFromFileSources(corev1secret, secret.FileSources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if secret.LiteralSources != nil {
|
||||
if err := cutil.HandleFromLiteralSources(corev1secret, secret.LiteralSources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return corev1secret, nil
|
||||
}
|
||||
|
||||
func makeTlsSecret(secret manifest.TLSSecret) (*corev1.Secret, error) {
|
||||
corev1secret := &corev1.Secret{}
|
||||
corev1secret.APIVersion = "v1"
|
||||
corev1secret.Kind = "Secret"
|
||||
corev1secret.Name = secret.Name
|
||||
corev1secret.Type = corev1.SecretTypeTLS
|
||||
corev1secret.Data = map[string][]byte{}
|
||||
|
||||
if err := validateTLS(secret.CertFile, secret.KeyFile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsCrt, err := ioutil.ReadFile(secret.CertFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsKey, err := ioutil.ReadFile(secret.KeyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
corev1secret.Data[corev1.TLSCertKey] = []byte(tlsCrt)
|
||||
corev1secret.Data[corev1.TLSPrivateKeyKey] = []byte(tlsKey)
|
||||
|
||||
return corev1secret, err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -212,9 +212,8 @@ func TestConstructConfigMap(t *testing.T) {
|
|||
{
|
||||
description: "construct config map from env",
|
||||
input: manifest.ConfigMap{
|
||||
Type: "env",
|
||||
NamePrefix: "envConfigMap",
|
||||
Generic: manifest.Generic{
|
||||
Name: "envConfigMap",
|
||||
DataSources: manifest.DataSources{
|
||||
EnvSource: "../examples/simple/instances/exampleinstance/configmap/app.env",
|
||||
},
|
||||
},
|
||||
|
|
@ -223,9 +222,8 @@ func TestConstructConfigMap(t *testing.T) {
|
|||
{
|
||||
description: "construct config map from file",
|
||||
input: manifest.ConfigMap{
|
||||
Type: "file",
|
||||
NamePrefix: "fileConfigMap",
|
||||
Generic: manifest.Generic{
|
||||
Name: "fileConfigMap",
|
||||
DataSources: manifest.DataSources{
|
||||
FileSources: []string{"../examples/simple/instances/exampleinstance/configmap/app-init.ini"},
|
||||
},
|
||||
},
|
||||
|
|
@ -234,9 +232,8 @@ func TestConstructConfigMap(t *testing.T) {
|
|||
{
|
||||
description: "construct config map from literal",
|
||||
input: manifest.ConfigMap{
|
||||
Type: "literal",
|
||||
NamePrefix: "literalConfigMap",
|
||||
Generic: manifest.Generic{
|
||||
Name: "literalConfigMap",
|
||||
DataSources: manifest.DataSources{
|
||||
LiteralSources: []string{"a=x", "b=y"},
|
||||
},
|
||||
},
|
||||
|
|
@ -246,6 +243,36 @@ func TestConstructConfigMap(t *testing.T) {
|
|||
|
||||
for _, tc := range testCases {
|
||||
cm, err := makeConfigMap(tc.input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(*cm, *tc.expected) {
|
||||
t.Fatalf("in testcase: %q updated:\n%#v\ndoesn't match expected:\n%#v\n", tc.description, *cm, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstructTLSSecret(t *testing.T) {
|
||||
type testCase struct {
|
||||
description string
|
||||
input manifest.TLSSecret
|
||||
expected *corev1.Secret
|
||||
}
|
||||
|
||||
testCases := []testCase{
|
||||
{
|
||||
description: "construct secret from tls",
|
||||
input: manifest.TLSSecret{
|
||||
Name: "tlsSecret",
|
||||
CertFile: "../examples/simple/instances/exampleinstance/secret/tls.cert",
|
||||
KeyFile: "../examples/simple/instances/exampleinstance/secret/tls.key",
|
||||
},
|
||||
expected: makeTLSSecret("tlsSecret"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
cm, err := makeTlsSecret(tc.input)
|
||||
if err != nil {
|
||||
t.Fatalf("unepxected error: %v", err)
|
||||
}
|
||||
|
|
@ -255,32 +282,19 @@ func TestConstructConfigMap(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestConstructSecret(t *testing.T) {
|
||||
func TestConstructGenericSecret(t *testing.T) {
|
||||
type testCase struct {
|
||||
description string
|
||||
input manifest.Secret
|
||||
input manifest.GenericSecret
|
||||
expected *corev1.Secret
|
||||
}
|
||||
|
||||
testCases := []testCase{
|
||||
{
|
||||
description: "construct secret from tls",
|
||||
input: manifest.Secret{
|
||||
Type: "tls",
|
||||
NamePrefix: "tlsSecret",
|
||||
TLS: &manifest.TLS{
|
||||
CertFile: "../examples/simple/instances/exampleinstance/secret/tls.cert",
|
||||
KeyFile: "../examples/simple/instances/exampleinstance/secret/tls.key",
|
||||
},
|
||||
},
|
||||
expected: makeTLSSecret("tlsSecret"),
|
||||
},
|
||||
{
|
||||
description: "construct secret from env",
|
||||
input: manifest.Secret{
|
||||
Type: "env",
|
||||
NamePrefix: "envSecret",
|
||||
Generic: manifest.Generic{
|
||||
input: manifest.GenericSecret{
|
||||
Name: "envSecret",
|
||||
DataSources: manifest.DataSources{
|
||||
EnvSource: "../examples/simple/instances/exampleinstance/configmap/app.env",
|
||||
},
|
||||
},
|
||||
|
|
@ -288,10 +302,9 @@ func TestConstructSecret(t *testing.T) {
|
|||
},
|
||||
{
|
||||
description: "construct secret from file",
|
||||
input: manifest.Secret{
|
||||
Type: "file",
|
||||
NamePrefix: "fileSecret",
|
||||
Generic: manifest.Generic{
|
||||
input: manifest.GenericSecret{
|
||||
Name: "fileSecret",
|
||||
DataSources: manifest.DataSources{
|
||||
FileSources: []string{"../examples/simple/instances/exampleinstance/configmap/app-init.ini"},
|
||||
},
|
||||
},
|
||||
|
|
@ -299,10 +312,9 @@ func TestConstructSecret(t *testing.T) {
|
|||
},
|
||||
{
|
||||
description: "construct secret from literal",
|
||||
input: manifest.Secret{
|
||||
Type: "literal",
|
||||
NamePrefix: "literalSecret",
|
||||
Generic: manifest.Generic{
|
||||
input: manifest.GenericSecret{
|
||||
Name: "literalSecret",
|
||||
DataSources: manifest.DataSources{
|
||||
LiteralSources: []string{"a=x", "b=y"},
|
||||
},
|
||||
},
|
||||
|
|
@ -311,9 +323,9 @@ func TestConstructSecret(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
cm, err := makeSecret(tc.input)
|
||||
cm, err := makeGenericSecret(tc.input)
|
||||
if err != nil {
|
||||
t.Fatalf("unepxected error: %v", err)
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(*cm, *tc.expected) {
|
||||
t.Fatalf("in testcase: %q updated:\n%#v\ndoesn't match expected:\n%#v\n", tc.description, *cm, tc.expected)
|
||||
|
|
|
|||
|
|
@ -22,19 +22,15 @@ patches:
|
|||
- deployment/deployment.yaml
|
||||
#There could also be configmaps in Base, which would make these overlays
|
||||
configmaps:
|
||||
- type: env
|
||||
namePrefix: app-env
|
||||
- name: app-env
|
||||
env: configmap/app.env
|
||||
- type: file
|
||||
namePrefix: app-config
|
||||
- name: app-config
|
||||
files:
|
||||
- configmap/app-init.ini
|
||||
#There could be secrets in Base, if just using a fork/rebase workflow
|
||||
secrets:
|
||||
- type: tls
|
||||
namePrefix: app-tls
|
||||
tls:
|
||||
certFile: secret/tls.cert
|
||||
keyFile: secret/tls.key
|
||||
tlsSecrets:
|
||||
- name: app-tls
|
||||
certFile: secret/tls.cert
|
||||
keyFile: secret/tls.key
|
||||
recursive: false
|
||||
prune: true # I’d make this the default
|
||||
|
|
|
|||
|
|
@ -86,8 +86,8 @@ func populateConfigMapAndSecretMap(manifest *manifest.Manifest, m map[gvkn.Group
|
|||
}
|
||||
}
|
||||
|
||||
for _, secret := range manifest.Secrets {
|
||||
unstructuredSecret, nameWithHash, err := cutil.MakeSecretAndGenerateName(secret)
|
||||
for _, secret := range manifest.GenericSecrets {
|
||||
unstructuredSecret, nameWithHash, err := cutil.MakeGenericSecretAndGenerateName(secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -96,6 +96,18 @@ func populateConfigMapAndSecretMap(manifest *manifest.Manifest, m map[gvkn.Group
|
|||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, secret := range manifest.TLSSecrets {
|
||||
unstructuredSecret, nameWithHash, err := cutil.MakeTLSSecretAndGenerateName(secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = populateMap(m, unstructuredSecret, nameWithHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,44 @@ func makeUnstructuredEnvSecret(name string) *unstructured.Unstructured {
|
|||
}
|
||||
}
|
||||
|
||||
func makeUnstructuredTLSSecret(name string) *unstructured.Unstructured {
|
||||
return &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Secret",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": name,
|
||||
"creationTimestamp": nil,
|
||||
},
|
||||
"type": string(corev1.SecretTypeTLS),
|
||||
"data": map[string]interface{}{
|
||||
"tls.key": base64.StdEncoding.EncodeToString([]byte(`-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIBOwIBAAJBANLJhPHhITqQbPklG3ibCVxwGMRfp/v4XqhfdQHdcVfHap6NQ5Wo
|
||||
k/4xIA+ui35/MmNartNuC+BdZ1tMuVCPFZcCAwEAAQJAEJ2N+zsR0Xn8/Q6twa4G
|
||||
6OB1M1WO+k+ztnX/1SvNeWu8D6GImtupLTYgjZcHufykj09jiHmjHx8u8ZZB/o1N
|
||||
MQIhAPW+eyZo7ay3lMz1V01WVjNKK9QSn1MJlb06h/LuYv9FAiEA25WPedKgVyCW
|
||||
SmUwbPw8fnTcpqDWE3yTO3vKcebqMSsCIBF3UmVue8YU3jybC3NxuXq3wNm34R8T
|
||||
xVLHwDXh/6NJAiEAl2oHGGLz64BuAfjKrqwz7qMYr9HCLIe/YsoWq/olzScCIQDi
|
||||
D2lWusoe2/nEqfDVVWGWlyJ7yOmqaVm/iNUN9B2N2g==
|
||||
-----END RSA PRIVATE KEY-----
|
||||
`)),
|
||||
"tls.crt": base64.StdEncoding.EncodeToString([]byte(`-----BEGIN CERTIFICATE-----
|
||||
MIIB0zCCAX2gAwIBAgIJAI/M7BYjwB+uMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV
|
||||
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
|
||||
aWRnaXRzIFB0eSBMdGQwHhcNMTIwOTEyMjE1MjAyWhcNMTUwOTEyMjE1MjAyWjBF
|
||||
MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50
|
||||
ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANLJ
|
||||
hPHhITqQbPklG3ibCVxwGMRfp/v4XqhfdQHdcVfHap6NQ5Wok/4xIA+ui35/MmNa
|
||||
rtNuC+BdZ1tMuVCPFZcCAwEAAaNQME4wHQYDVR0OBBYEFJvKs8RfJaXTH08W+SGv
|
||||
zQyKn0H8MB8GA1UdIwQYMBaAFJvKs8RfJaXTH08W+SGvzQyKn0H8MAwGA1UdEwQF
|
||||
MAMBAf8wDQYJKoZIhvcNAQEFBQADQQBJlffJHybjDGxRMqaRmDhX0+6v02TUKZsW
|
||||
r5QuVbpQhH6u+0UgcW0jp9QwpxoPTLTWGXEWBBBurxFwiCBhkQ+V
|
||||
-----END CERTIFICATE-----
|
||||
`)),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
func TestPopulateMap(t *testing.T) {
|
||||
expectedMap := map[gvkn.GroupVersionKindName]*unstructured.Unstructured{
|
||||
{
|
||||
|
|
@ -80,6 +118,13 @@ func TestPopulateMap(t *testing.T) {
|
|||
},
|
||||
Name: "envSecret",
|
||||
}: makeUnstructuredEnvSecret("newNameSecret"),
|
||||
{
|
||||
GVK: schema.GroupVersionKind{
|
||||
Version: "v1",
|
||||
Kind: "Secret",
|
||||
},
|
||||
Name: "tlsSecret",
|
||||
}: makeUnstructuredTLSSecret("newNameTLSSecret"),
|
||||
}
|
||||
|
||||
m := map[gvkn.GroupVersionKindName]*unstructured.Unstructured{}
|
||||
|
|
@ -91,6 +136,10 @@ func TestPopulateMap(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
err = populateMap(m, makeUnstructuredTLSSecret("tlsSecret"), "newNameTLSSecret")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(m, expectedMap) {
|
||||
t.Fatalf("%#v\ndoesn't match expected\n%#v\n", m, expectedMap)
|
||||
|
|
@ -107,18 +156,16 @@ func TestPopulateMapOfConfigMapAndSecret(t *testing.T) {
|
|||
manifest := &manifest.Manifest{
|
||||
Configmaps: []manifest.ConfigMap{
|
||||
{
|
||||
Type: "env",
|
||||
NamePrefix: "envConfigMap",
|
||||
Generic: manifest.Generic{
|
||||
Name: "envConfigMap",
|
||||
DataSources: manifest.DataSources{
|
||||
EnvSource: "examples/simple/instances/exampleinstance/configmap/app.env",
|
||||
},
|
||||
},
|
||||
},
|
||||
Secrets: []manifest.Secret{
|
||||
GenericSecrets: []manifest.GenericSecret{
|
||||
{
|
||||
Type: "env",
|
||||
NamePrefix: "envSecret",
|
||||
Generic: manifest.Generic{
|
||||
Name: "envSecret",
|
||||
DataSources: manifest.DataSources{
|
||||
EnvSource: "examples/simple/instances/exampleinstance/configmap/app.env",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue