fix(helm): add service, secret manifests in `init -o`

When `helm init -o yaml|json` is run, the service and optional secret manifests
will be included in the output. The manifests are included in a Kubernetes
[List](https://godoc.org/k8s.io/apimachinery/pkg/apis/meta/v1#List) object.

This also includes some refactoring and consolidates how manifests are output in
`--dry-run` and removes some unnecessary marshaling between Kubernetes objects
and strings.

Fixes #3145
pull/3556/head
ryane 7 years ago
parent d0883dd813
commit af80059b45
No known key found for this signature in database
GPG Key ID: EF68CF41D42A593D

@ -176,58 +176,29 @@ func (i *initCmd) run() error {
i.opts.ServiceAccount = i.serviceAccount i.opts.ServiceAccount = i.serviceAccount
i.opts.MaxHistory = i.maxHistory i.opts.MaxHistory = i.maxHistory
writeYAMLManifest := func(apiVersion, kind, body string, first, last bool) error {
w := i.out
if !first {
// YAML starting document boundary marker
if _, err := fmt.Fprintln(w, "---"); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w, "apiVersion:", apiVersion); err != nil {
return err
}
if _, err := fmt.Fprintln(w, "kind:", kind); err != nil {
return err
}
if _, err := fmt.Fprint(w, body); err != nil {
return err
}
if !last {
return nil
}
// YAML ending document boundary marker
_, err := fmt.Fprintln(w, "...")
return err
}
if len(i.opts.Output) > 0 { if len(i.opts.Output) > 0 {
var body string var body []byte
var err error var err error
const tm = `{"apiVersion":"extensions/v1beta1","kind":"Deployment",` if body, err = installer.TillerManifests(&i.opts); err != nil {
if body, err = installer.DeploymentManifest(&i.opts); err != nil {
return err return err
} }
switch i.opts.Output.String() { switch i.opts.Output.String() {
case "json": case "json":
var out bytes.Buffer var out bytes.Buffer
jsonb, err := yaml.ToJSON([]byte(body)) jsonb, err := yaml.ToJSON(body)
if err != nil { if err != nil {
return err return err
} }
buf := bytes.NewBuffer(make([]byte, 0, len(tm)+len(jsonb)-1)) buf := bytes.NewBuffer(jsonb)
buf.WriteString(tm)
// Drop the opening object delimiter ('{').
buf.Write(jsonb[1:])
if err := json.Indent(&out, buf.Bytes(), "", " "); err != nil { if err := json.Indent(&out, buf.Bytes(), "", " "); err != nil {
return err return err
} }
if _, err = i.out.Write(out.Bytes()); err != nil { if _, err = i.out.Write(out.Bytes()); err != nil {
return err return err
} }
return nil return nil
case "yaml": case "yaml":
if err := writeYAMLManifest("extensions/v1beta1", "Deployment", body, true, false); err != nil { if _, err = i.out.Write(body); err != nil {
return err return err
} }
return nil return nil
@ -236,35 +207,17 @@ func (i *initCmd) run() error {
} }
} }
if settings.Debug { if settings.Debug {
var body []byte
var body string
var err error var err error
// write Deployment manifest // write Tiller manifests
if body, err = installer.DeploymentManifest(&i.opts); err != nil { if body, err = installer.TillerManifests(&i.opts); err != nil {
return err
}
if err := writeYAMLManifest("extensions/v1beta1", "Deployment", body, true, false); err != nil {
return err return err
} }
// write Service manifest if _, err = i.out.Write(body); err != nil {
if body, err = installer.ServiceManifest(i.namespace); err != nil {
return err
}
if err := writeYAMLManifest("v1", "Service", body, false, !i.opts.EnableTLS); err != nil {
return err return err
} }
// write Secret manifest
if i.opts.EnableTLS {
if body, err = installer.SecretManifest(&i.opts); err != nil {
return err
}
if err := writeYAMLManifest("v1", "Secret", body, false, true); err != nil {
return err
}
}
} }
if i.dryRun { if i.dryRun {

@ -167,15 +167,13 @@ func TestInitCmd_dryRun(t *testing.T) {
t.Errorf("expected no server calls, got %d", got) t.Errorf("expected no server calls, got %d", got)
} }
docs := bytes.Split(buf.Bytes(), []byte("\n---")) list := &metav1.List{}
if got, want := len(docs), 2; got != want { if err := yaml.Unmarshal(buf.Bytes(), &list); err != nil {
t.Fatalf("Expected document count of %d, got %d", want, got) t.Errorf("Expected parseable List, got %q\n\t%s", buf.String(), err)
} }
for _, doc := range docs {
var y map[string]interface{} if got, want := len(list.Items), 2; got != want {
if err := yaml.Unmarshal(doc, &y); err != nil { t.Fatalf("Expected resource count of %d, got %d", want, got)
t.Errorf("Expected parseable YAML, got %q\n\t%s", doc, err)
}
} }
} }

@ -27,7 +27,9 @@ import (
"k8s.io/api/core/v1" "k8s.io/api/core/v1"
"k8s.io/api/extensions/v1beta1" "k8s.io/api/extensions/v1beta1"
apierrors "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors"
meta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
corev1 "k8s.io/client-go/kubernetes/typed/core/v1" corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
@ -104,7 +106,7 @@ func semverCompare(image string) int {
// createDeployment creates the Tiller Deployment resource. // createDeployment creates the Tiller Deployment resource.
func createDeployment(client extensionsclient.DeploymentsGetter, opts *Options) error { func createDeployment(client extensionsclient.DeploymentsGetter, opts *Options) error {
obj, err := deployment(opts) obj, err := generateDeployment(opts)
if err != nil { if err != nil {
return err return err
} }
@ -113,40 +115,66 @@ func createDeployment(client extensionsclient.DeploymentsGetter, opts *Options)
} }
// deployment gets the deployment object that installs Tiller. // Deployment gets the deployment object that installs Tiller.
func deployment(opts *Options) (*v1beta1.Deployment, error) { func Deployment(opts *Options) (*v1beta1.Deployment, error) {
return generateDeployment(opts) dep, err := generateDeployment(opts)
if err != nil {
return nil, err
}
dep.TypeMeta = metav1.TypeMeta{
Kind: "Deployment",
APIVersion: "extensions/v1beta1",
}
return dep, nil
} }
// createService creates the Tiller service resource // createService creates the Tiller service resource
func createService(client corev1.ServicesGetter, namespace string) error { func createService(client corev1.ServicesGetter, namespace string) error {
obj := service(namespace) obj := generateService(namespace)
_, err := client.Services(obj.Namespace).Create(obj) _, err := client.Services(obj.Namespace).Create(obj)
return err return err
} }
// service gets the service object that installs Tiller. // Service gets the service object that installs Tiller.
func service(namespace string) *v1.Service { func Service(namespace string) *v1.Service {
return generateService(namespace) svc := generateService(namespace)
svc.TypeMeta = metav1.TypeMeta{
Kind: "Service",
APIVersion: "v1",
}
return svc
} }
// DeploymentManifest gets the manifest (as a string) that describes the Tiller Deployment // TillerManifests gets the Deployment, Service, and Secret (if tls-enabled) manifests
// resource. func TillerManifests(opts *Options) ([]byte, error) {
func DeploymentManifest(opts *Options) (string, error) { dep, err := Deployment(opts)
obj, err := deployment(opts)
if err != nil { if err != nil {
return "", err return []byte{}, err
} }
buf, err := yaml.Marshal(obj)
return string(buf), err
}
// ServiceManifest gets the manifest (as a string) that describes the Tiller Service svc := Service(opts.Namespace)
// resource.
func ServiceManifest(namespace string) (string, error) { objs := []runtime.Object{dep, svc}
obj := service(namespace)
buf, err := yaml.Marshal(obj) if opts.EnableTLS {
return string(buf), err secret, err := Secret(opts)
if err != nil {
return []byte{}, err
}
objs = append(objs, secret)
}
list := &metav1.List{
TypeMeta: metav1.TypeMeta{
Kind: "List",
APIVersion: "v1",
},
}
if err := meta.SetList(list, objs); err != nil {
return []byte{}, err
}
buf, err := yaml.Marshal(list)
return buf, err
} }
func generateLabels(labels map[string]string) map[string]string { func generateLabels(labels map[string]string) map[string]string {
@ -320,14 +348,19 @@ func generateService(namespace string) *v1.Service {
return s return s
} }
// SecretManifest gets the manifest (as a string) that describes the Tiller Secret resource. // Secret gets the Tiller secret resource.
func SecretManifest(opts *Options) (string, error) { func Secret(opts *Options) (*v1.Secret, error) {
o, err := generateSecret(opts) secret, err := generateSecret(opts)
if err != nil { if err != nil {
return "", err return nil, err
}
secret.TypeMeta = metav1.TypeMeta{
Kind: "Secret",
APIVersion: "v1",
} }
buf, err := yaml.Marshal(o)
return string(buf), err return secret, nil
} }
// createSecret creates the Tiller secret resource. // createSecret creates the Tiller secret resource.

@ -34,7 +34,7 @@ import (
"k8s.io/helm/pkg/version" "k8s.io/helm/pkg/version"
) )
func TestDeploymentManifest(t *testing.T) { func TestDeployment(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
image string image string
@ -48,14 +48,10 @@ func TestDeploymentManifest(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
o, err := DeploymentManifest(&Options{Namespace: v1.NamespaceDefault, ImageSpec: tt.image, UseCanary: tt.canary}) dep, err := Deployment(&Options{Namespace: v1.NamespaceDefault, ImageSpec: tt.image, UseCanary: tt.canary})
if err != nil { if err != nil {
t.Fatalf("%s: error %q", tt.name, err) t.Fatalf("%s: error %q", tt.name, err)
} }
var dep v1beta1.Deployment
if err := yaml.Unmarshal([]byte(o), &dep); err != nil {
t.Fatalf("%s: error %q", tt.name, err)
}
if got := dep.Spec.Template.Spec.Containers[0].Image; got != tt.expect { if got := dep.Spec.Template.Spec.Containers[0].Image; got != tt.expect {
t.Errorf("%s: expected image %q, got %q", tt.name, tt.expect, got) t.Errorf("%s: expected image %q, got %q", tt.name, tt.expect, got)
@ -71,7 +67,7 @@ func TestDeploymentManifest(t *testing.T) {
} }
} }
func TestDeploymentManifestForServiceAccount(t *testing.T) { func TestDeploymentForServiceAccount(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
image string image string
@ -84,22 +80,18 @@ func TestDeploymentManifestForServiceAccount(t *testing.T) {
{"withoutSA", "", false, "gcr.io/kubernetes-helm/tiller:latest", "IfNotPresent", ""}, {"withoutSA", "", false, "gcr.io/kubernetes-helm/tiller:latest", "IfNotPresent", ""},
} }
for _, tt := range tests { for _, tt := range tests {
o, err := DeploymentManifest(&Options{Namespace: v1.NamespaceDefault, ImageSpec: tt.image, UseCanary: tt.canary, ServiceAccount: tt.serviceAccount}) d, err := Deployment(&Options{Namespace: v1.NamespaceDefault, ImageSpec: tt.image, UseCanary: tt.canary, ServiceAccount: tt.serviceAccount})
if err != nil { if err != nil {
t.Fatalf("%s: error %q", tt.name, err) t.Fatalf("%s: error %q", tt.name, err)
} }
var d v1beta1.Deployment
if err := yaml.Unmarshal([]byte(o), &d); err != nil {
t.Fatalf("%s: error %q", tt.name, err)
}
if got := d.Spec.Template.Spec.ServiceAccountName; got != tt.serviceAccount { if got := d.Spec.Template.Spec.ServiceAccountName; got != tt.serviceAccount {
t.Errorf("%s: expected service account value %q, got %q", tt.name, tt.serviceAccount, got) t.Errorf("%s: expected service account value %q, got %q", tt.name, tt.serviceAccount, got)
} }
} }
} }
func TestDeploymentManifest_WithTLS(t *testing.T) { func TestDeployment_WithTLS(t *testing.T) {
tests := []struct { tests := []struct {
opts Options opts Options
name string name string
@ -126,15 +118,11 @@ func TestDeploymentManifest_WithTLS(t *testing.T) {
}, },
} }
for _, tt := range tests { for _, tt := range tests {
o, err := DeploymentManifest(&tt.opts) d, err := Deployment(&tt.opts)
if err != nil { if err != nil {
t.Fatalf("%s: error %q", tt.name, err) t.Fatalf("%s: error %q", tt.name, err)
} }
var d v1beta1.Deployment
if err := yaml.Unmarshal([]byte(o), &d); err != nil {
t.Fatalf("%s: error %q", tt.name, err)
}
// verify environment variable in deployment reflect the use of tls being enabled. // verify environment variable in deployment reflect the use of tls being enabled.
if got := d.Spec.Template.Spec.Containers[0].Env[2].Value; got != tt.verify { if got := d.Spec.Template.Spec.Containers[0].Env[2].Value; got != tt.verify {
t.Errorf("%s: expected tls verify env value %q, got %q", tt.name, tt.verify, got) t.Errorf("%s: expected tls verify env value %q, got %q", tt.name, tt.verify, got)
@ -146,14 +134,7 @@ func TestDeploymentManifest_WithTLS(t *testing.T) {
} }
func TestServiceManifest(t *testing.T) { func TestServiceManifest(t *testing.T) {
o, err := ServiceManifest(v1.NamespaceDefault) svc := Service(v1.NamespaceDefault)
if err != nil {
t.Fatalf("error %q", err)
}
var svc v1.Service
if err := yaml.Unmarshal([]byte(o), &svc); err != nil {
t.Fatalf("error %q", err)
}
if got := svc.ObjectMeta.Namespace; got != v1.NamespaceDefault { if got := svc.ObjectMeta.Namespace; got != v1.NamespaceDefault {
t.Errorf("expected namespace %s, got %s", v1.NamespaceDefault, got) t.Errorf("expected namespace %s, got %s", v1.NamespaceDefault, got)
@ -161,7 +142,7 @@ func TestServiceManifest(t *testing.T) {
} }
func TestSecretManifest(t *testing.T) { func TestSecretManifest(t *testing.T) {
o, err := SecretManifest(&Options{ obj, err := Secret(&Options{
VerifyTLS: true, VerifyTLS: true,
EnableTLS: true, EnableTLS: true,
Namespace: v1.NamespaceDefault, Namespace: v1.NamespaceDefault,
@ -174,11 +155,6 @@ func TestSecretManifest(t *testing.T) {
t.Fatalf("error %q", err) t.Fatalf("error %q", err)
} }
var obj v1.Secret
if err := yaml.Unmarshal([]byte(o), &obj); err != nil {
t.Fatalf("error %q", err)
}
if got := obj.ObjectMeta.Namespace; got != v1.NamespaceDefault { if got := obj.ObjectMeta.Namespace; got != v1.NamespaceDefault {
t.Errorf("expected namespace %s, got %s", v1.NamespaceDefault, got) t.Errorf("expected namespace %s, got %s", v1.NamespaceDefault, got)
} }
@ -335,13 +311,13 @@ func TestInstall_canary(t *testing.T) {
func TestUpgrade(t *testing.T) { func TestUpgrade(t *testing.T) {
image := "gcr.io/kubernetes-helm/tiller:v2.0.0" image := "gcr.io/kubernetes-helm/tiller:v2.0.0"
serviceAccount := "newServiceAccount" serviceAccount := "newServiceAccount"
existingDeployment, _ := deployment(&Options{ existingDeployment, _ := generateDeployment(&Options{
Namespace: v1.NamespaceDefault, Namespace: v1.NamespaceDefault,
ImageSpec: "imageToReplace:v1.0.0", ImageSpec: "imageToReplace:v1.0.0",
ServiceAccount: "serviceAccountToReplace", ServiceAccount: "serviceAccountToReplace",
UseCanary: false, UseCanary: false,
}) })
existingService := service(v1.NamespaceDefault) existingService := generateService(v1.NamespaceDefault)
fc := &fake.Clientset{} fc := &fake.Clientset{}
fc.AddReactor("get", "deployments", func(action testcore.Action) (bool, runtime.Object, error) { fc.AddReactor("get", "deployments", func(action testcore.Action) (bool, runtime.Object, error) {
@ -376,7 +352,7 @@ func TestUpgrade(t *testing.T) {
func TestUpgrade_serviceNotFound(t *testing.T) { func TestUpgrade_serviceNotFound(t *testing.T) {
image := "gcr.io/kubernetes-helm/tiller:v2.0.0" image := "gcr.io/kubernetes-helm/tiller:v2.0.0"
existingDeployment, _ := deployment(&Options{ existingDeployment, _ := generateDeployment(&Options{
Namespace: v1.NamespaceDefault, Namespace: v1.NamespaceDefault,
ImageSpec: "imageToReplace", ImageSpec: "imageToReplace",
UseCanary: false, UseCanary: false,
@ -419,13 +395,13 @@ func TestUpgrade_serviceNotFound(t *testing.T) {
func TestUgrade_newerVersion(t *testing.T) { func TestUgrade_newerVersion(t *testing.T) {
image := "gcr.io/kubernetes-helm/tiller:v2.0.0" image := "gcr.io/kubernetes-helm/tiller:v2.0.0"
serviceAccount := "newServiceAccount" serviceAccount := "newServiceAccount"
existingDeployment, _ := deployment(&Options{ existingDeployment, _ := generateDeployment(&Options{
Namespace: v1.NamespaceDefault, Namespace: v1.NamespaceDefault,
ImageSpec: "imageToReplace:v100.5.0", ImageSpec: "imageToReplace:v100.5.0",
ServiceAccount: "serviceAccountToReplace", ServiceAccount: "serviceAccountToReplace",
UseCanary: false, UseCanary: false,
}) })
existingService := service(v1.NamespaceDefault) existingService := generateService(v1.NamespaceDefault)
fc := &fake.Clientset{} fc := &fake.Clientset{}
fc.AddReactor("get", "deployments", func(action testcore.Action) (bool, runtime.Object, error) { fc.AddReactor("get", "deployments", func(action testcore.Action) (bool, runtime.Object, error) {
@ -479,13 +455,13 @@ func TestUgrade_newerVersion(t *testing.T) {
func TestUpgrade_identical(t *testing.T) { func TestUpgrade_identical(t *testing.T) {
image := "gcr.io/kubernetes-helm/tiller:v2.0.0" image := "gcr.io/kubernetes-helm/tiller:v2.0.0"
serviceAccount := "newServiceAccount" serviceAccount := "newServiceAccount"
existingDeployment, _ := deployment(&Options{ existingDeployment, _ := generateDeployment(&Options{
Namespace: v1.NamespaceDefault, Namespace: v1.NamespaceDefault,
ImageSpec: "imageToReplace:v2.0.0", ImageSpec: "imageToReplace:v2.0.0",
ServiceAccount: "serviceAccountToReplace", ServiceAccount: "serviceAccountToReplace",
UseCanary: false, UseCanary: false,
}) })
existingService := service(v1.NamespaceDefault) existingService := generateService(v1.NamespaceDefault)
fc := &fake.Clientset{} fc := &fake.Clientset{}
fc.AddReactor("get", "deployments", func(action testcore.Action) (bool, runtime.Object, error) { fc.AddReactor("get", "deployments", func(action testcore.Action) (bool, runtime.Object, error) {
@ -520,13 +496,13 @@ func TestUpgrade_identical(t *testing.T) {
func TestUpgrade_canaryClient(t *testing.T) { func TestUpgrade_canaryClient(t *testing.T) {
image := "gcr.io/kubernetes-helm/tiller:canary" image := "gcr.io/kubernetes-helm/tiller:canary"
serviceAccount := "newServiceAccount" serviceAccount := "newServiceAccount"
existingDeployment, _ := deployment(&Options{ existingDeployment, _ := generateDeployment(&Options{
Namespace: v1.NamespaceDefault, Namespace: v1.NamespaceDefault,
ImageSpec: "imageToReplace:v1.0.0", ImageSpec: "imageToReplace:v1.0.0",
ServiceAccount: "serviceAccountToReplace", ServiceAccount: "serviceAccountToReplace",
UseCanary: false, UseCanary: false,
}) })
existingService := service(v1.NamespaceDefault) existingService := generateService(v1.NamespaceDefault)
fc := &fake.Clientset{} fc := &fake.Clientset{}
fc.AddReactor("get", "deployments", func(action testcore.Action) (bool, runtime.Object, error) { fc.AddReactor("get", "deployments", func(action testcore.Action) (bool, runtime.Object, error) {
@ -561,13 +537,13 @@ func TestUpgrade_canaryClient(t *testing.T) {
func TestUpgrade_canaryServer(t *testing.T) { func TestUpgrade_canaryServer(t *testing.T) {
image := "gcr.io/kubernetes-helm/tiller:v2.0.0" image := "gcr.io/kubernetes-helm/tiller:v2.0.0"
serviceAccount := "newServiceAccount" serviceAccount := "newServiceAccount"
existingDeployment, _ := deployment(&Options{ existingDeployment, _ := generateDeployment(&Options{
Namespace: v1.NamespaceDefault, Namespace: v1.NamespaceDefault,
ImageSpec: "imageToReplace:canary", ImageSpec: "imageToReplace:canary",
ServiceAccount: "serviceAccountToReplace", ServiceAccount: "serviceAccountToReplace",
UseCanary: false, UseCanary: false,
}) })
existingService := service(v1.NamespaceDefault) existingService := generateService(v1.NamespaceDefault)
fc := &fake.Clientset{} fc := &fake.Clientset{}
fc.AddReactor("get", "deployments", func(action testcore.Action) (bool, runtime.Object, error) { fc.AddReactor("get", "deployments", func(action testcore.Action) (bool, runtime.Object, error) {
@ -607,7 +583,8 @@ func tlsTestFile(t *testing.T, path string) string {
} }
return path return path
} }
func TestDeploymentManifest_WithNodeSelectors(t *testing.T) {
func TestDeployment_WithNodeSelectors(t *testing.T) {
tests := []struct { tests := []struct {
opts Options opts Options
name string name string
@ -631,15 +608,11 @@ func TestDeploymentManifest_WithNodeSelectors(t *testing.T) {
}, },
} }
for _, tt := range tests { for _, tt := range tests {
o, err := DeploymentManifest(&tt.opts) d, err := Deployment(&tt.opts)
if err != nil { if err != nil {
t.Fatalf("%s: error %q", tt.name, err) t.Fatalf("%s: error %q", tt.name, err)
} }
var d v1beta1.Deployment
if err := yaml.Unmarshal([]byte(o), &d); err != nil {
t.Fatalf("%s: error %q", tt.name, err)
}
// Verify that environment variables in Deployment reflect the use of TLS being enabled. // Verify that environment variables in Deployment reflect the use of TLS being enabled.
got := d.Spec.Template.Spec.NodeSelector got := d.Spec.Template.Spec.NodeSelector
for k, v := range tt.expect { for k, v := range tt.expect {
@ -649,7 +622,8 @@ func TestDeploymentManifest_WithNodeSelectors(t *testing.T) {
} }
} }
} }
func TestDeploymentManifest_WithSetValues(t *testing.T) {
func TestDeployment_WithSetValues(t *testing.T) {
tests := []struct { tests := []struct {
opts Options opts Options
name string name string
@ -676,11 +650,17 @@ func TestDeploymentManifest_WithSetValues(t *testing.T) {
}, },
} }
for _, tt := range tests { for _, tt := range tests {
o, err := DeploymentManifest(&tt.opts) d, err := Deployment(&tt.opts)
if err != nil { if err != nil {
t.Fatalf("%s: error %q", tt.name, err) t.Fatalf("%s: error %q", tt.name, err)
} }
values, err := chartutil.ReadValues([]byte(o))
o, err := yaml.Marshal(d)
if err != nil {
t.Errorf("Error marshaling Deployment: %s", err)
}
values, err := chartutil.ReadValues(o)
if err != nil { if err != nil {
t.Errorf("Error converting Deployment manifest to Values: %s", err) t.Errorf("Error converting Deployment manifest to Values: %s", err)
} }

Loading…
Cancel
Save