From 54c6aff09cb68518eef7b20b4af1289391bea1c1 Mon Sep 17 00:00:00 2001 From: Kristiyan Gostev Date: Thu, 17 Aug 2023 15:36:42 +0300 Subject: [PATCH] Add containers update agent service (#187) [#186] Implement containers update agent service Merge update-agent feature branch * Extend daemon configuration and dummy implementation for update agent (#168) * Upgrade google.golang.org/grpc to version 1.53.0 or later (#172) * Preparation for implementing apply of containers desired state (#175) * Implement apply of desired state for containers update agent (#177) * Implement current state reporting for containers update agent (#179) Signed-off-by: Kristiyan Gostev Co-authored-by: Dimitar Dimitrov Co-authored-by: Stoyan Zoubev --- containerm/cli/cli_commamd_ctrs_base_test.go | 7 +- containerm/cli/cli_command_ctrs_create.go | 128 +--- .../cli/cli_command_ctrs_create_test.go | 6 +- containerm/daemon/daemon_command.go | 6 + containerm/daemon/daemon_config.go | 10 + containerm/daemon/daemon_config_default.go | 11 + containerm/daemon/daemon_config_util.go | 57 +- containerm/daemon/daemon_internal.go | 63 +- containerm/daemon/daemon_internal_init.go | 9 + containerm/daemon/daemon_test.go | 16 + .../pkg/testutil/config/daemon-config.json | 8 +- containerm/registry/registry.go | 2 + containerm/updateagent/from_containers.go | 210 ++++++ .../updateagent/from_containers_test.go | 641 ++++++++++++++++++ .../updateagent/internal_desired_state.go | 110 +++ containerm/updateagent/to_containers.go | 175 +++++ containerm/updateagent/update_agent_init.go | 101 +++ containerm/updateagent/update_agent_opts.go | 160 +++++ .../updateagent/update_agent_service.go | 30 + containerm/updateagent/update_manager.go | 169 +++++ containerm/updateagent/update_manager_util.go | 68 ++ .../updateagent/update_manager_util_test.go | 39 ++ containerm/updateagent/update_operation.go | 620 +++++++++++++++++ containerm/util/containers_compare.go | 18 + containerm/util/containers_parse.go | 234 +++++++ containerm/util/containers_parse_test.go | 371 ++++++++++ containerm/util/containers_validation.go | 3 + containerm/util/containers_validation_test.go | 14 + containerm/util/util_base_test.go | 2 +- go.mod | 1 + go.sum | 4 +- 31 files changed, 3149 insertions(+), 144 deletions(-) create mode 100644 containerm/updateagent/from_containers.go create mode 100644 containerm/updateagent/from_containers_test.go create mode 100644 containerm/updateagent/internal_desired_state.go create mode 100644 containerm/updateagent/to_containers.go create mode 100644 containerm/updateagent/update_agent_init.go create mode 100644 containerm/updateagent/update_agent_opts.go create mode 100644 containerm/updateagent/update_agent_service.go create mode 100644 containerm/updateagent/update_manager.go create mode 100644 containerm/updateagent/update_manager_util.go create mode 100644 containerm/updateagent/update_manager_util_test.go create mode 100644 containerm/updateagent/update_operation.go create mode 100644 containerm/util/containers_parse.go create mode 100644 containerm/util/containers_parse_test.go diff --git a/containerm/cli/cli_commamd_ctrs_base_test.go b/containerm/cli/cli_commamd_ctrs_base_test.go index 5e72273..c8be58f 100644 --- a/containerm/cli/cli_commamd_ctrs_base_test.go +++ b/containerm/cli/cli_commamd_ctrs_base_test.go @@ -122,7 +122,12 @@ func execTestsRun(t *testing.T, cliTest cliCommandTest) { // perform the real call resultErr := cliTest.runCommand(testCase.args) // assert result - testutil.AssertError(t, expectedRunErr, resultErr) + if expectedRunErr == nil { + testutil.AssertNil(t, resultErr) + } else { + testutil.AssertNotNil(t, resultErr) + testutil.AssertContainsString(t, resultErr.Error(), expectedRunErr.Error()) + } }) } } diff --git a/containerm/cli/cli_command_ctrs_create.go b/containerm/cli/cli_command_ctrs_create.go index f8ed095..36d0f46 100644 --- a/containerm/cli/cli_command_ctrs_create.go +++ b/containerm/cli/cli_command_ctrs_create.go @@ -15,9 +15,6 @@ package main import ( "context" "fmt" - "net" - "strconv" - "strings" "time" "github.com/eclipse-kanto/container-management/containerm/containers/types" @@ -119,7 +116,7 @@ func (cc *createCmd) run(args []string) error { } if cc.config.devices != nil { - devs, err := parseDevices(cc.config.devices) + devs, err := util.ParseDeviceMappings(cc.config.devices) if err != nil { return err } @@ -127,7 +124,7 @@ func (cc *createCmd) run(args []string) error { } if cc.config.mountPoints != nil { - mounts, err := parseMountPoints(cc.config.mountPoints) + mounts, err := util.ParseMountPoints(cc.config.mountPoints) if err != nil { return err } else if mounts != nil { @@ -135,7 +132,7 @@ func (cc *createCmd) run(args []string) error { } } if cc.config.ports != nil { - mappings, err := parsePortMappings(cc.config.ports) + mappings, err := util.ParsePortMappings(cc.config.ports) if err != nil { return err } @@ -310,122 +307,3 @@ func (cc *createCmd) setupFlags() { flagSet.StringSliceVar(&cc.config.decKeys, "dec-keys", nil, "Sets a list of private keys filenames (GPG private key ring, JWE and PKCS7 private key). Each entry can include an optional password separated by a colon after the filename.") flagSet.StringSliceVar(&cc.config.decRecipients, "dec-recipients", nil, "Sets a recipients certificates list of the image (used only for PKCS7 and must be an x509)") } - -func parseDevices(devices []string) ([]types.DeviceMapping, error) { - var devs []types.DeviceMapping - for _, devPair := range devices { - pair := strings.Split(strings.TrimSpace(devPair), ":") - if len(pair) == 2 { - devs = append(devs, types.DeviceMapping{ - PathOnHost: pair[0], - PathInContainer: pair[1], - CgroupPermissions: "rwm", - }) - } else if len(pair) == 3 { - if len(pair[2]) == 0 || len(pair[2]) > 3 { - return nil, log.NewError("incorrect device cgroup permissions format") - } - for i := 0; i < len(pair[2]); i++ { - if (pair[2])[i] != "w"[0] && (pair[2])[i] != "r"[0] && (pair[2])[i] != "m"[0] { - return nil, log.NewError("incorrect device cgroup permissions format") - } - } - - devs = append(devs, types.DeviceMapping{ - PathOnHost: pair[0], - PathInContainer: pair[1], - CgroupPermissions: pair[2], - }) - } else { - return nil, log.NewError("incorrect device configuration format") - } - } - return devs, nil -} - -func parseMountPoints(mps []string) ([]types.MountPoint, error) { - var mountPoints []types.MountPoint - var mountPoint types.MountPoint - for _, mp := range mps { - mount := strings.Split(strings.TrimSpace(mp), ":") - // if propagation mode is omitted, "rprivate" is set as default - if len(mount) < 2 || len(mount) > 3 { - return nil, log.NewError("Incorrect number of parameters of the mount point") - } - mountPoint = types.MountPoint{ - Destination: mount[1], - Source: mount[0], - } - if len(mount) == 2 { - log.Debug("propagation mode ommited - setting default to rprivate") - mountPoint.PropagationMode = types.RPrivatePropagationMode - } else { - mountPoint.PropagationMode = mount[2] - } - mountPoints = append(mountPoints, mountPoint) - } - return mountPoints, nil -} - -func parsePortMappings(mappings []string) ([]types.PortMapping, error) { - var ( - portMappings []types.PortMapping - err error - protocol string - containerPort int64 - hostIP string - hostPort int64 - hostPortEnd int64 - ) - - for _, mapping := range mappings { - mappingWithProto := strings.Split(strings.TrimSpace(mapping), "/") - mapping = mappingWithProto[0] - if len(mappingWithProto) == 2 { - // port is specified, e.g.80:80/tcp - protocol = mappingWithProto[1] - } - addressAndPorts := strings.Split(strings.TrimSpace(mapping), ":") - hostPortIdx := 0 // if host ip not set - if len(addressAndPorts) == 2 { - // host address not specified, e.g. 80:80 - } else if len(addressAndPorts) == 3 { - hostPortIdx = 1 - hostIP = addressAndPorts[0] - validIP := net.ParseIP(hostIP) - if validIP == nil { - return nil, log.NewError("Incorrect host ip port mapping configuration") - } - hostPort, err = strconv.ParseInt(addressAndPorts[1], 10, 32) - containerPort, err = strconv.ParseInt(addressAndPorts[2], 10, 32) - - } else { - return nil, log.NewError("Incorrect port mapping configuration") - } - - hostPortWithRange := strings.Split(strings.TrimSpace(addressAndPorts[hostPortIdx]), "-") - if len(hostPortWithRange) == 2 { - hostPortEnd, err = strconv.ParseInt(hostPortWithRange[1], 10, 32) - if err != nil { - return nil, log.NewError("Incorrect host range port mapping configuration") - } - hostPort, err = strconv.ParseInt(hostPortWithRange[0], 10, 32) - } else { - hostPort, err = strconv.ParseInt(addressAndPorts[hostPortIdx], 10, 32) - } - containerPort, err = strconv.ParseInt(addressAndPorts[hostPortIdx+1], 10, 32) - if err != nil { - return nil, log.NewError("Incorrect port mapping configuration, parsing error") - } - - portMappings = append(portMappings, types.PortMapping{ - Proto: protocol, - ContainerPort: uint16(containerPort), - HostIP: hostIP, - HostPort: uint16(hostPort), - HostPortEnd: uint16(hostPortEnd), - }) - } - return portMappings, nil - -} diff --git a/containerm/cli/cli_command_ctrs_create_test.go b/containerm/cli/cli_command_ctrs_create_test.go index 19f6df4..62ce9db 100644 --- a/containerm/cli/cli_command_ctrs_create_test.go +++ b/containerm/cli/cli_command_ctrs_create_test.go @@ -701,12 +701,12 @@ func (createTc *createCommandTest) mockExecCreateDevicesWithPrivileged(args []st func (createTc *createCommandTest) mockExecCreateDevicesErrConfigFormat(args []string) error { createTc.mockClient.EXPECT().Create(gomock.AssignableToTypeOf(context.Background()), gomock.Any()).Times(0) - return log.NewError("incorrect device configuration format") + return log.NewError("incorrect configuration value for device mapping") } func (createTc *createCommandTest) mockExecCreateDevicesErrCgroupFormat(args []string) error { createTc.mockClient.EXPECT().Create(gomock.AssignableToTypeOf(context.Background()), gomock.Any()).Times(0) - return log.NewError("incorrect device cgroup permissions format") + return log.NewError("incorrect cgroup permissions format for device mapping") } func (createTc *createCommandTest) mockExecCreateWithMountPoints(args []string) error { @@ -878,7 +878,7 @@ func (createTc *createCommandTest) mockExecCreateWithPortsIncorrectPortsConfig(a func (createTc *createCommandTest) mockExecCreateWithPortsIncorrectPortsConfigParseErr(args []string) error { createTc.mockClient.EXPECT().Create(gomock.AssignableToTypeOf(context.Background()), gomock.Any()).Times(0) - return log.NewError("Incorrect port mapping configuration, parsing error") + return log.NewError("Incorrect container port mapping configuration") } func (createTc *createCommandTest) mockExecCreateWithPortsIncorrectHostRange(args []string) error { diff --git a/containerm/daemon/daemon_command.go b/containerm/daemon/daemon_command.go index f9dfa7d..2677129 100644 --- a/containerm/daemon/daemon_command.go +++ b/containerm/daemon/daemon_command.go @@ -85,6 +85,12 @@ func setupCommandFlags(cmd *cobra.Command) { flagSet.StringVar(&cfg.ThingsConfig.ThingsMetaPath, "things-home-dir", cfg.ThingsConfig.ThingsMetaPath, "Specify the home directory for the things container management service persistent storage") flagSet.StringSliceVar(&cfg.ThingsConfig.Features, "things-features", cfg.ThingsConfig.Features, "Specify the desired Ditto features that will be registered for the containers Ditto thing") + // init update agent + flagSet.BoolVar(&cfg.UpdateAgentConfig.UpdateAgentEnable, "ua-enable", cfg.UpdateAgentConfig.UpdateAgentEnable, "Enable the update agent for containers") + flagSet.StringVar(&cfg.UpdateAgentConfig.DomainName, "ua-domain", cfg.UpdateAgentConfig.DomainName, "Specify the domain name for the containers update agent") + flagSet.StringSliceVar(&cfg.UpdateAgentConfig.SystemContainers, "ua-system-containers", cfg.UpdateAgentConfig.SystemContainers, "Specify the list of system containers which shall be skipped during update process by the update agent") + flagSet.BoolVar(&cfg.UpdateAgentConfig.VerboseInventoryReport, "ua-verbose-inventory-report", cfg.UpdateAgentConfig.VerboseInventoryReport, "Enables verbose reporting of current inventory of containers by the update agent") + // init local communication flags flagSet.StringVar(&cfg.LocalConnection.BrokerURL, "conn-broker-url", cfg.LocalConnection.BrokerURL, "Specify the MQTT broker URL to connect to") flagSet.StringVar(&cfg.LocalConnection.KeepAlive, "conn-keep-alive", cfg.LocalConnection.KeepAlive, "Specify the keep alive duration for the MQTT requests as duration string") diff --git a/containerm/daemon/daemon_config.go b/containerm/daemon/daemon_config.go index 578f302..d071b59 100644 --- a/containerm/daemon/daemon_config.go +++ b/containerm/daemon/daemon_config.go @@ -35,6 +35,8 @@ type config struct { ThingsConfig *thingsConfig `json:"things,omitempty"` + UpdateAgentConfig *updateAgentConfig `json:"update_agent,omitempty"` + LocalConnection *localConnectionConfig `json:"connection,omitempty"` } @@ -151,6 +153,14 @@ type thingsConfig struct { ThingsConnectionConfig *thingsConnectionConfig `json:"connection,omitempty"` } +// things client configuration +type updateAgentConfig struct { + UpdateAgentEnable bool `json:"enable,omitempty"` + DomainName string `json:"domain,omitempty"` + SystemContainers []string `json:"system_containers,omitempty"` + VerboseInventoryReport bool `json:"verbose_inventory_report,omitempty"` +} + // local connection config type localConnectionConfig struct { BrokerURL string `json:"broker_url,omitempty"` diff --git a/containerm/daemon/daemon_config_default.go b/containerm/daemon/daemon_config_default.go index c965118..c279fbd 100644 --- a/containerm/daemon/daemon_config_default.go +++ b/containerm/daemon/daemon_config_default.go @@ -95,6 +95,11 @@ const ( deploymentModeDefault = string(deployment.UpdateMode) deploymentMetaPathDefault = managerMetaPathDefault deploymentCtrPathDefault = "/etc/container-management/containers" + + // default update agent config + updateAgentEnableDefault = false + updateAgentDomainDefault = "containers" + updateAgentVerboseInventoryReportDefault = false ) var ( @@ -178,6 +183,12 @@ func getDefaultInstance() *config { DeploymentMetaPath: deploymentMetaPathDefault, DeploymentCtrPath: deploymentCtrPathDefault, }, + UpdateAgentConfig: &updateAgentConfig{ + UpdateAgentEnable: updateAgentEnableDefault, + DomainName: updateAgentDomainDefault, + SystemContainers: []string{}, // no system containers by defaults + VerboseInventoryReport: updateAgentVerboseInventoryReportDefault, + }, LocalConnection: &localConnectionConfig{ BrokerURL: connectionBrokerURLDefault, KeepAlive: connectionKeepAliveDefault, diff --git a/containerm/daemon/daemon_config_util.go b/containerm/daemon/daemon_config_util.go index f5e58a3..00099ee 100644 --- a/containerm/daemon/daemon_config_util.go +++ b/containerm/daemon/daemon_config_util.go @@ -28,6 +28,7 @@ import ( "github.com/eclipse-kanto/container-management/containerm/network" "github.com/eclipse-kanto/container-management/containerm/server" "github.com/eclipse-kanto/container-management/containerm/things" + "github.com/eclipse-kanto/container-management/containerm/updateagent" "github.com/spf13/pflag" ) @@ -117,15 +118,6 @@ func extractThingsOptions(daemonConfig *config) []things.ContainerThingsManagerO } } - parseDuration := func(duration, defaultDuration string) time.Duration { - d, err := time.ParseDuration(duration) - if err != nil { - log.Warn("Invalid Duration string: %s", duration) - d, _ = time.ParseDuration(defaultDuration) - } - return d - } - thingsOpts = append(thingsOpts, things.WithMetaPath(daemonConfig.ThingsConfig.ThingsMetaPath), things.WithFeatures(daemonConfig.ThingsConfig.Features), @@ -145,6 +137,30 @@ func extractThingsOptions(daemonConfig *config) []things.ContainerThingsManagerO return thingsOpts } +func extractUpdateAgentOptions(daemonConfig *config) []updateagent.ContainersUpdateAgentOpt { + updateAgentOpts := []updateagent.ContainersUpdateAgentOpt{} + updateAgentOpts = append(updateAgentOpts, + updateagent.WithDomainName(daemonConfig.UpdateAgentConfig.DomainName), + updateagent.WithSystemContainers(daemonConfig.UpdateAgentConfig.SystemContainers), + updateagent.WithVerboseInventoryReport(daemonConfig.UpdateAgentConfig.VerboseInventoryReport), + + updateagent.WithConnectionBroker(daemonConfig.LocalConnection.BrokerURL), + updateagent.WithConnectionKeepAlive(parseDuration(daemonConfig.LocalConnection.KeepAlive, connectionKeepAliveDefault)), + updateagent.WithConnectionDisconnectTimeout(parseDuration(daemonConfig.LocalConnection.DisconnectTimeout, connectionDisconnectTimeoutDefault)), + updateagent.WithConnectionClientUsername(daemonConfig.LocalConnection.ClientUsername), + updateagent.WithConnectionClientPassword(daemonConfig.LocalConnection.ClientPassword), + updateagent.WithConnectionConnectTimeout(parseDuration(daemonConfig.LocalConnection.ConnectTimeout, connectTimeoutTimeoutDefault)), + updateagent.WithConnectionAcknowledgeTimeout(parseDuration(daemonConfig.LocalConnection.AcknowledgeTimeout, acknowledgeTimeoutDefault)), + updateagent.WithConnectionSubscribeTimeout(parseDuration(daemonConfig.LocalConnection.SubscribeTimeout, subscribeTimeoutDefault)), + updateagent.WithConnectionUnsubscribeTimeout(parseDuration(daemonConfig.LocalConnection.UnsubscribeTimeout, unsubscribeTimeoutDefault)), + ) + transport := daemonConfig.LocalConnection.Transport + if transport != nil { + updateAgentOpts = append(updateAgentOpts, updateagent.WithTLSConfig(transport.RootCA, transport.ClientCert, transport.ClientKey)) + } + return updateAgentOpts +} + func extractDeploymentMgrOptions(daemonConfig *config) []deployment.Opt { return []deployment.Opt{ deployment.WithMode(daemonConfig.DeploymentManagerConfig.DeploymentMode), @@ -218,6 +234,9 @@ func dumpConfiguration(configInstance *config) { // dump things client config dumpThingsClient(configInstance) + // dump update agent config + dumpUpdateAgent(configInstance) + // dump deployment manager config dumpDeploymentManager(configInstance) @@ -335,6 +354,17 @@ func dumpThingsClient(configInstance *config) { } } +func dumpUpdateAgent(configInstance *config) { + if configInstance.UpdateAgentConfig != nil { + log.Debug("[daemon_cfg][ua-enable] : %v", configInstance.UpdateAgentConfig.UpdateAgentEnable) + if configInstance.UpdateAgentConfig.UpdateAgentEnable { + log.Debug("[daemon_cfg][ua-domain] : %s", configInstance.UpdateAgentConfig.DomainName) + log.Debug("[daemon_cfg][ua-system-containers] : %s", configInstance.UpdateAgentConfig.SystemContainers) + log.Debug("[daemon_cfg][ua-verbose-inventory-report] : %v", configInstance.UpdateAgentConfig.VerboseInventoryReport) + } + } +} + func dumpDeploymentManager(configInstance *config) { if configInstance.DeploymentManagerConfig != nil { log.Debug("[daemon_cfg][deployment-enable] : %v", configInstance.DeploymentManagerConfig.DeploymentEnable) @@ -428,3 +458,12 @@ func applyInsecureRegistryConfig(registriesConfig map[string]*ctr.RegistryConfig } return res } + +func parseDuration(duration, defaultDuration string) time.Duration { + d, err := time.ParseDuration(duration) + if err != nil { + log.Warn("Invalid Duration string: %s", duration) + d, _ = time.ParseDuration(defaultDuration) + } + return d +} diff --git a/containerm/daemon/daemon_internal.go b/containerm/daemon/daemon_internal.go index 42c2d4e..39cf38e 100644 --- a/containerm/daemon/daemon_internal.go +++ b/containerm/daemon/daemon_internal.go @@ -20,6 +20,8 @@ import ( "github.com/eclipse-kanto/container-management/containerm/mgr" "github.com/eclipse-kanto/container-management/containerm/registry" "github.com/eclipse-kanto/container-management/containerm/things" + + "github.com/eclipse-kanto/update-manager/api" ) func (d *daemon) start() error { @@ -38,14 +40,21 @@ func (d *daemon) start() error { } if d.config.ThingsConfig.ThingsEnable { - err := d.startThingsManagers() - if err != nil { + if err := d.startThingsManagers(); err != nil { log.ErrorErr(err, "could not start the Things Container Manager Services") } } - return d.startGrpcServers() + if d.config.UpdateAgentConfig.UpdateAgentEnable { + log.Debug("Containers Update Agent is enabled.") + if err := d.startUpdateAgents(); err != nil { + log.ErrorErr(err, "could not start the Containers Update Agent Services") + } + } else { + log.Debug("Containers Update Agent is not enabled.") + } + return d.startGrpcServers() } func (d *daemon) stop() { @@ -61,6 +70,11 @@ func (d *daemon) stop() { log.Debug("stopping management local services") d.stopContainerManagers() + if d.config.UpdateAgentConfig.UpdateAgentEnable { + log.Debug("stopping Containers Update Agents services") + d.stopUpdateAgents() + } + if d.config.ThingsConfig.ThingsEnable { log.Debug("stopping Things Container Manager service") d.stopThingsManagers() @@ -215,6 +229,49 @@ func (d *daemon) stopThingsManagers() { } } +func (d *daemon) startUpdateAgents() error { + log.Debug("starting Update Agent services ") + updateAgentInfos := d.serviceInfoSet.GetAll(registry.UpdateAgentService) + var instance interface{} + var err error + + log.Debug("there are %d Update Agent services to be started", len(updateAgentInfos)) + for _, updateAgentInfo := range updateAgentInfos { + log.Debug("will try to start Update Agent service instance with service ID = %s", updateAgentInfo.Registration.ID) + instance, err = updateAgentInfo.Instance() + if err != nil { + log.ErrorErr(err, "could not get Update Agent service instance with service ID = %s", updateAgentInfo.Registration.ID) + } else { + err = instance.(api.UpdateAgent).Start(context.Background()) + if err != nil { + log.ErrorErr(err, "could not start Update Agent service instance with service ID = %s", updateAgentInfo.Registration.ID) + } else { + log.Debug("successfully started Update Agent service instance with service ID = %s ", updateAgentInfo.Registration.ID) + } + } + } + return err +} + +func (d *daemon) stopUpdateAgents() { + log.Debug("will stop Update Agent services") + updateAgentInfos := d.serviceInfoSet.GetAll(registry.UpdateAgentService) + + for _, updateAgentInfo := range updateAgentInfos { + instance, err := updateAgentInfo.Instance() + if err != nil { + log.ErrorErr(err, "could not get Update Agent service instance with service ID = %s", updateAgentInfo.Registration.ID) + } else { + err = instance.(api.UpdateAgent).Stop() + if err != nil { + log.ErrorErr(err, "could not stop gracefully Update Agent service instance with service ID = %s", updateAgentInfo.Registration.ID) + } else { + log.Debug("successfully stopped Update Agent service with service ID = %s ", updateAgentInfo.Registration.ID) + } + } + } +} + func (d *daemon) stopContainerManagers() { log.Debug("will stop container management local services") ctrMrgServices := d.serviceInfoSet.GetAll(registry.ContainerManagerService) diff --git a/containerm/daemon/daemon_internal_init.go b/containerm/daemon/daemon_internal_init.go index 7513f43..b170c1b 100644 --- a/containerm/daemon/daemon_internal_init.go +++ b/containerm/daemon/daemon_internal_init.go @@ -60,6 +60,13 @@ func (d *daemon) init() { log.Info("Deployment Manager is disabled - no Deployment Manager Services will be registered. If you would like to enable Deployment support, please, reconfigure deployment-enable to true") } + //init update agent manager service + if daemonConfig.UpdateAgentConfig.UpdateAgentEnable { + initService(ctx, d, registrationsMap, registry.UpdateAgentService) + } else { + log.Info("Containers Update Agent is disabled - no Update Agent Services will be registered. If you would like to enable Update Agent support, please, reconfigure ua-enable to true") + } + //init grpc services initService(ctx, d, registrationsMap, registry.GRPCService) @@ -92,6 +99,8 @@ func initService(ctx context.Context, d *daemon, registrationsMap map[registry.T case registry.DeploymentManagerService: config = extractDeploymentMgrOptions(d.config) break + case registry.UpdateAgentService: + config = extractUpdateAgentOptions(d.config) default: config = nil } diff --git a/containerm/daemon/daemon_test.go b/containerm/daemon/daemon_test.go index eb09a38..937d270 100644 --- a/containerm/daemon/daemon_test.go +++ b/containerm/daemon/daemon_test.go @@ -453,6 +453,22 @@ func TestSetCommandFlags(t *testing.T) { flag: "conn-client-key", expectedType: reflect.String.String(), }, + "test_flags-ua-enable": { + flag: "ua-enable", + expectedType: reflect.Bool.String(), + }, + "test_flags-ua-domain": { + flag: "ua-domain", + expectedType: reflect.String.String(), + }, + "test_flags-ua-system-containers": { + flag: "ua-system-containers", + expectedType: "stringSlice", + }, + "test_flags-ua-verbose-inventory-report": { + flag: "ua-verbose-inventory-report", + expectedType: reflect.Bool.String(), + }, } for testName, testCase := range tests { diff --git a/containerm/pkg/testutil/config/daemon-config.json b/containerm/pkg/testutil/config/daemon-config.json index a39042e..55a1480 100644 --- a/containerm/pkg/testutil/config/daemon-config.json +++ b/containerm/pkg/testutil/config/daemon-config.json @@ -70,6 +70,12 @@ "home_dir": "/var/lib/container-management", "ctr_dir": "/etc/container-management/containers" }, + "update_agent": { + "enable": false, + "domain": "containers", + "system_containers": [], + "verbose_inventory": false + }, "connection": { "broker_url": "tcp://localhost:1883", "keep_alive": "20s", @@ -81,4 +87,4 @@ "subscribe_timeout": "15s", "unsubscribe_timeout": "5s" } -} \ No newline at end of file +} diff --git a/containerm/registry/registry.go b/containerm/registry/registry.go index 2892f60..0f063ea 100644 --- a/containerm/registry/registry.go +++ b/containerm/registry/registry.go @@ -44,6 +44,8 @@ const ( GRPCServer Type = "container-management.server.grpc.v1" // DeploymentManagerService implements THE container deployment manager service DeploymentManagerService Type = "container-management.service.deployment.ctrs.manager.v1" + // UpdateAgentService implements the UpdateAgent API for containers domain + UpdateAgentService Type = "container-management.service.ctrs.updateagent.v1" ) // Registration holds service's information that will be added to the registry diff --git a/containerm/updateagent/from_containers.go b/containerm/updateagent/from_containers.go new file mode 100644 index 0000000..3b132ef --- /dev/null +++ b/containerm/updateagent/from_containers.go @@ -0,0 +1,210 @@ +// Copyright (c) 2023 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package updateagent + +import ( + "strconv" + + ctrtypes "github.com/eclipse-kanto/container-management/containerm/containers/types" + "github.com/eclipse-kanto/container-management/containerm/util" + + "github.com/eclipse-kanto/update-manager/api/types" +) + +const ( + defaultRestartCount = 0 + defaultRestartPolicyType = ctrtypes.UnlessStopped + defaultRestartPolicyMaximumRetryCount = 0 + defaultRestartPolicyMaximumRetryTimeout = 0 + defaultHostConfigNetworkMode = ctrtypes.NetworkModeBridge + defaultLogConfigDriverConfigType = ctrtypes.LogConfigDriverJSONFile + defaultLogConfigMaxSize = "100M" + defaultLogConfigModeConfigMode = ctrtypes.LogModeBlocking + defaultLogConfigMaxFiles = 2 + defaultLogConfigModeConfigMaxBufferSize = "1M" + defaultExitCode = 0 +) + +func fromContainers(containers []*ctrtypes.Container, verbose bool) []*types.SoftwareNode { + softwareNodes := make([]*types.SoftwareNode, len(containers)) + for i, container := range containers { + softwareNodes[i] = fromContainer(container, verbose) + } + return softwareNodes +} + +// The default values for some container config options and runtime state values are skipped and not included in the result unless verbose parameter is set to true. +func fromContainer(container *ctrtypes.Container, verbose bool) *types.SoftwareNode { + params := []*types.KeyValuePair{} + + if verbose || len(container.Image.Name) > 0 { + appendParameter(¶ms, keyImage, container.Image.Name) + } + if verbose || container.DomainName != container.Name+"-domain" { + appendParameter(¶ms, keyDomainName, container.DomainName) + } + if verbose || container.HostName != container.Name+"-host" { + appendParameter(¶ms, keyHostName, container.HostName) + } + if container.IOConfig != nil { + params = append(params, ioConfigParameters(container.IOConfig, verbose)...) + } + if container.HostConfig != nil { + params = append(params, hostConfigParameters(container.HostConfig, verbose)...) + } + if len(container.Mounts) > 0 { + params = append(params, mountPointParameters(container.Mounts)...) + } + if container.Config != nil { + params = append(params, containerConfigParameters(container.Config)...) + } + if container.State != nil { + params = append(params, stateParameters(container.State, verbose)...) + } + appendParameter(¶ms, keyCreated, container.Created) + if verbose || container.RestartCount != defaultRestartCount { + appendParameter(¶ms, keyRestartCount, strconv.FormatInt(int64(container.RestartCount), 10)) + } + if verbose || (container.ManuallyStopped && container.State.Status != ctrtypes.Running) { + appendParameter(¶ms, keyManuallyStopped, strconv.FormatBool(container.ManuallyStopped)) + } + if verbose || (container.StartedSuccessfullyBefore && container.State.Status != ctrtypes.Running) { + appendParameter(¶ms, keyStartedSuccessfullyBefore, strconv.FormatBool(container.StartedSuccessfullyBefore)) + } + + return &types.SoftwareNode{ + InventoryNode: types.InventoryNode{ + ID: container.Name, + Version: findContainerVersion(container.Image.Name), + Parameters: params, + }, + Type: types.SoftwareTypeContainer, + } +} + +func hostConfigParameters(hostConfig *ctrtypes.HostConfig, verbose bool) []*types.KeyValuePair { + kvPair := []*types.KeyValuePair{} + if verbose || hostConfig.Privileged { + appendParameter(&kvPair, keyPrivileged, strconv.FormatBool(hostConfig.Privileged)) + } + + if hostConfig.RestartPolicy != nil { + if verbose || hostConfig.RestartPolicy.Type != defaultRestartPolicyType { + appendParameter(&kvPair, keyRestartPolicy, string(hostConfig.RestartPolicy.Type)) + } + if verbose || hostConfig.RestartPolicy.MaximumRetryCount != defaultRestartPolicyMaximumRetryCount { + appendParameter(&kvPair, keyRestartMaxRetries, strconv.FormatInt(int64(hostConfig.RestartPolicy.MaximumRetryCount), 10)) + } + if verbose || hostConfig.RestartPolicy.RetryTimeout != defaultRestartPolicyMaximumRetryTimeout { + appendParameter(&kvPair, keyRestartTimeout, hostConfig.RestartPolicy.RetryTimeout.String()) + } + } + for _, device := range hostConfig.Devices { + appendParameter(&kvPair, keyDevice, util.DeviceMappingToString(&device)) + } + for _, portMapping := range hostConfig.PortMappings { + appendParameter(&kvPair, keyPort, util.PortMappingToString(&portMapping)) + } + if (verbose || hostConfig.NetworkMode != defaultHostConfigNetworkMode) && len(hostConfig.NetworkMode) > 0 { + appendParameter(&kvPair, keyNetwork, string(hostConfig.NetworkMode)) + } + for _, host := range hostConfig.ExtraHosts { + appendParameter(&kvPair, keyHost, host) + } + if hostConfig.LogConfig != nil { + if hostConfig.LogConfig.DriverConfig != nil { + logDriverConfig := hostConfig.LogConfig.DriverConfig + fileLogging := logDriverConfig.Type == ctrtypes.LogConfigDriverJSONFile + if verbose || (len(logDriverConfig.Type) != 0 && logDriverConfig.Type != defaultLogConfigDriverConfigType) { + appendParameter(&kvPair, keyLogDriver, string(logDriverConfig.Type)) + } + if fileLogging && (verbose || logDriverConfig.MaxFiles != defaultLogConfigMaxFiles) { + appendParameter(&kvPair, keyLogMaxFiles, strconv.FormatInt(int64(logDriverConfig.MaxFiles), 10)) + } + if fileLogging && (verbose || (len(logDriverConfig.MaxSize) > 0 && logDriverConfig.MaxSize != defaultLogConfigMaxSize)) { + appendParameter(&kvPair, keyLogMaxSize, logDriverConfig.MaxSize) + } + if fileLogging && len(logDriverConfig.RootDir) > 0 { + appendParameter(&kvPair, keyLogPath, logDriverConfig.RootDir) + } + } + if hostConfig.LogConfig.ModeConfig != nil { + logModeConfig := hostConfig.LogConfig.ModeConfig + bufferedLogging := logModeConfig.Mode == ctrtypes.LogModeNonBlocking + if verbose || (len(logModeConfig.Mode) > 0 && logModeConfig.Mode != defaultLogConfigModeConfigMode) { + appendParameter(&kvPair, keyLogMode, string(logModeConfig.Mode)) + } + if bufferedLogging && (verbose || (len(logModeConfig.MaxBufferSize) > 0 && logModeConfig.MaxBufferSize != defaultLogConfigModeConfigMaxBufferSize)) { + appendParameter(&kvPair, keyLogMaxBufferSize, logModeConfig.MaxBufferSize) + } + } + } + if hostConfig.Resources != nil { + if len(hostConfig.Resources.Memory) > 0 { + appendParameter(&kvPair, keyMemory, hostConfig.Resources.Memory) + } + if len(hostConfig.Resources.MemoryReservation) > 0 { + appendParameter(&kvPair, keyMemoryReservation, hostConfig.Resources.MemoryReservation) + } + if len(hostConfig.Resources.MemorySwap) > 0 { + appendParameter(&kvPair, keyMemorySwap, hostConfig.Resources.MemorySwap) + } + } + return kvPair +} + +func mountPointParameters(mounts []ctrtypes.MountPoint) []*types.KeyValuePair { + kvPair := make([]*types.KeyValuePair, len(mounts)) + for i, mount := range mounts { + kvPair[i] = &types.KeyValuePair{Key: keyMount, Value: util.MountPointToString(&mount)} + } + return kvPair +} + +func containerConfigParameters(config *ctrtypes.ContainerConfiguration) []*types.KeyValuePair { + kvPair := make([]*types.KeyValuePair, len(config.Env)+len(config.Cmd)) + for i, env := range config.Env { + kvPair[i] = &types.KeyValuePair{Key: keyEnv, Value: env} + } + for i, cmd := range config.Cmd { + kvPair[i+len(config.Env)] = &types.KeyValuePair{Key: keyCmd, Value: cmd} + } + return kvPair +} + +func stateParameters(containerState *ctrtypes.State, verbose bool) []*types.KeyValuePair { + kvPair := []*types.KeyValuePair{} + appendParameter(&kvPair, keyStatus, containerState.Status.String()) + if verbose || (len(containerState.FinishedAt) > 0 && containerState.Status != ctrtypes.Running) { + appendParameter(&kvPair, keyFinishedAt, containerState.FinishedAt) + } + if verbose || (containerState.ExitCode != defaultExitCode && containerState.Status != ctrtypes.Running) { + appendParameter(&kvPair, keyExitCode, strconv.FormatInt(containerState.ExitCode, 10)) + } + return kvPair +} + +func ioConfigParameters(ioconfig *ctrtypes.IOConfig, verbose bool) []*types.KeyValuePair { + kvPair := []*types.KeyValuePair{} + if verbose || ioconfig.Tty { + appendParameter(&kvPair, keyTerminal, strconv.FormatBool(ioconfig.Tty)) + } + if verbose || ioconfig.OpenStdin { + appendParameter(&kvPair, keyInteractive, strconv.FormatBool(ioconfig.OpenStdin)) + } + return kvPair +} + +func appendParameter(kv *[]*types.KeyValuePair, key string, value string) { + *kv = append(*kv, &types.KeyValuePair{Key: key, Value: value}) +} diff --git a/containerm/updateagent/from_containers_test.go b/containerm/updateagent/from_containers_test.go new file mode 100644 index 0000000..e3c8a0e --- /dev/null +++ b/containerm/updateagent/from_containers_test.go @@ -0,0 +1,641 @@ +// Copyright (c) 2023 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package updateagent + +import ( + "strconv" + "testing" + "time" + + ctrtypes "github.com/eclipse-kanto/container-management/containerm/containers/types" + "github.com/eclipse-kanto/container-management/containerm/pkg/testutil" + "github.com/eclipse-kanto/container-management/containerm/util" + + "github.com/eclipse-kanto/update-manager/api/types" +) + +type testExpectedParams struct { + nonVerboseParams []*types.KeyValuePair + verboseParams []*types.KeyValuePair +} + +var verboseNonPrivilegedKV = &types.KeyValuePair{ + Key: keyPrivileged, + Value: "false", +} + +var verboseNonPrivilegedKVs = []*types.KeyValuePair{ + verboseNonPrivilegedKV, +} + +func TestFromContainers(t *testing.T) { + container := createTestContainer("test-container-0") + util.SetContainerStatusCreated(container) + container.StartedSuccessfullyBefore = true + util.SetContainerStatusRunning(container, 3421) + + testContainers := []*ctrtypes.Container{container} + swNodes := fromContainers(testContainers, true) + testutil.AssertEqual(t, len(testContainers), len(swNodes)) + for i, node := range swNodes { + testutil.AssertEqual(t, testContainers[i].Name, node.ID) + testutil.AssertEqual(t, "v1.2.3", node.Version) + testutil.AssertEqual(t, types.SoftwareTypeContainer, node.Type) + // TODO make assert parameters better + assertParameter(t, node.Parameters, keyDomainName, testContainers[i].DomainName) + assertParameter(t, node.Parameters, keyHostName, testContainers[i].HostName) + assertParameter(t, node.Parameters, keyRestartCount, strconv.Itoa(testContainers[i].RestartCount)) + assertParameter(t, node.Parameters, keyCreated, testContainers[i].Created) + assertParameter(t, node.Parameters, keyManuallyStopped, strconv.FormatBool(testContainers[i].ManuallyStopped)) + assertParameter(t, node.Parameters, keyStartedSuccessfullyBefore, strconv.FormatBool(testContainers[i].StartedSuccessfullyBefore)) + } +} + +func TestHostConfigParameters(t *testing.T) { + testCases := map[string]struct { + hostConfig ctrtypes.HostConfig + expectedParams testExpectedParams + }{ + "test_host_config_params_privileged": { + hostConfig: ctrtypes.HostConfig{Privileged: true}, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyPrivileged, Value: "true"}, + }, + }, + }, + "test_host_config_params_non_privileged": { + hostConfig: ctrtypes.HostConfig{Privileged: false}, + expectedParams: testExpectedParams{ + verboseParams: verboseNonPrivilegedKVs, + }, + }, + + "test_host_config_params_restart_policy_no": { + hostConfig: ctrtypes.HostConfig{RestartPolicy: &ctrtypes.RestartPolicy{Type: ctrtypes.No}}, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyRestartPolicy, Value: "no"}, + }, + verboseParams: []*types.KeyValuePair{ + verboseNonPrivilegedKV, + {Key: keyRestartMaxRetries, Value: "0"}, + {Key: keyRestartTimeout, Value: "0s"}, + }, + }, + }, + "test_host_config_params_restart_policy_always": { + hostConfig: ctrtypes.HostConfig{RestartPolicy: &ctrtypes.RestartPolicy{Type: ctrtypes.Always}}, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyRestartPolicy, Value: "always"}, + }, + verboseParams: []*types.KeyValuePair{ + verboseNonPrivilegedKV, + {Key: keyRestartMaxRetries, Value: "0"}, + {Key: keyRestartTimeout, Value: "0s"}, + }, + }, + }, + "test_host_config_params_restart_policy_on_failure": { + hostConfig: ctrtypes.HostConfig{RestartPolicy: &ctrtypes.RestartPolicy{Type: ctrtypes.OnFailure, MaximumRetryCount: 5, RetryTimeout: 3 * time.Minute}}, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyRestartPolicy, Value: "on-failure"}, + {Key: keyRestartMaxRetries, Value: "5"}, + {Key: keyRestartTimeout, Value: "3m0s"}, + }, + verboseParams: verboseNonPrivilegedKVs, + }, + }, + "test_host_config_params_restart_policy_unless_stopped": { + hostConfig: ctrtypes.HostConfig{RestartPolicy: &ctrtypes.RestartPolicy{Type: ctrtypes.UnlessStopped}}, + expectedParams: testExpectedParams{ + verboseParams: []*types.KeyValuePair{ + verboseNonPrivilegedKV, + {Key: keyRestartPolicy, Value: "unless-stopped"}, + {Key: keyRestartMaxRetries, Value: "0"}, + {Key: keyRestartTimeout, Value: "0s"}, + }, + }, + }, + + "test_host_config_params_network_mode_bridge": { + hostConfig: ctrtypes.HostConfig{NetworkMode: ctrtypes.NetworkModeBridge}, + expectedParams: testExpectedParams{ + verboseParams: []*types.KeyValuePair{ + verboseNonPrivilegedKV, + {Key: keyNetwork, Value: "bridge"}, + }, + }, + }, + "test_host_config_params_network_mode_host": { + hostConfig: ctrtypes.HostConfig{NetworkMode: ctrtypes.NetworkModeHost}, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyNetwork, Value: "host"}, + }, + verboseParams: verboseNonPrivilegedKVs, + }, + }, + + "test_host_config_params_log_driver_none": { + hostConfig: ctrtypes.HostConfig{LogConfig: &ctrtypes.LogConfiguration{ + DriverConfig: &ctrtypes.LogDriverConfiguration{Type: ctrtypes.LogConfigDriverNone}, + }}, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyLogDriver, Value: "none"}, + }, + verboseParams: verboseNonPrivilegedKVs, + }, + }, + "test_host_config_params_log_driver_json_with_custom_max_files_and_size": { + hostConfig: ctrtypes.HostConfig{LogConfig: &ctrtypes.LogConfiguration{ + DriverConfig: &ctrtypes.LogDriverConfiguration{Type: ctrtypes.LogConfigDriverJSONFile, MaxFiles: 6, MaxSize: "100K"}, + }}, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyLogMaxFiles, Value: "6"}, + {Key: keyLogMaxSize, Value: "100K"}, + }, + verboseParams: []*types.KeyValuePair{ + verboseNonPrivilegedKV, + {Key: keyLogDriver, Value: "json-file"}, + }, + }, + }, + "test_host_config_params_log_driver_json_with_custom_log_path": { + hostConfig: ctrtypes.HostConfig{LogConfig: &ctrtypes.LogConfiguration{ + DriverConfig: &ctrtypes.LogDriverConfiguration{Type: ctrtypes.LogConfigDriverJSONFile, MaxFiles: 2, MaxSize: "100M", RootDir: "/tmp/logs"}, + }}, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyLogPath, Value: "/tmp/logs"}, + }, + verboseParams: []*types.KeyValuePair{ + verboseNonPrivilegedKV, + {Key: keyLogDriver, Value: "json-file"}, + {Key: keyLogMaxFiles, Value: "2"}, + {Key: keyLogMaxSize, Value: "100M"}, + }, + }, + }, + + "test_host_config_params_log_mode_blocking": { + hostConfig: ctrtypes.HostConfig{LogConfig: &ctrtypes.LogConfiguration{ + ModeConfig: &ctrtypes.LogModeConfiguration{Mode: ctrtypes.LogModeBlocking}, + }}, + expectedParams: testExpectedParams{ + verboseParams: []*types.KeyValuePair{ + verboseNonPrivilegedKV, + {Key: keyLogMode, Value: "blocking"}, + }, + }, + }, + "test_host_config_params_log_mode_non_blocking_with_custom_max_buffer_size": { + hostConfig: ctrtypes.HostConfig{LogConfig: &ctrtypes.LogConfiguration{ + ModeConfig: &ctrtypes.LogModeConfiguration{Mode: ctrtypes.LogModeNonBlocking, MaxBufferSize: "100K"}, + }}, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyLogMode, Value: "non-blocking"}, + {Key: keyLogMaxBufferSize, Value: "100K"}, + }, + verboseParams: verboseNonPrivilegedKVs, + }, + }, + "test_host_config_params_log_mode_non_blocking_with_default_max_buffer_size": { + hostConfig: ctrtypes.HostConfig{LogConfig: &ctrtypes.LogConfiguration{ + ModeConfig: &ctrtypes.LogModeConfiguration{Mode: ctrtypes.LogModeNonBlocking, MaxBufferSize: "1M"}, + }}, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyLogMode, Value: "non-blocking"}, + }, + verboseParams: []*types.KeyValuePair{ + verboseNonPrivilegedKV, + {Key: keyLogMaxBufferSize, Value: "1M"}, + }, + }, + }, + + "test_host_config_params_resources_with_memory_reservation_swap": { + hostConfig: ctrtypes.HostConfig{Resources: &ctrtypes.Resources{Memory: "200m", MemoryReservation: "100m", MemorySwap: "50m"}}, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyMemory, Value: "200m"}, + {Key: keyMemoryReservation, Value: "100m"}, + {Key: keyMemorySwap, Value: "50m"}, + }, + verboseParams: verboseNonPrivilegedKVs, + }, + }, + "test_host_config_params_resources_with_memory_and_reservation_only": { + hostConfig: ctrtypes.HostConfig{Resources: &ctrtypes.Resources{Memory: "300m", MemoryReservation: "300m"}}, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyMemory, Value: "300m"}, + {Key: keyMemoryReservation, Value: "300m"}, + }, + verboseParams: verboseNonPrivilegedKVs, + }, + }, + "test_host_config_params_resources_with_memory_and_swap_only": { + hostConfig: ctrtypes.HostConfig{Resources: &ctrtypes.Resources{Memory: "1g", MemorySwap: "150m"}}, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyMemory, Value: "1g"}, + {Key: keyMemorySwap, Value: "150m"}, + }, + verboseParams: verboseNonPrivilegedKVs, + }, + }, + } + for _, verbose := range []bool{true, false} { + for testName, testCase := range testCases { + t.Run(fullTestName(testName, verbose), func(t *testing.T) { + assertParameters(t, testCase.expectedParams, hostConfigParameters(&testCase.hostConfig, verbose), verbose) + }) + } + } +} + +func TestHostConfigParametersDevices(t *testing.T) { + hostConfig := &ctrtypes.HostConfig{} + testDevices := []string{ + "/dev/ttyACM0:/dev/ttyUSB0:rwm", + "/dev/ttyACM1:/dev/ttyUSB1:r", + "/dev/ttyACM2:/dev/ttyUSB2:mw", + } + testDeviceMappings, err := util.ParseDeviceMappings(testDevices) + testutil.AssertNil(t, err) + hostConfig.Devices = testDeviceMappings + params := hostConfigParameters(hostConfig, false) + for _, testDevice := range testDevices { + assertMultipleParameter(t, params, keyDevice, testDevice) + } + testutil.AssertEqual(t, len(testDeviceMappings), len(params)) +} + +func TestHostConfigParametersPortMappings(t *testing.T) { + hostConfig := &ctrtypes.HostConfig{} + testPorts := []string{ + "80:80", + "88:8888/udp", + "5000-6000:8080/udp", + "192.168.0.1:7000-8000:8081/tcp", + } + testPortMappings, err := util.ParsePortMappings(testPorts) + testutil.AssertNil(t, err) + hostConfig.PortMappings = testPortMappings + params := hostConfigParameters(hostConfig, false) + for _, testPort := range testPorts { + assertMultipleParameter(t, params, keyPort, testPort) + } + testutil.AssertEqual(t, len(testPortMappings), len(params)) +} + +func TestHostConfigParametersExtraHosts(t *testing.T) { + hostConfig := &ctrtypes.HostConfig{ + ExtraHosts: []string{"ctrhost:host_ip", "somehost:192.168.0.1"}, + } + params := hostConfigParameters(hostConfig, false) + for _, host := range hostConfig.ExtraHosts { + assertMultipleParameter(t, params, keyHost, host) + } + testutil.AssertEqual(t, len(hostConfig.ExtraHosts), len(params)) +} + +func TestMountPointParameters(t *testing.T) { + testMounts := []string{ + "/home/someuser:/home/root:private", "/var:/var:rprivate", + "/etc:/etc:shared", "/usr/bin:/usr/bin:rshared", + "/data:/data:slave", "/tmp:/tmp:rslave", + } + testMountPoints, err := util.ParseMountPoints(testMounts) + testutil.AssertNil(t, err) + params := mountPointParameters(testMountPoints) + for _, testMount := range testMounts { + assertMultipleParameter(t, params, keyMount, testMount) + } + testutil.AssertEqual(t, len(testMounts), len(params)) +} + +func TestContainerConfigParameters(t *testing.T) { + testCases := map[string]struct { + args []string + envs []string + expectedParams []*types.KeyValuePair + }{ + "test_container_config_parameters_nil_args_and_nil_envs": {}, + "test_container_config_parameters_nil_args_and_empty_envs": { + envs: []string{}, + }, + "test_container_config_parameters_nil_args_and_some_envs": { + args: []string{}, + envs: []string{"ENV_X=VAL_A", "ENV_Y=VAL_B", "ENV_C="}, + expectedParams: []*types.KeyValuePair{ + {Key: keyEnv, Value: "ENV_X=VAL_A"}, + {Key: keyEnv, Value: "ENV_Y=VAL_B"}, + {Key: keyEnv, Value: "ENV_C="}, + }, + }, + "test_container_config_parameters_empty_args_and_nil_envs": { + args: []string{}, + }, + "test_container_config_parameters_empty_args_and_empty_envs": { + args: []string{}, + envs: []string{}, + }, + "test_container_config_parameters_empty_args_and_some_envs": { + args: []string{}, + envs: []string{"ENV_X=VAL_A", "ENV_Y=VAL_B", "ENV_C="}, + expectedParams: []*types.KeyValuePair{ + {Key: keyEnv, Value: "ENV_X=VAL_A"}, + {Key: keyEnv, Value: "ENV_Y=VAL_B"}, + {Key: keyEnv, Value: "ENV_C="}, + }, + }, + "test_container_config_parameters_some_args_and_nil_envs": { + args: []string{"arg1", "arg2", "arg3"}, + expectedParams: []*types.KeyValuePair{ + {Key: keyCmd, Value: "arg1"}, + {Key: keyCmd, Value: "arg2"}, + {Key: keyCmd, Value: "arg3"}, + }, + }, + "test_container_config_parameters_some_args_and_empty_envs": { + args: []string{"arg1", "arg2", "arg3"}, + envs: []string{}, + expectedParams: []*types.KeyValuePair{ + {Key: keyCmd, Value: "arg1"}, + {Key: keyCmd, Value: "arg2"}, + {Key: keyCmd, Value: "arg3"}, + }, + }, + "test_container_config_parameters_some_args_and_some_envs": { + args: []string{"arg1", "arg2", "arg3"}, + envs: []string{"ENV_X=VAL_A", "ENV_Y=VAL_B", "ENV_C="}, + expectedParams: []*types.KeyValuePair{ + {Key: keyCmd, Value: "arg1"}, + {Key: keyCmd, Value: "arg2"}, + {Key: keyCmd, Value: "arg3"}, + {Key: keyEnv, Value: "ENV_X=VAL_A"}, + {Key: keyEnv, Value: "ENV_Y=VAL_B"}, + {Key: keyEnv, Value: "ENV_C="}, + }, + }, + } + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + params := containerConfigParameters(&ctrtypes.ContainerConfiguration{Env: testCase.envs, Cmd: testCase.args}) + for _, kv := range testCase.expectedParams { + assertMultipleParameter(t, params, kv.Key, kv.Value) + } + testutil.AssertEqual(t, len(testCase.expectedParams), len(params)) + }) + } +} + +func TestStateParameters(t *testing.T) { + commonVerboseExpectedParams := []*types.KeyValuePair{ + {Key: keyFinishedAt, Value: ""}, + {Key: keyExitCode, Value: "0"}, + } + testCases := map[string]struct { + testSetup func(*ctrtypes.Container) + expectedParams testExpectedParams + }{ + "test_state_params_container_creating": { + testSetup: func(c *ctrtypes.Container) { c.State.Status = ctrtypes.Creating }, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyStatus, Value: "Creating"}, + }, + verboseParams: commonVerboseExpectedParams, + }, + }, + "test_state_params_container_created": { + testSetup: func(c *ctrtypes.Container) { util.SetContainerStatusCreated(c) }, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyStatus, Value: "Created"}, + }, + verboseParams: commonVerboseExpectedParams, + }, + }, + "test_state_params_container_running": { + testSetup: func(c *ctrtypes.Container) { util.SetContainerStatusRunning(c, 1234) }, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyStatus, Value: "Running"}, + }, + verboseParams: commonVerboseExpectedParams, + }, + }, + "test_state_params_container_stopped_normally": { + testSetup: func(c *ctrtypes.Container) { + util.SetContainerStatusStopped(c, 0, "") + c.State.FinishedAt = "2023-01-01T15:04:05.999999999Z07:00" + }, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyStatus, Value: "Stopped"}, + {Key: keyFinishedAt, Value: "2023-01-01T15:04:05.999999999Z07:00"}, + }, + verboseParams: []*types.KeyValuePair{ + {Key: keyExitCode, Value: "0"}, + }, + }, + }, + "test_state_params_container_stopped_error": { + testSetup: func(c *ctrtypes.Container) { + util.SetContainerStatusStopped(c, -1, "stopped with error") + c.State.FinishedAt = "2023-01-11T15:04:05.999999999Z07:00" + }, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyStatus, Value: "Stopped"}, + {Key: keyFinishedAt, Value: "2023-01-11T15:04:05.999999999Z07:00"}, + {Key: keyExitCode, Value: "-1"}, + }, + }, + }, + "test_state_params_container_paused": { + testSetup: func(c *ctrtypes.Container) { util.SetContainerStatusPaused(c) }, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyStatus, Value: "Paused"}, + }, + verboseParams: commonVerboseExpectedParams, + }, + }, + "test_state_params_container_exited": { + testSetup: func(c *ctrtypes.Container) { + util.SetContainerStatusExited(c, 1234, "unexpected exit", false) + c.State.FinishedAt = "2023-01-13T15:04:05.999999999Z07:00" + }, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyStatus, Value: "Exited"}, + {Key: keyFinishedAt, Value: "2023-01-13T15:04:05.999999999Z07:00"}, + {Key: keyExitCode, Value: "1234"}, + }, + }, + }, + "test_state_params_container_dead": { + testSetup: func(c *ctrtypes.Container) { util.SetContainerStatusDead(c) }, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyStatus, Value: "Dead"}, + }, + verboseParams: commonVerboseExpectedParams, + }, + }, + "test_state_params_container_unknown": { + testSetup: func(c *ctrtypes.Container) { + c.State.Status = ctrtypes.Unknown + c.State.FinishedAt = "2023-01-02T15:04:05.999999999Z07:00" + c.State.ExitCode = 999 + }, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyStatus, Value: "Unknown"}, + {Key: keyFinishedAt, Value: "2023-01-02T15:04:05.999999999Z07:00"}, + {Key: keyExitCode, Value: "999"}, + }, + }, + }, + } + for _, verbose := range []bool{true, false} { + for testName, testCase := range testCases { + t.Run(fullTestName(testName, verbose), func(t *testing.T) { + testContainer := createTestContainer("test-container") + testContainer.State = &ctrtypes.State{} + testCase.testSetup(testContainer) + assertParameters(t, testCase.expectedParams, stateParameters(testContainer.State, verbose), verbose) + }) + } + } +} + +func TestIOConfigParameters(t *testing.T) { + testCases := map[string]struct { + ioConfig *ctrtypes.IOConfig + expectedParams testExpectedParams + }{ + "test_io_config_params_no_tty_no_openstdin": { + ioConfig: &ctrtypes.IOConfig{}, + expectedParams: testExpectedParams{ + verboseParams: []*types.KeyValuePair{ + {Key: keyTerminal, Value: "false"}, + {Key: keyInteractive, Value: "false"}, + }, + }, + }, + "test_io_config_params_no_tty_with_openstdin": { + ioConfig: &ctrtypes.IOConfig{OpenStdin: true}, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyInteractive, Value: "true"}, + }, + verboseParams: []*types.KeyValuePair{ + {Key: keyTerminal, Value: "false"}, + }, + }, + }, + "test_io_config_params_with_tty_no_openstdin": { + ioConfig: &ctrtypes.IOConfig{Tty: true}, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyTerminal, Value: "true"}, + }, + verboseParams: []*types.KeyValuePair{ + {Key: keyInteractive, Value: "false"}, + }, + }, + }, + "test_io_config_params_with_tty_with_openstdin": { + ioConfig: &ctrtypes.IOConfig{Tty: true, OpenStdin: true}, + expectedParams: testExpectedParams{ + nonVerboseParams: []*types.KeyValuePair{ + {Key: keyTerminal, Value: "true"}, + {Key: keyInteractive, Value: "true"}, + }, + }, + }, + } + for _, verbose := range []bool{true, false} { + for testName, testCase := range testCases { + t.Run(fullTestName(testName, verbose), func(t *testing.T) { + assertParameters(t, testCase.expectedParams, ioConfigParameters(testCase.ioConfig, verbose), verbose) + }) + } + } +} + +func createTestContainer(name string) *ctrtypes.Container { + container := &ctrtypes.Container{ + Name: name, + Image: ctrtypes.Image{Name: "my-container-registry.com/" + name + ":v1.2.3"}, + HostConfig: &ctrtypes.HostConfig{Privileged: true}, + Mounts: []ctrtypes.MountPoint{{Source: "/etc", Destination: "/etc", PropagationMode: ctrtypes.RPrivatePropagationMode}}, + Config: &ctrtypes.ContainerConfiguration{}, + State: &ctrtypes.State{}, + } + util.FillDefaults(container) + return container +} + +func assertParameters(t *testing.T, expectedParams testExpectedParams, actualParams []*types.KeyValuePair, verbose bool) { + expected := expectedParams.nonVerboseParams + if verbose { + expected = append(expected, expectedParams.verboseParams...) + } + testutil.AssertEqual(t, len(expected), len(actualParams)) + for _, param := range expected { + assertParameter(t, actualParams, param.Key, param.Value) + } +} + +func assertParameter(t *testing.T, params []*types.KeyValuePair, key string, value string) { + for _, kv := range params { + if kv.Key == key { + if value != kv.Value { + t.Errorf("param '%s' has wrong value: expected %s , got %s", key, value, kv.Value) + t.Fail() + } + return + } + } + t.Errorf("expected param '%s' with value %s not present as key-value pair", key, value) + t.Fail() +} + +func assertMultipleParameter(t *testing.T, params []*types.KeyValuePair, key string, value string) { + for _, kv := range params { + if kv.Key == key && kv.Value == value { + return + } + } + t.Errorf("expected param '%s' with value %s not present as key-value pair", key, value) + t.Fail() +} + +func fullTestName(testName string, verbose bool) string { + if !verbose { + return testName + } + return testName + "_verbose" +} diff --git a/containerm/updateagent/internal_desired_state.go b/containerm/updateagent/internal_desired_state.go new file mode 100644 index 0000000..7085645 --- /dev/null +++ b/containerm/updateagent/internal_desired_state.go @@ -0,0 +1,110 @@ +// Copyright (c) 2023 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package updateagent + +import ( + "fmt" + "strings" + + ctrtypes "github.com/eclipse-kanto/container-management/containerm/containers/types" + "github.com/eclipse-kanto/container-management/containerm/util" + + "github.com/eclipse-kanto/update-manager/api/types" + "github.com/pkg/errors" +) + +const ( + keyImage = "image" + keyTerminal = "terminal" + keyInteractive = "interactive" + keyPrivileged = "privileged" + keyRestartPolicy = "restartPolicy" + keyRestartMaxRetries = "restartMaxRetries" + keyRestartTimeout = "restartTimeout" + keyDevice = "device" + keyPort = "port" + keyNetwork = "network" + keyHost = "host" + keyMount = "mount" + keyEnv = "env" + keyCmd = "cmd" + keyLogDriver = "logDriver" + keyLogMaxFiles = "logMaxFiles" + keyLogMaxSize = "logMaxSize" + keyLogPath = "logPath" + keyLogMode = "logMode" + keyLogMaxBufferSize = "logMaxBufferSize" + keyMemory = "memory" + keyMemoryReservation = "memoryReservation" + keyMemorySwap = "memorySwap" + keyDomainName = "domainName" + keyHostName = "hostName" + keyStatus = "status" + keyFinishedAt = "finishedAt" + keyExitCode = "exitCode" + keyCreated = "created" + keyRestartCount = "restartCount" + keyManuallyStopped = "manuallyStopped" + keyStartedSuccessfullyBefore = "startedSuccessfullyBefore" + + keySystemContainers = "systemContainers" +) + +type internalDesiredState struct { + desiredState *types.DesiredState + systemContainers []string + + containers map[string]*ctrtypes.Container + baselines map[string][]*ctrtypes.Container +} + +func (ds *internalDesiredState) findComponent(name string) types.Component { + for _, component := range ds.desiredState.Domains[0].Components { + if component.ID == name { + return component.Component + } + } + return types.Component{} +} + +// toInternalDesiredState converts incoming desired state into an internal desired state structure +func toInternalDesiredState(desiredState *types.DesiredState, domainName string) (*internalDesiredState, error) { + if len(desiredState.Domains) != 1 { + return nil, fmt.Errorf("one domain expected in desired state specification, but got %d", len(desiredState.Domains)) + } + if desiredState.Domains[0].ID != domainName { + return nil, fmt.Errorf("domain id mismatch - expecting %s, received %s", domainName, desiredState.Domains[0].ID) + } + + containers, err := toContainers(desiredState.Domains[0].Components) + if err != nil { + return nil, errors.Wrap(err, "cannot convert desired state components to container configurations") + } + baselines, err := baselinesWithContainers(domainName+":", desiredState.Baselines, util.AsNamedMap(containers)) + if err != nil { + return nil, errors.Wrap(err, "cannot process desired state baselines with containers") + } + var systemContainers []string + for _, configPair := range desiredState.Domains[0].Config { + if configPair.Key == keySystemContainers { + systemContainers = strings.Split(configPair.Value, ",") + } + } + + return &internalDesiredState{ + desiredState: desiredState, + containers: util.AsNamedMap(containers), + baselines: baselines, + systemContainers: systemContainers, + }, nil +} diff --git a/containerm/updateagent/to_containers.go b/containerm/updateagent/to_containers.go new file mode 100644 index 0000000..3333a5e --- /dev/null +++ b/containerm/updateagent/to_containers.go @@ -0,0 +1,175 @@ +// Copyright (c) 2023 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package updateagent + +import ( + "strconv" + "time" + + ctrtypes "github.com/eclipse-kanto/container-management/containerm/containers/types" + "github.com/eclipse-kanto/container-management/containerm/log" + "github.com/eclipse-kanto/container-management/containerm/util" + + "github.com/eclipse-kanto/update-manager/api/types" + "github.com/pkg/errors" +) + +func toContainers(components []*types.ComponentWithConfig) ([]*ctrtypes.Container, error) { + containers := []*ctrtypes.Container{} + for _, component := range components { + container, err := toContainer(component) + if err != nil { + return nil, errors.Wrapf(err, "invalid configuration for container %s", component.ID) + } + containers = append(containers, container) + } + return containers, nil +} + +func toContainer(component *types.ComponentWithConfig) (*ctrtypes.Container, error) { + var ( + env []string + cmd []string + extraHosts []string + mountPoints []ctrtypes.MountPoint + portMappings []ctrtypes.PortMapping + deviceMappings []ctrtypes.DeviceMapping + ) + + config := make(map[string]string, len(component.Config)) + for _, keyValuePair := range component.Config { + switch keyValuePair.Key { + case keyDevice: + deviceMapping, err := util.ParseDeviceMapping(keyValuePair.Value) + if err != nil { + log.WarnErr(err, "Ignoring invalid device mapping") + } else { + deviceMappings = append(deviceMappings, *deviceMapping) + } + case keyPort: + portMapping, err := util.ParsePortMapping(keyValuePair.Value) + if err != nil { + log.WarnErr(err, "Ignoring invalid port mapping") + } else { + portMappings = append(portMappings, *portMapping) + } + case keyHost: + extraHosts = append(extraHosts, keyValuePair.Value) + case keyMount: + mountPoint, err := util.ParseMountPoint(keyValuePair.Value) + if err != nil { + log.WarnErr(err, "Ignoring invalid mount point") + } else { + mountPoints = append(mountPoints, *mountPoint) + } + case keyEnv: + env = append(env, keyValuePair.Value) + case keyCmd: + cmd = append(cmd, keyValuePair.Value) + default: + config[keyValuePair.Key] = keyValuePair.Value + } + } + + imageName, ok := config[keyImage] + if !ok { + imageName = component.ID + ":" + component.Version + } + container := &ctrtypes.Container{ + Name: component.ID, + Image: ctrtypes.Image{ + Name: imageName, + }, + IOConfig: &ctrtypes.IOConfig{ + Tty: parseBool(keyTerminal, config), + OpenStdin: parseBool(keyInteractive, config), + }, + Mounts: mountPoints, + HostConfig: &ctrtypes.HostConfig{ + Privileged: parseBool(keyPrivileged, config), + NetworkMode: ctrtypes.NetworkMode(config[keyNetwork]), + Devices: deviceMappings, + ExtraHosts: extraHosts, + PortMappings: portMappings, + LogConfig: &ctrtypes.LogConfiguration{ + DriverConfig: &ctrtypes.LogDriverConfiguration{ + Type: ctrtypes.LogDriver(config[keyLogDriver]), + MaxFiles: parseInt(keyLogMaxFiles, config), + MaxSize: config[keyLogMaxSize], + RootDir: config[keyLogPath], + }, + ModeConfig: &ctrtypes.LogModeConfiguration{ + Mode: ctrtypes.LogMode(config[keyLogMode]), + MaxBufferSize: config[keyLogMaxBufferSize], + }, + }, + }, + } + if config[keyMemory] != "" || config[keyMemorySwap] != "" || config[keyMemoryReservation] != "" { + container.HostConfig.Resources = &ctrtypes.Resources{ + Memory: config[keyMemory], + MemorySwap: config[keyMemorySwap], + MemoryReservation: config[keyMemoryReservation], + } + } + + if env != nil || cmd != nil { + container.Config = &ctrtypes.ContainerConfiguration{ + Env: env, + Cmd: cmd, + } + } + + if rpType, ok := config[keyRestartPolicy]; ok { + container.HostConfig.RestartPolicy = &ctrtypes.RestartPolicy{ + Type: ctrtypes.PolicyType(rpType), + } + if container.HostConfig.RestartPolicy.Type == ctrtypes.OnFailure { + container.HostConfig.RestartPolicy.MaximumRetryCount = parseInt(keyRestartMaxRetries, config) + container.HostConfig.RestartPolicy.RetryTimeout = time.Duration(parseInt(keyRestartTimeout, config)) * time.Second + } + } + + util.FillDefaults(container) + if err := util.ValidateContainer(container); err != nil { + return container, err + } + + return container, nil +} + +func parseBool(key string, config map[string]string) bool { + value, ok := config[key] + if !ok { + return false + } + result, err := strconv.ParseBool(value) + if err != nil { + log.Warn("Unknown boolean value for key %s = %s", key, value) + return false + } + return result +} + +func parseInt(key string, config map[string]string) int { + value, ok := config[key] + if !ok { + return 0 + } + result, err := strconv.Atoi(value) + if err != nil { + log.Warn("Unknown integer value for key %s = %s", key, value) + return 0 + } + return result +} diff --git a/containerm/updateagent/update_agent_init.go b/containerm/updateagent/update_agent_init.go new file mode 100644 index 0000000..2b1d4c8 --- /dev/null +++ b/containerm/updateagent/update_agent_init.go @@ -0,0 +1,101 @@ +// Copyright (c) 2023 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package updateagent + +import ( + "time" + + "github.com/eclipse-kanto/container-management/containerm/events" + "github.com/eclipse-kanto/container-management/containerm/mgr" + "github.com/eclipse-kanto/container-management/containerm/registry" + + "github.com/eclipse-kanto/update-manager/api" + "github.com/eclipse-kanto/update-manager/api/agent" + "github.com/eclipse-kanto/update-manager/mqtt" +) + +func newUpdateAgent(mgr mgr.ContainerManager, eventsMgr events.ContainerEventsManager, + domainName string, + systemContainers []string, + verboseInventoryReport bool, + broker string, + keepAlive time.Duration, + disconnectTimeout time.Duration, + clientUsername string, + clientPassword string, + connectTimeout time.Duration, + acknowledgeTimeout time.Duration, + subscribeTimeout time.Duration, + unsubscribeTimeout time.Duration, + tlsConfig *tlsConfig) (api.UpdateAgent, error) { + + mqttClient := mqtt.NewUpdateAgentClient(domainName, &mqtt.ConnectionConfig{ + Broker: broker, + KeepAlive: keepAlive.Milliseconds(), + DisconnectTimeout: disconnectTimeout.Milliseconds(), + Username: clientUsername, + Password: clientPassword, + ConnectTimeout: connectTimeout.Milliseconds(), + AcknowledgeTimeout: acknowledgeTimeout.Milliseconds(), + SubscribeTimeout: subscribeTimeout.Milliseconds(), + UnsubscribeTimeout: unsubscribeTimeout.Milliseconds(), + }) + + return agent.NewUpdateAgent(mqttClient, newUpdateManager(mgr, eventsMgr, domainName, systemContainers, verboseInventoryReport)), nil +} + +// newUpdateManager instantiates a new update manager instance +func newUpdateManager(mgr mgr.ContainerManager, eventsMgr events.ContainerEventsManager, + domainName string, systemContainers []string, verboseInventoryReport bool) api.UpdateManager { + return &containersUpdateManager{ + domainName: domainName, + systemContainers: systemContainers, + verboseInventoryReport: verboseInventoryReport, + + mgr: mgr, + eventsMgr: eventsMgr, + createUpdateOperation: newOperation, + } +} + +func registryInit(registryCtx *registry.ServiceRegistryContext) (interface{}, error) { + eventsMgr, err := registryCtx.Get(registry.EventsManagerService) + if err != nil { + return nil, err + } + mgrService, err := registryCtx.Get(registry.ContainerManagerService) + if err != nil { + return nil, err + } + + // init options processing + uaOpts := &updateAgentOpts{} + if err := applyOptsUpdateAgent(uaOpts, registryCtx.Config.([]ContainersUpdateAgentOpt)...); err != nil { + return nil, err + } + return newUpdateAgent(mgrService.(mgr.ContainerManager), eventsMgr.(events.ContainerEventsManager), + uaOpts.domainName, + uaOpts.systemContainers, + uaOpts.verboseInventoryReport, + uaOpts.broker, + uaOpts.keepAlive, + uaOpts.disconnectTimeout, + uaOpts.clientUsername, + uaOpts.clientPassword, + uaOpts.connectTimeout, + uaOpts.acknowledgeTimeout, + uaOpts.subscribeTimeout, + uaOpts.unsubscribeTimeout, + uaOpts.tlsConfig, + ) +} diff --git a/containerm/updateagent/update_agent_opts.go b/containerm/updateagent/update_agent_opts.go new file mode 100644 index 0000000..4ddc464 --- /dev/null +++ b/containerm/updateagent/update_agent_opts.go @@ -0,0 +1,160 @@ +// Copyright (c) 2023 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package updateagent + +import ( + "time" +) + +// ContainersUpdateAgentOpt represents the available configuration options for the Containers UpdateAgent service +type ContainersUpdateAgentOpt func(updateAgentOptions *updateAgentOpts) error + +type updateAgentOpts struct { + domainName string + systemContainers []string + verboseInventoryReport bool + broker string + keepAlive time.Duration + disconnectTimeout time.Duration + clientUsername string + clientPassword string + connectTimeout time.Duration + acknowledgeTimeout time.Duration + subscribeTimeout time.Duration + unsubscribeTimeout time.Duration + tlsConfig *tlsConfig +} + +// tls-secured communication config +type tlsConfig struct { + RootCA string + ClientCert string + ClientKey string +} + +func applyOptsUpdateAgent(updateAgentOpts *updateAgentOpts, opts ...ContainersUpdateAgentOpt) error { + for _, o := range opts { + if err := o(updateAgentOpts); err != nil { + return err + } + } + return nil +} + +// WithDomainName configures the domain name for the containers update agent +func WithDomainName(domain string) ContainersUpdateAgentOpt { + return func(updateAgentOptions *updateAgentOpts) error { + updateAgentOptions.domainName = domain + return nil + } +} + +// WithSystemContainers configures the list of system containers (names) that will not be processed by the containers update agent +func WithSystemContainers(systemContainers []string) ContainersUpdateAgentOpt { + return func(updateAgentOptions *updateAgentOpts) error { + updateAgentOptions.systemContainers = systemContainers + return nil + } +} + +// WithVerboseInventoryReport enables / disables verbose inventory reporting of current containers +func WithVerboseInventoryReport(verboseInventoryReport bool) ContainersUpdateAgentOpt { + return func(updateAgentOptions *updateAgentOpts) error { + updateAgentOptions.verboseInventoryReport = verboseInventoryReport + return nil + } +} + +// WithConnectionBroker configures the broker, where the connection will be established +func WithConnectionBroker(broker string) ContainersUpdateAgentOpt { + return func(updateAgentOptions *updateAgentOpts) error { + updateAgentOptions.broker = broker + return nil + } +} + +// WithConnectionKeepAlive configures the time between between each check for the connection presence +func WithConnectionKeepAlive(keepAlive time.Duration) ContainersUpdateAgentOpt { + return func(updateAgentOptions *updateAgentOpts) error { + updateAgentOptions.keepAlive = keepAlive + return nil + } +} + +// WithConnectionDisconnectTimeout configures the duration of inactivity before disconnecting from the broker +func WithConnectionDisconnectTimeout(disconnectTimeout time.Duration) ContainersUpdateAgentOpt { + return func(updateAgentOptions *updateAgentOpts) error { + updateAgentOptions.disconnectTimeout = disconnectTimeout + return nil + } +} + +// WithConnectionClientUsername configures the client username used when establishing connection to the broker +func WithConnectionClientUsername(username string) ContainersUpdateAgentOpt { + return func(updateAgentOptions *updateAgentOpts) error { + updateAgentOptions.clientUsername = username + return nil + } +} + +// WithConnectionClientPassword configures the client password used when establishing connection to the broker +func WithConnectionClientPassword(password string) ContainersUpdateAgentOpt { + return func(updateAgentOptions *updateAgentOpts) error { + updateAgentOptions.clientPassword = password + return nil + } +} + +// WithConnectionConnectTimeout configures the timeout before terminating the connect attempt +func WithConnectionConnectTimeout(connectTimeout time.Duration) ContainersUpdateAgentOpt { + return func(updateAgentOptions *updateAgentOpts) error { + updateAgentOptions.connectTimeout = connectTimeout + return nil + } +} + +// WithConnectionAcknowledgeTimeout configures the timeout for the acknowledge receival +func WithConnectionAcknowledgeTimeout(acknowledgeTimeout time.Duration) ContainersUpdateAgentOpt { + return func(updateAgentOptions *updateAgentOpts) error { + updateAgentOptions.acknowledgeTimeout = acknowledgeTimeout + return nil + } +} + +// WithConnectionSubscribeTimeout configures the timeout before terminating the subscribe attempt +func WithConnectionSubscribeTimeout(subscribeTimeout time.Duration) ContainersUpdateAgentOpt { + return func(updateAgentOptions *updateAgentOpts) error { + updateAgentOptions.subscribeTimeout = subscribeTimeout + return nil + } +} + +// WithConnectionUnsubscribeTimeout configures the timeout before terminating the unsubscribe attempt +func WithConnectionUnsubscribeTimeout(unsubscribeTimeout time.Duration) ContainersUpdateAgentOpt { + return func(updateAgentOptions *updateAgentOpts) error { + updateAgentOptions.unsubscribeTimeout = unsubscribeTimeout + return nil + } +} + +// WithTLSConfig configures the CA certificate for TLS communication +func WithTLSConfig(rootCA, clientCert, clientKey string) ContainersUpdateAgentOpt { + return func(updateAgentOptions *updateAgentOpts) error { + updateAgentOptions.tlsConfig = &tlsConfig{ + RootCA: rootCA, + ClientCert: clientCert, + ClientKey: clientKey, + } + return nil + } +} diff --git a/containerm/updateagent/update_agent_service.go b/containerm/updateagent/update_agent_service.go new file mode 100644 index 0000000..ea5be05 --- /dev/null +++ b/containerm/updateagent/update_agent_service.go @@ -0,0 +1,30 @@ +// Copyright (c) 2023 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package updateagent + +import ( + "github.com/eclipse-kanto/container-management/containerm/registry" +) + +const ( + // ContainerUpdateAgentServiceLocalID is the ID of the local container update agent + ContainerUpdateAgentServiceLocalID = "container-management.service.local.v1.service-container-update-agent" +) + +func init() { + registry.Register(®istry.Registration{ + ID: ContainerUpdateAgentServiceLocalID, + Type: registry.UpdateAgentService, + InitFunc: registryInit, + }) +} diff --git a/containerm/updateagent/update_manager.go b/containerm/updateagent/update_manager.go new file mode 100644 index 0000000..cb5d4be --- /dev/null +++ b/containerm/updateagent/update_manager.go @@ -0,0 +1,169 @@ +// Copyright (c) 2023 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package updateagent + +import ( + "context" + "sync" + + "github.com/eclipse-kanto/container-management/containerm/events" + "github.com/eclipse-kanto/container-management/containerm/log" + "github.com/eclipse-kanto/container-management/containerm/mgr" + "github.com/eclipse-kanto/container-management/containerm/version" + + "github.com/eclipse-kanto/update-manager/api" + "github.com/eclipse-kanto/update-manager/api/types" +) + +const ( + updateManagerName = "Eclipse Kanto Containers Update Agent" + parameterDomain = "domain" +) + +type containersUpdateManager struct { + domainName string + systemContainers []string + verboseInventoryReport bool + + mgr mgr.ContainerManager + eventsMgr events.ContainerEventsManager + + applyLock sync.Mutex + eventCallback api.UpdateManagerCallback + createUpdateOperation createUpdateOperation + operation UpdateOperation +} + +// Name returns the name of this update manager, e.g. "containers". +func (updMgr *containersUpdateManager) Name() string { + return updMgr.domainName +} + +// Apply triggers the update operation with the given activity ID and desired state with containers. +// First, it validates the received desired state specification and identifies the actions to be applied. +// If errors are detected, then IDENTIFICATION_FAILED feedback status is reported and operation finishes unsuccessfully. +// Otherwise, IDENTIFIED feedback status with identified actions is reported and it will wait for further commands to proceed. +func (updMgr *containersUpdateManager) Apply(ctx context.Context, activityID string, desiredState *types.DesiredState) { + updMgr.applyLock.Lock() + defer updMgr.applyLock.Unlock() + + log.Debug("processing desired state - start") + // create operation instance + internalDesiredState, err := toInternalDesiredState(desiredState, updMgr.domainName) + if err != nil { + log.ErrorErr(err, "could not parse desired state components as container configurations") + updMgr.eventCallback.HandleDesiredStateFeedbackEvent(updMgr.Name(), activityID, "", types.StatusIdentificationFailed, err.Error(), []*types.Action{}) + return + } + updMgr.operation = updMgr.createUpdateOperation(updMgr, activityID, internalDesiredState) + + // identification phase + updMgr.operation.Feedback(types.StatusIdentifying, "", "") + if err := updMgr.operation.Identify(); err != nil { + updMgr.operation.Feedback(types.StatusIdentificationFailed, err.Error(), "") + log.ErrorErr(err, "processing desired state - identification phase failed") + return + } + updMgr.operation.Feedback(types.StatusIdentified, "", "") + log.Debug("processing desired state - identification phase completed, waiting for commands...") +} + +// Command processes received desired state command. +func (updMgr *containersUpdateManager) Command(ctx context.Context, activityID string, command *types.DesiredStateCommand) { + if command == nil { + log.Error("Skipping received command for activityId %s, but no payload.", activityID) + return + } + updMgr.applyLock.Lock() + defer updMgr.applyLock.Unlock() + + operation := updMgr.operation + if operation == nil { + log.Warn("Ignoring received command %s for baseline %s and activityId %s, but no operation in progress.", command.Command, command.Baseline, activityID) + return + } + if operation.GetActivityID() != activityID { + log.Warn("Ignoring received command %s for baseline %s and activityId %s, but not matching operation in progress [%s].", + command.Command, command.Baseline, activityID, operation.GetActivityID()) + return + } + operation.Execute(command.Command, command.Baseline) +} + +// Get returns the current state as an inventory graph. +// The inventory graph includes a root software node (type APPLICATION) representing the update agent itself and a list of software nodes (type CONTAINER) representing the available containers. +func (updMgr *containersUpdateManager) Get(ctx context.Context, activityID string) (*types.Inventory, error) { + return toInventory(updMgr.asSoftwareNode(), updMgr.getCurrentContainers()), nil +} + +func toInventory(swNodeAgent *types.SoftwareNode, swNodeContainers []*types.SoftwareNode) *types.Inventory { + swNodes := []*types.SoftwareNode{swNodeAgent} + associations := []*types.Association{} + if len(swNodeContainers) > 0 { + swNodes = append(swNodes, swNodeContainers...) + + for _, swNodeContainer := range swNodeContainers { + swNodeContainer.ID = swNodeAgent.Parameters[0].Value + ":" + swNodeContainer.ID + associations = append(associations, &types.Association{ + SourceID: swNodeAgent.ID, + TargetID: swNodeContainer.ID, + }) + } + } + return &types.Inventory{ + SoftwareNodes: swNodes, + Associations: associations, + } +} + +func (updMgr *containersUpdateManager) asSoftwareNode() *types.SoftwareNode { + return &types.SoftwareNode{ + InventoryNode: types.InventoryNode{ + ID: updMgr.Name() + "-update-agent", + Version: version.ProjectVersion, + Name: updateManagerName, + Parameters: []*types.KeyValuePair{ + { + Key: parameterDomain, + Value: updMgr.Name(), + }, + }, + }, + Type: types.SoftwareTypeApplication, + } +} + +func (updMgr *containersUpdateManager) getCurrentContainers() []*types.SoftwareNode { + containers, err := updMgr.mgr.List(context.Background()) + if err != nil { + log.ErrorErr(err, "could not list all existing containers") + return nil + } + return fromContainers(containers, updMgr.verboseInventoryReport) +} + +// Dispose releases all resources used by this instance +func (updMgr *containersUpdateManager) Dispose() error { + return nil +} + +// WatchEvents subscribes for events that update the current state inventory +func (updMgr *containersUpdateManager) WatchEvents(ctx context.Context) { + // no container events handled yet - current state inventory reported only on initial start or explicit get request +} + +// SetCallback sets the callback instance that is used for desired state feedback / current state notifications. +// It is set when the update agent instance is started +func (updMgr *containersUpdateManager) SetCallback(callback api.UpdateManagerCallback) { + updMgr.eventCallback = callback +} diff --git a/containerm/updateagent/update_manager_util.go b/containerm/updateagent/update_manager_util.go new file mode 100644 index 0000000..c82dbc0 --- /dev/null +++ b/containerm/updateagent/update_manager_util.go @@ -0,0 +1,68 @@ +// Copyright (c) 2023 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package updateagent + +import ( + "strings" + + ctrtypes "github.com/eclipse-kanto/container-management/containerm/containers/types" + + "github.com/eclipse-kanto/update-manager/api/types" + "github.com/pkg/errors" +) + +func findContainerVersion(imageName string) string { + if len(imageName) > 0 { + name := imageName[strings.LastIndex(imageName, "/")+1:] + sep := strings.Index(name, "@") + if sep != -1 && sep != len(name)-1 { + return name[sep+1:] + } + sep = strings.Index(name, ":") + if sep != -1 && sep != len(name)-1 { + return name[sep+1:] + } + } + return "n/a" +} + +func baselinesWithContainers(prefix string, baselines []*types.Baseline, containers map[string]*ctrtypes.Container) (map[string][]*ctrtypes.Container, error) { + result := make(map[string][]*ctrtypes.Container) + for _, baseline := range baselines { + baselineContainers, err := containersForBaseline(prefix, baseline.Components, containers) + if err != nil { + return nil, errors.Wrap(err, "problem with baseline "+baseline.Title) + } + result[baseline.Title] = baselineContainers + } + for name, container := range containers { // all containers that are not included in a baseline are mapped to single-container baselines + result[prefix+name] = []*ctrtypes.Container{container} + } + return result, nil +} + +func containersForBaseline(prefix string, components []string, containers map[string]*ctrtypes.Container) ([]*ctrtypes.Container, error) { + result := []*ctrtypes.Container{} + for _, component := range components { + if strings.HasPrefix(component, prefix) { + name := component[len(prefix):] + container, ok := containers[name] + if !ok { + return nil, errors.New("cannot find container component " + component) + } + result = append(result, container) + delete(containers, name) + } + } + return result, nil +} diff --git a/containerm/updateagent/update_manager_util_test.go b/containerm/updateagent/update_manager_util_test.go new file mode 100644 index 0000000..0ed7f28 --- /dev/null +++ b/containerm/updateagent/update_manager_util_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2023 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package updateagent + +import ( + "testing" + + "github.com/eclipse-kanto/container-management/containerm/pkg/testutil" +) + +func TestFindContainerVersion(t *testing.T) { + testCases := []struct { + imageName string + version string + }{ + {imageName: "mycontainerregistry.com:8080/my-container:v1", version: "v1"}, + {imageName: "my-container:my-branch:123456", version: "my-branch:123456"}, + {imageName: "my-container@sha256:1234567890123456789012345678901234567890123456789012345678901234", version: "sha256:1234567890123456789012345678901234567890123456789012345678901234"}, + {imageName: "my-container", version: "n/a"}, + {imageName: "my-container:", version: "n/a"}, + {imageName: "my-container@", version: "n/a"}, + {imageName: "", version: "n/a"}, + } + + for _, testCase := range testCases { + t.Log(testCase.imageName) + testutil.AssertEqual(t, testCase.version, findContainerVersion(testCase.imageName)) + } +} diff --git a/containerm/updateagent/update_operation.go b/containerm/updateagent/update_operation.go new file mode 100644 index 0000000..1565245 --- /dev/null +++ b/containerm/updateagent/update_operation.go @@ -0,0 +1,620 @@ +// Copyright (c) 2023 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package updateagent + +import ( + "context" + "fmt" + "strings" + + ctrtypes "github.com/eclipse-kanto/container-management/containerm/containers/types" + "github.com/eclipse-kanto/container-management/containerm/log" + "github.com/eclipse-kanto/container-management/containerm/util" + + "github.com/eclipse-kanto/update-manager/api/types" +) + +type containerAction struct { + desired *ctrtypes.Container + current *ctrtypes.Container + + feedbackAction *types.Action + actionType util.ActionType +} + +type baselineAction struct { + baseline string + status types.StatusType + actions []*containerAction +} + +type operation struct { + ctx context.Context + updateManager *containersUpdateManager + activityID string + desiredState *internalDesiredState + + allActions *baselineAction + baselineActions map[string]*baselineAction +} + +// UpdateOperation defines an interface for an update operation process +type UpdateOperation interface { + GetActivityID() string + Identify() error + Execute(command types.CommandType, baseline string) + Feedback(status types.StatusType, message string, baseline string) +} + +type createUpdateOperation func(*containersUpdateManager, string, *internalDesiredState) UpdateOperation + +func newOperation(updMgr *containersUpdateManager, activityID string, desiredState *internalDesiredState) UpdateOperation { + return &operation{ + updateManager: updMgr, + activityID: activityID, + desiredState: desiredState, + } +} + +// GetActivityID returns the activity ID associated with this operation +func (o *operation) GetActivityID() string { + return o.activityID +} + +// Identify executes the IDENTIFYING phase, triggered with the full desired state for the domain +func (o *operation) Identify() error { + if o.ctx == nil { + o.ctx = context.Background() + } + currentContainers, err := o.updateManager.mgr.List(o.ctx) + if err != nil { + log.ErrorErr(err, "could not list all existing containers") + return err + } + currentContainersMap := util.AsNamedMap(currentContainers) + + allActions := []*containerAction{} + log.Debug("checking desired vs current containers") + for _, desired := range o.desiredState.containers { + id := desired.Name + if o.isSystemContainer(id) { + log.Warn("[%s] System container cannot be updated with desired state.", id) + continue + } + current := currentContainersMap[id] + if current != nil { + delete(currentContainersMap, id) + } + allActions = append(allActions, o.newContainerAction(current, desired)) + } + + destroyActions := o.newDestroyActions(currentContainersMap) + allActions = append(allActions, destroyActions...) + + // identify baseline actions, e.g. actions that are grouped together as a baseline + baselineActions := make(map[string]*baselineAction) + baselineRemoveContainers := o.updateManager.domainName + ":remove-components" + baselineActions[baselineRemoveContainers] = &baselineAction{ + baseline: baselineRemoveContainers, + status: types.StatusIdentified, + actions: destroyActions, + } + for baseline, containers := range o.desiredState.baselines { + baselineActions[baseline] = &baselineAction{ + baseline: baseline, + status: types.StatusIdentified, + actions: filterActions(allActions, containers), + } + } + o.allActions = &baselineAction{ + baseline: "", + status: types.StatusIdentified, + actions: allActions, + } + o.baselineActions = baselineActions + + return nil +} + +func (o *operation) newContainerAction(current *ctrtypes.Container, desired *ctrtypes.Container) *containerAction { + actionType := util.DetermineUpdateAction(current, desired) + message := util.GetActionMessage(actionType) + + log.Debug("[%s] %s", desired.Name, message) + return &containerAction{ + desired: desired, + current: current, + feedbackAction: &types.Action{ + Component: &types.Component{ + ID: o.updateManager.domainName + ":" + desired.Name, + Version: o.desiredState.findComponent(desired.Name).Version, + }, + Status: types.ActionStatusIdentified, + Message: message, + }, + actionType: actionType, + } +} + +func (o *operation) newDestroyActions(toBeRemoved map[string]*ctrtypes.Container) []*containerAction { + destroyActions := []*containerAction{} + message := util.GetActionMessage(util.ActionDestroy) + for id, current := range toBeRemoved { + if o.isSystemContainer(id) { + continue + } + log.Debug("[%s] %s", current.Name, message) + destroyActions = append(destroyActions, &containerAction{ + desired: nil, + current: current, + feedbackAction: &types.Action{ + Component: &types.Component{ + ID: o.updateManager.domainName + ":" + current.Name, + Version: findContainerVersion(current.Image.Name), + }, + Status: types.ActionStatusIdentified, + Message: message, + }, + actionType: util.ActionDestroy, + }) + } + return destroyActions +} + +func filterActions(actions []*containerAction, containers []*ctrtypes.Container) []*containerAction { + result := []*containerAction{} + for _, container := range containers { + for _, action := range actions { + if action.desired == container { + result = append(result, action) + } + } + } + return result +} + +// Execute executes each COMMAND (download, update, activate, etc) phase, triggered per baseline or for all the identified actions +func (o *operation) Execute(command types.CommandType, baseline string) { + commandHandler, baselineAction := o.getBaselineCommandHandler(baseline, command) + if baselineAction == nil { + return + } + commandHandler(o, baselineAction) +} + +type baselineCommandHandler func(*operation, *baselineAction) + +var baselineCommandHandlers = map[types.CommandType]struct { + expectedBaselineStatus []types.StatusType + baselineFailureStatus types.StatusType + commandHandler baselineCommandHandler +}{ + types.CommandDownload: { + expectedBaselineStatus: []types.StatusType{types.StatusIdentified}, + baselineFailureStatus: types.BaselineStatusDownloadFailure, + commandHandler: download, + }, + types.CommandUpdate: { + expectedBaselineStatus: []types.StatusType{types.BaselineStatusDownloadSuccess}, + baselineFailureStatus: types.BaselineStatusUpdateFailure, + commandHandler: update, + }, + types.CommandActivate: { + expectedBaselineStatus: []types.StatusType{types.BaselineStatusUpdateSuccess}, + baselineFailureStatus: types.BaselineStatusActivationFailure, + commandHandler: activate, + }, + types.CommandRollback: { + expectedBaselineStatus: []types.StatusType{types.BaselineStatusActivationFailure, types.BaselineStatusActivationSuccess}, + baselineFailureStatus: types.BaselineStatusRollbackFailure, + commandHandler: rollback, + }, + types.CommandCleanup: { + baselineFailureStatus: types.BaselineStatusCleanup, + commandHandler: cleanup, + }, +} + +func (o *operation) getBaselineCommandHandler(baseline string, command types.CommandType) (baselineCommandHandler, *baselineAction) { + handler, ok := baselineCommandHandlers[command] + if !ok { + log.Warn("Ignoring unknown command %", command) + return nil, nil + } + var baselineAction *baselineAction + if baseline == "*" || baseline == "" { + o.allActions.baseline = baseline + baselineAction = o.allActions + } else { + baselineAction = o.baselineActions[baseline] + } + if baselineAction == nil { + o.Feedback(handler.baselineFailureStatus, "Unknown baseline "+baseline, baseline) + return nil, nil + } + if len(handler.expectedBaselineStatus) > 0 && !hasStatus(handler.expectedBaselineStatus, baselineAction.status) { + o.Feedback(handler.baselineFailureStatus, fmt.Sprintf("%s is possible only after status %s is reported", command, asStatusString(handler.expectedBaselineStatus)), baseline) + return nil, nil + } + return handler.commandHandler, baselineAction +} + +// ActionCreate and ActionRecreate: create new container instance, this will download the container image. +func download(o *operation, baselineAction *baselineAction) { + var lastAction *containerAction + var lastActionErr error + lastActionMessage := "" + + log.Debug("downloading for baseline %s - starting...", baselineAction.baseline) + defer func() { + if lastActionErr == nil { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusDownloadSuccess, lastAction, types.ActionStatusDownloadSuccess, lastActionMessage) + } else { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusDownloadFailure, lastAction, types.ActionStatusDownloadFailure, lastActionErr.Error()) + } + log.Debug("downloading for baseline %s - done", baselineAction.baseline) + }() + + actions := baselineAction.actions + for _, action := range actions { + if lastAction != nil { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusDownloading, lastAction, types.ActionStatusDownloadSuccess, lastActionMessage) + } + lastAction = action + if action.actionType == util.ActionCreate || action.actionType == util.ActionRecreate { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusDownloading, action, types.ActionStatusDownloading, action.feedbackAction.Message) + log.Debug("new container %s to be created...", action.feedbackAction.Component.ID) + if err := o.createContainer(action.desired); err != nil { + lastActionErr = err + return + } + lastActionMessage = "New container created." + } else { + lastAction = nil + } + } +} + +// ActionRecreate, ActionDestroy: stops the current container instance. +// ActionUpdate: update the running container configuration. +func update(o *operation, baselineAction *baselineAction) { + var lastAction *containerAction + var lastActionErr error + lastActionMessage := "" + + log.Debug("updating for baseline %s - starting...", baselineAction.baseline) + defer func() { + if lastActionErr == nil { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusUpdateSuccess, lastAction, types.ActionStatusUpdateSuccess, lastActionMessage) + } else { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusUpdateFailure, lastAction, types.ActionStatusUpdateFailure, lastActionErr.Error()) + } + log.Debug("updating for baseline %s - done.", baselineAction.baseline) + }() + + actions := baselineAction.actions + for _, action := range actions { + if lastAction != nil { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusUpdating, lastAction, types.ActionStatusUpdateSuccess, lastActionMessage) + } + + log.Debug("container %s to be updated...", action.feedbackAction.Component.ID) + lastAction = action + if action.actionType == util.ActionRecreate || action.actionType == util.ActionDestroy { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusUpdating, action, types.ActionStatusUpdating, action.feedbackAction.Message) + if err := o.stopContainer(action.current); err != nil { + lastActionErr = err + return + } + lastActionMessage = "Old container instance is stopped." + } else if action.actionType == util.ActionUpdate { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusUpdating, action, types.ActionStatusUpdating, action.feedbackAction.Message) + if err := o.updateContainer(action.current, action.desired); err != nil { + lastActionErr = err + return + } + lastActionMessage = "Container instance is updated with new configuration." + } else { + lastActionMessage = action.feedbackAction.Message + } + } +} + +// ActionCreate, ActionRecreate: starts the newly created container instance (from DOWNLOAD phase). +// ActionUpdate, ActionCheck: ensure the existing container is running (call start/unpause container). +func activate(o *operation, baselineAction *baselineAction) { + var lastAction *containerAction + var lastActionErr error + lastActionMessage := "" + + log.Debug("activating for baseline %s - starting...", baselineAction.baseline) + defer func() { + if lastActionErr == nil { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusActivationSuccess, lastAction, types.ActionStatusActivationSuccess, lastActionMessage) + } else { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusActivationFailure, lastAction, types.ActionStatusActivationFailure, lastActionErr.Error()) + } + log.Debug("activating for baseline %s - done...", baselineAction.baseline) + }() + + actions := baselineAction.actions + for _, action := range actions { + if lastAction != nil { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusActivating, lastAction, types.ActionStatusActivationSuccess, lastActionMessage) + } + + log.Debug("container %s to be activated...", action.feedbackAction.Component.ID) + lastAction = action + if action.actionType == util.ActionCheck || action.actionType == util.ActionUpdate { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusActivating, action, types.ActionStatusActivating, action.feedbackAction.Message) + if err := o.ensureRunningContainer(action.current); err != nil { + lastActionErr = err + return + } + if action.actionType == util.ActionCheck { + lastActionMessage = "Existing container instance is running." + } else { + lastActionMessage = action.feedbackAction.Message + } + } else if action.actionType == util.ActionCreate || action.actionType == util.ActionRecreate { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusActivating, action, types.ActionStatusActivating, action.feedbackAction.Message) + if err := o.startContainer(action.desired); err != nil { + lastActionErr = err + return + } + lastActionMessage = "New container instance is started." + } else { + lastAction = nil + } + } +} + +// ActionCreate: removes the newly created container instance (from DOWNLOAD phase) +// ActionRecreate: removes the newly created container instance (from DOWNLOAD phase) and restarts the old existing container instance. +// ActionUpdate: restores the old configuration to the existing container and ensures it is started. +func rollback(o *operation, baselineAction *baselineAction) { + var failure bool + var lastAction *containerAction + var lastActionMessage string + + log.Debug("rollback for baseline %s - starting...", baselineAction.baseline) + defer func() { + if !failure { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusRollbackSuccess, lastAction, types.ActionStatusUpdateFailure, lastActionMessage) + } else { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusRollbackFailure, lastAction, types.ActionStatusUpdateFailure, lastActionMessage) + } + log.Debug("rollback for baseline %s - done.", baselineAction.baseline) + }() + + actions := baselineAction.actions + for _, action := range actions { + if lastAction != nil { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusRollback, lastAction, types.ActionStatusUpdateFailure, lastActionMessage) + } + log.Debug("container %s to be rolled back...", action.feedbackAction.Component.ID) + lastAction = action + if action.actionType == util.ActionUpdate { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusRollback, action, types.ActionStatusUpdating, action.feedbackAction.Message) + if err := o.updateContainer(action.current, action.current); err != nil { + lastActionMessage = err.Error() + failure = true + continue + } + if err := o.ensureRunningContainer(action.current); err != nil { + lastActionMessage = err.Error() + failure = true + continue + } + lastActionMessage = "Update unsuccessful, but rollback succeeded - container configuration restored from older instance." + } else if action.actionType == util.ActionCreate || action.actionType == util.ActionRecreate { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusRollback, action, types.ActionStatusUpdating, action.feedbackAction.Message) + if err := o.removeContainer(action.desired); err != nil { + lastActionMessage = err.Error() + failure = true + continue + } + if action.current != nil { + if err := o.startContainer(action.current); err != nil { + lastActionMessage = err.Error() + failure = true + continue + } + lastActionMessage = "Update unsuccessful, but rollback succeeded - new container instance destroyed, old container instance restored." + } else { + lastActionMessage = "Update unsuccessful, but rollback succeeded - new container instance destroyed." + } + } else { + lastAction = nil + } + } +} + +// ActionRecreate, ActionDestroy: removes the old existing container instance. +func cleanup(o *operation, baselineAction *baselineAction) { + baseline := baselineAction.baseline + actions := baselineAction.actions + if baseline == "*" || baseline == "" { + for b := range o.baselineActions { + delete(o.baselineActions, b) + } + } else { + delete(o.baselineActions, baseline) + } + log.Debug("cleanup for baseline %s - starting...", baseline) + for _, action := range actions { + if action.actionType == util.ActionRecreate || action.actionType == util.ActionDestroy { + log.Debug("container %s to be cleanup...", action.feedbackAction.Component.ID) + err := o.removeContainer(action.current) + if action.actionType == util.ActionDestroy { + if err != nil { + action.feedbackAction.Status = types.ActionStatusRemovalFailure + action.feedbackAction.Message = err.Error() + } else { + action.feedbackAction.Status = types.ActionStatusRemovalSuccess + action.feedbackAction.Message = "Old container instance is removed." + } + } + } + } + o.Feedback(types.BaselineStatusCleanupSuccess, "", baseline) + log.Debug("cleanup for baseline %s - done...", baseline) +} + +func (o *operation) isSystemContainer(containerID string) bool { + systemContainers := o.desiredState.systemContainers + if systemContainers == nil { + systemContainers = o.updateManager.systemContainers + } + for _, systemContainerID := range systemContainers { + if systemContainerID == containerID { + return true + } + } + return false +} + +// Feedback sends desired state feedback responses, baseline parameter is optional +func (o *operation) Feedback(status types.StatusType, message string, baseline string) { + o.updateManager.eventCallback.HandleDesiredStateFeedbackEvent(o.updateManager.domainName, o.activityID, baseline, status, message, o.toFeedbackActions()) +} + +func (o *operation) updateBaselineActionStatus(baseline *baselineAction, baselineStatus types.StatusType, + action *containerAction, actionStatus types.ActionStatusType, message string) { + if action != nil { + action.feedbackAction.Status = actionStatus + action.feedbackAction.Message = message + } + baseline.status = baselineStatus + o.Feedback(baselineStatus, "", baseline.baseline) +} + +func (o *operation) toFeedbackActions() []*types.Action { + if o.allActions == nil { + return nil + } + result := make([]*types.Action, len(o.allActions.actions)) + for i, action := range o.allActions.actions { + result[i] = action.feedbackAction + } + return result +} + +func hasStatus(where []types.StatusType, what types.StatusType) bool { + for _, status := range where { + if status == what { + return true + } + } + return false +} + +func asStatusString(what []types.StatusType) string { + var sb strings.Builder + for _, status := range what { + if sb.Len() > 0 { + sb.WriteRune('|') + } + sb.WriteString(string(status)) + } + return sb.String() +} + +func (o *operation) createContainer(desired *ctrtypes.Container) error { + log.Debug("container [%s] does not exist - will create a new one", desired.Name) + _, err := o.updateManager.mgr.Create(o.ctx, desired) + if err != nil { + log.ErrorErr(err, "could not create container [%s]", desired.Name) + return err + } + log.Debug("successfully created container [%s]", desired.Name) + return nil +} + +func (o *operation) startContainer(container *ctrtypes.Container) error { + if err := o.updateManager.mgr.Start(o.ctx, container.ID); err != nil { + log.ErrorErr(err, "could not start container [%s]", container.Name) + return err + } + log.Debug("successfully started container [%s]", container.Name) + return nil +} + +func (o *operation) unpauseContainer(container *ctrtypes.Container) error { + if err := o.updateManager.mgr.Unpause(o.ctx, container.ID); err != nil { + log.ErrorErr(err, "could not unpause container [%s]", container.Name) + return err + } + log.Debug("successfully unpaused container [%s]", container.Name) + return nil +} + +func (o *operation) updateContainer(current *ctrtypes.Container, desired *ctrtypes.Container) error { + log.Debug("there is an already existing container [%s] - will be updated with newer configuration", desired.Name) + updateOpts := &ctrtypes.UpdateOpts{ + RestartPolicy: desired.HostConfig.RestartPolicy, + Resources: desired.HostConfig.Resources, + } + if err := o.updateManager.mgr.Update(o.ctx, current.ID, updateOpts); err != nil { + log.ErrorErr(err, "could not update configuration for container [%s]", desired.Name) + return err + } + log.Debug("successfully updated container [%s]", desired.Name) + return nil +} + +func (o *operation) ensureRunningContainer(current *ctrtypes.Container) error { + container, err := o.updateManager.mgr.Get(o.ctx, current.ID) + if err != nil { + log.DebugErr(err, "cannot get current state for container [%s]", current.Name) + return err + } + if container.State.Running { + log.Debug("container [%s] is RUNNING - nothing to do more", current.Name) + return nil + } + if container.State.Paused { + log.Debug("container [%s] is PAUSED - will try to unpause it", current.Name) + return o.unpauseContainer(container) + } + log.Debug("container [%s] is not RUNNING - will try to start it", current.Name) + return o.startContainer(container) +} + +func (o *operation) removeContainer(container *ctrtypes.Container) error { + log.Debug("container [%s] is not desired - will be removed", container.Name) + if err := o.updateManager.mgr.Remove(o.ctx, container.ID, true); err != nil { + log.ErrorErr(err, "could not remove undesired container [%s]", container.Name) + return err + } + log.Debug("successfully removed container [%s]", container.Name) + return nil +} + +func (o *operation) stopContainer(container *ctrtypes.Container) error { + if !util.IsContainerRunningOrPaused(container) { + log.Debug("container [%s] is not RUNNING, nor PAUSED - nothing to do more", container.Name) + return nil + } + stopOpts := &ctrtypes.StopOpts{ + Force: true, + Signal: "SIGTERM", + } + log.Debug("container [%s] will be updated - will stop current instance", container.Name) + if err := o.updateManager.mgr.Stop(o.ctx, container.ID, stopOpts); err != nil { + log.ErrorErr(err, "could not stop outdated container [%s]", container.Name) + return err + } + log.Debug("successfully stopped outdated container [%s]", container.Name) + return nil +} diff --git a/containerm/util/containers_compare.go b/containerm/util/containers_compare.go index 98b2708..3e1146a 100644 --- a/containerm/util/containers_compare.go +++ b/containerm/util/containers_compare.go @@ -13,6 +13,7 @@ package util import ( + "fmt" "reflect" "github.com/eclipse-kanto/container-management/containerm/containers/types" @@ -60,6 +61,23 @@ func DetermineUpdateAction(current *types.Container, desired *types.Container) A return ActionCheck } +// GetActionMessage returns a text message describing the given action type +func GetActionMessage(actionType ActionType) string { + switch actionType { + case ActionCheck: + return "No changes detected, existing container will be check only if it is running." + case ActionCreate: + return "New container will be created and started." + case ActionRecreate: + return "Existing container will be destroyed and replaced by a new one." + case ActionUpdate: + return "Existing container will be updated with new configuration." + case ActionDestroy: + return "Existing container will be destroyed, no longer needed." + } + return "Unknown action type: " + fmt.Sprint(actionType) +} + func isEqualImage(currentImage types.Image, newImage types.Image) bool { return currentImage.Name == newImage.Name } diff --git a/containerm/util/containers_parse.go b/containerm/util/containers_parse.go new file mode 100644 index 0000000..a6e23b4 --- /dev/null +++ b/containerm/util/containers_parse.go @@ -0,0 +1,234 @@ +// Copyright (c) 2023 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package util + +import ( + "net" + "strconv" + "strings" + + "github.com/eclipse-kanto/container-management/containerm/containers/types" + "github.com/eclipse-kanto/container-management/containerm/log" +) + +// ParseDeviceMappings converts string representations of container's device mappings to structured DeviceMapping instances. +// The string representation format for a device mapping is defined with ParseDeviceMapping function. +func ParseDeviceMappings(devices []string) ([]types.DeviceMapping, error) { + var devs []types.DeviceMapping + for _, devPair := range devices { + dev, err := ParseDeviceMapping(devPair) + if err != nil { + return nil, err + } + devs = append(devs, *dev) + } + return devs, nil +} + +// ParseDeviceMapping converts a single string representation of a container's device mapping to a structured DeviceMapping instance. +// Format: :[:propagation_mode]. +// Both path on host and in container must be set. +// The string representation may contain optional cgroups permissions configuration. +// Possible cgroup permissions options are “r” (read), “w” (write), “m” (mknod) and all combinations of the three are possible. If not set, “rwm” is default device configuration. +// Example: /dev/ttyACM0:/dev/ttyUSB0[:rwm]. +func ParseDeviceMapping(device string) (*types.DeviceMapping, error) { + pair := strings.Split(strings.TrimSpace(device), ":") + if len(pair) == 2 { + return &types.DeviceMapping{ + PathOnHost: pair[0], + PathInContainer: pair[1], + CgroupPermissions: "rwm", + }, nil + } + if len(pair) == 3 { + if len(pair[2]) == 0 || len(pair[2]) > 3 { + return nil, log.NewErrorf("incorrect cgroup permissions format for device mapping %s", device) + } + for i := 0; i < len(pair[2]); i++ { + if (pair[2])[i] != "w"[0] && (pair[2])[i] != "r"[0] && (pair[2])[i] != "m"[0] { + return nil, log.NewErrorf("incorrect cgroup permissions format for device mapping %s", device) + } + } + return &types.DeviceMapping{ + PathOnHost: pair[0], + PathInContainer: pair[1], + CgroupPermissions: pair[2], + }, nil + } + return nil, log.NewErrorf("incorrect configuration value for device mapping %s", device) +} + +// ParseMountPoints converts string representations of container's mounts to structured MountPoint instances. +// The string representation format for a mount point is defined with ParseMountPoint function. +func ParseMountPoints(mps []string) ([]types.MountPoint, error) { + var mountPoints []types.MountPoint + for _, mp := range mps { + mount, err := ParseMountPoint(mp) + if err != nil { + return nil, err + } + mountPoints = append(mountPoints, *mount) + } + return mountPoints, nil +} + +// ParseMountPoint converts a single string representation of a container's mount to a structured MountPoint instance. +// Format: source:destination[:propagation_mode]. +// If the propagation mode parameter is omitted, rprivate will be set by default. +// Available propagation modes are: rprivate, private, rshared, shared, rslave, slave. +func ParseMountPoint(mp string) (*types.MountPoint, error) { + mount := strings.Split(strings.TrimSpace(mp), ":") + if len(mount) < 2 || len(mount) > 3 { + return nil, log.NewErrorf("Incorrect number of parameters of the mount point %s", mp) + } + mountPoint := &types.MountPoint{ + Destination: mount[1], + Source: mount[0], + } + if len(mount) == 2 { + // if propagation mode is omitted, "rprivate" is set as default + mountPoint.PropagationMode = types.RPrivatePropagationMode + } else { + mountPoint.PropagationMode = mount[2] + } + return mountPoint, nil +} + +// ParsePortMappings converts string representations of container's port mappings to structured PortMapping instances. +// The string representation format for a port mapping is defined with ParsePortMapping function. +func ParsePortMappings(mappings []string) ([]types.PortMapping, error) { + var portMappings []types.PortMapping + for _, mapping := range mappings { + pm, err := ParsePortMapping(mapping) + if err != nil { + return nil, err + } + portMappings = append(portMappings, *pm) + } + return portMappings, nil +} + +// ParsePortMapping converts a single string representation of container's port mapping to a structured PortMapping instance. +// Format: [:][-]:[/]. +// Most common use-case: 80:80 +// Mapping the container’s 80 port to a host port in the 5000-6000 range: 5000-6000:80/udp +// Specifying port protocol (default is tcp): 80:80/udp +// By default the port mapping will set on all network interfaces, but this is also manageable: 0.0.0.0:80-100:80/udp +func ParsePortMapping(mapping string) (*types.PortMapping, error) { + var ( + err error + protocol string + containerPort int64 + hostIP string + hostPort int64 + hostPortEnd int64 + ) + + mapping0 := mapping + mappingWithProto := strings.Split(strings.TrimSpace(mapping), "/") + mapping = mappingWithProto[0] + if len(mappingWithProto) == 2 { + // port is specified, e.g.80:80/tcp + protocol = mappingWithProto[1] + } + addressAndPorts := strings.Split(strings.TrimSpace(mapping), ":") + hostPortIdx := 0 // if host ip not set + if len(addressAndPorts) == 3 { + hostPortIdx = 1 + hostIP = addressAndPorts[0] + validIP := net.ParseIP(hostIP) + if validIP == nil { + return nil, log.NewErrorf("Incorrect host ip port mapping configuration %s", mapping0) + } + } else if len(addressAndPorts) != 2 { // len==2: host address not specified, e.g. 80:80 + return nil, log.NewErrorf("Incorrect port mapping configuration %s", mapping0) + } + hostPortWithRange := strings.Split(strings.TrimSpace(addressAndPorts[hostPortIdx]), "-") + if len(hostPortWithRange) == 2 { + hostPortEnd, err = strconv.ParseInt(hostPortWithRange[1], 10, 32) + if err != nil { + return nil, log.NewErrorf("Incorrect host range port mapping configuration %s", mapping0) + } + hostPort, err = strconv.ParseInt(hostPortWithRange[0], 10, 32) + } else { + hostPort, err = strconv.ParseInt(addressAndPorts[hostPortIdx], 10, 32) + } + if err != nil { + return nil, log.NewErrorf("Incorrect host port mapping configuration %s", mapping0) + } + containerPort, err = strconv.ParseInt(addressAndPorts[hostPortIdx+1], 10, 32) + if err != nil { + return nil, log.NewErrorf("Incorrect container port mapping configuration %s", mapping0) + } + return &types.PortMapping{ + Proto: protocol, + ContainerPort: uint16(containerPort), + HostIP: hostIP, + HostPort: uint16(hostPort), + HostPortEnd: uint16(hostPortEnd), + }, nil +} + +// DeviceMappingToString returns the string representation of the given device mapping. +// The string representation format for a device mapping is defined with ParseDeviceMapping function. +func DeviceMappingToString(deviceMapping *types.DeviceMapping) string { + var device strings.Builder + if len(deviceMapping.PathOnHost) > 0 { + device.WriteString(deviceMapping.PathOnHost) + } + if len(deviceMapping.PathInContainer) > 0 { + if device.Len() > 0 { + device.WriteRune(':') + } + device.WriteString(deviceMapping.PathInContainer) + } + if len(deviceMapping.CgroupPermissions) > 0 { + if device.Len() > 0 { + device.WriteRune(':') + } + device.WriteString(deviceMapping.CgroupPermissions) + } + return device.String() +} + +// MountPointToString returns the string representation of the given mount point. +// The string representation format for a mount point is defined with ParseMountPoint function. +func MountPointToString(mountPoint *types.MountPoint) string { + return mountPoint.Source + ":" + mountPoint.Destination + ":" + mountPoint.PropagationMode +} + +// PortMappingToString returns the string representation of the given port mapping. +// The string representation format for a port mapping is defined with ParsePortMapping function. +func PortMappingToString(portMapping *types.PortMapping) string { + var ports strings.Builder + if len(portMapping.HostIP) > 0 && portMapping.HostIP != "0.0.0.0" { //ex. 1.2.3.4:80:80 + ports.WriteString(portMapping.HostIP) + ports.WriteRune(':') + } + if portMapping.HostPort != 0 { + ports.WriteString(strconv.FormatUint(uint64(portMapping.HostPort), 10)) + if portMapping.HostPortEnd != 0 && portMapping.HostPort != portMapping.HostPortEnd { //ex. 5000-6000:80 + ports.WriteRune('-') + ports.WriteString(strconv.FormatUint(uint64(portMapping.HostPortEnd), 10)) + } + } + if portMapping.ContainerPort != 0 { + ports.WriteRune(':') + ports.WriteString(strconv.FormatUint(uint64(portMapping.ContainerPort), 10)) + } + if len(portMapping.Proto) > 0 { //ex. 80:80/tcp + ports.WriteRune('/') + ports.WriteString(portMapping.Proto) + } + return ports.String() +} diff --git a/containerm/util/containers_parse_test.go b/containerm/util/containers_parse_test.go new file mode 100644 index 0000000..e82cdc3 --- /dev/null +++ b/containerm/util/containers_parse_test.go @@ -0,0 +1,371 @@ +// Copyright (c) 2023 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package util + +import ( + "testing" + + "github.com/eclipse-kanto/container-management/containerm/containers/types" + "github.com/eclipse-kanto/container-management/containerm/log" + "github.com/eclipse-kanto/container-management/containerm/pkg/testutil" +) + +type errorTest struct { + inputString string + errMessage string +} + +func TestParseDeviceMappings(t *testing.T) { + testCases := map[string]struct { + inputString string + expectedDevice *types.DeviceMapping + }{ + "test_parse_device_mapping_valid_input_without_cgrops": { + inputString: "/dev/ttyACM0:/dev/ttyUSB0", + expectedDevice: &types.DeviceMapping{ + PathOnHost: "/dev/ttyACM0", + PathInContainer: "/dev/ttyUSB0", + CgroupPermissions: "rwm", + }, + }, + "test_parse_device_mapping_valid_input_with_readonly": { + inputString: "/dev/ttyACM1:/dev/ttyUSB1:r", + expectedDevice: &types.DeviceMapping{ + PathOnHost: "/dev/ttyACM1", + PathInContainer: "/dev/ttyUSB1", + CgroupPermissions: "r", + }, + }, + "test_parse_device_mapping_valid_input_with_two_cgroup_permissions": { + inputString: "/dev/ttyACM2:/dev/ttyUSB2:mw", + expectedDevice: &types.DeviceMapping{ + PathOnHost: "/dev/ttyACM2", + PathInContainer: "/dev/ttyUSB2", + CgroupPermissions: "mw", + }, + }, + } + + index := 0 + inputStrings := make([]string, len(testCases)) + expectedDevices := make([]types.DeviceMapping, len(testCases)) + + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + t.Log(testCase.inputString) + + res, err := ParseDeviceMapping(testCase.inputString) + testutil.AssertNil(t, err) + testutil.AssertEqual(t, testCase.expectedDevice, res) + + res, err = ParseDeviceMapping(DeviceMappingToString(res)) + testutil.AssertNil(t, err) + testutil.AssertEqual(t, testCase.expectedDevice, res) + + inputStrings[index] = testCase.inputString + expectedDevices[index] = *res + index++ + }) + } + + t.Run("test_parse_device_mapping_multiple", func(t *testing.T) { + res, err := ParseDeviceMappings(inputStrings) + testutil.AssertNil(t, err) + testutil.AssertEqual(t, expectedDevices, res) + }) +} + +func TestParseDeviceMappingsError(t *testing.T) { + testCases := map[string]errorTest{ + "test_parse_device_mapping_input_empty": { + inputString: "", + errMessage: "incorrect configuration value for device mapping", + }, + "test_parse_device_mapping_input_too_long": { + inputString: "/dev/ttyACM1:/dev/ttyUSB1:r:w:m", + errMessage: "incorrect configuration value for device mapping", + }, + "test_parse_device_mapping_input_too_short": { + inputString: "/dev/ttyACM1", + errMessage: "incorrect configuration value for device mapping", + }, + "test_parse_device_mapping_no_cgroup_permissions": { + inputString: "/dev/ttyACM1:/dev/ttyUSB1:", + errMessage: "incorrect cgroup permissions format for device mapping", + }, + "test_parse_device_mapping_cgroup_permissions_too_long": { + inputString: "/dev/ttyACM1:/dev/ttyUSB1:rwmrwm", + errMessage: "incorrect cgroup permissions format for device mapping", + }, + "test_parse_device_mapping_invalid_cgroup_permission": { + inputString: "/dev/ttyACM1:/dev/ttyUSB1:R", + errMessage: "incorrect cgroup permissions format for device mapping", + }, + } + + inputStrings := make([]string, 2) + inputStrings[0] = "/dev/ttyACM0:/dev/ttyUSB0" + + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + t.Log(testName) + + inputStrings[1] = testCase.inputString + + res, err := ParseDeviceMappings(inputStrings) + testutil.AssertError(t, log.NewErrorf(testCase.errMessage+" %s", testCase.inputString), err) + testutil.AssertNil(t, res) + }) + } +} + +func TestParseMountPoints(t *testing.T) { + testCases := map[string]struct { + inputString string + expectedMount *types.MountPoint + }{ + "test_parse_mount_point_valid_input_propagation_mode_missing": { + inputString: "/home/someuser:/home/root", + expectedMount: &types.MountPoint{ + Source: "/home/someuser", + Destination: "/home/root", + PropagationMode: types.RPrivatePropagationMode, + }, + }, + "test_parse_mount_point_valid_input_propagation_mode_private": { + inputString: "/home/someuser:/home/root:private", + expectedMount: &types.MountPoint{ + Source: "/home/someuser", + Destination: "/home/root", + PropagationMode: types.PrivatePropagationMode, + }, + }, + "test_parse_mount_point_valid_input_propagation_mode_rprivate": { + inputString: "/var:/var:rprivate", + expectedMount: &types.MountPoint{ + Source: "/var", + Destination: "/var", + PropagationMode: types.RPrivatePropagationMode, + }, + }, + "test_parse_mount_point_valid_input_propagation_mode_shared": { + inputString: "/etc:/etc:shared", + expectedMount: &types.MountPoint{ + Source: "/etc", + Destination: "/etc", + PropagationMode: types.SharedPropagationMode, + }, + }, + "test_parse_mount_point_valid_input_propagation_mode_rshared": { + inputString: "/usr/bin:/usr/bin:rshared", + expectedMount: &types.MountPoint{ + Source: "/usr/bin", + Destination: "/usr/bin", + PropagationMode: types.RSharedPropagationMode, + }, + }, + "test_parse_mount_point_valid_input_propagation_mode_slave": { + inputString: "/data:/data:slave", + expectedMount: &types.MountPoint{ + Source: "/data", + Destination: "/data", + PropagationMode: types.SlavePropagationMode, + }, + }, + "test_parse_mount_point_valid_input_propagation_mode_rslave": { + inputString: "/tmp:/tmp:rslave", + expectedMount: &types.MountPoint{ + Source: "/tmp", + Destination: "/tmp", + PropagationMode: types.RSlavePropagationMode, + }, + }, + } + + index := 0 + inputStrings := make([]string, len(testCases)) + expectedMounts := make([]types.MountPoint, len(testCases)) + + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + + t.Log(testCase.inputString) + + res, err := ParseMountPoint(testCase.inputString) + testutil.AssertNil(t, err) + testutil.AssertEqual(t, testCase.expectedMount, res) + + res, err = ParseMountPoint(MountPointToString(res)) + testutil.AssertNil(t, err) + testutil.AssertEqual(t, testCase.expectedMount, res) + + inputStrings[index] = testCase.inputString + expectedMounts[index] = *res + index++ + }) + } + + t.Run("test_parse_mount_points_multiple", func(t *testing.T) { + res, err := ParseMountPoints(inputStrings) + testutil.AssertNil(t, err) + testutil.AssertEqual(t, expectedMounts, res) + }) +} + +func TestParseMountPointsError(t *testing.T) { + testCases := map[string]errorTest{ + "test_parse_mount_point_input_empty": { + inputString: "", + errMessage: "Incorrect number of parameters of the mount point", + }, + "test_parse_mount_point_input_too_long": { + inputString: "/data:/data:private:shared", + errMessage: "Incorrect number of parameters of the mount point", + }, + "test_parse_mount_point_input_too_short": { + inputString: "/home", + errMessage: "Incorrect number of parameters of the mount point", + }, + } + + inputStrings := make([]string, 2) + inputStrings[0] = "/etc:/etc" + + for testName, testCase := range testCases { + t.Log(testName) + + inputStrings[1] = testCase.inputString + + res, err := ParseMountPoints(inputStrings) + testutil.AssertError(t, log.NewErrorf(testCase.errMessage+" %s", testCase.inputString), err) + testutil.AssertNil(t, res) + } +} + +func TestParsePortMappings(t *testing.T) { + testCases := map[string]struct { + inputString string + expectedPort *types.PortMapping + }{ + "test_parse_port_mapping_input_host_and_container_port_only": { + inputString: "80:80", + expectedPort: &types.PortMapping{ + ContainerPort: 80, + HostPort: 80, + }, + }, + "test_parse_port_mapping_input_host_and_container_port_plus_protocol": { + inputString: "88:8888/udp", + expectedPort: &types.PortMapping{ + Proto: "udp", + ContainerPort: 8888, + HostPort: 88, + }, + }, + "test_parse_port_mapping_input_host_range": { + inputString: "5000-6000:8080/udp", + expectedPort: &types.PortMapping{ + Proto: "udp", + ContainerPort: 8080, + HostPort: 5000, + HostPortEnd: 6000, + }, + }, + "test_parse_port_mapping_input_host_ip_included": { + inputString: "192.168.0.1:7000-8000:8081/tcp", + expectedPort: &types.PortMapping{ + Proto: "tcp", + ContainerPort: 8081, + HostPort: 7000, + HostPortEnd: 8000, + HostIP: "192.168.0.1", + }, + }, + } + + index := 0 + inputStrings := make([]string, len(testCases)) + expectedPorts := make([]types.PortMapping, len(testCases)) + + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + t.Log(testCase.inputString) + + res, err := ParsePortMapping(testCase.inputString) + testutil.AssertNil(t, err) + testutil.AssertEqual(t, testCase.expectedPort, res) + + res, err = ParsePortMapping(PortMappingToString(res)) + testutil.AssertNil(t, err) + testutil.AssertEqual(t, testCase.expectedPort, res) + + inputStrings[index] = testCase.inputString + expectedPorts[index] = *res + index++ + }) + } + + t.Run("test_parse_port_mappings_multiple", func(t *testing.T) { + res, err := ParsePortMappings(inputStrings) + testutil.AssertNil(t, err) + testutil.AssertEqual(t, expectedPorts, res) + }) +} + +func TestParsePortMappingsError(t *testing.T) { + testCases := map[string]errorTest{ + "test_parse_port_mapping_input_empty": { + inputString: "", + errMessage: "Incorrect port mapping configuration", + }, + "test_parse_port_mapping_input_too_long": { + inputString: "192.168.1.100:5000-6000:127.0.0.1:80/tcp", + errMessage: "Incorrect port mapping configuration", + }, + "test_parse_port_mapping_input_too_short": { + inputString: "8080", + errMessage: "Incorrect port mapping configuration", + }, + "test_parse_port_mapping_invalid_host_ip": { + inputString: "192.168.1.300:8080:8080", + errMessage: "Incorrect host ip port mapping configuration", + }, + "test_parse_port_mapping_invalid_host_port": { + inputString: "FF00:8080", + errMessage: "Incorrect host port mapping configuration", + }, + "test_parse_port_mapping_invalid_host_port_range": { + inputString: "100-FF:8080", + errMessage: "Incorrect host range port mapping configuration", + }, + "test_parse_port_mapping_invalid_container_port": { + inputString: "5000-6000:BABE", + errMessage: "Incorrect container port mapping configuration", + }, + } + + inputStrings := make([]string, 2) + inputStrings[0] = "80:80" + + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + t.Log(testName) + + inputStrings[1] = testCase.inputString + + res, err := ParsePortMappings(inputStrings) + testutil.AssertError(t, log.NewErrorf(testCase.errMessage+" %s", testCase.inputString), err) + testutil.AssertNil(t, res) + }) + } +} diff --git a/containerm/util/containers_validation.go b/containerm/util/containers_validation.go index 0a16136..06489d5 100644 --- a/containerm/util/containers_validation.go +++ b/containerm/util/containers_validation.go @@ -80,6 +80,9 @@ func ValidateName(name string) error { // ValidateHostConfig validates the container host configuration func ValidateHostConfig(hostConfig *types.HostConfig) error { + if hostConfig.Privileged && len(hostConfig.Devices) > 0 { + return log.NewError("cannot have a privileged container with specified devices") + } if err := ValidateNetworking(hostConfig); err != nil { return err } diff --git a/containerm/util/containers_validation_test.go b/containerm/util/containers_validation_test.go index 9835fe6..c524388 100644 --- a/containerm/util/containers_validation_test.go +++ b/containerm/util/containers_validation_test.go @@ -120,6 +120,20 @@ func TestNegativeContainerValidations(t *testing.T) { }, expectedErr: log.NewErrorf("the containers host config is mandatory and is missing"), }, + "test_validate_host_config_privileged_with_devices": { + ctr: &types.Container{ + Image: types.Image{Name: "image"}, + HostConfig: &types.HostConfig{ + Privileged: true, + Devices: []types.DeviceMapping{{ + PathOnHost: hostConfigDeviceHost, + PathInContainer: hostConfigDeviceContainer, + CgroupPermissions: hostConfigDevicePerm, + }}, + }, + }, + expectedErr: log.NewErrorf("cannot have a privileged container with specified devices"), + }, "test_validate_host_config_device_mappings_invalid_host_path": { ctr: &types.Container{ Image: types.Image{Name: "image"}, diff --git a/containerm/util/util_base_test.go b/containerm/util/util_base_test.go index 3a43d41..9f08c79 100644 --- a/containerm/util/util_base_test.go +++ b/containerm/util/util_base_test.go @@ -42,7 +42,7 @@ const ( configEnv5 = "VAR5=test,comma" configEnv6 = "_VAR6=test_underscore" - hostConfigPrivileged = true + hostConfigPrivileged = false hostConfigNetType = "bridge" hostConfigContainerPort = 80 hostConfigHostPort = 81 diff --git a/go.mod b/go.mod index 95aac3b..26de5bb 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/containers/ocicrypt v1.1.6 github.com/docker/docker v20.10.24+incompatible github.com/eclipse-kanto/kanto/integration/util v0.0.0-20230103144956-911e45a2bf55 + github.com/eclipse-kanto/update-manager v0.0.0-20230628072101-b91f0c30e00f github.com/eclipse/ditto-clients-golang v0.0.0-20220225085802-cf3b306280d3 github.com/eclipse/paho.mqtt.golang v1.4.1 github.com/gogo/protobuf v1.3.2 diff --git a/go.sum b/go.sum index fbd11f6..89b652a 100644 --- a/go.sum +++ b/go.sum @@ -72,8 +72,8 @@ github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZ github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw= github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/toml v1.2.0 h1:Rt8g24XnyGTyglgET/PRUNlrUeu9F5L+7FilkXfZgs0= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs= github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= @@ -389,6 +389,8 @@ github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:Htrtb github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/eclipse-kanto/kanto/integration/util v0.0.0-20230103144956-911e45a2bf55 h1:a9tYAvpMoDUfcaGL6HZSEOFq82r8VYP+M2dIzTHve2U= github.com/eclipse-kanto/kanto/integration/util v0.0.0-20230103144956-911e45a2bf55/go.mod h1:mhkMBNIG+JDz35JAj8JVWMe0/ROpKmk/i6RYHU6Rea4= +github.com/eclipse-kanto/update-manager v0.0.0-20230628072101-b91f0c30e00f h1:+oeyDIcFCE4cnczhQp7UycS9jZNUuml2x6YccVGjIaY= +github.com/eclipse-kanto/update-manager v0.0.0-20230628072101-b91f0c30e00f/go.mod h1:wzaUsK5uU6OgdarSTYyV8Wakb3MZ3ifOUR9FNGfoYcA= github.com/eclipse/ditto-clients-golang v0.0.0-20220225085802-cf3b306280d3 h1:bfFGs26yNSfhSi6xmnmykB0jZn1Vu5e1/7JA5Wu5aGc= github.com/eclipse/ditto-clients-golang v0.0.0-20220225085802-cf3b306280d3/go.mod h1:ey7YwfHSQJsinGkGbgeEgqZA7qJnoB0YiFVTFEY50Jg= github.com/eclipse/paho.mqtt.golang v1.3.5/go.mod h1:eTzb4gxwwyWpqBUHGQZ4ABAV7+Jgm1PklsYT/eo8Hcc=