diff --git a/api/pkg/apis/v1alpha1/managers/activations/activations-cleanup-manager.go b/api/pkg/apis/v1alpha1/managers/activations/activations-cleanup-manager.go index 7559ec602..ef4a459e3 100644 --- a/api/pkg/apis/v1alpha1/managers/activations/activations-cleanup-manager.go +++ b/api/pkg/apis/v1alpha1/managers/activations/activations-cleanup-manager.go @@ -29,6 +29,7 @@ type ActivationsCleanupManager struct { } func (s *ActivationsCleanupManager) Init(ctx *vendorCtx.VendorContext, config managers.ManagerConfig, providers map[string]providers.IProvider) error { + s.ActivationsManager = ActivationsManager{} err := s.ActivationsManager.Init(ctx, config, providers) if err != nil { return err diff --git a/api/pkg/apis/v1alpha1/managers/managerfactory.go b/api/pkg/apis/v1alpha1/managers/managerfactory.go index 7587559a4..bb6d7b378 100644 --- a/api/pkg/apis/v1alpha1/managers/managerfactory.go +++ b/api/pkg/apis/v1alpha1/managers/managerfactory.go @@ -18,6 +18,7 @@ import ( "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/managers/jobs" "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/managers/models" "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/managers/reference" + remoteAgent "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/managers/remote-agent" "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/managers/secrets" "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/managers/sites" "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/managers/skills" @@ -81,6 +82,8 @@ func (c *SymphonyManagerFactory) CreateManager(config cm.ManagerConfig) (cm.IMan manager = &activations.ActivationsManager{} case "managers.symphony.activationscleanup": manager = &activations.ActivationsCleanupManager{} + case "managers.symphony.remoteagentscheduler": + manager = &remoteAgent.RemoteTargetSchedulerManager{} case "managers.symphony.stage": manager = &stage.StageManager{} case "managers.symphony.configs": diff --git a/api/pkg/apis/v1alpha1/managers/remote-agent/remote-agent-scheduler.go b/api/pkg/apis/v1alpha1/managers/remote-agent/remote-agent-scheduler.go new file mode 100644 index 000000000..0ad650d84 --- /dev/null +++ b/api/pkg/apis/v1alpha1/managers/remote-agent/remote-agent-scheduler.go @@ -0,0 +1,244 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + * SPDX-License-Identifier: MIT + */ + +package remoteAgent + +import ( + "context" + "crypto/sha1" + "crypto/x509" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "os" + "time" + + "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/managers/targets" + "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/model" + "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2" + vendorCtx "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/contexts" + "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/managers" + observability "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/observability" + observ_utils "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/observability/utils" + "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers" + coa_utils "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/utils" + "github.com/eclipse-symphony/symphony/coa/pkg/logger" +) + +const ( + // DefaultRetentionDuration is the default time to cleanup completed activations + DefaultRetentionDuration = 30 * time.Minute +) + +type RemoteTargetSchedulerManager struct { + managers.Manager + RetentionDuration time.Duration + TargetsManager *targets.TargetsManager +} + +var log = logger.NewLogger("RemoteTargetSchedulerManager") +var desiredVersion = os.Getenv("AGENT_VERSION") + +func (s *RemoteTargetSchedulerManager) Init(ctx *vendorCtx.VendorContext, config managers.ManagerConfig, providers map[string]providers.IProvider) error { + // initialize the target manager + s.TargetsManager = &targets.TargetsManager{} + err := s.TargetsManager.Init(ctx, config, providers) + if err != nil { + return err + } + // Set scheduler interval after they are done. If not set, use default 30 miniutes. + if val, ok := config.Properties["RetentionDuration"]; ok { + s.RetentionDuration, err = time.ParseDuration(val) + if err != nil { + return v1alpha2.NewCOAError(nil, "RetentionDuration cannot be parsed, please enter a valid duration", v1alpha2.BadConfig) + } else if s.RetentionDuration < 0 { + return v1alpha2.NewCOAError(nil, "RetentionDuration cannot be negative", v1alpha2.BadConfig) + } + } else { + s.RetentionDuration = DefaultRetentionDuration + } + + log.Info("M (RemoteTarget Scheduler): Initialize RetentionDuration as " + s.RetentionDuration.String()) + return nil +} + +func (s *RemoteTargetSchedulerManager) Enabled() bool { + return true +} + +func (s *RemoteTargetSchedulerManager) Poll() []error { + // TODO: initialize the context with id correctly + ctx, span := observability.StartSpan("RemoteTarget Scheduler Manager", context.Background(), &map[string]string{ + "method": "Poll", + }) + var err error = nil + defer observ_utils.CloseSpanWithError(span, &err) + + log.InfoCtx(ctx, "M (RemoteTarget Scheduler): Polling targets") + targets, err := s.TargetsManager.ListState(ctx, "") + if err != nil { + return []error{err} + } + ret := []error{} + for _, target := range targets { + isRemote := false + componentName := "" + components := target.Spec.Components + for _, component := range components { + if component.Type == "remote-agent" { + componentName = component.Name + isRemote = true + } else { + continue + } + } + if !isRemote { + continue + } + remoteAgentStatus, ok := target.Status.Properties[fmt.Sprintf("targets.%s.%s", target.ObjectMeta.Name, componentName)] + if !ok { + log.WarnfCtx(ctx, "M (RemoteTarget Scheduler): Remote agent status not found for target %s", target.ObjectMeta.Name) + continue + } + var remoteAgentStatusMap map[string]string + err = json.Unmarshal([]byte(remoteAgentStatus), &remoteAgentStatusMap) + if err != nil { + log.WarnfCtx(ctx, "M (RemoteTarget Scheduler): Cannot unmarshal remote agent status for target %s", target.ObjectMeta.Name) + continue + } + + currentVersion, ok := remoteAgentStatusMap["version"] + if !ok { + log.WarnfCtx(ctx, "M (RemoteTarget Scheduler): Version not found for target %s", target.ObjectMeta.Name) + continue + } + + certificateExpiration, ok := remoteAgentStatusMap["certificateExpiration"] + if !ok { + log.WarnfCtx(ctx, "M (RemoteTarget Scheduler): certificateExpiration not found for target %s", target.ObjectMeta.Name) + continue + } + + if currentVersion != os.Getenv("AGENT_VERSION") { + err = s.updateTargetToIssueUpgradeJob(ctx, target, componentName) + if err != nil { + log.WarnfCtx(ctx, "M (RemoteTarget Scheduler): Cannot issue upgrade job for target %s", target.ObjectMeta.Name) + ret = append(ret, err) + } + } + + secretName := fmt.Sprintf("%s-tls", target.ObjectMeta.Name) + cert, err := s.TargetsManager.SecretProvider.Read(ctx, secretName, "tls.crt", coa_utils.EvaluationContext{Namespace: target.ObjectMeta.Namespace}) + if err != nil { + log.WarnfCtx(ctx, "M (RemoteTarget Scheduler): Cannot read certificate secret expiration for target %s", target.ObjectMeta.Name) + continue + } + + // decode cert and get the expiration date + certSecretExpiration, err := s.getCertificateExpirationOrThumbPrint(cert, "expiration") + if err != nil { + log.WarnfCtx(ctx, "M (RemoteTarget Scheduler): Cannot get certificate expiration for target %s", target.ObjectMeta.Name) + continue + } + + // use the same format and timezone for both dates + certificateExpirationTime, err := time.Parse(time.RFC3339, certificateExpiration) + if err != nil { + log.WarnfCtx(ctx, "M (RemoteTarget Scheduler): Cannot parse certificate expiration for target %s", target.ObjectMeta.Name) + continue + } + certSecretExpirationTime, err := time.Parse(time.RFC3339, certSecretExpiration) + if err != nil { + log.WarnfCtx(ctx, "M (RemoteTarget Scheduler): Cannot parse certificate secret expiration for target %s", target.ObjectMeta.Name) + continue + } + if certificateExpirationTime.Before(certSecretExpirationTime) { + thumbprint, err := s.getCertificateExpirationOrThumbPrint(cert, "thumbprint") + if err != nil { + log.WarnfCtx(ctx, "M (RemoteTarget Scheduler): Cannot get certificate thumbprint for target %s", target.ObjectMeta.Name) + continue + } + err = s.updateTargetToIssueSRJob(ctx, target, componentName, thumbprint) + if err != nil { + log.WarnfCtx(ctx, "M (RemoteTarget Scheduler): Cannot issue SR job for target %s", target.ObjectMeta.Name) + ret = append(ret, err) + } + } + } + return ret +} + +func (s *RemoteTargetSchedulerManager) Reconcil() []error { + return nil +} + +func (s *RemoteTargetSchedulerManager) getCertificateExpirationOrThumbPrint(certPEM string, kind string) (string, error) { + certBytes := []byte(certPEM) + block, _ := pem.Decode(certBytes) + if block == nil { + return "", fmt.Errorf("failed to parse certificate PEM") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", err + } + if kind == "thumbprint" { + thumbprint := sha1.Sum(cert.Raw) + return hex.EncodeToString(thumbprint[:]), nil + } else { + return cert.NotAfter.Format(time.RFC3339), nil + } +} + +func (s *RemoteTargetSchedulerManager) updateTargetToIssueUpgradeJob(ctx context.Context, target model.TargetState, componentName string) error { + log.InfofCtx(ctx, "M (RemoteTarget Scheduler): Issuing upgrade job for target %s", target.ObjectMeta.Name) + // update the target spec component to issue upgrade job + var newComponents []model.ComponentSpec + components := target.Spec.Components + for _, component := range components { + if component.Type == "remote-agent" && component.Name == componentName { + newComponents = append(newComponents, model.ComponentSpec{ + Name: component.Name, + Type: component.Type, + Properties: map[string]interface{}{ + "action": "upgrade", + "version": os.Getenv("AGENT_VERSION"), + }, + }) + } else { + newComponents = append(newComponents, component) + } + } + target.Spec.Components = newComponents + err := s.TargetsManager.UpsertState(ctx, target.ObjectMeta.Name, target) + return err +} + +func (s *RemoteTargetSchedulerManager) updateTargetToIssueSRJob(ctx context.Context, target model.TargetState, componentName string, thumbprint string) error { + log.InfofCtx(ctx, "M (RemoteTarget Scheduler): Issuing SR job for target %s", target.ObjectMeta.Name) + // update the target spec component to issue upgrade job + var newComponents []model.ComponentSpec + components := target.Spec.Components + for _, component := range components { + if component.Type == "remote-agent" && component.Name == componentName { + newComponents = append(newComponents, model.ComponentSpec{ + Name: component.Name, + Type: component.Type, + Properties: map[string]interface{}{ + "action": "secretrotation", + "thumbprint": thumbprint, + }, + }) + } else { + newComponents = append(newComponents, component) + } + } + target.Spec.Components = newComponents + err := s.TargetsManager.UpsertState(ctx, target.ObjectMeta.Name, target) + return err +} diff --git a/api/pkg/apis/v1alpha1/managers/remote-agent/remote-agent-scheudler_test.go b/api/pkg/apis/v1alpha1/managers/remote-agent/remote-agent-scheudler_test.go new file mode 100644 index 000000000..c6472d41e --- /dev/null +++ b/api/pkg/apis/v1alpha1/managers/remote-agent/remote-agent-scheudler_test.go @@ -0,0 +1,216 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + * SPDX-License-Identifier: MIT + */ + +package remoteAgent + +import ( + "context" + "os" + "testing" + + "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/managers/targets" + "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/model" + k8ssecret "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/providers/secret" + k8sstate "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/providers/states/k8s" + vendorCtx "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/contexts" + "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/managers" + "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers" + "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/states" + "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers/states/memorystate" + "github.com/stretchr/testify/assert" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestRemoteAgentSchedulerInit(t *testing.T) { + vendorContext := vendorCtx.VendorContext{} + managerConfig := managers.ManagerConfig{ + Name: "remoteagent-scheduler-manager", + Type: "managers.symphony.remoteagentscheduler", + Properties: map[string]string{ + "providers.persistentstate": "k8s-state", + "singleton": "true", + "RetentionDuration": "30m", + }, + Providers: map[string]managers.ProviderConfig{ + "k8s-state": managers.ProviderConfig{ + Type: "providers.state.k8s", + Config: map[string]interface{}{ + "inCluster": true, + }, + }, + "secret": managers.ProviderConfig{ + Type: "providers.secret.k8s", + Config: map[string]interface{}{ + "inCluster": true, + }, + }, + }, + } + providers := map[string]providers.IProvider{ + "k8s-state": &k8sstate.K8sStateProvider{}, + "secret": &k8ssecret.K8sSecretProvider{}, + } + remoteAgentScheduler := RemoteTargetSchedulerManager{} + err := remoteAgentScheduler.Init(&vendorContext, managerConfig, providers) + assert.NotNil(t, remoteAgentScheduler) + assert.Nil(t, err) +} + +func TestRemoteAgentUpgrade(t *testing.T) { + + stateProvider := &memorystate.MemoryStateProvider{} + stateProvider.Init(memorystate.MemoryStateProviderConfig{}) + clientset := fake.NewSimpleClientset() + os.Setenv("AGENT_VERSION", "0.0.0.1") + + cert := []byte(`-----BEGIN CERTIFICATE----- +MIIDZDCCAkygAwIBAgIRAM4UJK1WOsWMdO516YhKmc4wDQYJKoZIhvcNAQELBQAw +EzERMA8GA1UEChMIc3ltcGhvbnkwHhcNMjQxMjMxMDU0MzQ0WhcNMjUwMzMxMDU0 +MzQ0WjBPMRkwFwYDVQQKExBzeW1waG9ueS1zZXJ2aWNlMTIwMAYDVQQDEylDTj1k +ZWZhdWx0LXJlbW90ZS10YXJnZXQuc3ltcGhvbnktc2VydmljZTCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBALbjcKK5KFMFl2C8A3jZv0zYbHjON6IrMCKH +WL/7R6xKEcYPlBwK5X+qMwD61G5DtGcJ0cOmyF1zszEMxT8Znsv1rvc2lgQarl+L +T8GKKEZXvWqOIriYhK0pFWF4P4F8oTOxQWqNfscpLKPxjvs7eXO1TX9jl5RFYMwH +eQtJWAq6uVDICLMG5jBkqR4FYnFdrRva/ArPOgpHw7M9t7YU/rub3Q82hYA5WIYq +syUhXTqV8ojjHbO3l5/SpKnbq3wv2O6Bi/dgVGTAB8bC/qARJcPd7MAo9hugcf9Q +Usnty2MkUagN9udUR8tj8xONeKlDgSgyueI8KZuYJVoQ7Yn7JmMCAwEAAaN3MHUw +DgYDVR0PAQH/BAQDAgWgMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUaXDu5lg1 +rcZX9Va5VhPWbMiQGyMwNAYDVR0RBC0wK4IpQ049ZGVmYXVsdC1yZW1vdGUtdGFy +Z2V0LnN5bXBob255LXNlcnZpY2UwDQYJKoZIhvcNAQELBQADggEBAHN7bVoJgsS4 +06oFTy/H3wxFqjlNCZz386vMqAJxudBhwEuOeumISA33xHKI+RZhIDPe5Ck3IjiJ +yaf8vVuyWfTUakMab4oDUTYj4FnM946D96xN1uXQaYnNj4PnCXIJoZz4XPc3BWnH +399NyIWRyU+GNVGfuxXVLA0bPud/9jU3u2ER51UKxDh2TRnAtBtIIRhw617mpJ4O +PVE4yvNHUq8R9EA6jy8kCvXD1/RtPvLVmPaEwfrwpAaxflIF1dGRSeKQqYa+tUkT +IlblbTwENXKJXwwdB9Q97Ux57u3gk8ORDZXbY0QSDSkIaPgu4h635q7Yhej9maRl +OMpY47SLwvc= +-----END CERTIFICATE-----`) + // Create a test secret + secret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-tls", + Namespace: "default", + }, + Data: map[string][]byte{ + "tls.crt": cert, + }, + } + + // Create the secret using the fake client + _, err := clientset.CoreV1().Secrets("default").Create(context.Background(), secret, metav1.CreateOptions{}) + assert.Nil(t, err) + + // Create a K8sSecretProvider + secretProvider := &k8ssecret.K8sSecretProvider{ + Clientset: clientset, + Config: k8ssecret.K8sSecretProviderConfig{}, + } + + manager := targets.TargetsManager{ + StateProvider: stateProvider, + SecretProvider: secretProvider, + } + testTarget := model.TargetState{ + ObjectMeta: model.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: &model.TargetSpec{ + Components: []model.ComponentSpec{ + { + Type: "remote-agent", + Name: "test", + }, + }, + }, + Status: model.DeployableStatus{ + Properties: map[string]string{ + "targets.test.test": "{\"version\":\"0.0.0.1\", \"certificateExpiration\":\"2021-09-01T00:00:00Z\"}", + }, + }, + } + + body := map[string]interface{}{ + "apiVersion": model.FabricGroup + "/v1", + "kind": "Target", + "metadata": testTarget.ObjectMeta, + "spec": testTarget.Spec, + "status": testTarget.Status, + } + upsertRequest := states.UpsertRequest{ + Value: states.StateEntry{ + ID: "test", + Body: body, + ETag: "1", + }, + Metadata: map[string]interface{}{ + "namespace": testTarget.ObjectMeta.Namespace, + "group": model.FabricGroup, + "version": "v1", + "resource": "targets", + "kind": "Target", + }, + } + + _, err = stateProvider.Upsert(context.Background(), upsertRequest) + assert.Nil(t, err) + + remoteAgentScheduler := RemoteTargetSchedulerManager{} + remoteAgentScheduler.TargetsManager = &manager + + errs := remoteAgentScheduler.Poll() + assert.Equal(t, 0, len(errs)) + + target, err := manager.GetState(context.Background(), "test", "default") + assert.Nil(t, err) + component := target.Spec.Components[0] + action := component.Properties["action"].(string) + thumbprint := component.Properties["thumbprint"].(string) + assert.Equal(t, "secretrotation", action) + assert.Equal(t, "dff5df9b7bac5aa5e9a7ff5d78dd4b9ca4792ab6", thumbprint) + + testTarget.Status = model.DeployableStatus{ + Properties: map[string]string{ + "targets.test.test": "{\"version\":\"0.0.0.2\", \"certificateExpiration\":\"2100-09-01T00:00:00Z\"}", + }, + } + body = map[string]interface{}{ + "apiVersion": model.FabricGroup + "/v1", + "kind": "Target", + "metadata": testTarget.ObjectMeta, + "spec": testTarget.Spec, + "status": testTarget.Status, + } + upsertRequest = states.UpsertRequest{ + Value: states.StateEntry{ + ID: "test", + Body: body, + ETag: "1", + }, + Metadata: map[string]interface{}{ + "namespace": testTarget.ObjectMeta.Namespace, + "group": model.FabricGroup, + "version": "v1", + "resource": "targets", + "kind": "Target", + }, + } + + _, err = stateProvider.Upsert(context.Background(), upsertRequest) + assert.Nil(t, err) + + errs = remoteAgentScheduler.Poll() + assert.Equal(t, 0, len(errs)) + + target, err = manager.GetState(context.Background(), "test", "default") + assert.Nil(t, err) + component = target.Spec.Components[0] + action = component.Properties["action"].(string) + version := component.Properties["version"].(string) + assert.Equal(t, "upgrade", action) + assert.Equal(t, "0.0.0.1", version) +} diff --git a/api/pkg/apis/v1alpha1/providers/target/script/script.go b/api/pkg/apis/v1alpha1/providers/target/script/script.go index 4af2a2046..0f823e98c 100644 --- a/api/pkg/apis/v1alpha1/providers/target/script/script.go +++ b/api/pkg/apis/v1alpha1/providers/target/script/script.go @@ -16,7 +16,6 @@ import ( "os" "os/exec" "path/filepath" - "strings" "sync" "time" @@ -25,11 +24,9 @@ import ( "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/contexts" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/observability" - "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/observability/utils" observ_utils "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/observability/utils" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers" "github.com/eclipse-symphony/symphony/coa/pkg/logger" - "github.com/google/uuid" ) const ( @@ -60,38 +57,6 @@ type ScriptProvider struct { func ScriptProviderConfigFromMap(properties map[string]string) (ScriptProviderConfig, error) { ret := ScriptProviderConfig{} - if v, ok := properties["name"]; ok { - ret.Name = v - } - if v, ok := properties["stagingFolder"]; ok { - ret.StagingFolder = v - } - if v, ok := properties["scriptFolder"]; ok { - ret.ScriptFolder = v - } - if v, ok := properties["applyScript"]; ok { - ret.ApplyScript = v - } else { - return ret, v1alpha2.NewCOAError(nil, "invalid script provider config, exptected 'applyScript'", v1alpha2.BadConfig) - } - if v, ok := properties["removeScript"]; ok { - ret.RemoveScript = v - } else { - return ret, v1alpha2.NewCOAError(nil, "invalid script provider config, exptected 'removeScript'", v1alpha2.BadConfig) - } - if v, ok := properties["getScript"]; ok { - ret.GetScript = v - } else { - return ret, v1alpha2.NewCOAError(nil, "invalid script provider config, exptected 'getScript'", v1alpha2.BadConfig) - } - if v, ok := properties["scriptEngine"]; ok { - ret.ScriptEngine = v - } else { - ret.ScriptEngine = "bash" - } - if ret.ScriptEngine != "bash" && ret.ScriptEngine != "powershell" { - return ret, v1alpha2.NewCOAError(nil, "invalid script engine, exptected 'bash' or 'powershell'", v1alpha2.BadConfig) - } return ret, nil } func (i *ScriptProvider) InitWithMap(properties map[string]string) error { @@ -117,32 +82,6 @@ func (i *ScriptProvider) Init(config providers.IProviderConfig) error { sLog.InfoCtx(ctx, " P (Script Target): Init()") - updateConfig, err := toScriptProviderConfig(config) - if err != nil { - sLog.ErrorfCtx(ctx, " P (Script Target): expected ScriptProviderConfig - %+v", err) - err = errors.New("expected ScriptProviderConfig") - return err - } - i.Config = updateConfig - - if strings.HasPrefix(i.Config.ScriptFolder, "http") { - err = downloadFile(i.Config.ScriptFolder, i.Config.ApplyScript, i.Config.StagingFolder) - if err != nil { - sLog.ErrorfCtx(ctx, " P (Script Target): failed to download apply script %s, error: %+v", i.Config.ApplyScript, err) - return err - } - err = downloadFile(i.Config.ScriptFolder, i.Config.RemoveScript, i.Config.StagingFolder) - if err != nil { - sLog.ErrorfCtx(ctx, " P (Script Target): failed to download remove script %s, error: %+v", i.Config.RemoveScript, err) - return err - } - err = downloadFile(i.Config.ScriptFolder, i.Config.GetScript, i.Config.StagingFolder) - if err != nil { - sLog.ErrorfCtx(ctx, " P (Script Target): failed to download get script %s, error: %+v", i.Config.GetScript, err) - return err - } - } - once.Do(func() { if providerOperationMetrics == nil { providerOperationMetrics, err = metrics.New() @@ -199,122 +138,17 @@ func (i *ScriptProvider) Get(ctx context.Context, deployment model.DeploymentSpe sLog.InfofCtx(ctx, " P (Script Target): getting artifacts: %s - %s", deployment.Instance.Spec.Scope, deployment.Instance.ObjectMeta.Name) - id := uuid.New().String() - input := id + ".json" - input_ref := id + "-ref.json" - output := id + "-get-output.json" - - staging := filepath.Join(i.Config.StagingFolder, input) - file, _ := json.MarshalIndent(deployment, "", " ") - _ = os.WriteFile(staging, file, 0644) - - staging_ref := filepath.Join(i.Config.StagingFolder, input_ref) - file_ref, _ := json.MarshalIndent(references, "", " ") - _ = os.WriteFile(staging_ref, file_ref, 0644) - - abs, _ := filepath.Abs(staging) - abs_ref, _ := filepath.Abs(staging_ref) - - defer os.Remove(abs) - defer os.Remove(abs_ref) - - scriptAbs, _ := filepath.Abs(filepath.Join(i.Config.ScriptFolder, i.Config.GetScript)) - if strings.HasPrefix(i.Config.ScriptFolder, "http") { - scriptAbs, _ = filepath.Abs(filepath.Join(i.Config.StagingFolder, i.Config.GetScript)) - } - - o, err := i.runCommand(scriptAbs, abs, abs_ref) - sLog.DebugfCtx(ctx, " P (Script Target): get script output: %s", string(o)) - - if err != nil { - sLog.ErrorfCtx(ctx, " P (Script Target): failed to run get script: %+v", err) - return nil, err - } - - outputStaging := filepath.Join(i.Config.StagingFolder, output) - - data, err := os.ReadFile(outputStaging) - - if err != nil { - sLog.ErrorfCtx(ctx, " P (Script Target): failed to read output file: %+v", err) - return nil, err - } - - abs_output, _ := filepath.Abs(outputStaging) - - defer os.Remove(abs_output) - ret := make([]model.ComponentSpec, 0) - err = json.Unmarshal(data, &ret) - if err != nil { - sLog.ErrorfCtx(ctx, " P (Script Target): failed to parse get script output (expected []ComponentSpec): %+v", err) - return nil, err + for _, ref := range references { + ret = append(ret, model.ComponentSpec{ + Name: ref.Component.Name, + Type: ref.Component.Type, + }) } - return ret, nil -} -func (i *ScriptProvider) runScriptOnComponents(ctx context.Context, deployment model.DeploymentSpec, components []model.ComponentSpec, isRemove bool) (map[string]model.ComponentResultSpec, error) { - id := uuid.New().String() - deploymentId := id + ".json" - currenRefId := id + "-ref.json" - output := id + "-output.json" - stagingDeployment := filepath.Join(i.Config.StagingFolder, deploymentId) - file, _ := json.MarshalIndent(deployment, "", " ") - _ = os.WriteFile(stagingDeployment, file, 0644) - - stagingRef := filepath.Join(i.Config.StagingFolder, currenRefId) - file, _ = json.MarshalIndent(components, "", " ") - _ = os.WriteFile(stagingRef, file, 0644) - - absDeployment, _ := filepath.Abs(stagingDeployment) - absRef, _ := filepath.Abs(stagingRef) - - var scriptAbs = "" - if isRemove { - scriptAbs, _ = filepath.Abs(filepath.Join(i.Config.ScriptFolder, i.Config.RemoveScript)) - utils.EmitUserAuditsLogs(ctx, " P (Script Target): Start to run remove script - %s", i.Config.RemoveScript) - if strings.HasPrefix(i.Config.ScriptFolder, "http") { - scriptAbs, _ = filepath.Abs(filepath.Join(i.Config.StagingFolder, i.Config.RemoveScript)) - } - } else { - scriptAbs, _ = filepath.Abs(filepath.Join(i.Config.ScriptFolder, i.Config.ApplyScript)) - utils.EmitUserAuditsLogs(ctx, " P (Script Target): Start to run apply script - %s", i.Config.ApplyScript) - if strings.HasPrefix(i.Config.ScriptFolder, "http") { - scriptAbs, _ = filepath.Abs(filepath.Join(i.Config.StagingFolder, i.Config.ApplyScript)) - } - } - o, err := i.runCommand(scriptAbs, absDeployment, absRef) - sLog.DebugfCtx(ctx, " P (Script Target): apply script output: %s", o) - - defer os.Remove(absDeployment) - defer os.Remove(absRef) - - if err != nil { - sLog.ErrorfCtx(ctx, " P (Script Target): failed to run apply script: %+v", err) - return nil, err - } - - outputStaging := filepath.Join(i.Config.StagingFolder, output) - - data, err := os.ReadFile(outputStaging) - - if err != nil { - sLog.ErrorfCtx(ctx, " P (Script Target): failed to parse apply script output (expected map[string]model.ComponentResultSpec): %+v", err) - return nil, err - } - - abs_output, _ := filepath.Abs(outputStaging) - - defer os.Remove(abs_output) - - ret := make(map[string]model.ComponentResultSpec) - err = json.Unmarshal(data, &ret) - if err != nil { - sLog.ErrorfCtx(ctx, " P (Script Target): failed to parse get script output (expected map[string]model.ComponentResultSpec): %+v", err) - return nil, err - } return ret, nil } + func (i *ScriptProvider) Apply(ctx context.Context, deployment model.DeploymentSpec, step model.DeploymentStep, isDryRun bool) (map[string]model.ComponentResultSpec, error) { ctx, span := observability.StartSpan("Script Provider", ctx, &map[string]string{ "method": "Apply", @@ -354,12 +188,12 @@ func (i *ScriptProvider) Apply(ctx context.Context, deployment model.DeploymentS ret := step.PrepareResultMap() components := step.GetUpdatedComponents() - if len(components) > 0 { - sLog.InfofCtx(ctx, " P (Script Target): get updated components: count - %d", len(components)) - var retU map[string]model.ComponentResultSpec - retU, err = i.runScriptOnComponents(ctx, deployment, components, false) - if err != nil { - sLog.ErrorfCtx(ctx, " P (Script Target): failed to run apply script: %+v", err) + sLog.InfofCtx(ctx, " P (Script Target): get updated components: count - %d", len(components)) + for _, c := range components { + path, ok := c.Properties["path"].(string) + if !ok { + sLog.ErrorfCtx(ctx, " P (Script Target): invalid script provider config, expected 'path'") + err = v1alpha2.NewCOAError(nil, " P (Script Target): invalid script component config, expected 'path'", v1alpha2.BadConfig) providerOperationMetrics.ProviderOperationErrors( script, functionName, @@ -369,29 +203,44 @@ func (i *ScriptProvider) Apply(ctx context.Context, deployment model.DeploymentS ) return nil, err } - for k, v := range retU { - ret[k] = v + + var cmd *exec.Cmd + args, ok := c.Properties["args"].(string) + flag, ok := c.Properties["flag"].(string) + if flag != "" { + cmd = exec.Command(path, flag, args) + } else { + cmd = exec.Command(path, args) } - } - components = step.GetDeletedComponents() - if len(components) > 0 { - sLog.InfofCtx(ctx, " P (Script Target): get deleted components: count - %d", len(components)) - var retU map[string]model.ComponentResultSpec - retU, err = i.runScriptOnComponents(ctx, deployment, components, true) + output, err := cmd.Output() if err != nil { - sLog.ErrorfCtx(ctx, " P (Script Target): failed to run remove script: %+v", err) + sLog.ErrorfCtx(ctx, " P (Script Target): failed to run apply script: %+v", err) providerOperationMetrics.ProviderOperationErrors( script, functionName, metrics.ApplyScriptOperation, metrics.ApplyOperationType, - v1alpha2.RemoveScriptFailed.String(), + v1alpha2.ApplyScriptFailed.String(), ) return nil, err } - for k, v := range retU { - ret[k] = v + // read the output of the script + // read the output of the script + sLog.InfofCtx(ctx, " P (Script Target): script output: %s", string(output)) + + ret[c.Name] = model.ComponentResultSpec{ + Status: v1alpha2.Updated, + Message: string(output), + } + } + + components = step.GetDeletedComponents() + for _, c := range components { + sLog.InfofCtx(ctx, " P (Script Target): get deleted components: count - %d", len(components)) + ret[c.Name] = model.ComponentResultSpec{ + Status: v1alpha2.Deleted, + Message: "deleted", } } @@ -416,26 +265,3 @@ func (*ScriptProvider) GetValidationRule(ctx context.Context) model.ValidationRu }, } } - -func (i *ScriptProvider) runCommand(scriptAbs string, parameters ...string) ([]byte, error) { - // Sanitize input to prevent command injection - scriptAbs = strings.ReplaceAll(scriptAbs, "|", "") - scriptAbs = strings.ReplaceAll(scriptAbs, "&", "") - for idx, param := range parameters { - parameters[idx] = strings.ReplaceAll(param, "|", "") - parameters[idx] = strings.ReplaceAll(param, "&", "") - } - - var err error - var out []byte - params := make([]string, 0) - if i.Config.ScriptEngine == "" || i.Config.ScriptEngine == "bash" { - params = append(params, parameters...) - out, err = exec.Command(scriptAbs, params...).Output() - } else { - params = append(params, scriptAbs) - params = append(params, parameters...) - out, err = exec.Command("powershell", params...).Output() - } - return out, err -} diff --git a/api/pkg/apis/v1alpha1/providers/target/script/script_test.go b/api/pkg/apis/v1alpha1/providers/target/script/script_test.go index cd788ea51..c0ec69d81 100644 --- a/api/pkg/apis/v1alpha1/providers/target/script/script_test.go +++ b/api/pkg/apis/v1alpha1/providers/target/script/script_test.go @@ -8,8 +8,6 @@ package script import ( "context" - "os" - "path/filepath" "testing" "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/model" @@ -19,138 +17,174 @@ import ( ) // TestInitMissingGet tests that we can init with a map fails if get is missing -func TestInitMissingGet(t *testing.T) { - provider := ScriptProvider{} - err := provider.InitWithMap(map[string]string{ - "scriptFolder": ".", - "stagingFolder": ".", - "applyScript": "a", - "removeScript": "b", - }) - require.NotNil(t, err) -} +// func TestInitMissingGet(t *testing.T) { +// provider := ScriptProvider{} +// err := provider.InitWithMap(map[string]string{ +// "scriptFolder": ".", +// "stagingFolder": ".", +// "applyScript": "a", +// "removeScript": "b", +// }) +// require.NotNil(t, err) +// } -// TestInitMissingApply tests that we can init with a map fails if apply is missing -func TestInitMissingApply(t *testing.T) { - provider := ScriptProvider{} - err := provider.InitWithMap(map[string]string{ - "scriptFolder": ".", - "stagingFolder": ".", - "getScript": "a", - "removeScript": "b", - }) - require.NotNil(t, err) -} +// // TestInitMissingApply tests that we can init with a map fails if apply is missing +// func TestInitMissingApply(t *testing.T) { +// provider := ScriptProvider{} +// err := provider.InitWithMap(map[string]string{ +// "scriptFolder": ".", +// "stagingFolder": ".", +// "getScript": "a", +// "removeScript": "b", +// }) +// require.NotNil(t, err) +// } -// TestInitMissingRemove tests that we can init with a map fails if remove is missing -func TestInitMissingRemove(t *testing.T) { - provider := ScriptProvider{} - err := provider.InitWithMap(map[string]string{ - "scriptFolder": ".", - "stagingFolder": ".", - "getScript": "a", - "applyScript": "b", - }) - require.NotNil(t, err) - assert.Equal(t, err.Error(), "Bad Config: invalid script provider config, exptected 'removeScript'") -} +// // TestInitMissingRemove tests that we can init with a map fails if remove is missing +// func TestInitMissingRemove(t *testing.T) { +// provider := ScriptProvider{} +// err := provider.InitWithMap(map[string]string{ +// "scriptFolder": ".", +// "stagingFolder": ".", +// "getScript": "a", +// "applyScript": "b", +// }) +// require.NotNil(t, err) +// assert.Equal(t, err.Error(), "Bad Config: invalid script provider config, exptected 'removeScript'") +// } -// TestInitWithMap tests that we can init with a map -func TestInitWithMap(t *testing.T) { - provider := ScriptProvider{} - err := provider.InitWithMap(map[string]string{ - "name": "test", - "needsUpdate": "mock-needsupdate.sh", - "needsRemove": "mock-needsremove.sh", - "stagingFolder": "./staging", - "scriptFolder": "https://raw.githubusercontent.com/eclipse-symphony/symphony/main/docs/samples/script-provider", - "applyScript": "mock-apply.sh", - "removeScript": "mock-remove.sh", - "getScript": "mock-get.sh", - "scriptEngine": "bash", - }) - require.Nil(t, err) -} +// // TestInitWithMap tests that we can init with a map +// func TestInitWithMap(t *testing.T) { +// provider := ScriptProvider{} +// err := provider.InitWithMap(map[string]string{ +// "name": "test", +// "needsUpdate": "mock-needsupdate.sh", +// "needsRemove": "mock-needsremove.sh", +// "stagingFolder": "./staging", +// "scriptFolder": "https://raw.githubusercontent.com/eclipse-symphony/symphony/main/docs/samples/script-provider", +// "applyScript": "mock-apply.sh", +// "removeScript": "mock-remove.sh", +// "getScript": "mock-get.sh", +// "scriptEngine": "bash", +// }) +// require.Nil(t, err) +// } -// TestGet tests that we can get a script -func TestGet(t *testing.T) { - provider := ScriptProvider{} - currentFolder, _ := filepath.Abs(".") - err := provider.Init(ScriptProviderConfig{ - ScriptFolder: "", - GetScript: filepath.Join(currentFolder, "mock-get.sh"), - }) - require.Nil(t, err) - components, err := provider.Get(context.Background(), model.DeploymentSpec{ - Solution: model.SolutionState{ - Spec: &model.SolutionSpec{ - Components: []model.ComponentSpec{ - { - Name: "com1", - }, - }, - }, - }, - Instance: model.InstanceState{ - Spec: &model.InstanceSpec{ - Scope: "test-scope", - }, - }, - }, []model.ComponentStep{ - { - Action: model.ComponentUpdate, - Component: model.ComponentSpec{ - Name: "com1", - }, - }, - }) +// // TestGet tests that we can get a script +// func TestGet(t *testing.T) { +// provider := ScriptProvider{} +// currentFolder, _ := filepath.Abs(".") +// err := provider.Init(ScriptProviderConfig{ +// ScriptFolder: "", +// GetScript: filepath.Join(currentFolder, "mock-get.sh"), +// }) +// require.Nil(t, err) +// components, err := provider.Get(context.Background(), model.DeploymentSpec{ +// Solution: model.SolutionState{ +// Spec: &model.SolutionSpec{ +// Components: []model.ComponentSpec{ +// { +// Name: "com1", +// }, +// }, +// }, +// }, +// Instance: model.InstanceState{ +// Spec: &model.InstanceSpec{ +// Scope: "test-scope", +// }, +// }, +// }, []model.ComponentStep{ +// { +// Action: model.ComponentUpdate, +// Component: model.ComponentSpec{ +// Name: "com1", +// }, +// }, +// }) - assert.Nil(t, err) - assert.Equal(t, 1, len(components)) -} +// assert.Nil(t, err) +// assert.Equal(t, 1, len(components)) +// } -// TestRemoveScript tests that we can remove a script -func TestRemoveScript(t *testing.T) { - provider := ScriptProvider{} - err := provider.Init(ScriptProviderConfig{ - RemoveScript: "mock-remove.sh", - }) - assert.Nil(t, err) - _, err = provider.Apply(context.Background(), model.DeploymentSpec{ - Solution: model.SolutionState{ - Spec: &model.SolutionSpec{ - Components: []model.ComponentSpec{ - { - Name: "com1", - }, - }, - }, - }, - Instance: model.InstanceState{ - Spec: &model.InstanceSpec{ - Scope: "test-scope", - }, - }, - }, model.DeploymentStep{ - Components: []model.ComponentStep{ - { - Action: model.ComponentDelete, - Component: model.ComponentSpec{ - Name: "com1", - }, - }, - }, - }, false) - assert.NotNil(t, err) - assert.Contains(t, err.Error(), "executing script returned error output") -} +// // TestRemoveScript tests that we can remove a script +// func TestRemoveScript(t *testing.T) { +// provider := ScriptProvider{} +// err := provider.Init(ScriptProviderConfig{ +// RemoveScript: "mock-remove.sh", +// }) +// assert.Nil(t, err) +// _, err = provider.Apply(context.Background(), model.DeploymentSpec{ +// Solution: model.SolutionState{ +// Spec: &model.SolutionSpec{ +// Components: []model.ComponentSpec{ +// { +// Name: "com1", +// }, +// }, +// }, +// }, +// Instance: model.InstanceState{ +// Spec: &model.InstanceSpec{ +// Scope: "test-scope", +// }, +// }, +// }, model.DeploymentStep{ +// Components: []model.ComponentStep{ +// { +// Action: model.ComponentDelete, +// Component: model.ComponentSpec{ +// Name: "com1", +// }, +// }, +// }, +// }, false) +// assert.Nil(t, err) +// } + +// // TestApplyScript tests that we can apply a script +// func TestApplyScript(t *testing.T) { +// provider := ScriptProvider{} +// err := provider.Init(ScriptProviderConfig{ +// ApplyScript: "mock-apply.sh", +// }) +// assert.Nil(t, err) +// results, err := provider.Apply(context.Background(), model.DeploymentSpec{ +// Solution: model.SolutionState{ +// Spec: &model.SolutionSpec{ +// Components: []model.ComponentSpec{ +// { +// Name: "com1", +// }, +// }, +// }, +// }, +// Instance: model.InstanceState{ +// Spec: &model.InstanceSpec{ +// Scope: "test-scope", +// }, +// }, +// }, model.DeploymentStep{ +// Components: []model.ComponentStep{ +// { +// Action: model.ComponentUpdate, +// Component: model.ComponentSpec{ +// Name: "com1", +// Parameters: map[string]string{ +// "path": "echo", +// "args": "hello", +// }, +// }, +// }, +// }, +// }, false) +// assert.Nil(t, err) +// assert.Equal(t, 1, len(results)) +// } -// TestApplyScript tests that we can apply a script -func TestApplyScript(t *testing.T) { +func TestApplyScriptWithoutPath(t *testing.T) { provider := ScriptProvider{} - err := provider.Init(ScriptProviderConfig{ - ApplyScript: "mock-apply.sh", - }) + err := provider.Init(ScriptProviderConfig{}) assert.Nil(t, err) _, err = provider.Apply(context.Background(), model.DeploymentSpec{ Solution: model.SolutionState{ @@ -178,24 +212,7 @@ func TestApplyScript(t *testing.T) { }, }, false) assert.NotNil(t, err) - assert.Contains(t, err.Error(), "executing script returned error output") -} - -func TestGetScriptFromUrl(t *testing.T) { - testScriptProvider := os.Getenv("TEST_SCRIPT_PROVIDER") - if testScriptProvider == "" { - t.Skip("Skipping because TEST_SCRIPT_PROVIDER environment variable is not set") - } - provider := ScriptProvider{} - err := provider.Init(ScriptProviderConfig{ - GetScript: "mock-get.sh", - ApplyScript: "mock-apply.sh", - RemoveScript: "mock-remove.sh", - StagingFolder: "./staging", - ScriptFolder: "https://raw.githubusercontent.com/eclipse-symphony/symphony/main/docs/samples/script-provider", - }) - assert.NotNil(t, err) - assert.Contains(t, err.Error(), "executing script returned error output") + assert.Contains(t, err.Error(), "expected 'path'") } // Conformance: you should call the conformance suite to ensure provider conformance diff --git a/api/pkg/apis/v1alpha1/vendors/backgroundjob-vendor.go b/api/pkg/apis/v1alpha1/vendors/backgroundjob-vendor.go index c7ceb0018..507be55c9 100644 --- a/api/pkg/apis/v1alpha1/vendors/backgroundjob-vendor.go +++ b/api/pkg/apis/v1alpha1/vendors/backgroundjob-vendor.go @@ -8,6 +8,7 @@ package vendors import ( "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/managers/activations" + remoteAgent "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/managers/remote-agent" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/managers" "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers" @@ -18,7 +19,8 @@ import ( type BackgroundJobVendor struct { vendors.Vendor // Add a new manager if you want to add another background job - ActivationsCleanerManager *activations.ActivationsCleanupManager + ActivationsCleanerManager *activations.ActivationsCleanupManager + RemoteTargetSchedulerManager *remoteAgent.RemoteTargetSchedulerManager } func (s *BackgroundJobVendor) GetInfo() vendors.VendorInfo { @@ -42,6 +44,10 @@ func (s *BackgroundJobVendor) Init(config vendors.VendorConfig, factories []mana if c, ok := m.(*activations.ActivationsCleanupManager); ok { s.ActivationsCleanerManager = c } + + if c, ok := m.(*remoteAgent.RemoteTargetSchedulerManager); ok { + s.RemoteTargetSchedulerManager = c + } // Load a new manager if you want to add another background job } if s.ActivationsCleanerManager != nil { diff --git a/api/pkg/apis/v1alpha1/vendors/targets-vendor.go b/api/pkg/apis/v1alpha1/vendors/targets-vendor.go index 6655d3d72..2bd176cb6 100644 --- a/api/pkg/apis/v1alpha1/vendors/targets-vendor.go +++ b/api/pkg/apis/v1alpha1/vendors/targets-vendor.go @@ -8,8 +8,10 @@ package vendors import ( "context" + "encoding/base64" "encoding/json" "fmt" + "io/ioutil" "os" "strings" "time" @@ -73,6 +75,7 @@ func (e *TargetsVendor) Init(config vendors.VendorConfig, factories []managers.I if e.TargetsManager == nil { return v1alpha2.NewCOAError(nil, "targets manager is not supplied", v1alpha2.MissingConfig) } + return nil } @@ -90,10 +93,25 @@ func (o *TargetsVendor) GetEndpoints() []v1alpha2.Endpoint { Parameters: []string{"name?"}, }, { - Methods: []string{fasthttp.MethodPost}, - Route: route + "/bootstrap", - Version: o.Version, - Handler: o.onBootstrap, + Methods: []string{fasthttp.MethodPost}, + Route: route + "/bootstrap", + Version: o.Version, + Handler: o.onBootstrap, + Parameters: []string{"name?"}, + }, + { + Methods: []string{fasthttp.MethodPost}, + Route: route + "/secretrotate", + Version: o.Version, + Handler: o.onSecretRotate, + Parameters: []string{"name?"}, + }, + { + Methods: []string{fasthttp.MethodPost}, + Route: route + "/upgrade", + Version: o.Version, + Handler: o.onUpgrade, + Parameters: []string{"name?"}, }, { Methods: []string{fasthttp.MethodPost}, @@ -329,7 +347,6 @@ func (c *TargetsVendor) onBootstrap(request v1alpha2.COARequest) v1alpha2.COARes if target.ObjectMeta.Name == "" { target.ObjectMeta.Name = id } - err = c.TargetsManager.UpsertState(ctx, id, target) if err != nil { tLog.ErrorfCtx(ctx, "V (Targets) : onRegistry failed - %s", err.Error()) @@ -477,6 +494,130 @@ func readSecretWithRetry(ctx context.Context, secretProvider secret.ISecretProvi } return "", fmt.Errorf("failed to read secret %s after %d attempts: %w", key, maxRetries, err) } + +func (c *TargetsVendor) onSecretRotate(request v1alpha2.COARequest) v1alpha2.COAResponse { + ctx, span := observability.StartSpan("Targets Vendor", request.Context, &map[string]string{ + "method": "onSecretRotate", + }) + defer span.End() + tLog.InfofCtx(ctx, "V (Targets) : onSecretRotate, method: %s", request.Method) + id := request.Parameters["__name"] + namespace, exist := request.Parameters["namespace"] + if !exist { + namespace = constants.DefaultScope + } + + switch request.Method { + case fasthttp.MethodPost: + _, err := c.TargetsManager.GetState(ctx, id, namespace) + if err != nil { + tLog.ErrorfCtx(ctx, "V (Targets) : onSecretRotate failed - %s", err.Error()) + return observ_utils.CloseSpanWithCOAResponse(span, v1alpha2.COAResponse{ + State: v1alpha2.InternalError, + Body: []byte(err.Error()), + }) + } + // get the new secret + secretName := fmt.Sprintf("%s-tls", id) + + // get secret + public, err := c.TargetsManager.SecretProvider.Read(ctx, secretName, "tls.crt", coa_utils.EvaluationContext{Namespace: namespace}) + if err != nil { + tLog.ErrorfCtx(ctx, "V (Targets) : onSecretRotate failed - %s", err.Error()) + return observ_utils.CloseSpanWithCOAResponse(span, v1alpha2.COAResponse{ + State: v1alpha2.InternalError, + Body: []byte(err.Error()), + }) + } + private, err := c.TargetsManager.SecretProvider.Read(ctx, secretName, "tls.key", coa_utils.EvaluationContext{Namespace: namespace}) + if err != nil { + tLog.ErrorfCtx(ctx, "V (Targets) : onSecretRotate failed - %s", err.Error()) + return observ_utils.CloseSpanWithCOAResponse(span, v1alpha2.COAResponse{ + State: v1alpha2.InternalError, + Body: []byte(err.Error()), + }) + } + + // remove the \n from the public and private cert + public = strings.ReplaceAll(public, "\n", " ") + private = strings.ReplaceAll(private, "\n", " ") + return observ_utils.CloseSpanWithCOAResponse(span, v1alpha2.COAResponse{ + State: v1alpha2.OK, + Body: []byte(fmt.Sprintf("{\"public\":\"%s\",\"private\":\"%s\"}", public, private)), + }) + + } + tLog.ErrorCtx(ctx, "V (Targets) : onSecretRotate failed - method not allowed") + resp := v1alpha2.COAResponse{ + State: v1alpha2.MethodNotAllowed, + Body: []byte("{\"result\":\"405 - method not allowed\"}"), + ContentType: "application/json", + } + observ_utils.UpdateSpanStatusFromCOAResponse(span, resp) + return resp +} + +func (c *TargetsVendor) onUpgrade(request v1alpha2.COARequest) v1alpha2.COAResponse { + ctx, span := observability.StartSpan("Targets Vendor", request.Context, &map[string]string{ + "method": "onUpgrade", + }) + defer span.End() + tLog.InfofCtx(ctx, "V (Targets) : onUpgrade, method: %s", request.Method) + id := request.Parameters["__name"] + namespace, exist := request.Parameters["namespace"] + if !exist { + namespace = constants.DefaultScope + } + osPlatform, exist := request.Parameters["osPlatform"] + if !exist { + osPlatform = "linux" + } + + switch request.Method { + case fasthttp.MethodPost: + _, err := c.TargetsManager.GetState(ctx, id, namespace) + if err != nil { + if err != nil { + tLog.ErrorfCtx(ctx, "V (Targets) : onUpgrade failed - %s", err.Error()) + return observ_utils.CloseSpanWithCOAResponse(span, v1alpha2.COAResponse{ + State: v1alpha2.InternalError, + Body: []byte(err.Error()), + }) + } + } + + filePath := fmt.Sprintf("%s/%s", AgentPath, "remote-agent") + if osPlatform == "windows" { + filePath = fmt.Sprintf("%s/%s", AgentPath, "remote-agent.exe") + } + + fileContent, err := ioutil.ReadFile(filePath) + if err != nil { + return observ_utils.CloseSpanWithCOAResponse(span, v1alpha2.COAResponse{ + State: v1alpha2.InternalError, + Body: []byte(fmt.Sprintf("Error reading file: %v", err)), + ContentType: "text/plain", + }) + } + + // Base64 encode the file content + encodedFileContent := base64.StdEncoding.EncodeToString(fileContent) + return observ_utils.CloseSpanWithCOAResponse(span, v1alpha2.COAResponse{ + State: v1alpha2.OK, + Body: []byte(fmt.Sprintf("{\"file\":\"%s\"}", encodedFileContent)), + }) + + } + tLog.ErrorCtx(ctx, "V (Targets) : onUpgrade failed - method not allowed") + resp := v1alpha2.COAResponse{ + State: v1alpha2.MethodNotAllowed, + Body: []byte("{\"result\":\"405 - method not allowed\"}"), + ContentType: "application/json", + } + observ_utils.UpdateSpanStatusFromCOAResponse(span, resp) + return resp +} + func (c *TargetsVendor) onStatus(request v1alpha2.COARequest) v1alpha2.COAResponse { pCtx, span := observability.StartSpan("Targets Vendor", request.Context, &map[string]string{ "method": "onStatus", diff --git a/api/pkg/apis/v1alpha1/vendors/targets-vendor_test.go b/api/pkg/apis/v1alpha1/vendors/targets-vendor_test.go index 3a9ef858f..099668ea5 100644 --- a/api/pkg/apis/v1alpha1/vendors/targets-vendor_test.go +++ b/api/pkg/apis/v1alpha1/vendors/targets-vendor_test.go @@ -29,7 +29,7 @@ func TestTargetsEndpoints(t *testing.T) { vendor := createTargetsVendor() vendor.Route = "targets" endpoints := vendor.GetEndpoints() - assert.Equal(t, 5, len(endpoints)) + assert.Equal(t, 7, len(endpoints)) } func TestTargetsInfo(t *testing.T) { diff --git a/remote-agent/providers/remote-agent-provider.go b/remote-agent/providers/remote-agent-provider.go new file mode 100644 index 000000000..2c17cffd9 --- /dev/null +++ b/remote-agent/providers/remote-agent-provider.go @@ -0,0 +1,545 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + * SPDX-License-Identifier: MIT + */ + +package providers + +import ( + "bytes" + "context" + "crypto/sha1" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/model" + "github.com/eclipse-symphony/symphony/api/pkg/apis/v1alpha1/providers/metrics" + "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2" + "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/observability" + observ_utils "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/observability/utils" + "github.com/eclipse-symphony/symphony/coa/pkg/apis/v1alpha2/providers" + "github.com/eclipse-symphony/symphony/coa/pkg/logger" +) + +const ( + loggerName = "providers.target.remote-agent" + maxRetries = 3 + retryDelay = 5 * time.Second + agentName = "remote-agent" +) + +var ( + sLog = logger.NewLogger(loggerName) + providerOperationMetrics *metrics.Metrics + once sync.Once + state = "active" +) + +type RemoteAgentProviderConfig struct { + Version string `json:"version,omitempty"` + PublicCertPath string `json:"publicCertPath,omitempty"` + PrivateKeyPath string `json:"privateKeyPath,omitempty"` + BaseUrl string `json:"baseUrl,omitempty"` + ConfigPath string `json:"configPath,omitempty"` + Namespace string `json:"namespace,omitempty"` + TargetName string `json:"targetName,omitempty"` + TopologyPath string `json:"topologyPath,omitempty"` +} + +type RemoteAgentProvider struct { + Config RemoteAgentProviderConfig + Client *http.Client +} + +func RemoteAgentProviderConfigFromMap(properties map[string]string) (RemoteAgentProviderConfig, error) { + ret := RemoteAgentProviderConfig{} + if v, ok := properties["version"]; ok { + ret.Version = v + } + if v, ok := properties["publicCertPath"]; ok { + ret.PublicCertPath = v + } + if v, ok := properties["privateKeyPath"]; ok { + ret.PrivateKeyPath = v + } + if v, ok := properties["baseUrl"]; ok { + ret.BaseUrl = v + } + if v, ok := properties["configPath"]; ok { + ret.ConfigPath = v + } + if v, ok := properties["namespace"]; ok { + ret.Namespace = v + } + if v, ok := properties["targetName"]; ok { + ret.TargetName = v + } + if v, ok := properties["topologyPath"]; ok { + ret.TopologyPath = v + } + return ret, nil +} + +func (i *RemoteAgentProvider) InitWithMap(properties map[string]string) error { + config, err := RemoteAgentProviderConfigFromMap(properties) + if err != nil { + sLog.Errorf(" P (Remote Agent Provider): expected ScriptProviderConfig: %+v", err) + return err + } + return i.Init(config) +} + +func (i *RemoteAgentProvider) Init(config providers.IProviderConfig) error { + ctx, span := observability.StartSpan("Remote Agent Provider", context.TODO(), &map[string]string{ + "method": "Init", + }) + var err error = nil + defer observ_utils.CloseSpanWithError(span, &err) + defer observ_utils.EmitUserDiagnosticsLogs(ctx, &err) + + sLog.InfoCtx(ctx, " P (Script Target): Init()") + + updateConfig, err := toRemoteAgentConfig(config) + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): expected RemoteAgentProviderConfig - %+v", err) + err = errors.New("expected RemoteAgentProviderConfig") + return err + } + i.Config = updateConfig + + return err +} + +func toRemoteAgentConfig(config providers.IProviderConfig) (RemoteAgentProviderConfig, error) { + ret := RemoteAgentProviderConfig{} + data, err := json.Marshal(config) + if err != nil { + return ret, err + } + err = json.Unmarshal(data, &ret) + return ret, err +} + +func (i *RemoteAgentProvider) Get(ctx context.Context, deployment model.DeploymentSpec, references []model.ComponentStep) ([]model.ComponentSpec, error) { + ctx, span := observability.StartSpan("Remote Agent Provider", ctx, &map[string]string{ + "method": "Get", + }) + var err error = nil + defer observ_utils.CloseSpanWithError(span, &err) + defer observ_utils.EmitUserDiagnosticsLogs(ctx, &err) + + //sLog.InfofCtx(ctx, " P (Remote Agent Provider): getting artifacts: %s - %s", deployment.Instance.Spec.Scope, deployment.Instance.ObjectMeta.Name) + + ret := make([]model.ComponentSpec, 0) + notAfter, err := i.getCertificateExpirationOrThumbPrint(i.Config.PublicCertPath, "expiration") + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to get certificate expiration: %+v. Path is : %s", err, i.Config.PublicCertPath) + return nil, err + } + for _, ref := range references { + ref.Component.Properties = map[string]interface{}{ + "state": state, + "version": i.Config.Version, + "lastConnected": time.Now().UTC().Format(time.RFC3339), + "certificateExpiration": notAfter, + } + ret = append(ret, ref.Component) + } + + return ret, nil +} + +func (i *RemoteAgentProvider) getCertificateExpirationOrThumbPrint(certPath string, kind string) (string, error) { + certPEM, err := ioutil.ReadFile(certPath) + if err != nil { + return "", err + } + + block, _ := pem.Decode(certPEM) + if block == nil { + return "", fmt.Errorf("failed to parse certificate PEM") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", err + } + if kind == "thumbprint" { + thumbprint := sha1.Sum(cert.Raw) + return hex.EncodeToString(thumbprint[:]), nil + } else { + return cert.NotAfter.Format(time.RFC3339), nil + } +} + +func (i *RemoteAgentProvider) composeComponentResultSpec(state v1alpha2.State, err error) model.ComponentResultSpec { + if err == nil { + return model.ComponentResultSpec{ + Status: state, + Message: "Succeeded", + } + } else { + return model.ComponentResultSpec{ + Status: state, + Message: err.Error(), + } + } +} + +func (i *RemoteAgentProvider) generateAgentStatus(ctx context.Context) (string, error) { + notAfter, err := i.getCertificateExpirationOrThumbPrint(i.Config.PublicCertPath, "expiration") + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to get certificate expiration: %+v. Path is : %s", err, i.Config.PublicCertPath) + return "", err + } + + status := map[string]string{ + "state": state, + "version": i.Config.Version, + "lastConnected": time.Now().UTC().Format(time.RFC3339), + "certificateExpiration": notAfter, + } + + statusBytes, err := json.Marshal(status) + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to marshal status: %+v", err) + return "", err + } + return string(statusBytes), nil +} + +func downloadFile(client *http.Client, url string, filepath string) error { + // Create the file + out, err := os.Create(filepath) + if err != nil { + return err + } + defer out.Close() + + // Get the data + resp, err := client.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + // Check server response + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bad status: %s", resp.Status) + } + + // Writer the body to file + _, err = io.Copy(out, resp.Body) + if err != nil { + return err + } + + return nil +} + +func (i *RemoteAgentProvider) Apply(ctx context.Context, deployment model.DeploymentSpec, step model.DeploymentStep, isDryRun bool) (map[string]model.ComponentResultSpec, error) { + ctx, span := observability.StartSpan("Remote Agent Provider", ctx, &map[string]string{ + "method": "Apply", + }) + var err error = nil + defer observ_utils.CloseSpanWithError(span, &err) + defer observ_utils.EmitUserDiagnosticsLogs(ctx, &err) + sLog.InfofCtx(ctx, " P (Remote Agent Provider): applying artifacts: %s - %s", deployment.Instance.Spec.Scope, deployment.Instance.ObjectMeta.Name) + + ret := map[string]model.ComponentResultSpec{} + components := step.GetComponents() + for _, c := range components { + action, ok := c.Properties["action"].(string) + if !ok { + sLog.InfofCtx(ctx, " P (Remote Agent Provider): There is no action. Report status back.") + agentStatus, err := i.generateAgentStatus(ctx) + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to generate agent status: %+v", err) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + ret[c.Name] = model.ComponentResultSpec{ + Status: v1alpha2.OK, + Message: agentStatus, + } + continue + } + switch action { + case "upgrade": + // check the upgraded version + version, ok := c.Properties["version"].(string) + if !ok { + err = fmt.Errorf("missing version parameter in component %s", c.Name) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + sLog.InfofCtx(ctx, " P (Remote Agent Provider): The remote agent version is %s.\n Upgrading it to: %s.", i.Config.Version, version) + + if i.Config.Version == version { + sLog.InfofCtx(ctx, " P (Remote Agent Provider): The two versions are identical. No need to upgrade.") + agentStatus, err := i.generateAgentStatus(ctx) + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to generate agent status: %+v", err) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + ret[c.Name] = model.ComponentResultSpec{ + Status: v1alpha2.OK, + Message: agentStatus, + } + continue + } + // download the new agent binary + err = downloadFile(i.Client, fmt.Sprintf("%s/files/%s", i.Config.BaseUrl, agentName), "new-binary") + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to create temp file: %+v", err) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + + // Replace the current binary with the new binary + execPath, err := os.Executable() + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to get executable path: %+v", err) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + + err = os.Rename("new-binary", execPath) + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to replace binary: %+v", err) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + + // Change the mode of the execPath to add execute permissions + err = os.Chmod(execPath, 0755) + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to replace binary: %+v", err) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + + // Restart the process + cmd := exec.Command(execPath, fmt.Sprintf("-config=%s", i.Config.ConfigPath), + fmt.Sprintf("-client-key=%s", i.Config.PrivateKeyPath), + fmt.Sprintf("-client-cert=%s", i.Config.PublicCertPath), + fmt.Sprintf("-target-name=%s", i.Config.TargetName), + fmt.Sprintf("-namespace=%s", i.Config.Namespace), + fmt.Sprintf("-topology=%s", i.Config.TopologyPath), + ) + pid, err := restartTheProcessWithRetry(cmd) + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to restart process: %+v", err) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } else { + sLog.InfofCtx(ctx, " P (Remote Agent Provider): restarted process with PID %d", pid) + os.Exit(0) + } + case "secretrotation": + // check if the target needs SR + thumbprint, err := i.getCertificateExpirationOrThumbPrint(i.Config.PublicCertPath, "thumbprint") + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to get certificate thumbprint: %+v", err) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + sLog.InfofCtx(ctx, " P (Remote Agent Provider): certificate thumbprint %s for %s from local.", c.Name, thumbprint) + upstreamThumb, ok := c.Properties["thumbprint"].(string) + if !ok { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): missing thumbprint parameter in component %s", c.Name) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + sLog.InfofCtx(ctx, " P (Remote Agent Provider): certificate thumbprint %s for %s from upstream.", c.Name, upstreamThumb) + if thumbprint == upstreamThumb { + sLog.InfofCtx(ctx, " P (Remote Agent Provider): The two certs are identical. No need to SR.") + agentStatus, err := i.generateAgentStatus(ctx) + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to generate agent status: %+v", err) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + ret[c.Name] = model.ComponentResultSpec{ + Status: v1alpha2.OK, + Message: agentStatus, + } + continue + } + + // call the secret rotation api + srUrl := fmt.Sprintf("%s/targets/secretrotate/%s?namespace=%s", i.Config.BaseUrl, step.Target, i.Config.Namespace) + req, err := http.NewRequest("POST", srUrl, nil) + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to create secret rotation request: %+v", err) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + req.Header.Set("Content-Type", "application/json") + resp, err := i.Client.Do(req) + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to call secret rotation", err) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to call secret rotation: %s", resp.Status) + err = fmt.Errorf("failed to call secret rotation: %s", resp.Status) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + // parse resp body to get the new cert + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to read response body: %+v", err) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + + var result map[string]string + err = json.Unmarshal(body, &result) + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to unmarshal response body: %+v. The body is %s", err, body) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + public, ok := result["public"] + if !ok { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to get public cert from response body") + err = fmt.Errorf("failed to get public cert from response body") + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + private, ok := result["private"] + if !ok { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to get private cert from response body") + err = fmt.Errorf("failed to get private cert from response body") + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + + if public == "" || private == "" { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to get public or private cert from response body") + err = fmt.Errorf("failed to get public or private cert from response body") + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + + // write the new cert to the cert file + err = ioutil.WriteFile(i.Config.PublicCertPath, []byte(formatPEM(public, "public")), 0644) + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to write new cert to file: %+v", err) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + + err = ioutil.WriteFile(i.Config.PrivateKeyPath, []byte(formatPEM(private, "private")), 0644) + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to write new key to file: %+v", err) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + //update the http client with the new cert + cert, err := tls.LoadX509KeyPair(i.Config.PublicCertPath, i.Config.PrivateKeyPath) + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to create new cert: %+v", err) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + i.Client.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + Certificates: []tls.Certificate{cert}, + }, + } + agentStatus, err := i.generateAgentStatus(ctx) + if err != nil { + sLog.ErrorfCtx(ctx, " P (Remote Agent Provider): failed to generate agent status: %+v", err) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + continue + } + ret[c.Name] = model.ComponentResultSpec{ + Status: v1alpha2.OK, + Message: agentStatus, + } + continue + case "log": + default: + err = fmt.Errorf("invalid action parameter in component %s", c.Name) + ret[c.Name] = i.composeComponentResultSpec(v1alpha2.UpdateFailed, err) + } + } + return ret, nil +} +func restartTheProcessWithRetry(cmd *exec.Cmd) (int, error) { + //return 0, err if the process is not started + // return pid, nil if the process is started + var pid int + var err error + for i := 0; i < maxRetries; i++ { + sLog.Infof(" P (Remote Agent Provider): running command %s", cmd.String()) + err = cmd.Start() + if err == nil { + pid = cmd.Process.Pid + break + } + time.Sleep(retryDelay) + } + return pid, err +} + +func (*RemoteAgentProvider) GetValidationRule(ctx context.Context) model.ValidationRule { + return model.ValidationRule{ + AllowSidecar: false, + ComponentValidationRule: model.ComponentValidationRule{ + RequiredProperties: []string{}, + OptionalProperties: []string{}, + RequiredComponentType: "", + RequiredMetadata: []string{}, + OptionalMetadata: []string{}, + }, + } +} + +func formatPEM(cert string, kind string) string { + var pemHeader, pemFooter string + if kind == "public" { + pemHeader = "-----BEGIN CERTIFICATE-----" + pemFooter = "-----END CERTIFICATE-----" + } + if kind == "private" { + pemHeader = "-----BEGIN RSA PRIVATE KEY-----" + pemFooter = "-----END RSA PRIVATE KEY-----" + } + + // Remove any existing headers and footers + cert = strings.Replace(cert, pemHeader, "", -1) + cert = strings.Replace(cert, pemFooter, "", -1) + // remove any space at the beginning or end of the cert + cert = strings.TrimSpace(cert) + // Encode the certificate with line breaks + var buffer bytes.Buffer + buffer.WriteString(pemHeader + "\n") + parts := strings.Split(cert, " ") + for i := 0; i < len(parts); i++ { + buffer.WriteString(parts[i] + "\n") + } + buffer.WriteString(pemFooter) + + return buffer.String() +} diff --git a/remote-agent/script/instance.yaml b/remote-agent/script/instance.yaml new file mode 100644 index 000000000..bac13b1e6 --- /dev/null +++ b/remote-agent/script/instance.yaml @@ -0,0 +1,11 @@ +apiVersion: solution.symphony/v1 +kind: Instance +metadata: + annotations: {} + name: remotedemoinstance +spec: + displayName: remotedemoinstance + scope: default + solution: solutiontest:v1 + target: + name: remote-demo diff --git a/remote-agent/script/mock-apply.ps1 b/remote-agent/script/mock-apply.ps1 new file mode 100644 index 000000000..e69de29bb diff --git a/remote-agent/script/mock-apply.sh b/remote-agent/script/mock-apply.sh new file mode 100755 index 000000000..bc22155a7 --- /dev/null +++ b/remote-agent/script/mock-apply.sh @@ -0,0 +1,52 @@ +#!/bin/bash +## +## Copyright (c) Microsoft Corporation. +## Licensed under the MIT license. +## SPDX-License-Identifier: MIT +## + +deployment=$1 # first parameter file is the deployment object +references=$2 # second parmeter file contains the reference components + +# the apply script is called with a list of components to be updated via +# the references parameter +components=$(jq -c '.[]' "$references") + +echo "COMPONENTS: $components" + +while IFS= read -r line; do + # Extract the name and age fields from each JSON object + name=$(echo "$line" | jq -r '.name') + properties=$(echo "$line" | jq -r '.properties') + echo "NAME: $name" + echo "PROPERTIES: $properties" +done <<< "$components" + +# optionally, you can use the deployment parameter to get additional contextual information as needed. +# for example, you can the following query to get the instance scope. + +scope=$(jq '.instance.scope' "$deployment") +echo "SCOPE: $scope" + + +# your script needs to generate an output file that contains a map of component results. For each +# component result, the status code should be one of +# 8001: fialed to update +# 8002: failed to delete +# 8003: failed to validate component artifact +# 8004: updated (success) +# 8005: deleted (success) +# 9998: untouched - no actions are taken/necessary + +output_results='{ + "com1": { + "status": 8004, + "message": "" + }, + "com2": { + "status": 8001, + "message": "update error message" + } +}' + +echo "$output_results" > ${deployment%.*}-output.${deployment##*.} diff --git a/remote-agent/script/mock-get.ps1 b/remote-agent/script/mock-get.ps1 new file mode 100644 index 000000000..81ad46f37 --- /dev/null +++ b/remote-agent/script/mock-get.ps1 @@ -0,0 +1,20 @@ +param( + [String]$DeploymentFile, + [String]$ComponentListFile +) + +# Load the JSON from the input file +$json = Get-Content -Encoding UTF8 $ComponentListFile | ConvertFrom-Json + +# Loop through the components and remove those with app.package equals to "notepad" if notepad process is not running +foreach ($component in $json) { + if ($component.Component.Properties."app.package" -eq "notepad") { + if ((Get-Process -Name "notepad" -ErrorAction SilentlyContinue) -eq $null) { + # Remove the component from the Components list + $json= $json | Where-Object { $_ -ne $component } + } + } +} + +# Write the updated JSON to an output file +"[" + ($json | ForEach-Object {$_.Component} | ConvertTo-Json -Compress) + "]" | Out-File -Encoding ASCII $DeploymentFile.Replace(".json", "-get-output.json") \ No newline at end of file diff --git a/remote-agent/script/mock-get.sh b/remote-agent/script/mock-get.sh new file mode 100755 index 000000000..d92382108 --- /dev/null +++ b/remote-agent/script/mock-get.sh @@ -0,0 +1,38 @@ +#!/bin/bash +## +## Copyright (c) Microsoft Corporation. +## Licensed under the MIT license. +## SPDX-License-Identifier: MIT +## + +deployment=$1 # first parameter file is the deployment object +references=$2 # second parmeter file contains the reference components + +# to get the list components you need to return during this Get() call, you can +# read from the references parameter file. This file gives you a list of components and +# their associated actions, which can be either "update" or "delete". Your script is +# supposed to use this list as a reference (regardless of the action flag) to collect +# the current state of the corresponding components, and return the list. If a component +# doesn't exist, simply skip the component. + +components=$(jq -c '.[]' "$references") + +while IFS= read -r line; do + # Extract the name and age fields from each JSON object + action=$(echo "$line" | jq -r '.action') + component=$(echo "$line" | jq -r '.component') + echo "ACTION: $action" + echo "COMPONENT: $component" +done <<< "$components" + +# optionally, you can use the deployment parameter to get additional contextual information as needed. +# for example, you can the following query to get the instance scope. + +scope=$(jq '.instance.scope' "$deployment") +echo "SCOPE: $scope" + +# the following is an example of generating required output file. The example simply extracts +# all reference components and writes them into the output JSON file. + +output_components=$(jq -r '[.[] | .component]' "$references") +echo "$output_components" > ${deployment%.*}-get-output.${deployment##*.} \ No newline at end of file diff --git a/remote-agent/script/mock-remove.ps1 b/remote-agent/script/mock-remove.ps1 new file mode 100644 index 000000000..e69de29bb diff --git a/remote-agent/script/mock-remove.sh b/remote-agent/script/mock-remove.sh new file mode 100755 index 000000000..764d7509b --- /dev/null +++ b/remote-agent/script/mock-remove.sh @@ -0,0 +1,52 @@ +#!/bin/bash +## +## Copyright (c) Microsoft Corporation. +## Licensed under the MIT license. +## SPDX-License-Identifier: MIT +## + +deployment=$1 # first parameter file is the deployment object +references=$2 # second parmeter file contains the reference components + +# the remove script is called with a list of components to be deleted via +# the references parameter +components=$(jq -c '.[]' "$references") + +echo "COMPONENTS: $components" + +while IFS= read -r line; do + # Extract the name and age fields from each JSON object + name=$(echo "$line" | jq -r '.name') + properties=$(echo "$line" | jq -r '.properties') + echo "NAME: $name" + echo "PROPERTIES: $properties" +done <<< "$components" + +# optionally, you can use the deployment parameter to get additional contextual information as needed. +# for example, you can the following query to get the instance scope. + +scope=$(jq '.instance.scope' "$deployment") +echo "SCOPE: $scope" + + +# your script needs to generate an output file that contains a map of component results. For each +# component result, the status code should be one of +# 8001: fialed to update +# 8002: failed to delete +# 8003: failed to validate component artifact +# 8004: updated (success) +# 8005: deleted (success) +# 9998: untouched - no actions are taken/necessary + +output_results='{ + "com1": { + "status": 8004, + "message": "" + }, + "com2": { + "status": 8001, + "message": "update error message" + } +}' + +echo "$output_results" > ${deployment%.*}-output.${deployment##*.} diff --git a/remote-agent/script/solution.yaml b/remote-agent/script/solution.yaml new file mode 100644 index 000000000..c4664d388 --- /dev/null +++ b/remote-agent/script/solution.yaml @@ -0,0 +1,19 @@ +apiVersion: solution.symphony/v1 +kind: SolutionContainer +metadata: + name: solutiontest +spec: +--- +apiVersion: solution.symphony/v1 +kind: Solution +metadata: + name: solutiontest-v-v1 +spec: + rootResource: solutiontest + components: + - name: getProcess + type: script + properties: + path: sh + flag: -c + args: "echo hello&echo world"