diff --git a/pkg/phase/client.go b/pkg/phase/client.go new file mode 100644 index 000000000..5265e9647 --- /dev/null +++ b/pkg/phase/client.go @@ -0,0 +1,197 @@ +/* + 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 ( + "path/filepath" + + "opendev.org/airship/airshipctl/pkg/api/v1alpha1" + "opendev.org/airship/airshipctl/pkg/document" + "opendev.org/airship/airshipctl/pkg/events" + "opendev.org/airship/airshipctl/pkg/k8s/kubeconfig" + "opendev.org/airship/airshipctl/pkg/k8s/utils" + "opendev.org/airship/airshipctl/pkg/phase/ifc" +) + +var _ ifc.Phase = &phase{} + +// Phase implements phase interface +type phase struct { + helper ifc.Helper + apiObj *v1alpha1.Phase + registry ExecutorRegistry + processor events.EventProcessor +} + +// Executor returns executor interface associated with the phase +func (p *phase) Executor() (ifc.Executor, error) { + executorDoc, err := p.helper.ExecutorDoc(ifc.ID{Name: p.apiObj.Name, Namespace: p.apiObj.Namespace}) + if err != nil { + return nil, err + } + + var bundle document.Bundle + // just pass nil bundle if DocumentRoot is empty, executors should be ready for that + if docRoot := p.DocumentRoot(); docRoot != "" { + bundle, err = document.NewBundleByPath(docRoot) + if err != nil { + return nil, err + } + } + + refGVK := p.apiObj.Config.ExecutorRef.GroupVersionKind() + // Look for executor factory defined in registry + executorFactory, found := p.registry()[refGVK] + if !found { + return nil, ErrExecutorNotFound{GVK: refGVK} + } + + cMap, err := p.helper.ClusterMap() + if err != nil { + return nil, err + } + + wd, err := p.helper.WorkDir() + if err != nil { + return nil, err + } + kubeconf := kubeconfig.NewBuilder(). + WithBundle(p.helper.PhaseRoot()). + WithClusterMap(cMap). + WithClusterName(p.apiObj.ClusterName). + WithTempRoot(wd). + Build() + + return executorFactory( + ifc.ExecutorConfig{ + ClusterMap: cMap, + ExecutorBundle: bundle, + PhaseName: p.apiObj.Name, + KubeConfig: kubeconf, + ExecutorDocument: executorDoc, + ClusterName: p.apiObj.ClusterName, + Helper: p.helper, + }) +} + +// Run runs the phase via executor +func (p *phase) Run(ro ifc.RunOptions) error { + executor, err := p.Executor() + if err != nil { + return err + } + ch := make(chan events.Event) + + go func() { + executor.Run(ch, ro) + }() + return p.processor.Process(ch) +} + +// Validate makes sure that phase is properly configured +// TODO implement this +func (p *phase) Validate() error { + return nil +} + +// DocumentRoot root that holds all the documents associated with the phase +func (p *phase) DocumentRoot() string { + if p.apiObj.Config.DocumentEntryPoint == "" { + return "" + } + + targetPath := p.helper.TargetPath() + return filepath.Join(targetPath, p.apiObj.Config.DocumentEntryPoint) +} + +// Details returns description of the phase +// TODO implement this: add details field to api.Phase and method to executor and combine them here +// to give a clear understanding to user of what this phase is about +func (p *phase) Details() (string, error) { + return "", nil +} + +var _ ifc.Client = &client{} + +type client struct { + ifc.Helper + + registry ExecutorRegistry + processorFunc ProcessorFunc +} + +// ProcessorFunc that returns processor interface +type ProcessorFunc func() events.EventProcessor + +// Option allows to add various options to a phase +type Option func(*client) + +// InjectProcessor is an option that allows to inject event processor into phase client +func InjectProcessor(procFunc ProcessorFunc) Option { + return func(c *client) { + c.processorFunc = procFunc + } +} + +// InjectRegistry is an option that allows to inject executor registry into phase client +func InjectRegistry(registry ExecutorRegistry) Option { + return func(c *client) { + c.registry = registry + } +} + +// NewClient returns implementation of phase Client interface +func NewClient(helper ifc.Helper, opts ...Option) ifc.Client { + c := &client{Helper: helper} + for _, opt := range opts { + opt(c) + } + if c.registry == nil { + c.registry = DefaultExecutorRegistry + } + if c.processorFunc == nil { + c.processorFunc = defaultProcessor + } + return c +} + +func (c *client) PhaseByID(id ifc.ID) (ifc.Phase, error) { + phaseObj, err := c.Phase(id) + if err != nil { + return nil, err + } + + phase := &phase{ + apiObj: phaseObj, + helper: c.Helper, + processor: c.processorFunc(), + registry: c.registry, + } + return phase, nil +} + +func (c *client) PhaseByAPIObj(phaseObj *v1alpha1.Phase) (ifc.Phase, error) { + phase := &phase{ + apiObj: phaseObj, + helper: c.Helper, + processor: c.processorFunc(), + registry: c.registry, + } + return phase, nil +} + +func defaultProcessor() events.EventProcessor { + return events.NewDefaultProcessor(utils.Streams()) +} diff --git a/pkg/phase/client_test.go b/pkg/phase/client_test.go new file mode 100644 index 000000000..9ea011f18 --- /dev/null +++ b/pkg/phase/client_test.go @@ -0,0 +1,180 @@ +/* + 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_test + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime/schema" + + "opendev.org/airship/airshipctl/pkg/api/v1alpha1" + "opendev.org/airship/airshipctl/pkg/config" + "opendev.org/airship/airshipctl/pkg/events" + "opendev.org/airship/airshipctl/pkg/phase" + "opendev.org/airship/airshipctl/pkg/phase/ifc" +) + +func TestClientPhaseExecutor(t *testing.T) { + tests := []struct { + name string + errContains string + expectedExecutor ifc.Executor + phaseID ifc.ID + configFunc func(t *testing.T) *config.Config + registryFunc phase.ExecutorRegistry + }{ + { + name: "Success fake executor", + expectedExecutor: fakeExecutor{}, + configFunc: testConfig, + phaseID: ifc.ID{Name: "capi_init"}, + registryFunc: fakeRegistry, + }, + { + name: "Error executor doc doesn't exist", + expectedExecutor: fakeExecutor{}, + configFunc: testConfig, + phaseID: ifc.ID{Name: "some_phase"}, + registryFunc: fakeRegistry, + errContains: "found no documents", + }, + { + name: "Error executor doc not registered", + expectedExecutor: fakeExecutor{}, + configFunc: testConfig, + phaseID: ifc.ID{Name: "capi_init"}, + registryFunc: func() map[schema.GroupVersionKind]ifc.ExecutorFactory { + return make(map[schema.GroupVersionKind]ifc.ExecutorFactory) + }, + errContains: "executor identified by 'airshipit.org/v1alpha1, Kind=Clusterctl' is not found", + }, + } + + for _, tt := range tests { + tt := tt + t.Run("", func(t *testing.T) { + conf := tt.configFunc(t) + helper, err := phase.NewHelper(conf) + require.NoError(t, err) + require.NotNil(t, helper) + client := phase.NewClient(helper, phase.InjectRegistry(tt.registryFunc)) + require.NotNil(t, client) + p, err := client.PhaseByID(tt.phaseID) + require.NotNil(t, client) + executor, err := p.Executor() + if tt.errContains != "" { + require.Error(t, err) + assert.Nil(t, executor) + assert.Contains(t, err.Error(), tt.errContains) + } else { + require.NoError(t, err) + require.NotNil(t, executor) + assertEqualExecutor(t, tt.expectedExecutor, executor) + } + }) + } +} + +func TestPhaseRun(t *testing.T) { + tests := []struct { + name string + errContains string + phaseID ifc.ID + configFunc func(t *testing.T) *config.Config + registryFunc phase.ExecutorRegistry + }{ + { + name: "Success fake executor", + configFunc: testConfig, + phaseID: ifc.ID{Name: "capi_init"}, + registryFunc: fakeRegistry, + }, + { + name: "Error executor doc doesn't exist", + configFunc: testConfig, + phaseID: ifc.ID{Name: "some_phase"}, + registryFunc: fakeRegistry, + errContains: "found no documents", + }, + } + + for _, tt := range tests { + tt := tt + t.Run("", func(t *testing.T) { + conf := tt.configFunc(t) + helper, err := phase.NewHelper(conf) + require.NoError(t, err) + require.NotNil(t, helper) + client := phase.NewClient(helper, phase.InjectRegistry(tt.registryFunc)) + require.NotNil(t, client) + p, err := client.PhaseByID(tt.phaseID) + require.NotNil(t, client) + err = p.Run(ifc.RunOptions{DryRun: true}) + if tt.errContains != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + } else { + require.NoError(t, err) + } + }) + } +} + +// TODO develop tests, when we add phase object validation +func TestClientByAPIObj(t *testing.T) { + helper, err := phase.NewHelper(testConfig(t)) + require.NoError(t, err) + require.NotNil(t, helper) + client := phase.NewClient(helper) + require.NotNil(t, client) + p, err := client.PhaseByAPIObj(&v1alpha1.Phase{}) + require.NoError(t, err) + require.NotNil(t, p) +} + +func fakeRegistry() map[schema.GroupVersionKind]ifc.ExecutorFactory { + gvk := schema.GroupVersionKind{ + Group: "airshipit.org", + Version: "v1alpha1", + Kind: "Clusterctl", + } + return map[schema.GroupVersionKind]ifc.ExecutorFactory{ + gvk: fakeExecFactory, + } +} + +func fakeExecFactory(config ifc.ExecutorConfig) (ifc.Executor, error) { + return fakeExecutor{}, nil +} + +var _ ifc.Executor = fakeExecutor{} + +type fakeExecutor struct { +} + +func (e fakeExecutor) Render(w io.Writer, ro ifc.RenderOptions) error { + return nil +} + +func (e fakeExecutor) Run(ch chan events.Event, ro ifc.RunOptions) { + defer close(ch) +} + +func (e fakeExecutor) Validate() error { + return nil +} diff --git a/pkg/phase/helper_test.go b/pkg/phase/helper_test.go index ecf70a857..f1a3fb0f2 100644 --- a/pkg/phase/helper_test.go +++ b/pkg/phase/helper_test.go @@ -64,7 +64,7 @@ func TestHelperPhase(t *testing.T) { APIVersion: "airshipit.org/v1alpha1", Name: "clusterctl-v1", }, - DocumentEntryPoint: "manifests/site/test-site/auth", + DocumentEntryPoint: "valid_site/phases", }, }, }, diff --git a/pkg/phase/ifc/executor.go b/pkg/phase/ifc/executor.go index 6e9c91b19..3cc8e0e48 100644 --- a/pkg/phase/ifc/executor.go +++ b/pkg/phase/ifc/executor.go @@ -60,5 +60,6 @@ type ExecutorConfig struct { ExecutorDocument document.Document ExecutorBundle document.Bundle AirshipConfig *config.Config + Helper Helper KubeConfig kubeconfig.Interface } diff --git a/pkg/phase/phase_test.go b/pkg/phase/phase_test.go index 28e359808..049b5ac24 100644 --- a/pkg/phase/phase_test.go +++ b/pkg/phase/phase_test.go @@ -134,7 +134,7 @@ func TestGetPhase(t *testing.T) { APIVersion: "airshipit.org/v1alpha1", Name: "clusterctl-v1", }, - DocumentEntryPoint: "manifests/site/test-site/auth", + DocumentEntryPoint: "valid_site/phases", }, }, }, diff --git a/pkg/phase/testdata/valid_site/phases/capi_init.yaml b/pkg/phase/testdata/valid_site/phases/capi_init.yaml index 68418ad0d..72f7dd039 100644 --- a/pkg/phase/testdata/valid_site/phases/capi_init.yaml +++ b/pkg/phase/testdata/valid_site/phases/capi_init.yaml @@ -7,4 +7,4 @@ config: apiVersion: airshipit.org/v1alpha1 kind: Clusterctl name: clusterctl-v1 - documentEntryPoint: manifests/site/test-site/auth \ No newline at end of file + documentEntryPoint: valid_site/phases \ 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 index ee44a2f16..6faa17daf 100644 --- a/pkg/phase/testdata/valid_site/phases/some_phase.yaml +++ b/pkg/phase/testdata/valid_site/phases/some_phase.yaml @@ -5,6 +5,5 @@ metadata: config: executorRef: apiVersion: airshipit.org/v1alpha1 - kind: SomeExecutor + kind: Does not exist name: executor-name - documentEntryPoint: manifests/site/test-site/auth \ No newline at end of file