diff --git a/pkg/bootstrap/cloudinit/cloud-init.go b/pkg/bootstrap/cloudinit/cloud-init.go new file mode 100644 index 000000000..e85d20b3b --- /dev/null +++ b/pkg/bootstrap/cloudinit/cloud-init.go @@ -0,0 +1,78 @@ +package cloudinit + +import ( + b64 "encoding/base64" + + "opendev.org/airship/airshipctl/pkg/document" +) + +const ( + // TODO (dukov) This should depend on cluster api version once it is + // fully available for Metal3. In other words: + // - Sectet for v1alpha1 + // - KubeAdmConfig for v1alpha2 + EphemeralClusterConfKind = "Secret" +) + +func decodeData(cfg document.Document, key string) ([]byte, error) { + data, err := cfg.GetStringMap("data") + if err != nil { + return nil, ErrDataNotSupplied{DocName: cfg.GetName(), Key: key} + } + + res, ok := data[key] + if !ok { + return nil, ErrDataNotSupplied{DocName: cfg.GetName(), Key: key} + } + + return b64.StdEncoding.DecodeString(res) +} + +// getDataFromSecret extracts data from Secret with respect to overrides +func getDataFromSecret(cfg document.Document, key string) ([]byte, error) { + data, err := cfg.GetStringMap("stringData") + if err != nil { + return decodeData(cfg, key) + } + + res, ok := data[key] + if !ok { + return decodeData(cfg, key) + } + return []byte(res), nil +} + +// GetCloudData reads YAML document input and generates cloud-init data for +// node (i.e. Cluster API Machine) with bootstrap annotation. +func GetCloudData(docBundle document.Bundle, bsAnnotation string) ([]byte, []byte, error) { + var userData []byte + var netConf []byte + docs, err := docBundle.GetByAnnotation(bsAnnotation) + if err != nil { + return nil, nil, err + } + var ephemeralCfg document.Document + for _, doc := range docs { + if doc.GetKind() == EphemeralClusterConfKind { + ephemeralCfg = doc + break + } + } + if ephemeralCfg == nil { + return nil, nil, document.ErrDocNotFound{ + Annotation: bsAnnotation, + Kind: EphemeralClusterConfKind, + } + } + + netConf, err = getDataFromSecret(ephemeralCfg, "netconfig") + if err != nil { + return nil, nil, err + } + + userData, err = getDataFromSecret(ephemeralCfg, "userdata") + if err != nil { + return nil, nil, err + } + return userData, netConf, nil +} diff --git a/pkg/bootstrap/cloudinit/cloud-init_test.go b/pkg/bootstrap/cloudinit/cloud-init_test.go new file mode 100644 index 000000000..e7b76634f --- /dev/null +++ b/pkg/bootstrap/cloudinit/cloud-init_test.go @@ -0,0 +1,67 @@ +package cloudinit + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "opendev.org/airship/airshipctl/pkg/document" + "opendev.org/airship/airshipctl/testutil" +) + +func TestGetCloudData(t *testing.T) { + + fSys := testutil.SetupTestFs(t, "testdata") + bundle, err := document.NewBundle(fSys, "/", "/") + require.NoError(t, err, "Building Bundle Failed") + + tests := []struct { + ann string + expectedUserData []byte + expectedNetData []byte + expectedErr error + }{ + { + ann: "test=test", + expectedUserData: nil, + expectedNetData: nil, + expectedErr: document.ErrDocNotFound{ + Annotation: "test=test", + Kind: "Secret", + }, + }, + { + ann: "airshipit.org/clustertype=nodata", + expectedUserData: nil, + expectedNetData: nil, + expectedErr: ErrDataNotSupplied{ + DocName: "node1-bmc-secret1", + Key: "netconfig", + }, + }, + { + ann: "test=nodataforcfg", + expectedUserData: nil, + expectedNetData: nil, + expectedErr: ErrDataNotSupplied{ + DocName: "node1-bmc-secret2", + Key: "netconfig", + }, + }, + { + ann: "airshipit.org/clustertype=ephemeral", + expectedUserData: []byte("cloud-init"), + expectedNetData: []byte("netconfig\n"), + expectedErr: nil, + }, + } + + for _, tt := range tests { + actualUserData, actualNetData, actualErr := GetCloudData(bundle, tt.ann) + + assert.Equal(t, tt.expectedUserData, actualUserData) + assert.Equal(t, tt.expectedNetData, actualNetData) + assert.Equal(t, tt.expectedErr, actualErr) + } +} diff --git a/pkg/bootstrap/cloudinit/errors.go b/pkg/bootstrap/cloudinit/errors.go new file mode 100644 index 000000000..c076b2773 --- /dev/null +++ b/pkg/bootstrap/cloudinit/errors.go @@ -0,0 +1,16 @@ +package cloudinit + +import ( + "fmt" +) + +// ErrDataNotSupplied error returned of no user-data or network configuration +// in the Secret +type ErrDataNotSupplied struct { + DocName string + Key string +} + +func (e ErrDataNotSupplied) Error() string { + return fmt.Sprintf("Document %s has no key %s", e.DocName, e.Key) +} diff --git a/pkg/bootstrap/cloudinit/testdata/kustomization.yaml b/pkg/bootstrap/cloudinit/testdata/kustomization.yaml new file mode 100644 index 000000000..97a9721bd --- /dev/null +++ b/pkg/bootstrap/cloudinit/testdata/kustomization.yaml @@ -0,0 +1,2 @@ +resources: + - secret.yaml diff --git a/pkg/bootstrap/cloudinit/testdata/secret.yaml b/pkg/bootstrap/cloudinit/testdata/secret.yaml new file mode 100644 index 000000000..d0563b0e4 --- /dev/null +++ b/pkg/bootstrap/cloudinit/testdata/secret.yaml @@ -0,0 +1,29 @@ +apiVersion: v1 +kind: Secret +metadata: + annotations: + airshipit.org/clustertype: ephemeral + name: node1-bmc-secret +type: Opaque +data: + netconfig: bmV0Y29uZmlnCg== +stringData: + userdata: cloud-init +--- +apiVersion: v1 +kind: Secret +metadata: + annotations: + airshipit.org/clustertype: nodata + name: node1-bmc-secret1 +type: Opaque +--- +apiVersion: v1 +kind: Secret +metadata: + annotations: + test: nodataforcfg + name: node1-bmc-secret2 +type: Opaque +data: + foo: bmV0Y29uZmlnCg== diff --git a/pkg/bootstrap/isogen/command.go b/pkg/bootstrap/isogen/command.go index 323c2787b..70e811047 100644 --- a/pkg/bootstrap/isogen/command.go +++ b/pkg/bootstrap/isogen/command.go @@ -4,10 +4,20 @@ import ( "context" "fmt" "io" + "path/filepath" + "strings" + "opendev.org/airship/airshipctl/pkg/bootstrap/cloudinit" "opendev.org/airship/airshipctl/pkg/container" + "opendev.org/airship/airshipctl/pkg/document" "opendev.org/airship/airshipctl/pkg/errors" "opendev.org/airship/airshipctl/pkg/util" + + "sigs.k8s.io/kustomize/v3/pkg/fs" +) + +const ( + builderConfigFileName = "builder-conf.yaml" ) // GenerateBootstrapIso will generate data for cloud init and start ISO builder container @@ -24,6 +34,15 @@ func GenerateBootstrapIso(settings *Settings, args []string, out io.Writer) erro return err } + if err := verifyInputs(cfg, args, out); err != nil { + return err + } + + docBundle, err := document.NewBundle(fs.MakeRealFS(), args[0], "") + if err != nil { + return err + } + fmt.Fprintln(out, "Creating ISO builder container") builder, err := container.NewContainer( &ctx, cfg.Container.ContainerRuntime, @@ -32,17 +51,78 @@ func GenerateBootstrapIso(settings *Settings, args []string, out io.Writer) erro return err } - return generateBootstrapIso(builder, cfg, out, settings.Debug) + return generateBootstrapIso(docBundle, builder, cfg, out, settings.Debug) } -func generateBootstrapIso(builder container.Container, cfg *Config, out io.Writer, debug bool) error { +func verifyInputs(cfg *Config, args []string, out io.Writer) error { + if len(args) == 0 { + fmt.Fprintln(out, "Specify path to document model. Config param from global settings is not supported") + return errors.ErrNotImplemented{} + } + + if cfg.Container.Volume == "" { + fmt.Fprintln(out, "Specify volume bind for ISO builder container") + return errors.ErrWrongConfig{} + } + + if (cfg.Builder.UserDataFileName == "") || (cfg.Builder.NetworkConfigFileName == "") { + fmt.Fprintln(out, "UserDataFileName or NetworkConfigFileName are not specified in ISO builder config") + return errors.ErrWrongConfig{} + } + + vols := strings.Split(cfg.Container.Volume, ":") + switch { + case len(vols) == 1: + cfg.Container.Volume = fmt.Sprintf("%s:%s", vols[0], vols[0]) + case len(vols) > 2: + fmt.Fprintln(out, "Bad container volume format. Use hostPath:contPath") + return errors.ErrWrongConfig{} + } + return nil +} + +func getContainerCfg(cfg *Config, userData []byte, netConf []byte) (map[string][]byte, error) { + hostVol := strings.Split(cfg.Container.Volume, ":")[0] + + fls := make(map[string][]byte) + fls[filepath.Join(hostVol, cfg.Builder.UserDataFileName)] = userData + fls[filepath.Join(hostVol, cfg.Builder.NetworkConfigFileName)] = netConf + builderData, err := cfg.ToYAML() + if err != nil { + return nil, err + } + fls[filepath.Join(hostVol, builderConfigFileName)] = builderData + return fls, nil +} + +func generateBootstrapIso( + docBubdle document.Bundle, + builder container.Container, + cfg *Config, + out io.Writer, + debug bool, +) error { + cntVol := strings.Split(cfg.Container.Volume, ":")[1] + fmt.Fprintln(out, "Creating cloud-init for ephemeral K8s") + userData, netConf, err := cloudinit.GetCloudData(docBubdle, EphemeralClusterAnnotation) + if err != nil { + return err + } + + var fls map[string][]byte + fls, err = getContainerCfg(cfg, userData, netConf) + if err = util.WriteFiles(fls, 0600); err != nil { + return err + } + vols := []string{cfg.Container.Volume} + builderCfgLocation := filepath.Join(cntVol, builderConfigFileName) fmt.Fprintf(out, "Running default container command. Mounted dir: %s\n", vols) if err := builder.RunCommand( []string{}, nil, vols, - []string{}, + []string{fmt.Sprintf("BUILDER_CONFIG=%s", builderCfgLocation)}, debug, ); err != nil { return err diff --git a/pkg/bootstrap/isogen/command_test.go b/pkg/bootstrap/isogen/command_test.go index df763358e..a814b6bea 100644 --- a/pkg/bootstrap/isogen/command_test.go +++ b/pkg/bootstrap/isogen/command_test.go @@ -7,6 +7,11 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "opendev.org/airship/airshipctl/pkg/document" + "opendev.org/airship/airshipctl/pkg/errors" + "opendev.org/airship/airshipctl/testutil" ) type mockContainer struct { @@ -38,9 +43,25 @@ func (mc *mockContainer) GetId() string { } func TestBootstrapIso(t *testing.T) { + fSys := testutil.SetupTestFs(t, "testdata") + bundle, err := document.NewBundle(fSys, "/", "/") + require.NoError(t, err, "Building Bundle Failed") + + volBind := "/tmp:/dst" testErr := fmt.Errorf("TestErr") + testCfg := &Config{ + Container: Container{ + Volume: volBind, + ContainerRuntime: "docker", + }, + Builder: Builder{ + UserDataFileName: "user-data", + NetworkConfigFileName: "net-conf", + }, + } expOut := []string{ - "Running default container command. Mounted dir: []\n", + "Creating cloud-init for ephemeral K8s\n", + fmt.Sprintf("Running default container command. Mounted dir: [%s]\n", volBind), "ISO successfully built.\n", "Debug flag is set. Container TESTID stopped but not deleted.\n", "Removing container.\n", @@ -57,9 +78,9 @@ func TestBootstrapIso(t *testing.T) { builder: &mockContainer{ runCommand: func() error { return testErr }, }, - cfg: &Config{}, + cfg: testCfg, debug: false, - expectedOut: expOut[0], + expectedOut: expOut[0] + expOut[1], expectdErr: testErr, }, { @@ -67,9 +88,9 @@ func TestBootstrapIso(t *testing.T) { runCommand: func() error { return nil }, getId: func() string { return "TESTID" }, }, - cfg: &Config{}, + cfg: testCfg, debug: true, - expectedOut: expOut[0] + expOut[1] + expOut[2], + expectedOut: expOut[0] + expOut[1] + expOut[2] + expOut[3], expectdErr: nil, }, { @@ -78,16 +99,16 @@ func TestBootstrapIso(t *testing.T) { getId: func() string { return "TESTID" }, rmContainer: func() error { return testErr }, }, - cfg: &Config{}, + cfg: testCfg, debug: false, - expectedOut: expOut[0] + expOut[1] + expOut[3], + expectedOut: expOut[0] + expOut[1] + expOut[2] + expOut[4], expectdErr: testErr, }, } for _, tt := range tests { actualOut := bytes.NewBufferString("") - actualErr := generateBootstrapIso(tt.builder, tt.cfg, actualOut, tt.debug) + actualErr := generateBootstrapIso(bundle, tt.builder, tt.cfg, actualOut, tt.debug) errS := fmt.Sprintf("generateBootstrapIso should have return error %s, got %s", tt.expectdErr, actualErr) assert.Equal(t, actualErr, tt.expectdErr, errS) @@ -96,3 +117,63 @@ func TestBootstrapIso(t *testing.T) { assert.Equal(t, actualOut.String(), tt.expectedOut, errS) } } + +func TestVerifyInputs(t *testing.T) { + tests := []struct { + cfg *Config + args []string + expectedErr error + }{ + { + cfg: &Config{}, + args: []string{}, + expectedErr: errors.ErrNotImplemented{}, + }, + { + cfg: &Config{}, + args: []string{"."}, + expectedErr: errors.ErrWrongConfig{}, + }, + { + cfg: &Config{ + Container: Container{ + Volume: "/tmp:/dst", + }, + }, + args: []string{"."}, + expectedErr: errors.ErrWrongConfig{}, + }, + { + cfg: &Config{ + Container: Container{ + Volume: "/tmp", + }, + Builder: Builder{ + UserDataFileName: "user-data", + NetworkConfigFileName: "net-conf", + }, + }, + args: []string{"."}, + expectedErr: nil, + }, + { + cfg: &Config{ + Container: Container{ + Volume: "/tmp:/dst:/dst1", + }, + Builder: Builder{ + UserDataFileName: "user-data", + NetworkConfigFileName: "net-conf", + }, + }, + args: []string{"."}, + expectedErr: errors.ErrWrongConfig{}, + }, + } + + for _, tt := range tests { + actualErr := verifyInputs(tt.cfg, tt.args, bytes.NewBufferString("")) + assert.Equal(t, tt.expectedErr, actualErr) + } + +} diff --git a/pkg/bootstrap/isogen/config.go b/pkg/bootstrap/isogen/config.go index 50bb7fe48..800548144 100644 --- a/pkg/bootstrap/isogen/config.go +++ b/pkg/bootstrap/isogen/config.go @@ -8,6 +8,11 @@ import ( "opendev.org/airship/airshipctl/pkg/environment" ) +const ( + // TODO this should be part of a airshipctl config + EphemeralClusterAnnotation = "airshipit.org/clustertype=ephemeral" +) + // Settings settings for isogen command type Settings struct { *environment.AirshipCTLSettings diff --git a/pkg/bootstrap/isogen/testdata/kustomization.yaml b/pkg/bootstrap/isogen/testdata/kustomization.yaml new file mode 100644 index 000000000..97a9721bd --- /dev/null +++ b/pkg/bootstrap/isogen/testdata/kustomization.yaml @@ -0,0 +1,2 @@ +resources: + - secret.yaml diff --git a/pkg/bootstrap/isogen/testdata/secret.yaml b/pkg/bootstrap/isogen/testdata/secret.yaml new file mode 100644 index 000000000..ebc6976c3 --- /dev/null +++ b/pkg/bootstrap/isogen/testdata/secret.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + annotations: + airshipit.org/clustertype: ephemeral + name: node1-bmc-secret +type: Opaque +data: + netconfig: bmV0Y29uZmlnCg== +stringData: + userdata: cloud-init diff --git a/pkg/document/errors.go b/pkg/document/errors.go new file mode 100644 index 000000000..38a4c95fa --- /dev/null +++ b/pkg/document/errors.go @@ -0,0 +1,15 @@ +package document + +import ( + "fmt" +) + +// ErrDocNotFound returned if desired document not found +type ErrDocNotFound struct { + Annotation string + Kind string +} + +func (e ErrDocNotFound) Error() string { + return fmt.Sprintf("Document annotated by %s with Kind %s not found", e.Annotation, e.Kind) +} diff --git a/pkg/errors/common.go b/pkg/errors/common.go index 085be7b1d..16ec47ce1 100644 --- a/pkg/errors/common.go +++ b/pkg/errors/common.go @@ -7,3 +7,11 @@ type ErrNotImplemented struct { func (e ErrNotImplemented) Error() string { return "Error. Not implemented" } + +// ErrWrongConfig returned in case of incorrect configuration +type ErrWrongConfig struct { +} + +func (e ErrWrongConfig) Error() string { + return "Error. Wrong configuration" +} diff --git a/pkg/util/writefiles.go b/pkg/util/writefiles.go new file mode 100644 index 000000000..1ec393815 --- /dev/null +++ b/pkg/util/writefiles.go @@ -0,0 +1,17 @@ +package util + +import ( + "io/ioutil" + "os" +) + +// WriteFiles write multiple files described in a map +func WriteFiles(fls map[string][]byte, mode os.FileMode) error { + for fileName, data := range fls { + if err := ioutil.WriteFile(fileName, data, mode); err != nil { + return err + } + } + return nil + +}