diff --git a/pkg/phase/errors.go b/pkg/phase/errors.go new file mode 100644 index 000000000..8739f66cc --- /dev/null +++ b/pkg/phase/errors.go @@ -0,0 +1,31 @@ +/* + 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 + + https://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 phase + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// ErrExecutorNotFound is returned if phase executor was not found in executor +// registry map +type ErrExecutorNotFound struct { + GVK schema.GroupVersionKind +} + +func (e ErrExecutorNotFound) Error() string { + return fmt.Sprintf("executor identified by '%s' is not found", e.GVK) +} diff --git a/pkg/phase/ifc/executor.go b/pkg/phase/ifc/executor.go new file mode 100644 index 000000000..f7af81f8e --- /dev/null +++ b/pkg/phase/ifc/executor.go @@ -0,0 +1,42 @@ +/* + 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 + + https://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 ifc + +import ( + "io" + + "opendev.org/airship/airshipctl/pkg/document" + "opendev.org/airship/airshipctl/pkg/environment" +) + +// Executor interface should be implemented by each runner +type Executor interface { + Run(dryrun, debug bool) error + Render(io.Writer) error + Validate() error + Wait() error +} + +// ExecutorFactory for executor instantiation +// First argument is document object which represents executor +// configuration. +// Second argument is document bundle used by executor. +// Third argument airship configuration settings since each phase +// has to be aware of execution context and global settings +type ExecutorFactory func( + document.Document, + document.Bundle, + *environment.AirshipCTLSettings, +) (Executor, error) diff --git a/pkg/phase/phase.go b/pkg/phase/phase.go index 70cf35e5e..634642fa1 100644 --- a/pkg/phase/phase.go +++ b/pkg/phase/phase.go @@ -17,9 +17,13 @@ package phase import ( "path/filepath" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + airshipv1 "opendev.org/airship/airshipctl/pkg/api/v1alpha1" "opendev.org/airship/airshipctl/pkg/document" "opendev.org/airship/airshipctl/pkg/environment" + "opendev.org/airship/airshipctl/pkg/phase/ifc" ) const ( @@ -28,6 +32,11 @@ const ( PhaseDirName = "phases" ) +var ( + // ExecutorRegistry contins registered runner factories + ExecutorRegistry = make(map[schema.GroupVersionKind]ifc.ExecutorFactory) +) + // Cmd object to work with phase api type Cmd struct { *environment.AirshipCTLSettings @@ -42,6 +51,84 @@ func (p *Cmd) getBundle() (document.Bundle, error) { return document.NewBundleByPath(filepath.Join(ccm.TargetPath, ccm.SubPath, PhaseDirName)) } +// GetPhase returns particular phase object identified by name +func (p *Cmd) GetPhase(name string) (*airshipv1.Phase, error) { + bundle, err := p.getBundle() + if err != nil { + return nil, err + } + phaseConfig := &airshipv1.Phase{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } + selector, err := document.NewSelector().ByObject(phaseConfig, airshipv1.Scheme) + if err != nil { + return nil, err + } + doc, err := bundle.SelectOne(selector) + if err != nil { + return nil, err + } + + if err = doc.ToAPIObject(phaseConfig, airshipv1.Scheme); err != nil { + return nil, err + } + return phaseConfig, nil +} + +// GetExecutor referenced in a phase configuration +func (p *Cmd) GetExecutor(phase *airshipv1.Phase) (ifc.Executor, error) { + bundle, err := p.getBundle() + if err != nil { + return nil, err + } + phaseConfig := phase.Config + // Searching executor configuration document referenced in + // phase configuration + refGVK := phaseConfig.ExecutorRef.GroupVersionKind() + selector := document.NewSelector(). + ByGvk(refGVK.Group, refGVK.Version, refGVK.Kind). + ByName(phaseConfig.ExecutorRef.Name). + ByNamespace(phaseConfig.ExecutorRef.Namespace) + doc, err := bundle.SelectOne(selector) + if err != nil { + return nil, err + } + + // Define executor configuration options + targetPath, err := p.Config.CurrentContextTargetPath() + if err != nil { + return nil, err + } + executorDocBundle, err := document.NewBundleByPath(filepath.Join(targetPath, phaseConfig.DocumentEntryPoint)) + if err != nil { + return nil, err + } + + // Look for executor factory defined in registry + executorFactory, found := ExecutorRegistry[refGVK] + if !found { + return nil, ErrExecutorNotFound{GVK: refGVK} + } + return executorFactory(doc, executorDocBundle, p.AirshipCTLSettings) +} + +// Exec particular phase +func (p *Cmd) Exec(name string) error { + phaseConfig, err := p.GetPhase(name) + if err != nil { + return err + } + + executor, err := p.GetExecutor(phaseConfig) + if err != nil { + return err + } + + return executor.Run(p.DryRun, p.Debug) +} + // Plan shows available phase names func (p *Cmd) Plan() (map[string][]string, error) { bundle, err := p.getBundle() diff --git a/pkg/phase/phase_test.go b/pkg/phase/phase_test.go index a59ea9dc8..015247d98 100644 --- a/pkg/phase/phase_test.go +++ b/pkg/phase/phase_test.go @@ -19,13 +19,20 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/kustomize/api/resid" "sigs.k8s.io/kustomize/api/types" + airshipv1 "opendev.org/airship/airshipctl/pkg/api/v1alpha1" "opendev.org/airship/airshipctl/pkg/config" "opendev.org/airship/airshipctl/pkg/document" "opendev.org/airship/airshipctl/pkg/environment" "opendev.org/airship/airshipctl/pkg/phase" + "opendev.org/airship/airshipctl/pkg/phase/ifc" ) func TestPhasePlan(t *testing.T) { @@ -52,6 +59,8 @@ func TestPhasePlan(t *testing.T) { "isogen", "remotedirect", "initinfra", + "some_phase", + "capi_init", }, }, }, @@ -89,6 +98,149 @@ func TestPhasePlan(t *testing.T) { } } +func TestGetPhase(t *testing.T) { + testCases := []struct { + name string + settings func() *environment.AirshipCTLSettings + phaseName string + expectedPhase *airshipv1.Phase + expectedErr error + }{ + { + name: "No context", + settings: func() *environment.AirshipCTLSettings { + s := makeDefaultSettings() + s.Config.CurrentContext = "badCtx" + return s + }, + expectedErr: config.ErrMissingConfig{What: "Context with name 'badCtx'"}, + }, + { + name: "Get existing phase", + settings: makeDefaultSettings, + phaseName: "capi_init", + expectedPhase: &airshipv1.Phase{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "airshipit.org/v1alpha1", + Kind: "Phase", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "capi_init", + }, + Config: airshipv1.PhaseConfig{ + ExecutorRef: &corev1.ObjectReference{ + Kind: "Clusterctl", + APIVersion: "airshipit.org/v1alpha1", + Name: "clusterctl-v1", + }, + DocumentEntryPoint: "manifests/site/test-site/auth", + }, + }, + }, + { + name: "Get non-existing phase", + settings: makeDefaultSettings, + phaseName: "some_name", + expectedErr: document.ErrDocNotFound{ + Selector: document.Selector{ + Selector: types.Selector{ + Gvk: resid.Gvk{ + Group: "airshipit.org", + Version: "v1alpha1", + Kind: "Phase", + }, + Name: "some_name", + }, + }, + }, + }, + } + + for _, test := range testCases { + tt := test + t.Run(tt.name, func(t *testing.T) { + cmd := phase.Cmd{AirshipCTLSettings: tt.settings()} + actualPhase, actualErr := cmd.GetPhase(tt.phaseName) + assert.Equal(t, tt.expectedErr, actualErr) + assert.Equal(t, tt.expectedPhase, actualPhase) + }) + } +} + +func TestGetExecutor(t *testing.T) { + testCases := []struct { + name string + settings func() *environment.AirshipCTLSettings + phase *airshipv1.Phase + expectedExc ifc.Executor + expectedErr error + }{ + { + name: "No context", + settings: func() *environment.AirshipCTLSettings { + s := makeDefaultSettings() + s.Config.CurrentContext = "badCtx" + return s + }, + expectedErr: config.ErrMissingConfig{What: "Context with name 'badCtx'"}, + }, + { + name: "Get non-existing executor", + settings: makeDefaultSettings, + phase: &airshipv1.Phase{ + Config: airshipv1.PhaseConfig{ + ExecutorRef: &corev1.ObjectReference{ + APIVersion: "example.com/v1", + Kind: "SomeKind", + }, + }, + }, + expectedErr: document.ErrDocNotFound{ + Selector: document.Selector{ + Selector: types.Selector{ + Gvk: resid.Gvk{ + Group: "example.com", + Version: "v1", + Kind: "SomeKind", + }, + }, + }, + }, + }, + { + name: "Get unregistered executor", + settings: makeDefaultSettings, + phase: &airshipv1.Phase{ + Config: airshipv1.PhaseConfig{ + ExecutorRef: &corev1.ObjectReference{ + APIVersion: "airshipit.org/v1alpha1", + Kind: "SomeExecutor", + Name: "executor-name", + }, + DocumentEntryPoint: "valid_site/phases", + }, + }, + expectedErr: phase.ErrExecutorNotFound{ + GVK: schema.GroupVersionKind{ + Group: "airshipit.org", + Version: "v1alpha1", + Kind: "SomeExecutor", + }, + }, + }, + } + + for _, test := range testCases { + tt := test + t.Run(tt.name, func(t *testing.T) { + cmd := phase.Cmd{AirshipCTLSettings: tt.settings()} + actualExc, actualErr := cmd.GetExecutor(tt.phase) + assert.Equal(t, tt.expectedErr, actualErr) + assert.Equal(t, tt.expectedExc, actualExc) + }) + } +} + func makeDefaultSettings() *environment.AirshipCTLSettings { testSettings := &environment.AirshipCTLSettings{ AirshipConfigPath: "testdata/airshipconfig.yaml", diff --git a/pkg/phase/testdata/valid_site/phases/capi_init.yaml b/pkg/phase/testdata/valid_site/phases/capi_init.yaml new file mode 100644 index 000000000..68418ad0d --- /dev/null +++ b/pkg/phase/testdata/valid_site/phases/capi_init.yaml @@ -0,0 +1,10 @@ +apiVersion: airshipit.org/v1alpha1 +kind: Phase +metadata: + name: capi_init +config: + executorRef: + apiVersion: airshipit.org/v1alpha1 + kind: Clusterctl + name: clusterctl-v1 + documentEntryPoint: manifests/site/test-site/auth \ No newline at end of file diff --git a/pkg/phase/testdata/valid_site/phases/clusterctl.yaml b/pkg/phase/testdata/valid_site/phases/clusterctl.yaml new file mode 100644 index 000000000..3c939c15c --- /dev/null +++ b/pkg/phase/testdata/valid_site/phases/clusterctl.yaml @@ -0,0 +1,12 @@ +apiVersion: airshipit.org/v1alpha1 +kind: Clusterctl +metadata: + name: clusterctl-v1 +action: init +init-options: + core-provider: "cluster-api:v0.3.3" +providers: + - name: "cluster-api" + type: "CoreProvider" + versions: + v0.3.3: manifests/function/capi/v0.3.3 \ No newline at end of file diff --git a/pkg/phase/testdata/valid_site/phases/kustomization.yaml b/pkg/phase/testdata/valid_site/phases/kustomization.yaml index af85fe23b..5385fa3b4 100644 --- a/pkg/phase/testdata/valid_site/phases/kustomization.yaml +++ b/pkg/phase/testdata/valid_site/phases/kustomization.yaml @@ -1,2 +1,6 @@ resources: - phaseplan.yaml + - some_phase.yaml + - some_exc.yaml + - capi_init.yaml + - clusterctl.yaml diff --git a/pkg/phase/testdata/valid_site/phases/phaseplan.yaml b/pkg/phase/testdata/valid_site/phases/phaseplan.yaml index 2848cb2ce..8e07734e9 100644 --- a/pkg/phase/testdata/valid_site/phases/phaseplan.yaml +++ b/pkg/phase/testdata/valid_site/phases/phaseplan.yaml @@ -8,3 +8,5 @@ phaseGroups: - name: isogen - name: remotedirect - name: initinfra + - name: some_phase + - name: capi_init diff --git a/pkg/phase/testdata/valid_site/phases/some_exc.yaml b/pkg/phase/testdata/valid_site/phases/some_exc.yaml new file mode 100644 index 000000000..162e72afa --- /dev/null +++ b/pkg/phase/testdata/valid_site/phases/some_exc.yaml @@ -0,0 +1,13 @@ +apiVersion: airshipit.org/v1alpha1 +kind: SomeExecutor +metadata: + labels: + airshipit.org/deploy-k8s: "false" + name: executor-name +init-options: + core-provider: "cluster-api:v0.3.3" +providers: + - name: "cluster-api" + type: "CoreProvider" + versions: + v0.3.3: manifests/function/capi/v0.3.3 \ No newline at end of file diff --git a/pkg/phase/testdata/valid_site/phases/some_phase.yaml b/pkg/phase/testdata/valid_site/phases/some_phase.yaml new file mode 100644 index 000000000..ee44a2f16 --- /dev/null +++ b/pkg/phase/testdata/valid_site/phases/some_phase.yaml @@ -0,0 +1,10 @@ +apiVersion: airshipit.org/v1alpha1 +kind: Phase +metadata: + name: some_phase +config: + executorRef: + apiVersion: airshipit.org/v1alpha1 + kind: SomeExecutor + name: executor-name + documentEntryPoint: manifests/site/test-site/auth \ No newline at end of file