From e64fa8e717ad05cc5dc9c82b89b699c1e28afaa1 Mon Sep 17 00:00:00 2001 From: Cyril Cressent Date: Thu, 1 Aug 2024 18:02:26 -0700 Subject: [PATCH 01/32] Use otelcol-config for config manipulations --- install-script/install.sh | 195 ++++++-------------------------------- 1 file changed, 30 insertions(+), 165 deletions(-) diff --git a/install-script/install.sh b/install-script/install.sh index 927f2995..489a55a0 100755 --- a/install-script/install.sh +++ b/install-script/install.sh @@ -341,7 +341,7 @@ function parse_options() { value="$(echo -e "${OPTARG}" | sed 's/.*=//')" key="$(echo -e "${OPTARG}" | sed 's/\(.*\)=.*/\1/')" - line="${key}: $(escape_yaml_value "${value}")" + line="${key}=$(escape_yaml_value "${value}")" # Cannot use `\n` and have to use `\\` as break line due to OSx sed implementation FIELDS="${FIELDS}\\ @@ -691,27 +691,27 @@ function setup_config() { echo -e "Creating remote configurations directory (${REMOTE_CONFIG_DIRECTORY})" mkdir -p "${REMOTE_CONFIG_DIRECTORY}" - write_sumologic_extension "${CONFIG_PATH}" "${INDENTATION}" - write_opamp_extension "${CONFIG_PATH}" "${REMOTE_CONFIG_DIRECTORY}" "${INDENTATION}" "${EXT_INDENTATION}" "${OPAMP_API_URL}" + write_sumologic_extension + write_opamp_extension if [[ -n "${SUMOLOGIC_INSTALLATION_TOKEN}" && "${SYSTEMD_DISABLED}" == "true" ]]; then - write_installation_token "${SUMOLOGIC_INSTALLATION_TOKEN}" "${CONFIG_PATH}" "${EXT_INDENTATION}" + write_installation_token "${SUMOLOGIC_INSTALLATION_TOKEN}" fi if [[ "${EPHEMERAL}" == "true" ]]; then - write_ephemeral_true "${CONFIG_PATH}" "${EXT_INDENTATION}" + write_ephemeral_true fi if [[ -n "${API_BASE_URL}" ]]; then - write_api_url "${API_BASE_URL}" "${CONFIG_PATH}" "${EXT_INDENTATION}" + write_api_url "${API_BASE_URL}" fi if [[ -n "${OPAMP_API_URL}" ]]; then - write_opamp_endpoint "${OPAMP_API_URL}" "${CONFIG_PATH}" "${EXT_INDENTATION}" + write_opamp_endpoint "${OPAMP_API_URL}" fi if [[ -n "${FIELDS}" ]]; then - write_tags "${FIELDS}" "${CONFIG_PATH}" "${INDENTATION}" "${EXT_INDENTATION}" + write_tags "${FIELDS}" fi rm -f "${CONFIG_BAK_PATH}" @@ -751,28 +751,29 @@ function setup_config() { if [[ ( -n "${SUMOLOGIC_INSTALLATION_TOKEN}" && "${SYSTEMD_DISABLED}" == "true" ) || -n "${API_BASE_URL}" || -n "${FIELDS}" || "${EPHEMERAL}" == "true" ]]; then create_user_config_file "${COMMON_CONFIG_PATH}" add_extension_to_config "${COMMON_CONFIG_PATH}" - write_sumologic_extension "${COMMON_CONFIG_PATH}" "${INDENTATION}" + write_sumologic_extension if [[ -n "${SUMOLOGIC_INSTALLATION_TOKEN}" && -z "${USER_TOKEN}" && "${SYSTEMD_DISABLED}" == "true" ]]; then - write_installation_token "${SUMOLOGIC_INSTALLATION_TOKEN}" "${COMMON_CONFIG_PATH}" "${EXT_INDENTATION}" + write_installation_token "${SUMOLOGIC_INSTALLATION_TOKEN}" fi if [[ "${EPHEMERAL}" == "true" ]]; then - write_ephemeral_true "${COMMON_CONFIG_PATH}" "${EXT_INDENTATION}" + write_ephemeral_true fi # fill in api base url if [[ -n "${API_BASE_URL}" && -z "${USER_API_URL}" ]]; then - write_api_url "${API_BASE_URL}" "${COMMON_CONFIG_PATH}" "${EXT_INDENTATION}" + write_api_url "${API_BASE_URL}" fi # fill in opamp url if [[ -n "${OPAMP_API_URL}" && -z "${USER_OPAMP_API_URL}" ]]; then - write_opamp_extension "${CONFIG_PATH}" "${REMOTE_CONFIG_DIRECTORY}" "${INDENTATION}" "${EXT_INDENTATION}" "${OPAMP_API_URL}" + write_opamp_extension + write_opamp_endpoint "${OPAMP_API_URL}" fi if [[ -n "${FIELDS}" && -z "${USER_FIELDS}" ]]; then - write_tags "${FIELDS}" "${COMMON_CONFIG_PATH}" "${INDENTATION}" "${EXT_INDENTATION}" + write_tags "${FIELDS}" fi # clean up bak file @@ -795,19 +796,19 @@ function setup_config_darwin() { create_user_config_file "${config_path}" add_extension_to_config "${config_path}" - write_sumologic_extension "${config_path}" "${INDENTATION}" + write_sumologic_extension if [[ "${EPHEMERAL}" == "true" ]]; then - write_ephemeral_true "${config_path}" "${EXT_INDENTATION}" + write_ephemeral_true fi # fill in api base url if [[ -n "${API_BASE_URL}" ]]; then - write_api_url "${API_BASE_URL}" "${config_path}" "${EXT_INDENTATION}" + write_api_url "${API_BASE_URL}" fi if [[ -n "${FIELDS}" ]]; then - write_tags "${FIELDS}" "${config_path}" "${INDENTATION}" "${EXT_INDENTATION}" + write_tags "${FIELDS}" fi if [[ "${REMOTELY_MANAGED}" == "true" ]]; then @@ -816,7 +817,7 @@ function setup_config_darwin() { echo -e "Creating remote configurations directory (${REMOTE_CONFIG_DIRECTORY})" mkdir -p "${REMOTE_CONFIG_DIRECTORY}" - write_opamp_extension "${config_path}" "${REMOTE_CONFIG_DIRECTORY}" "${INDENTATION}" "${EXT_INDENTATION}" "${OPAMP_API_URL}" + write_opamp_extension write_remote_config_launchd "${LAUNCHD_CONFIG}" @@ -1196,19 +1197,7 @@ function add_extension_to_config() { # write sumologic extension to user configuration file function write_sumologic_extension() { - local file - readonly file="${1}" - - local indentation - readonly indentation="${2}" - - if sed -e '/^extensions/,/^[a-z]/!d' "${file}" | grep -qE '^\s+(sumologic|sumologic\/.*):\s*$'; then - return - fi - - # add sumologic extension on the top of the extensions - sed -i.bak -e "s/extensions:/extensions:\\ -${indentation}sumologic:/" "${file}" + otelcol-config --write-kv '.extensions.sumologic = {}' } # write installation token to user configuration file @@ -1216,30 +1205,7 @@ function write_installation_token() { local token readonly token="${1}" - local file - readonly file="${2}" - - local ext_indentation - readonly ext_indentation="${3}" - - # ToDo: ensure we override only sumologic `installation_token` - if grep "installation_token" "${file}" > /dev/null; then - # Do not expose token in sed command as it can be saw on processes list - echo "s/installation_token:.*$/installation_token: $(escape_sed "${token}")/" | sed -i.bak -f - "${file}" - - return - fi - - # ToDo: ensure we override only sumologic `install_token` - if grep "install_token" "${file}" > /dev/null; then - # Do not expose token in sed command as it can be saw on processes list - echo "s/install_token:.*$/installation_token: $(escape_sed "${token}")/" | sed -i.bak -f - "${file}" - else - # write installation token on the top of sumologic: extension - # Do not expose token in sed command as it can be saw on processes list - echo "1,/sumologic:/ s/sumologic:/sumologic:\\ -\\${ext_indentation}installation_token: $(escape_sed "${token}")/" | sed -i.bak -f - "${file}" - fi + otelcol-config --set-installation-token "$token" } # write ${ENV_TOKEN}" to systemd env configuration file @@ -1315,19 +1281,7 @@ function write_remote_config_launchd() { # write sumologic ephemeral: true to user configuration file function write_ephemeral_true() { - local file - readonly file="${1}" - - local ext_indentation - readonly ext_indentation="${2}" - - if grep "ephemeral:" "${file}" > /dev/null; then - sed -i.bak -e "1,/ephemeral:/ s/ephemeral:.*$/ephemeral: true/" "${file}" - else - # write ephemeral: true on the top of sumologic: extension - sed -i.bak -e "1,/sumologic:/ s/sumologic:/sumologic:\\ -\\${ext_indentation}ephemeral: true/" "${file}" - fi + otelcol-config --enable-ephemeral } # write api_url to user configuration file @@ -1335,20 +1289,7 @@ function write_api_url() { local api_url readonly api_url="${1}" - local file - readonly file="${2}" - - local ext_indentation - readonly ext_indentation="${3}" - - # ToDo: ensure we override only sumologic `api_base_url` - if grep "api_base_url" "${file}" > /dev/null; then - sed -i.bak -e "s/api_base_url:.*$/api_base_url: $(escape_sed "${api_url}")/" "${file}" - else - # write api_url on the top of sumologic: extension - sed -i.bak -e "1,/sumologic:/ s/sumologic:/sumologic:\\ -\\${ext_indentation}api_base_url: $(escape_sed "${api_url}")/" "${file}" - fi + otelcol-config --set-api-url "$api_url" } # write opamp endpoint to user configuration file @@ -1356,20 +1297,7 @@ function write_opamp_endpoint() { local opamp_endpoint readonly opamp_endpoint="${1}" - local file - readonly file="${2}" - - local ext_indentation - readonly ext_indentation="${3}" - - # ToDo: ensure we override only sumologic `api_base_url` - if grep "endpoint" "${file}" > /dev/null; then - sed -i.bak -e "s/endpoint:.*$/endpoint: $(escape_sed "${opamp_endpoint}")/" "${file}" - else - # write endpoint on the top of sumologic: opamp: extension - sed -i.bak -e "1,/opamp:/ s/opamp:/opamp:\\ -\\${ext_indentation}endpoint: $(escape_sed "${opamp_endpoint}")/" "${file}" - fi + otelcol-config --set-opamp-endpoint "$opamp_endpoint" } # write tags to user configuration file @@ -1377,78 +1305,15 @@ function write_tags() { local fields readonly fields="${1}" - local file - readonly file="${2}" - - local indentation - readonly indentation="${3}" - - local ext_indentation - readonly ext_indentation="${4}" - - local fields_indentation - readonly fields_indentation="${ext_indentation}${indentation}" - - local fields_to_write - fields_to_write="$(echo "${fields}" | sed -e "s/^\\([^\\]\\)/${fields_indentation}\\1/")" - readonly fields_to_write - - # ToDo: ensure we override only sumologic `collector_fields` - if grep "collector_fields" "${file}" > /dev/null; then - sed -i.bak -e "s/collector_fields:.*$/collector_fields: ${fields_to_write}/" "${file}" - else - # write installation token on the top of sumologic: extension - sed -i.bak -e "1,/sumologic:/ s/sumologic:/sumologic:\\ -\\${ext_indentation}collector_fields: ${fields_to_write}/" "${file}" - fi + for field in $fields + do + otelcol-config --add-tag "$field" + done } # configure and enable the opamp extension for remote management function write_opamp_extension() { - local file - readonly file="${1}" - - local directory - readonly directory="${2}" - - local indentation - readonly indentation="${3}" - - local ext_indentation - readonly ext_indentation="${4}" - - local api_url - readonly api_url="${5}" - - # add opamp extension if its missing - if ! grep "opamp:" "${file}" > /dev/null; then - sed -i.bak -e "1,/extensions:/ s/extensions:/extensions:\\ -${indentation}opamp:/" "${file}" - fi - - # set the remote_configuration_directory - if grep "remote_configuration_directory:" "${file}" > /dev/null; then - sed -i.bak -e "s/remote_configuration_directory:.*$/remote_configuration_directory: $(escape_sed "${directory}")/" "${file}" - else - sed -i.bak -e "s/opamp:/opamp:\\ -\\${ext_indentation}remote_configuration_directory: $(escape_sed "${directory}")/" "${file}" - fi - - # if a different base url is specified, configure the corresponding opamp endpoint - if [[ -n "${api_url}" ]]; then - if grep "endpoint: wss:" "${file}" > /dev/null; then - sed -i.bak -e "s/endpoint: wss:.*$/endpoint: $(escape_sed "${api_url}")/" "${file}" - else - sed -i.bak -e "s/opamp:/opamp:\\ -\\${ext_indentation}endpoint: $(escape_sed "${api_url}")/" "${file}" - fi - fi - - # enable the opamp extension - if ! grep "\- opamp" "${file}" > /dev/null; then - sed -i.bak -e "s/${indentation}extensions:/${indentation}extensions:\\ -\\${ext_indentation}- opamp/" "${file}" - fi + otelcol-config --enable-remote-control } function get_binary_from_branch() { From 97a27a490d84476d1ea190ce8a005f8f00bbeef6 Mon Sep 17 00:00:00 2001 From: Cyril Cressent Date: Tue, 27 Aug 2024 15:06:46 -0700 Subject: [PATCH 02/32] Remove write_sumologic_extension --- install-script/install.sh | 8 -------- 1 file changed, 8 deletions(-) diff --git a/install-script/install.sh b/install-script/install.sh index 489a55a0..e3aa1657 100755 --- a/install-script/install.sh +++ b/install-script/install.sh @@ -691,7 +691,6 @@ function setup_config() { echo -e "Creating remote configurations directory (${REMOTE_CONFIG_DIRECTORY})" mkdir -p "${REMOTE_CONFIG_DIRECTORY}" - write_sumologic_extension write_opamp_extension if [[ -n "${SUMOLOGIC_INSTALLATION_TOKEN}" && "${SYSTEMD_DISABLED}" == "true" ]]; then @@ -751,7 +750,6 @@ function setup_config() { if [[ ( -n "${SUMOLOGIC_INSTALLATION_TOKEN}" && "${SYSTEMD_DISABLED}" == "true" ) || -n "${API_BASE_URL}" || -n "${FIELDS}" || "${EPHEMERAL}" == "true" ]]; then create_user_config_file "${COMMON_CONFIG_PATH}" add_extension_to_config "${COMMON_CONFIG_PATH}" - write_sumologic_extension if [[ -n "${SUMOLOGIC_INSTALLATION_TOKEN}" && -z "${USER_TOKEN}" && "${SYSTEMD_DISABLED}" == "true" ]]; then write_installation_token "${SUMOLOGIC_INSTALLATION_TOKEN}" @@ -796,7 +794,6 @@ function setup_config_darwin() { create_user_config_file "${config_path}" add_extension_to_config "${config_path}" - write_sumologic_extension if [[ "${EPHEMERAL}" == "true" ]]; then write_ephemeral_true @@ -1195,11 +1192,6 @@ function add_extension_to_config() { | tee -a "${file}" > /dev/null 2>&1 } -# write sumologic extension to user configuration file -function write_sumologic_extension() { - otelcol-config --write-kv '.extensions.sumologic = {}' -} - # write installation token to user configuration file function write_installation_token() { local token From 0eb43e2a1f7bd50887f9263e7813740e8bb321a9 Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Wed, 28 Aug 2024 12:19:18 -0700 Subject: [PATCH 03/32] Install otelcol-config in test-install-script job (#111) Signed-off-by: Eric Chlebek --- .github/workflows/test-install-script.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test-install-script.yml b/.github/workflows/test-install-script.yml index fe938566..15eefe6a 100644 --- a/.github/workflows/test-install-script.yml +++ b/.github/workflows/test-install-script.yml @@ -41,6 +41,9 @@ jobs: with: go-version: stable + - name: Install otelcol-config + run: go install github.com/SumoLogic/sumologic-otel-collector/pkg/tools/otelcol-config@latest + - name: Run install script tests if: steps.changed-files.outputs.any_changed == 'true' working-directory: install-script/test From 39797a7f243bce9eb636276678ed489cad3a0b1c Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Wed, 28 Aug 2024 13:19:03 -0700 Subject: [PATCH 04/32] Remove the concept of systemd from install script Signed-off-by: Eric Chlebek --- install-script/test/check_linux.go | 36 --- install-script/test/command_unix.go | 5 - install-script/test/consts_linux.go | 6 +- .../test/install_linux_amd64_test.go | 1 - install-script/test/install_unix_test.go | 261 ++---------------- install-script/test/systemd.go | 14 - 6 files changed, 26 insertions(+), 297 deletions(-) delete mode 100644 install-script/test/systemd.go diff --git a/install-script/test/check_linux.go b/install-script/test/check_linux.go index 97f4da9a..2b597704 100644 --- a/install-script/test/check_linux.go +++ b/install-script/test/check_linux.go @@ -90,35 +90,6 @@ func checkOutputUserAddWarnings(c check) { require.NotContains(c.test, errOutput, "useradd", "unexpected useradd output") } -func checkSystemdAvailability(c check) bool { - return assert.DirExists(&testing.T{}, systemdDirectoryPath, "systemd is not supported") -} - -func checkSystemdConfigCreated(c check) { - require.FileExists(c.test, systemdPath, "systemd configuration has not been created properly") -} - -func checkSystemdConfigNotCreated(c check) { - require.NoFileExists(c.test, systemdPath, "systemd configuration has been created") -} - -func checkSystemdEnvDirExists(c check) { - require.DirExists(c.test, etcPath+"/env", "systemd env directory does not exist") -} - -func checkSystemdEnvDirPermissions(c check) { - PathHasPermissions(c.test, etcPath+"/env", configPathDirPermissions) -} - -func checkRemoteFlagInSystemdFile(c check) { - contents, err := getSystemdConfig(systemdPath) - - require.NoError(c.test, err) - - assert.Contains(c.test, contents, "--remote-config") - assert.NotContains(c.test, contents, "--config") -} - func checkTokenEnvFileCreated(c check) { require.FileExists(c.test, tokenEnvFilePath, "env token file has not been created") } @@ -259,13 +230,6 @@ func preActionMockStructure(c check) { require.NoError(c.test, err) } -func preActionMockSystemdStructure(c check) { - preActionMockStructure(c) - - _, err := os.Create(systemdPath) - require.NoError(c.test, err) -} - func preActionWriteDefaultAPIBaseURLToUserConfig(c check) { conf, err := getConfig(userConfigPath) require.NoError(c.test, err) diff --git a/install-script/test/command_unix.go b/install-script/test/command_unix.go index 3cbfa8d8..6747cb43 100644 --- a/install-script/test/command_unix.go +++ b/install-script/test/command_unix.go @@ -16,7 +16,6 @@ import ( type installOptions struct { installToken string autoconfirm bool - skipSystemd bool tags map[string]string skipConfig bool skipInstallToken bool @@ -48,10 +47,6 @@ func (io *installOptions) string() []string { opts = append(opts, "--fips") } - if io.skipSystemd { - opts = append(opts, "--skip-systemd") - } - if io.skipConfig { opts = append(opts, "--skip-config") } diff --git a/install-script/test/consts_linux.go b/install-script/test/consts_linux.go index 2b7ae23a..0221221b 100644 --- a/install-script/test/consts_linux.go +++ b/install-script/test/consts_linux.go @@ -1,10 +1,8 @@ package sumologic_scripts_tests const ( - envDirectoryPath string = etcPath + "/env" - systemdDirectoryPath string = "/run/systemd/system" - systemdPath string = "/etc/systemd/system/otelcol-sumo.service" - tokenEnvFilePath string = envDirectoryPath + "/token.env" + envDirectoryPath string = etcPath + "/env" + tokenEnvFilePath string = envDirectoryPath + "/token.env" // TODO: fix mismatch between package permissions & expected permissions commonConfigPathFilePermissions uint32 = 0550 diff --git a/install-script/test/install_linux_amd64_test.go b/install-script/test/install_linux_amd64_test.go index 2aa4f690..f97b7bc0 100644 --- a/install-script/test/install_linux_amd64_test.go +++ b/install-script/test/install_linux_amd64_test.go @@ -31,7 +31,6 @@ func TestInstallScriptLinuxAmd64(t *testing.T) { checkBinaryIsFIPS, checkConfigNotCreated, checkUserConfigNotCreated, - checkSystemdConfigNotCreated, checkUserNotExists, }, }, diff --git a/install-script/test/install_unix_test.go b/install-script/test/install_unix_test.go index f4f6231d..f050d5ce 100644 --- a/install-script/test/install_unix_test.go +++ b/install-script/test/install_unix_test.go @@ -21,7 +21,7 @@ func TestInstallScript(t *testing.T) { downloadOnly: true, }, preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkSystemdConfigNotCreated, checkUserNotExists}, + postChecks: []checkFunc{checkBinaryCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, }, { name: "download only with timeout", @@ -33,7 +33,7 @@ func TestInstallScript(t *testing.T) { // Skip this test as getting binary in github actions takes less than one second conditionalChecks: []condCheckFunc{checkSkipTest}, preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkSystemdConfigNotCreated, checkUserNotExists, + postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists, checkDownloadTimeout}, installCode: curlTimeoutErrorCode, }, @@ -58,7 +58,6 @@ func TestInstallScript(t *testing.T) { checkConfigCreated, checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), checkUserConfigNotCreated, - checkSystemdConfigNotCreated, }, }, { @@ -69,13 +68,11 @@ func TestInstallScript(t *testing.T) { }, preActions: []checkFunc{preActionMockConfig}, preChecks: []checkFunc{checkBinaryNotCreated, checkConfigCreated, checkUserConfigNotCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkConfigOverrided, checkUserConfigNotCreated, - checkSystemdConfigNotCreated}, + postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkConfigOverrided, checkUserConfigNotCreated}, }, { name: "installation token only", options: installOptions{ - skipSystemd: true, installToken: installToken, }, preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, @@ -88,7 +85,7 @@ func TestInstallScript(t *testing.T) { checkUserConfigCreated, checkEphemeralNotInConfig(userConfigPath), checkTokenInConfig, - checkSystemdConfigNotCreated, + checkUserNotExists, checkHostmetricsConfigNotCreated, checkTokenEnvFileNotCreated, @@ -97,7 +94,6 @@ func TestInstallScript(t *testing.T) { { name: "installation token and ephemeral", options: installOptions{ - skipSystemd: true, installToken: installToken, ephemeral: true, }, @@ -110,7 +106,7 @@ func TestInstallScript(t *testing.T) { checkUserConfigCreated, checkTokenInConfig, checkEphemeralInConfig(userConfigPath), - checkSystemdConfigNotCreated, + checkUserNotExists, checkHostmetricsConfigNotCreated, checkTokenEnvFileNotCreated, @@ -119,7 +115,6 @@ func TestInstallScript(t *testing.T) { { name: "installation token and hostmetrics", options: installOptions{ - skipSystemd: true, installToken: installToken, installHostmetrics: true, }, @@ -132,7 +127,7 @@ func TestInstallScript(t *testing.T) { checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), checkUserConfigCreated, checkTokenInConfig, - checkSystemdConfigNotCreated, + checkUserNotExists, checkHostmetricsConfigCreated, checkHostmetricsOwnershipAndPermissions(rootUser, rootGroup), @@ -141,7 +136,6 @@ func TestInstallScript(t *testing.T) { { name: "installation token and remotely-managed", options: installOptions{ - skipSystemd: true, installToken: installToken, remotelyManaged: true, }, @@ -154,14 +148,13 @@ func TestInstallScript(t *testing.T) { checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), checkTokenInSumoConfig, checkEphemeralNotInConfig(configPath), - checkSystemdConfigNotCreated, + checkUserNotExists, }, }, { name: "installation token, remotely-managed, and ephemeral", options: installOptions{ - skipSystemd: true, installToken: installToken, remotelyManaged: true, ephemeral: true, @@ -175,14 +168,13 @@ func TestInstallScript(t *testing.T) { checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), checkTokenInSumoConfig, checkEphemeralInConfig(configPath), - checkSystemdConfigNotCreated, + checkUserNotExists, }, }, { name: "installation token, remotely-managed, and opamp-api", options: installOptions{ - skipSystemd: true, installToken: installToken, remotelyManaged: true, opampEndpoint: "wss://example.com", @@ -196,7 +188,7 @@ func TestInstallScript(t *testing.T) { checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), checkTokenInSumoConfig, checkEphemeralNotInConfig(configPath), - checkSystemdConfigNotCreated, + checkUserNotExists, checkOpAmpEndpointSet, }, @@ -204,7 +196,6 @@ func TestInstallScript(t *testing.T) { { name: "installation token only, binary not in PATH", options: installOptions{ - skipSystemd: true, installToken: installToken, envs: map[string]string{ "PATH": "/sbin:/bin:/usr/sbin:/usr/bin", @@ -218,112 +209,99 @@ func TestInstallScript(t *testing.T) { checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), checkUserConfigCreated, checkTokenInConfig, - checkSystemdConfigNotCreated, + checkUserNotExists, }, }, { name: "same installation token", options: installOptions{ - skipSystemd: true, installToken: installToken, }, preActions: []checkFunc{preActionMockUserConfig, preActionWriteTokenToUserConfig}, preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkUserConfigCreated, checkTokenInConfig, checkSystemdConfigNotCreated}, + postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkUserConfigCreated, checkTokenInConfig}, }, { name: "different installation token", options: installOptions{ - skipSystemd: true, installToken: installToken, }, preActions: []checkFunc{preActionMockUserConfig, preActionWriteDifferentTokenToUserConfig}, preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkSystemdConfigNotCreated, checkAbortedDueToDifferentToken}, + postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkAbortedDueToDifferentToken}, installCode: 1, }, { name: "adding installation token", options: installOptions{ - skipSystemd: true, installToken: installToken, }, preActions: []checkFunc{preActionMockUserConfig}, preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkTokenInConfig, checkSystemdConfigNotCreated}, + postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkTokenInConfig}, }, { name: "editing installation token", options: installOptions{ - skipSystemd: true, apiBaseURL: apiBaseURL, installToken: installToken, }, preActions: []checkFunc{preActionMockUserConfig, preActionWriteEmptyUserConfig}, preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkTokenInConfig, checkSystemdConfigNotCreated}, + postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkTokenInConfig}, }, { name: "same api base url", options: installOptions{ - skipSystemd: true, apiBaseURL: apiBaseURL, skipInstallToken: true, }, preActions: []checkFunc{preActionMockUserConfig, preActionWriteAPIBaseURLToUserConfig}, preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkUserConfigCreated, checkAPIBaseURLInConfig, - checkSystemdConfigNotCreated}, + postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkUserConfigCreated, checkAPIBaseURLInConfig}, }, { name: "different api base url", options: installOptions{ - skipSystemd: true, apiBaseURL: apiBaseURL, skipInstallToken: true, }, preActions: []checkFunc{preActionMockUserConfig, preActionWriteDifferentAPIBaseURLToUserConfig}, preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkSystemdConfigNotCreated, + postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkAbortedDueToDifferentAPIBaseURL}, installCode: 1, }, { name: "adding api base url", options: installOptions{ - skipSystemd: true, apiBaseURL: apiBaseURL, skipInstallToken: true, }, preActions: []checkFunc{preActionMockUserConfig}, preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkAPIBaseURLInConfig, checkSystemdConfigNotCreated}, + postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkAPIBaseURLInConfig}, }, { name: "editing api base url", options: installOptions{ - skipSystemd: true, apiBaseURL: apiBaseURL, skipInstallToken: true, }, preActions: []checkFunc{preActionMockUserConfig, preActionWriteEmptyUserConfig}, preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkAPIBaseURLInConfig, checkSystemdConfigNotCreated}, + postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkAPIBaseURLInConfig}, }, { - name: "empty installation token", - options: installOptions{ - skipSystemd: true, - }, + name: "empty installation token", preActions: []checkFunc{preActionMockUserConfig, preActionWriteDifferentTokenToUserConfig}, preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkSystemdConfigNotCreated, checkDifferentTokenInConfig}, + postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkDifferentTokenInConfig}, }, { name: "configuration with tags", options: installOptions{ - skipSystemd: true, skipInstallToken: true, tags: map[string]string{ "lorem": "ipsum", @@ -340,13 +318,11 @@ func TestInstallScript(t *testing.T) { checkConfigCreated, checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), checkTags, - checkSystemdConfigNotCreated, }, }, { name: "same tags", options: installOptions{ - skipSystemd: true, skipInstallToken: true, tags: map[string]string{ "lorem": "ipsum", @@ -358,13 +334,11 @@ func TestInstallScript(t *testing.T) { }, preActions: []checkFunc{preActionMockUserConfig, preActionWriteTagsToUserConfig}, preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkUserConfigCreated, checkTags, - checkSystemdConfigNotCreated}, + postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkUserConfigCreated, checkTags}, }, { name: "different tags", options: installOptions{ - skipSystemd: true, skipInstallToken: true, tags: map[string]string{ "lorem": "ipsum", @@ -376,14 +350,13 @@ func TestInstallScript(t *testing.T) { }, preActions: []checkFunc{preActionMockUserConfig, preActionWriteDifferentTagsToUserConfig}, preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkDifferentTags, checkSystemdConfigNotCreated, + postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkDifferentTags, checkAbortedDueToDifferentTags}, installCode: 1, }, { name: "editing tags", options: installOptions{ - skipSystemd: true, skipInstallToken: true, tags: map[string]string{ "lorem": "ipsum", @@ -395,138 +368,7 @@ func TestInstallScript(t *testing.T) { }, preActions: []checkFunc{preActionMockUserConfig, preActionWriteEmptyUserConfig}, preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkTags, checkSystemdConfigNotCreated}, - }, - { - name: "systemd", - options: installOptions{ - installToken: installToken, - }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists, checkTokenEnvFileNotCreated}, - postChecks: []checkFunc{ - checkBinaryCreated, - checkBinaryIsRunning, - checkConfigCreated, - checkConfigFilesOwnershipAndPermissions(systemUser, systemUser), - checkUserConfigNotCreated, - checkSystemdConfigCreated, - checkSystemdEnvDirExists, - checkSystemdEnvDirPermissions, - checkTokenEnvFileCreated, - checkTokenInEnvFile, - checkUserExists, - checkVarLogACL, - }, - conditionalChecks: []condCheckFunc{checkSystemdAvailability}, - installCode: 1, // because of invalid installation token - }, - { - name: "systemd installation token with existing user directory", - options: installOptions{ - installToken: installToken, - }, - preActions: []checkFunc{preActionCreateHomeDirectory}, - preChecks: []checkFunc{ - checkBinaryNotCreated, - checkConfigNotCreated, - checkUserConfigNotCreated, - checkUserNotExists, - checkHomeDirectoryCreated, - }, - postChecks: []checkFunc{ - checkBinaryCreated, - checkBinaryIsRunning, - checkConfigCreated, - checkConfigFilesOwnershipAndPermissions(systemUser, systemUser), - checkSystemdConfigCreated, - checkSystemdEnvDirExists, - checkSystemdEnvDirPermissions, - checkTokenEnvFileCreated, - checkTokenInEnvFile, - checkUserExists, - checkVarLogACL, - checkOutputUserAddWarnings, - }, - conditionalChecks: []condCheckFunc{checkSystemdAvailability}, - installCode: 1, // because of invalid install token - }, - { - name: "systemd existing installation different token env", - options: installOptions{ - installToken: installToken, - }, - preActions: []checkFunc{preActionCreateHomeDirectory}, - preChecks: []checkFunc{ - checkBinaryNotCreated, - checkConfigNotCreated, - checkUserConfigNotCreated, - checkUserNotExists, - checkHomeDirectoryCreated, - }, - postChecks: []checkFunc{ - checkBinaryCreated, - checkBinaryIsRunning, - checkConfigCreated, - checkConfigFilesOwnershipAndPermissions(systemUser, systemUser), - checkSystemdConfigCreated, - checkSystemdEnvDirExists, - checkSystemdEnvDirPermissions, - checkTokenEnvFileCreated, - checkTokenInEnvFile, - checkUserExists, - checkVarLogACL, - checkOutputUserAddWarnings, - }, - conditionalChecks: []condCheckFunc{checkSystemdAvailability}, - installCode: 1, // because of invalid install token - }, - { - name: "installation of hostmetrics in systemd during upgrade", - options: installOptions{ - installToken: installToken, - installHostmetrics: true, - apiBaseURL: "http://127.0.0.1:3333", - }, - preActions: []checkFunc{preActionMockSystemdStructure, preActionCreateUser}, - conditionalChecks: []condCheckFunc{checkSystemdAvailability}, - preChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkUserExists}, - postChecks: []checkFunc{ - checkBinaryCreated, - checkBinaryIsRunning, - checkConfigCreated, - checkUserConfigCreated, - checkTokenInEnvFile, - checkUserExists, - checkHostmetricsConfigCreated, - checkHostmetricsOwnershipAndPermissions(systemUser, systemUser), - checkRemoteConfigDirectoryNotCreated, - }, - }, - { - name: "systemd with remotely-managed", - options: installOptions{ - installToken: installToken, - remotelyManaged: true, - }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists, checkTokenEnvFileNotCreated}, - postChecks: []checkFunc{ - checkBinaryCreated, - checkBinaryIsRunning, - checkConfigCreated, - checkRemoteConfigDirectoryCreated, - checkConfigFilesOwnershipAndPermissions(systemUser, systemUser), - checkUserConfigNotCreated, - checkSystemdConfigCreated, - checkRemoteFlagInSystemdFile, - checkSystemdEnvDirExists, - checkSystemdEnvDirPermissions, - checkTokenEnvFileCreated, - checkTokenInEnvFile, - checkUserExists, - checkVarLogACL, - }, - conditionalChecks: []condCheckFunc{checkSystemdAvailability}, - installCode: 1, // because of invalid installation token + postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkTags}, }, { name: "uninstallation without autoconfirm fails", @@ -548,17 +390,6 @@ func TestInstallScript(t *testing.T) { preChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkUserNotExists}, postChecks: []checkFunc{checkBinaryNotCreated, checkConfigCreated, checkUserConfigCreated, checkUninstallationOutput}, }, - { - name: "systemd uninstallation", - options: installOptions{ - autoconfirm: true, - uninstall: true, - }, - preActions: []checkFunc{preActionMockSystemdStructure}, - preChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkSystemdConfigCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryNotCreated, checkConfigCreated, checkUserConfigCreated, checkSystemdConfigCreated, checkUserNotExists}, - conditionalChecks: []condCheckFunc{checkSystemdAvailability}, - }, { name: "purge", options: installOptions{ @@ -570,50 +401,6 @@ func TestInstallScript(t *testing.T) { preChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkUserNotExists}, postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, }, - { - name: "systemd purge", - options: installOptions{ - uninstall: true, - purge: true, - autoconfirm: true, - }, - preActions: []checkFunc{preActionMockSystemdStructure}, - preChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkSystemdConfigCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkSystemdConfigNotCreated, checkUserNotExists}, - conditionalChecks: []condCheckFunc{checkSystemdAvailability}, - }, - { - name: "systemd creation if token in file", - options: installOptions{}, - preActions: []checkFunc{preActionMockUserConfig, preActionWriteDifferentTokenToUserConfig, preActionWriteDefaultAPIBaseURLToUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkDifferentTokenInConfig, checkSystemdConfigCreated, - checkUserExists, checkTokenEnvFileNotCreated}, - conditionalChecks: []condCheckFunc{checkSystemdAvailability}, - installCode: 1, // because of invalid installation token - }, - { - name: "systemd installation if token in file", - options: installOptions{ - installToken: installToken, - }, - preActions: []checkFunc{preActionWriteDifferentTokenToEnvFile}, - preChecks: []checkFunc{checkBinaryNotCreated, checkUserConfigNotCreated, checkUserNotExists}, - postChecks: []checkFunc{checkDifferentTokenInEnvFile, checkAbortedDueToDifferentToken}, - conditionalChecks: []condCheckFunc{checkSystemdAvailability}, - installCode: 1, // because of invalid installation token - }, - { - name: "systemd installation if deprecated token in file", - options: installOptions{ - installToken: installToken, - }, - preActions: []checkFunc{preActionWriteDifferentDeprecatedTokenToEnvFile}, - preChecks: []checkFunc{checkBinaryNotCreated, checkUserConfigNotCreated, checkUserNotExists}, - postChecks: []checkFunc{checkDifferentTokenInEnvFile, checkAbortedDueToDifferentToken}, - conditionalChecks: []condCheckFunc{checkSystemdAvailability}, - installCode: 1, // because of invalid installation token - }, { name: "don't keep downloads", options: installOptions{ @@ -621,7 +408,7 @@ func TestInstallScript(t *testing.T) { dontKeepDownloads: true, }, preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkUserConfigNotCreated, checkSystemdConfigNotCreated}, + postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkUserConfigNotCreated}, }, } { t.Run(spec.name, func(t *testing.T) { diff --git a/install-script/test/systemd.go b/install-script/test/systemd.go deleted file mode 100644 index fc912bf7..00000000 --- a/install-script/test/systemd.go +++ /dev/null @@ -1,14 +0,0 @@ -package sumologic_scripts_tests - -import ( - "os" -) - -func getSystemdConfig(path string) (string, error) { - systemdFile, err := os.ReadFile(path) - if err != nil { - return "", err - } - - return string(systemdFile), nil -} From a6885de536092f860fcb13c6661091568ec2e09b Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Wed, 28 Aug 2024 15:18:20 -0700 Subject: [PATCH 05/32] Remove systemd concepts Signed-off-by: Eric Chlebek --- install-script/install.sh | 178 +++----------------------------------- 1 file changed, 13 insertions(+), 165 deletions(-) diff --git a/install-script/install.sh b/install-script/install.sh index e3aa1657..8b9ef419 100755 --- a/install-script/install.sh +++ b/install-script/install.sh @@ -21,8 +21,6 @@ ARG_SHORT_FIPS='f' ARG_LONG_FIPS='fips' ARG_SHORT_YES='y' ARG_LONG_YES='yes' -ARG_SHORT_SKIP_SYSTEMD='d' -ARG_LONG_SKIP_SYSTEMD='skip-systemd' ARG_SHORT_SKIP_CONFIG='s' ARG_LONG_SKIP_CONFIG='skip-config' ARG_SHORT_UNINSTALL='u' @@ -58,7 +56,7 @@ PACKAGE_GITHUB_REPO="sumologic-otel-collector-packaging" readonly ARG_SHORT_TOKEN ARG_LONG_TOKEN ARG_SHORT_HELP ARG_LONG_HELP ARG_SHORT_API ARG_LONG_API readonly ARG_SHORT_TAG ARG_LONG_TAG ARG_SHORT_VERSION ARG_LONG_VERSION ARG_SHORT_YES ARG_LONG_YES -readonly ARG_SHORT_SKIP_SYSTEMD ARG_LONG_SKIP_SYSTEMD ARG_SHORT_UNINSTALL ARG_LONG_UNINSTALL +readonly ARG_SHORT_UNINSTALL ARG_LONG_UNINSTALL readonly ARG_SHORT_PURGE ARG_LONG_PURGE ARG_SHORT_DOWNLOAD ARG_LONG_DOWNLOAD readonly ARG_SHORT_CONFIG_BRANCH ARG_LONG_CONFIG_BRANCH ARG_SHORT_BINARY_BRANCH ARG_LONG_CONFIG_BRANCH readonly ARG_SHORT_BRANCH ARG_LONG_BRANCH ARG_SHORT_SKIP_CONFIG ARG_LONG_SKIP_CONFIG @@ -92,7 +90,6 @@ HOME_DIRECTORY="" CONFIG_DIRECTORY="" USER_CONFIG_DIRECTORY="" USER_ENV_DIRECTORY="" -SYSTEMD_CONFIG="" UNINSTALL="" SUMO_BINARY_PATH="" SKIP_TOKEN="" @@ -128,9 +125,6 @@ KEEP_DOWNLOADS=false CURL_MAX_TIME=1800 -# set by check_dependencies therefore cannot be set by set_defaults -SYSTEMD_DISABLED=false - ############################ Functions function usage() { @@ -154,7 +148,6 @@ Supported arguments: -${ARG_SHORT_API}, --${ARG_LONG_API} API URL, forces the collector to use non-default API -${ARG_SHORT_OPAMP_API}, --${ARG_LONG_OPAMP_API} OpAmp API URL, forces the collector to use non-default OpAmp API - -${ARG_SHORT_SKIP_SYSTEMD}, --${ARG_LONG_SKIP_SYSTEMD} Do not install systemd unit. -${ARG_SHORT_SKIP_CONFIG}, --${ARG_LONG_SKIP_CONFIG} Do not create default configuration. -${ARG_SHORT_VERSION}, --${ARG_LONG_VERSION} Version of Sumo Logic Distribution for OpenTelemetry Collector to install, e.g. 0.57.2-sumo-1. By default it gets latest version. @@ -177,7 +170,6 @@ function set_defaults() { FILE_STORAGE="${HOME_DIRECTORY}/file_storage" DOWNLOAD_CACHE_DIR="/var/cache/otelcol-sumo" # this is in case we want to keep downloaded binaries CONFIG_DIRECTORY="/etc/otelcol-sumo" - SYSTEMD_CONFIG="/etc/systemd/system/otelcol-sumo.service" SUMO_BINARY_PATH="/usr/local/bin/otelcol-sumo" USER_CONFIG_DIRECTORY="${CONFIG_DIRECTORY}/conf.d" REMOTE_CONFIG_DIRECTORY="${CONFIG_DIRECTORY}/opamp.d" @@ -235,9 +227,6 @@ function parse_options() { "--${ARG_LONG_FIPS}") set -- "$@" "-${ARG_SHORT_FIPS}" ;; - "--${ARG_LONG_SKIP_SYSTEMD}") - set -- "$@" "-${ARG_SHORT_SKIP_SYSTEMD}" - ;; "--${ARG_LONG_UNINSTALL}") set -- "$@" "-${ARG_SHORT_UNINSTALL}" ;; @@ -269,7 +258,7 @@ function parse_options() { "--${ARG_LONG_TIMEOUT}") set -- "$@" "-${ARG_SHORT_TIMEOUT}" ;; - "-${ARG_SHORT_TOKEN}"|"-${ARG_SHORT_HELP}"|"-${ARG_SHORT_API}"|"-${ARG_SHORT_OPAMP_API}"|"-${ARG_SHORT_TAG}"|"-${ARG_SHORT_SKIP_CONFIG}"|"-${ARG_SHORT_VERSION}"|"-${ARG_SHORT_FIPS}"|"-${ARG_SHORT_YES}"|"-${ARG_SHORT_SKIP_SYSTEMD}"|"-${ARG_SHORT_UNINSTALL}"|"-${ARG_SHORT_PURGE}"|"-${ARG_SHORT_SKIP_TOKEN}"|"-${ARG_SHORT_DOWNLOAD}"|"-${ARG_SHORT_CONFIG_BRANCH}"|"-${ARG_SHORT_BINARY_BRANCH}"|"-${ARG_SHORT_BRANCH}"|"-${ARG_SHORT_KEEP_DOWNLOADS}"|"-${ARG_SHORT_TIMEOUT}"|"-${ARG_SHORT_INSTALL_HOSTMETRICS}"|"-${ARG_SHORT_REMOTELY_MANAGED}"|"-${ARG_SHORT_EPHEMERAL}") + "-${ARG_SHORT_TOKEN}"|"-${ARG_SHORT_HELP}"|"-${ARG_SHORT_API}"|"-${ARG_SHORT_OPAMP_API}"|"-${ARG_SHORT_TAG}"|"-${ARG_SHORT_SKIP_CONFIG}"|"-${ARG_SHORT_VERSION}"|"-${ARG_SHORT_FIPS}"|"-${ARG_SHORT_YES}"|"-${ARG_SHORT_UNINSTALL}"|"-${ARG_SHORT_PURGE}"|"-${ARG_SHORT_SKIP_TOKEN}"|"-${ARG_SHORT_DOWNLOAD}"|"-${ARG_SHORT_CONFIG_BRANCH}"|"-${ARG_SHORT_BINARY_BRANCH}"|"-${ARG_SHORT_BRANCH}"|"-${ARG_SHORT_KEEP_DOWNLOADS}"|"-${ARG_SHORT_TIMEOUT}"|"-${ARG_SHORT_INSTALL_HOSTMETRICS}"|"-${ARG_SHORT_REMOTELY_MANAGED}"|"-${ARG_SHORT_EPHEMERAL}") set -- "$@" "${arg}" ;; "--${ARG_LONG_INSTALL_HOSTMETRICS}") @@ -293,7 +282,7 @@ function parse_options() { while true; do set +e - getopts "${ARG_SHORT_HELP}${ARG_SHORT_TOKEN}:${ARG_SHORT_API}:${ARG_SHORT_OPAMP_API}:${ARG_SHORT_TAG}:${ARG_SHORT_VERSION}:${ARG_SHORT_FIPS}${ARG_SHORT_YES}${ARG_SHORT_SKIP_SYSTEMD}${ARG_SHORT_UNINSTALL}${ARG_SHORT_PURGE}${ARG_SHORT_SKIP_TOKEN}${ARG_SHORT_SKIP_CONFIG}${ARG_SHORT_DOWNLOAD}${ARG_SHORT_KEEP_DOWNLOADS}${ARG_SHORT_CONFIG_BRANCH}:${ARG_SHORT_BINARY_BRANCH}:${ARG_SHORT_BRANCH}:${ARG_SHORT_EPHEMERAL}${ARG_SHORT_REMOTELY_MANAGED}${ARG_SHORT_INSTALL_HOSTMETRICS}${ARG_SHORT_TIMEOUT}:" opt + getopts "${ARG_SHORT_HELP}${ARG_SHORT_TOKEN}:${ARG_SHORT_API}:${ARG_SHORT_OPAMP_API}:${ARG_SHORT_TAG}:${ARG_SHORT_VERSION}:${ARG_SHORT_FIPS}${ARG_SHORT_YES}${ARG_SHORT_UNINSTALL}${ARG_SHORT_PURGE}${ARG_SHORT_SKIP_TOKEN}${ARG_SHORT_SKIP_CONFIG}${ARG_SHORT_DOWNLOAD}${ARG_SHORT_KEEP_DOWNLOADS}${ARG_SHORT_CONFIG_BRANCH}:${ARG_SHORT_BINARY_BRANCH}:${ARG_SHORT_BRANCH}:${ARG_SHORT_EPHEMERAL}${ARG_SHORT_REMOTELY_MANAGED}${ARG_SHORT_INSTALL_HOSTMETRICS}${ARG_SHORT_TIMEOUT}:" opt set -e # Invalid argument catched, print and exit @@ -313,7 +302,6 @@ function parse_options() { "${ARG_SHORT_VERSION}") VERSION="${OPTARG}" ;; "${ARG_SHORT_FIPS}") FIPS=true ;; "${ARG_SHORT_YES}") CONTINUE=true ;; - "${ARG_SHORT_SKIP_SYSTEMD}") SYSTEMD_DISABLED=true ;; "${ARG_SHORT_UNINSTALL}") UNINSTALL=true ;; "${ARG_SHORT_PURGE}") PURGE=true ;; "${ARG_SHORT_SKIP_TOKEN}") SKIP_TOKEN=true ;; @@ -423,10 +411,6 @@ function check_dependencies() { fi done - if [[ ! -d /run/systemd/system ]]; then - SYSTEMD_DISABLED=true - fi - if [[ "${error}" == "1" ]] ; then exit 1 fi @@ -693,7 +677,7 @@ function setup_config() { write_opamp_extension - if [[ -n "${SUMOLOGIC_INSTALLATION_TOKEN}" && "${SYSTEMD_DISABLED}" == "true" ]]; then + if [[ -n "${SUMOLOGIC_INSTALLATION_TOKEN}" ]]; then write_installation_token "${SUMOLOGIC_INSTALLATION_TOKEN}" fi @@ -747,11 +731,11 @@ function setup_config() { fi ## Check if there is anything to update in configuration - if [[ ( -n "${SUMOLOGIC_INSTALLATION_TOKEN}" && "${SYSTEMD_DISABLED}" == "true" ) || -n "${API_BASE_URL}" || -n "${FIELDS}" || "${EPHEMERAL}" == "true" ]]; then + if [[ -n "${SUMOLOGIC_INSTALLATION_TOKEN}" || -n "${API_BASE_URL}" || -n "${FIELDS}" || "${EPHEMERAL}" == "true" ]]; then create_user_config_file "${COMMON_CONFIG_PATH}" add_extension_to_config "${COMMON_CONFIG_PATH}" - if [[ -n "${SUMOLOGIC_INSTALLATION_TOKEN}" && -z "${USER_TOKEN}" && "${SYSTEMD_DISABLED}" == "true" ]]; then + if [[ -n "${SUMOLOGIC_INSTALLATION_TOKEN}" && -z "${USER_TOKEN}" ]]; then write_installation_token "${SUMOLOGIC_INSTALLATION_TOKEN}" fi @@ -862,40 +846,8 @@ function uninstall_darwin() { # uninstall otelcol-sumo on linux function uninstall_linux() { - local MSG - MSG="Going to remove Otelcol binary" - - if [[ "${PURGE}" == "true" ]]; then - MSG="${MSG}, user, file storage and configurations" - fi - - echo "${MSG}." - ask_to_continue - - # disable systemd service - if [[ -f "${SYSTEMD_CONFIG}" ]]; then - systemctl stop otelcol-sumo || true - systemctl disable otelcol-sumo || true - fi - - # remove binary - rm -f "${SUMO_BINARY_PATH}" - - if [[ "${PURGE}" == "true" ]]; then - # remove configuration and data - rm -rf "${CONFIG_DIRECTORY}" "${FILE_STORAGE}" "${SYSTEMD_CONFIG}" - - # remove user and group only if getent exists (it was required in order to create the user) - if command -v "getent" &> /dev/null; then - # remove user - if getent passwd "${SYSTEM_USER}" > /dev/null; then - userdel -r -f "${SYSTEM_USER}" - groupdel "${SYSTEM_USER}" 2>/dev/null || true - fi - fi - fi - - echo "Uninstallation completed" + echo "linux uninstall unimplemented" + exit 1 } function escape_sed() { @@ -1516,27 +1468,6 @@ function get_package_from_url() { fi } -function set_acl_on_log_paths() { - if command -v setfacl &> /dev/null; then - for log_path in ${ACL_LOG_FILE_PATHS}; do - if [ -d "$log_path" ]; then - echo -e "Running: setfacl -R -m d:u:${SYSTEM_USER}:r-x,u:${SYSTEM_USER}:r-x,g:${SYSTEM_USER}:r-x ${log_path}" - setfacl -R -m d:u:${SYSTEM_USER}:r-x,d:g:${SYSTEM_USER}:r-x,u:${SYSTEM_USER}:r-x,g:${SYSTEM_USER}:r-x "${log_path}" - fi - done - else - echo "" - echo "setfacl command not found, skipping ACL creation for system log file paths." - echo -e "You can fix it manually by installing setfacl and executing the following commands:" - for log_path in ${ACL_LOG_FILE_PATHS}; do - if [ -d "$log_path" ]; then - echo -e "-> setfacl -R -m d:u:${SYSTEM_USER}:r-x,d:g:${SYSTEM_USER}:r-x,u:${SYSTEM_USER}:r-x,g:${SYSTEM_USER}:r-x ${log_path}" - fi - done - echo "" - fi -} - function plutil_create_key() { local file key type value readonly file="${1}" @@ -1618,7 +1549,7 @@ set_tmpdir install_missing_dependencies check_dependencies -readonly SUMOLOGIC_INSTALLATION_TOKEN API_BASE_URL OPAMP_API_URL FIELDS CONTINUE FILE_STORAGE CONFIG_DIRECTORY SYSTEMD_CONFIG UNINSTALL +readonly SUMOLOGIC_INSTALLATION_TOKEN API_BASE_URL OPAMP_API_URL FIELDS CONTINUE FILE_STORAGE CONFIG_DIRECTORY UNINSTALL readonly USER_CONFIG_DIRECTORY USER_ENV_DIRECTORY CONFIG_DIRECTORY CONFIG_PATH COMMON_CONFIG_PATH readonly ACL_LOG_FILE_PATHS readonly INSTALL_HOSTMETRICS @@ -1632,19 +1563,10 @@ if [[ "${UNINSTALL}" == "true" ]]; then fi # Attempt to find a token from an existing installation -case "${OS_TYPE}" in -darwin) - USER_TOKEN="$(plutil_extract_key "${LAUNCHD_CONFIG}" "${LAUNCHD_TOKEN_KEY}")" - ;; -*) - USER_TOKEN="$(get_user_config "${COMMON_CONFIG_PATH}")" - - # If Systemd is not disabled, try to extract token from systemd env file - if [[ -z "${USER_TOKEN}" && "${SYSTEMD_DISABLED}" == "false" ]]; then - USER_TOKEN="$(get_user_env_config "${TOKEN_ENV_FILE}")" - fi - ;; -esac +USER_TOKEN=$(otelcol-config --read-kv .extensions.sumologic.installation_token) +if [[ -z "${USER_TOKEN}" ]]; then + USER_TOKEN="$(get_user_env_config "${TOKEN_ENV_FILE}")" +fi readonly USER_TOKEN # Exit if installation token is not set and there is no user configuration @@ -1695,13 +1617,6 @@ if [[ -n "${BINARY_BRANCH}" && -z "${GITHUB_TOKEN}" ]]; then fi set -u -# Disable systemd if token is not specified at all -if [[ -z "${SUMOLOGIC_INSTALLATION_TOKEN}" && -z "${USER_TOKEN}" ]]; then - SYSTEMD_DISABLED=true -fi - -readonly SYSTEMD_DISABLED - if [ "${FIPS}" == "true" ]; then case "${OS_TYPE}" in linux) @@ -1907,23 +1822,6 @@ if [[ "${SKIP_CONFIG}" == "false" ]]; then setup_config fi -if [[ "${SYSTEMD_DISABLED}" == "true" ]]; then - COMMAND_FLAGS="" - - if [[ "${REMOTELY_MANAGED}" == "true" ]]; then - COMMAND_FLAGS="--remote-config \"opamp:${CONFIG_PATH}\"" - else - COMMAND_FLAGS="--config=${CONFIG_PATH} --config \"glob:${CONFIG_DIRECTORY}/conf.d/*.yaml\"" - fi - - echo "" - echo Warning: running as a service is not supported on your operation system. - echo "Please use 'sudo otelcol-sumo ${COMMAND_FLAGS}' to run Sumo Logic Distribution for OpenTelemetry Collector" - exit 0 -fi - -echo 'We are going to set up a systemd service' - if [[ -n "${SUMOLOGIC_INSTALLATION_TOKEN}" && -z "${USER_TOKEN}" ]]; then echo 'Writing installation token to env file' write_installation_token_env "${SUMOLOGIC_INSTALLATION_TOKEN}" "${TOKEN_ENV_FILE}" @@ -1946,56 +1844,6 @@ else useradd "${ADDITIONAL_OPTIONS}" -rUs /bin/false -d "${HOME_DIRECTORY}" "${SYSTEM_USER}" fi -echo 'Creating ACL grants on log paths' -set_acl_on_log_paths - -if [[ "${SKIP_CONFIG}" == "false" ]]; then - echo 'Changing ownership for config and storage' - chown -R "${SYSTEM_USER}":"${SYSTEM_USER}" "${HOME_DIRECTORY}" "${CONFIG_DIRECTORY}"/* - chown -R "${SYSTEM_USER}":"${SYSTEM_USER}" "${USER_ENV_DIRECTORY}" - - if [[ "${REMOTELY_MANAGED}" == "true" ]]; then - chown -R "${SYSTEM_USER}":"${SYSTEM_USER}" "${REMOTE_CONFIG_DIRECTORY}" - fi -fi - -SYSTEMD_CONFIG_URL="https://raw.githubusercontent.com/SumoLogic/sumologic-otel-collector/${CONFIG_BRANCH}/examples/systemd/otelcol-sumo.service" - -TMP_SYSTEMD_CONFIG="${TMPDIR}/otelcol-sumo.service" -TMP_SYSTEMD_CONFIG_BAK="${TMP_SYSTEMD_CONFIG}.bak" -echo 'Getting service configuration' -curl --retry 5 --connect-timeout 5 --max-time 30 --retry-delay 0 --retry-max-time 150 -fL "${SYSTEMD_CONFIG_URL}" --output "${TMP_SYSTEMD_CONFIG}" --progress-bar -sed -i.bak -e "s%/etc/otelcol-sumo%${CONFIG_DIRECTORY}%" "${TMP_SYSTEMD_CONFIG}" -sed -i.bak -e "s%/etc/otelcol-sumo/env%${USER_ENV_DIRECTORY}%" "${TMP_SYSTEMD_CONFIG}" - -if [[ "${REMOTELY_MANAGED}" == "true" ]]; then - sed -i.bak -e "s% --config.*$% --remote-config \"opamp:${CONFIG_PATH}\"%" "${TMP_SYSTEMD_CONFIG}" -fi - -# clean up bak file -rm -f "${TMP_SYSTEMD_CONFIG_BAK}" - -mv "${TMP_SYSTEMD_CONFIG}" "${SYSTEMD_CONFIG}" - -if command -v sestatus && sestatus; then - echo "SELinux is enabled, relabeling binary and systemd unit file" - - if command -v semanage &> /dev/null; then - # Check if there's already an fcontext record for the collector bin. - if semanage fcontext -l | grep otelcol-sumo &> /dev/null; then - # Modify the existing fcontext record. - semanage fcontext -m -t bin_t /usr/local/bin/otelcol-sumo - else - # Add an fcontext record. - semanage fcontext -a -t bin_t /usr/local/bin/otelcol-sumo - fi - restorecon -v "${SUMO_BINARY_PATH}" - restorecon -v "${SYSTEMD_CONFIG}" - else - echo "semanage command not found, skipping SELinux relabeling" - fi -fi - echo 'Reloading systemd' systemctl daemon-reload From de2a294cc0b3d87e1b36cb3f03a446f2bb1f1c69 Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Wed, 28 Aug 2024 15:20:57 -0700 Subject: [PATCH 06/32] Remove unused code Signed-off-by: Eric Chlebek --- install-script/install.sh | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/install-script/install.sh b/install-script/install.sh index 8b9ef419..346a4235 100755 --- a/install-script/install.sh +++ b/install-script/install.sh @@ -111,8 +111,6 @@ USER_OPAMP_API_URL="" USER_TOKEN="" USER_FIELDS="" -ACL_LOG_FILE_PATHS="/var/log/ /srv/log/" - SYSTEM_USER="otelcol-sumo" INDENTATION="" @@ -936,33 +934,6 @@ function get_extension_indentation() { echo "${indentation}${indentation}" } -function get_user_config() { - local file - readonly file="${1}" - - if [[ ! -f "${file}" ]]; then - return - fi - - # extract installation_token and strip quotes - # fallback to deprecated install_token - grep -m 1 installation_token "${file}" \ - | sed 's/.*installation_token:[[:blank:]]*//' \ - | sed 's/[[:blank:]]*$//' \ - | sed 's/^"//' \ - | sed "s/^'//" \ - | sed 's/"$//' \ - | sed "s/'\$//" \ - || grep -m 1 install_token "${file}" \ - | sed 's/.*install_token:[[:blank:]]*//' \ - | sed 's/[[:blank:]]*$//' \ - | sed 's/^"//' \ - | sed "s/^'//" \ - | sed 's/"$//' \ - | sed "s/'\$//" \ - || echo "" -} - # remove quotes and double quotes from yaml `value`` for `key: value` form function unescape_yaml() { local fields @@ -1551,7 +1522,6 @@ check_dependencies readonly SUMOLOGIC_INSTALLATION_TOKEN API_BASE_URL OPAMP_API_URL FIELDS CONTINUE FILE_STORAGE CONFIG_DIRECTORY UNINSTALL readonly USER_CONFIG_DIRECTORY USER_ENV_DIRECTORY CONFIG_DIRECTORY CONFIG_PATH COMMON_CONFIG_PATH -readonly ACL_LOG_FILE_PATHS readonly INSTALL_HOSTMETRICS readonly REMOTELY_MANAGED readonly CURL_MAX_TIME From 388a81b3dbacbc3f4171b1fea5ed1657cb85963a Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Thu, 29 Aug 2024 15:58:57 -0700 Subject: [PATCH 07/32] Move much of the script to otelcol-config * Deprecate all systemd configuration manipulation * Deprecate several flags on Linux * Preserve Mac OS functionality Signed-off-by: Eric Chlebek --- install-script/install.sh | 603 +++-------------------- install-script/test/install_unix_test.go | 9 - 2 files changed, 82 insertions(+), 530 deletions(-) diff --git a/install-script/install.sh b/install-script/install.sh index 346a4235..b9ae5173 100755 --- a/install-script/install.sh +++ b/install-script/install.sh @@ -82,11 +82,10 @@ set -u API_BASE_URL="" OPAMP_API_URL="" -FIELDS="" +FIELDS=() VERSION="" FIPS=false CONTINUE=false -HOME_DIRECTORY="" CONFIG_DIRECTORY="" USER_CONFIG_DIRECTORY="" USER_ENV_DIRECTORY="" @@ -109,12 +108,6 @@ LAUNCHD_TOKEN_KEY="" USER_API_URL="" USER_OPAMP_API_URL="" USER_TOKEN="" -USER_FIELDS="" - -SYSTEM_USER="otelcol-sumo" - -INDENTATION="" -EXT_INDENTATION="" CONFIG_BRANCH="" BINARY_BRANCH="" @@ -136,7 +129,7 @@ Supported arguments: -${ARG_SHORT_SKIP_TOKEN}, --${ARG_LONG_SKIP_TOKEN} Skips requirement for installation token. This option do not disable default configuration creation. -${ARG_SHORT_TAG}, --${ARG_LONG_TAG} Sets tag for collector. This argument can be use multiple times. One per tag. - -${ARG_SHORT_DOWNLOAD}, --${ARG_LONG_DOWNLOAD} Download new binary only and skip configuration part. + -${ARG_SHORT_DOWNLOAD}, --${ARG_LONG_DOWNLOAD} Download new binary only and skip configuration part. (Mac OS only) -${ARG_SHORT_UNINSTALL}, --${ARG_LONG_UNINSTALL} Removes Sumo Logic Distribution for OpenTelemetry Collector from the system and disable Systemd service eventually. @@ -164,8 +157,6 @@ EOF } function set_defaults() { - HOME_DIRECTORY="/var/lib/otelcol-sumo" - FILE_STORAGE="${HOME_DIRECTORY}/file_storage" DOWNLOAD_CACHE_DIR="/var/cache/otelcol-sumo" # this is in case we want to keep downloaded binaries CONFIG_DIRECTORY="/etc/otelcol-sumo" SUMO_BINARY_PATH="/usr/local/bin/otelcol-sumo" @@ -174,11 +165,7 @@ function set_defaults() { USER_ENV_DIRECTORY="${CONFIG_DIRECTORY}/env" TOKEN_ENV_FILE="${USER_ENV_DIRECTORY}/token.env" CONFIG_PATH="${CONFIG_DIRECTORY}/sumologic.yaml" - CONFIG_BAK_PATH="${CONFIG_PATH}.bak" COMMON_CONFIG_PATH="${USER_CONFIG_DIRECTORY}/common.yaml" - COMMON_CONFIG_BAK_PATH="${USER_CONFIG_DIRECTORY}/common.yaml.bak" - INDENTATION=" " - EXT_INDENTATION="${INDENTATION}${INDENTATION}" LAUNCHD_CONFIG="/Library/LaunchDaemons/com.sumologic.otelcol-sumo.plist" LAUNCHD_ENV_KEY="EnvironmentVariables" @@ -318,28 +305,9 @@ function parse_options() { "${ARG_SHORT_EPHEMERAL}") EPHEMERAL=true ;; "${ARG_SHORT_KEEP_DOWNLOADS}") KEEP_DOWNLOADS=true ;; "${ARG_SHORT_TIMEOUT}") CURL_MAX_TIME="${OPTARG}" ;; - "${ARG_SHORT_TAG}") - if [[ "${OPTARG}" != ?*"="* ]]; then - echo "Invalid tag: '${OPTARG}'. Should be in 'key=value' format" - usage - exit 1 - fi - - value="$(echo -e "${OPTARG}" | sed 's/.*=//')" - key="$(echo -e "${OPTARG}" | sed 's/\(.*\)=.*/\1/')" - line="${key}=$(escape_yaml_value "${value}")" - - # Cannot use `\n` and have to use `\\` as break line due to OSx sed implementation - FIELDS="${FIELDS}\\ -$(escape_sed "${line}")" ;; - "?") ;; - *) usage; exit 1 ;; + "${ARG_SHORT_TAG}") FIELDS+=("${OPTARG}") ;; esac - # Exit loop as we iterated over all arguments - if [[ "${OPTIND}" -gt $# ]]; then - break - fi done } @@ -348,39 +316,6 @@ function github_rate_limit() { curl --retry 5 --connect-timeout 5 --max-time 30 --retry-delay 0 --retry-max-time 150 -X GET https://api.github.com/rate_limit -v 2>&1 | grep x-ratelimit-remaining | grep -oE "[0-9]+" } -# This function is applicable to very few platforms/distributions. -function install_missing_dependencies() { - local REQUIRED_COMMANDS - local REQUIRED_PACKAGES - REQUIRED_COMMANDS=() - REQUIRED_PACKAGES=() - if [[ -n "${BINARY_BRANCH}" ]]; then # unzip is only necessary for downloading from GHA artifacts - REQUIRED_COMMANDS+=(unzip) - REQUIRED_PACKAGES+=(unzip) - fi - if [[ -f "/etc/redhat-release" ]]; then # this will install semanage, which is necessary for SELinux relabeling - REQUIRED_COMMANDS+=(semanage) - REQUIRED_PACKAGES+=(policycoreutils-python-utils) - fi - if [ "${#REQUIRED_COMMANDS[@]}" == 0 ]; then - # not all bash versions handle empty array expansion correctly - # therefore we guard against this explicitly here - return - fi - for i in "${!REQUIRED_COMMANDS[@]}"; do - cmd=${REQUIRED_COMMANDS[i]} - pkg=${REQUIRED_PACKAGES[i]} - if ! command -v "${cmd}" &> /dev/null; then - # Attempt to install it via yum if on a RHEL distribution. - if [[ -f "/etc/redhat-release" ]]; then - echo "Command '${cmd}' not found. Attempting to install '${pkg}'..." - # This only works if the tool/command matches the system package name. - yum install -y "${pkg}" - fi - fi - done -} - # Ensure TMPDIR is set to a directory where we can safely store temporary files function set_tmpdir() { # generate a new tmpdir using mktemp @@ -640,33 +575,7 @@ function print_breaking_changes() { function setup_config() { echo 'We are going to get and set up a default configuration for you' - echo -e "Creating file_storage directory (${FILE_STORAGE})" - mkdir -p "${FILE_STORAGE}" - - echo -e "Creating configuration directory (${CONFIG_DIRECTORY})" - mkdir -p "${CONFIG_DIRECTORY}" - - echo -e "Creating user configurations directory (${USER_CONFIG_DIRECTORY})" - mkdir -p "${USER_CONFIG_DIRECTORY}" - - echo -e "Creating user env directory (${USER_ENV_DIRECTORY})" - mkdir -p "${USER_ENV_DIRECTORY}" - - echo 'Changing permissions for config files and storage' - chmod 551 "${CONFIG_DIRECTORY}" # config directory world traversable, as is the /etc/ standard - - echo 'Changing permissions for user env directory' - chmod 550 "${USER_ENV_DIRECTORY}" - chmod g+s "${USER_ENV_DIRECTORY}" - echo "Generating configuration and saving as ${CONFIG_PATH}" - - CONFIG_URL="https://raw.githubusercontent.com/SumoLogic/sumologic-otel-collector/${CONFIG_BRANCH}/examples/sumologic.yaml" - if ! curl --retry 5 --connect-timeout 5 --max-time 30 --retry-delay 0 --retry-max-time 150 -f -s "${CONFIG_URL}" -o "${CONFIG_PATH}"; then - echo "Cannot obtain configuration for '${CONFIG_BRANCH}' branch. Either '${CONFIG_URL}' is invalid, or the network connection is unstable." - exit 1 - fi - if [[ "${REMOTELY_MANAGED}" == "true" ]]; then echo "Warning: remote management is currently in beta." @@ -691,18 +600,7 @@ function setup_config() { write_opamp_endpoint "${OPAMP_API_URL}" fi - if [[ -n "${FIELDS}" ]]; then - write_tags "${FIELDS}" - fi - - rm -f "${CONFIG_BAK_PATH}" - - # Finish setting permissions after we're done creating config files - chmod -R 440 "${CONFIG_DIRECTORY}"/* # all files only readable by the owner - find "${CONFIG_DIRECTORY}/" -mindepth 1 -type d -exec chmod 550 {} \; # directories also traversable - - # Remote configuration directory must be writable - chmod 750 "${REMOTE_CONFIG_DIRECTORY}" + write_tags "${FIELDS[@]}" # Return/stop function execution return @@ -710,29 +608,18 @@ function setup_config() { if [[ "${INSTALL_HOSTMETRICS}" == "true" ]]; then echo -e "Installing ${OS_TYPE} hostmetrics configuration" - HOSTMETRICS_CONFIG_URL="https://raw.githubusercontent.com/SumoLogic/sumologic-otel-collector/${CONFIG_BRANCH}/examples/conf.d/${OS_TYPE}.yaml" - if ! curl --retry 5 --connect-timeout 5 --max-time 30 --retry-delay 0 --retry-max-time 150 -f -s "${HOSTMETRICS_CONFIG_URL}" -o "${CONFIG_DIRECTORY}/conf.d/hostmetrics.yaml"; then - echo "Cannot obtain hostmetrics configuration for '${CONFIG_BRANCH}' branch. Either '${HOSTMETRICS_CONFIG_URL}' is invalid, or the network connection is unstable." - exit 1 - fi + otelcol-config --enable-hostmetrics if [[ "${OS_TYPE}" == "linux" ]]; then echo -e "Setting the CAP_DAC_READ_SEARCH Linux capability on the collector binary to allow it to read host metrics from /proc directory: setcap 'cap_dac_read_search=ep' \"${SUMO_BINARY_PATH}\"" echo -e "You can remove it with the following command: sudo setcap -r \"${SUMO_BINARY_PATH}\"" echo -e "Without this capability, the collector will not be able to collect some of the host metrics." + # TODO(echlebek): remove this when it's supported in packaging setcap 'cap_dac_read_search=ep' "${SUMO_BINARY_PATH}" fi fi - # Ensure that configuration is created - if [[ -f "${COMMON_CONFIG_PATH}" ]]; then - echo "User configuration (${COMMON_CONFIG_PATH}) already exist)" - fi - ## Check if there is anything to update in configuration - if [[ -n "${SUMOLOGIC_INSTALLATION_TOKEN}" || -n "${API_BASE_URL}" || -n "${FIELDS}" || "${EPHEMERAL}" == "true" ]]; then - create_user_config_file "${COMMON_CONFIG_PATH}" - add_extension_to_config "${COMMON_CONFIG_PATH}" - + if [[ -n "${SUMOLOGIC_INSTALLATION_TOKEN}" || -n "${API_BASE_URL}" || ${#FIELDS[@]} -ne 0 || "${EPHEMERAL}" == "true" ]]; then if [[ -n "${SUMOLOGIC_INSTALLATION_TOKEN}" && -z "${USER_TOKEN}" ]]; then write_installation_token "${SUMOLOGIC_INSTALLATION_TOKEN}" fi @@ -752,16 +639,8 @@ function setup_config() { write_opamp_endpoint "${OPAMP_API_URL}" fi - if [[ -n "${FIELDS}" && -z "${USER_FIELDS}" ]]; then - write_tags "${FIELDS}" - fi - - # clean up bak file - rm -f "${COMMON_CONFIG_BAK_PATH}" + write_tags "${FIELDS[@]}" fi - # Finish setting permissions after we're done creating config files - chmod -R 440 "${CONFIG_DIRECTORY}"/* # all files only readable by the owner - find "${CONFIG_DIRECTORY}/" -mindepth 1 -type d -exec chmod 550 {} \; # directories also traversable } function setup_config_darwin() { @@ -786,14 +665,13 @@ function setup_config_darwin() { write_api_url "${API_BASE_URL}" fi - if [[ -n "${FIELDS}" ]]; then - write_tags "${FIELDS}" - fi + write_tags "${FIELDS[@]}" if [[ "${REMOTELY_MANAGED}" == "true" ]]; then echo "Warning: remote management is currently in beta." echo -e "Creating remote configurations directory (${REMOTE_CONFIG_DIRECTORY})" + # TODO(echlebek): remove this once packaging does it mkdir -p "${REMOTE_CONFIG_DIRECTORY}" write_opamp_extension @@ -807,9 +685,6 @@ function setup_config_darwin() { chown _otelcol-sumo:_otelcol-sumo "${REMOTE_CONFIG_DIRECTORY}" fi - # clean up bak files - rm -f "${CONFIG_BAK_PATH}" - rm -f "${COMMON_CONFIG_BAK_PATH}" } # uninstall otelcol-sumo @@ -858,136 +733,6 @@ function escape_sed() { | sed -e 's|/|\\/|g' } -function get_indentation() { - local file - readonly file="${1}" - - local default - readonly default="${2}" - - if [[ ! -f "${file}" ]]; then - echo "${default}" - return - fi - - local indentation - - # take indentation same as first extension - indentation="$(sed -e '/^extensions/,/^[a-z]/!d' "${file}" \ - | grep -m 1 -E '^\s+[a-z]' \ - | grep -m 1 -oE '^\s+' \ - || echo "")" - if [[ -n "${indentation}" ]]; then - echo "${indentation}" - return - fi - - # otherwise take indentation from any other package - indentation="$(grep -m 1 -E '^\s+[a-z]' "${file}" \ - | grep -m 1 -oE '^\s+' \ - || echo "")" - if [[ -n "${indentation}" ]]; then - echo "${indentation}" - return - fi - - # return default indentation - echo "${default}" -} - -function get_extension_indentation() { - local file - readonly file="${1}" - - local indentation="${2}" - readonly indentation - - if [[ ! -f "${file}" ]]; then - echo "${indentation}${indentation}" - return - fi - - local ext_indentation - - # take indentation same as properties of sumologic extension - ext_indentation="$(sed -e "/^${indentation}sumologic:/,/^${indentation}[a-z]/!d" "${file}" \ - | grep -m 1 -E "^${indentation}\s+[a-z]" \ - | grep -m 1 -oE '^\s+' \ - || echo "")" - - if [[ -n "${ext_indentation}" ]]; then - echo "${ext_indentation}" - return - fi - - # otherwise take indentation from properties of any other package - ext_indentation="$(grep -m 1 -E "^${indentation}\s+[a-z]" "${file}" \ - | grep -m 1 -oE '^\s+' \ - || echo "")" - - if [[ -n "${ext_indentation}" ]]; then - echo "${ext_indentation}" - return - fi - - # otherwise use double indentation - echo "${indentation}${indentation}" -} - -# remove quotes and double quotes from yaml `value`` for `key: value` form -function unescape_yaml() { - local fields - readonly fields="${1}" - - # Process the string line by line - echo -e "${fields}" | while IFS= read -r line; do - # strip `\` from the end of the line - line="$(echo "${line}" | sed 's/\\$//')" - # extract key - key="$(echo -e "${line}" | sed 's/\(.*\):.*/\1/')" - - # extract value - value="$(echo -e "${line}" | sed 's/.*:[[:blank:]]*//')" - # remove quote, double quote and escapes - value="$(unescape_yaml_value "${value}")" - if [[ -n "${key}" && -n "${value}" ]]; then - echo "${key}: ${value}" - fi - done -} - -# escape yaml value by replacing `'` with `''` and adding surrounding `'` -function escape_yaml_value() { - local value - readonly value="${1}" - - echo "'$(echo -e "${value}" | sed "s/'/''/")'" -} - -function unescape_yaml_value() { - local value - readonly value="${1}" - - if echo -e "${value}" | grep -oqE "^'"; then - # remove `'` from beginning and end of the string - # replace `''` with `'` - echo -e "${value}" \ - | sed "s/'[[:blank:]]*$//" \ - | sed "s/^[[:blank:]]*'//" \ - | sed "s/''/'/" - elif echo -e "${value}" | grep -oqE '^"'; then - # remove `"` from beginning and end of the string - # remove `'` from beginning and end of the string - # replace `\"` with `"` - echo -e "${value}" \ - | sed 's/"[[:blank:]]*$//' \ - | sed 's/^[[:blank:]]*"//' \ - | sed 's/\"/"/' - else - echo -e "${value}" - fi -} - function get_user_env_config() { local file readonly file="${1}" @@ -1015,104 +760,11 @@ function get_user_env_config() { } function get_user_api_url() { - local file - readonly file="${1}" - - if [[ ! -f "${file}" ]]; then - return - fi - - # extract api_base_url and strip quotes - grep -m 1 api_base_url "${file}" \ - | sed 's/.*api_base_url:[[:blank:]]*//' \ - | sed 's/[[:blank:]]*$//' \ - | sed 's/^"//' \ - | sed "s/^'//" \ - | sed 's/"$//' \ - | sed "s/'\$//" \ - || echo "" + otelcol-config --read-kv .extensions.sumologic.api_base_url } function get_user_opamp_endpoint() { - local file - readonly file="${1}" - - if [[ ! -f "${file}" ]]; then - return - fi - - # extract endpoint and strip quotes - grep -m 1 endpoint "${file}" \ - | sed 's/.*endpoint:[[:blank:]]*//' \ - | sed 's/[[:blank:]]*$//' \ - | sed 's/^"//' \ - | sed "s/^'//" \ - | sed 's/"$//' \ - | sed "s/'\$//" \ - || echo "" -} - -function get_user_tags() { - local file - readonly file="${1}" - - local indentation - readonly indentation="${2}" - - local ext_indentation - readonly ext_indentation="${3}" - - if [[ ! -f "${file}" ]]; then - return - fi - - local fields - fields="$(sed -e '/^extensions/,/^[a-z]/!d' "${file}" \ - | sed -e "/^${indentation}sumologic/,/^${indentation}[a-z]/!d" \ - | sed -e "/^${ext_indentation}collector_fields/,/^${ext_indentation}[a-z]/!d;" \ - | grep -vE "^${ext_indentation}\\S" \ - | sed -e 's/^[[:blank:]]*//' \ - || echo "")" - unescape_yaml "${fields}" \ - | sort \ - || echo "" -} - -function get_fields_to_compare() { - local fields - # replace \/ with / - fields="$(echo "${FIELDS}" | sed -e 's|\\/|/|')" - declare -r fields - - unescape_yaml "${fields}" \ - | grep -vE '^$' \ - | sort \ - || echo "" -} - -function create_user_config_file() { - local file - readonly file="${1}" - - if [[ -f "${file}" ]]; then - return - fi - - touch "${file}" - chmod 440 "${file}" -} - -# write extensions section to user configuration file -function add_extension_to_config() { - local file - readonly file="${1}" - - if grep -q 'extensions:$' "${file}"; then - return - fi - - echo "extensions:" \ - | tee -a "${file}" > /dev/null 2>&1 + otelcol-config --read-kv .extensions.opamp.endpoint } # write installation token to user configuration file @@ -1217,10 +869,7 @@ function write_opamp_endpoint() { # write tags to user configuration file function write_tags() { - local fields - readonly fields="${1}" - - for field in $fields + for field in "${1[@]}" do otelcol-config --add-tag "$field" done @@ -1231,109 +880,7 @@ function write_opamp_extension() { otelcol-config --enable-remote-control } -function get_binary_from_branch() { - local branch - readonly branch="${1}" - - local name - readonly name="${2}" - - - local actions_url actions_output artifacts_link artifact_id - readonly actions_url="https://api.github.com/repos/SumoLogic/sumologic-otel-collector/actions/runs?status=success&branch=${branch}&event=push&per_page=1" - echo -e "Getting artifacts from latest CI run for branch \"${branch}\":\t\t${actions_url}" - actions_output="$(curl -f -sS \ - --connect-timeout 5 \ - --max-time 30 \ - --retry 5 \ - --retry-delay 0 \ - --retry-max-time 150 \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: token ${GITHUB_TOKEN}" \ - "${actions_url}")" - readonly actions_output - - # get latest action run - artifacts_link="$(echo "${actions_output}" | grep '"url"' | grep -oE '"https.*collector/actions.*"' -m 1)" - # strip first and last double-quote from $artifacts_link - artifacts_link=${artifacts_link%\"} - artifacts_link="${artifacts_link#\"}" - artifacts_link="${artifacts_link}/artifacts" - readonly artifacts_link - - echo -e "Getting artifact id for CI run:\t\t${artifacts_link}" - artifact_id="$(curl -f -sS \ - --connect-timeout 5 \ - --max-time 30 \ - --retry 5 \ - --retry-delay 0 \ - --retry-max-time 150 \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: token ${GITHUB_TOKEN}" \ - "${artifacts_link}" \ - | grep -E '"(id|name)"' \ - | grep -B 1 "\"${name}\"" -m 1 \ - | grep -oE "[0-9]+" -m 1)" - readonly artifact_id - - local artifact_url download_path curl_args - readonly artifact_url="https://api.github.com/repos/SumoLogic/sumologic-otel-collector/actions/artifacts/${artifact_id}/zip" - readonly download_path="${DOWNLOAD_CACHE_DIR}/${name}.zip" - echo -e "Downloading binary from: ${artifact_url}" - curl_args=( - "-fL" - "--connect-timeout" "5" - "--max-time" "${CURL_MAX_TIME}" - "--retry" "5" - "--retry-delay" "0" - "--retry-max-time" "150" - "--output" "${download_path}" - "--progress-bar" - ) - if [ "${KEEP_DOWNLOADS}" == "true" ]; then - curl_args+=("-z" "${download_path}") - fi - curl "${curl_args[@]}" \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: token ${GITHUB_TOKEN}" \ - "${artifact_url}" - - unzip -p "$download_path" "${name}" >"${TMPDIR}"/otelcol-sumo - if [ "${KEEP_DOWNLOADS}" == "false" ]; then - rm -f "${download_path}" - fi -} - -function get_binary_from_url() { - local url download_filename download_path curl_args - readonly url="${1}" - echo -e "Downloading:\t\t${url}" - - download_filename=$(basename "${url}") - readonly download_filename - readonly download_path="${DOWNLOAD_CACHE_DIR}/${download_filename}" - curl_args=( - "-fL" - "--connect-timeout" "5" - "--max-time" "${CURL_MAX_TIME}" - "--retry" "5" - "--retry-delay" "0" - "--retry-max-time" "150" - "--output" "${download_path}" - "--progress-bar" - ) - if [ "${KEEP_DOWNLOADS}" == "true" ]; then - curl_args+=("-z" "${download_path}") - fi - curl "${curl_args[@]}" "${url}" - - cp -f "${download_path}" "${TMPDIR}"/otelcol-sumo - - if [ "${KEEP_DOWNLOADS}" == "false" ]; then - rm -f "${download_path}" - fi -} - +# NB: this function is only for Darwin function get_package_from_branch() { local branch readonly branch="${1}" @@ -1505,6 +1052,59 @@ function plutil_replace_key() { fi } +function get_package_manager() { + if which dnf > /dev/null 2>&1; then + echo "dnf" + elif which yum > /dev/null 2>&1; then + echo "yum" + elif which apt-get > /dev/null 2>&1; then + echo "apt-get" + else + echo "package manager not found [dnf, yum, apt-get]" + exit 1 + fi +} + +function install_linux_package() { + local package_with_version + readonly package_with_version="${1}" + + case $(get_package_manager) in + yum | dnf) + yum --disablerepo="*" --enablerepo="sumologic_stable" -y update + yum install "${package_with_version}" + ;; + apt-get) + apt-get update -y -o Dir::Etc::sourcelist="sources.list.d/sumologic_stable" + apt-get install "${package_with_version}" + ;; + esac +} + +function check_deprecated_linux_flags() { + if [[ "${OS_TYPE}" == "darwin" ]]; then + return + fi + + if [[ -n "${DOWNLOAD_ONLY}" ]]; then + echo "--download-only is only supported on darwin, use 'install.sh --upgrade' to upgrade otelcol-sumo" + exit 1 + fi + + if [[ -n "${BINARY_BRANCH}" ]]; then + echo "--binary-branch is only supported on darwin, use --version, --channel, and --channel-token on linux" + exit 1 + fi + + if [[ -n "${CONFIG_BRANCH}" ]]; then + echo "warning: --config-branch is deprecated" + fi + + if [[ "${PURGE}" == "true" ]]; then + echo "warning: purge is deprecated" + fi +} + ############################ Main code OS_TYPE="$(get_os_type)" @@ -1517,10 +1117,10 @@ echo -e "Detected architecture:\t${ARCH_TYPE}" set_defaults parse_options "$@" set_tmpdir -install_missing_dependencies check_dependencies +check_deprecated_linux_flags -readonly SUMOLOGIC_INSTALLATION_TOKEN API_BASE_URL OPAMP_API_URL FIELDS CONTINUE FILE_STORAGE CONFIG_DIRECTORY UNINSTALL +readonly SUMOLOGIC_INSTALLATION_TOKEN API_BASE_URL OPAMP_API_URL FIELDS CONTINUE CONFIG_DIRECTORY UNINSTALL readonly USER_CONFIG_DIRECTORY USER_ENV_DIRECTORY CONFIG_DIRECTORY CONFIG_PATH COMMON_CONFIG_PATH readonly INSTALL_HOSTMETRICS readonly REMOTELY_MANAGED @@ -1554,10 +1154,6 @@ if [[ -z "${DOWNLOAD_ONLY}" ]]; then fi if [[ -f "${COMMON_CONFIG_PATH}" ]]; then - INDENTATION="$(get_indentation "${COMMON_CONFIG_PATH}" "${INDENTATION}")" - EXT_INDENTATION="$(get_extension_indentation "${COMMON_CONFIG_PATH}" "${INDENTATION}")" - readonly INDENTATION EXT_INDENTATION - USER_API_URL="$(get_user_api_url "${COMMON_CONFIG_PATH}")" if [[ -n "${USER_API_URL}" && -n "${API_BASE_URL}" && "${USER_API_URL}" != "${API_BASE_URL}" ]]; then echo "You are trying to install with different api base url than in your configuration file!" @@ -1570,13 +1166,6 @@ if [[ -z "${DOWNLOAD_ONLY}" ]]; then exit 1 fi - USER_FIELDS="$(get_user_tags "${COMMON_CONFIG_PATH}" "${INDENTATION}" "${EXT_INDENTATION}")" - FIELDS_TO_COMPARE="$(get_fields_to_compare "${FIELDS}")" - - if [[ -n "${USER_FIELDS}" && -n "${FIELDS_TO_COMPARE}" && "${USER_FIELDS}" != "${FIELDS_TO_COMPARE}" ]]; then - echo "You are trying to install with different tags than in your configuration file!" - exit 1 - fi fi fi @@ -1739,17 +1328,14 @@ fi echo -e "Version to install:\t${VERSION}" -CONFIG_BRANCH="v${VERSION}" -readonly CONFIG_BRANCH BINARY_BRANCH - # Check if otelcol is already in newest version -if [[ "${INSTALLED_VERSION}" == "${VERSION}" && -z "${BINARY_BRANCH}" ]]; then +if [[ "${INSTALLED_VERSION}" == "${VERSION}" ]]; then echo -e "OpenTelemetry collector is already in newest (${VERSION}) version" else # add newline before breaking changes and changelog echo "" - if [[ -n "${INSTALLED_VERSION}" && -z "${BINARY_BRANCH}" ]]; then + if [[ -n "${INSTALLED_VERSION}" ]]; then # Take versions from installed up to the newest BETWEEN_VERSIONS="$(get_versions_from "${VERSIONS}" "${INSTALLED_VERSION}")" readonly BETWEEN_VERSIONS @@ -1760,34 +1346,26 @@ else # add newline after breaking changes and changelog echo "" - # Add -fips to the suffix if necessary - binary_suffix="${OS_TYPE}_${ARCH_TYPE}" - if [ "${FIPS}" == "true" ]; then + package_with_version="${VERSION}" + if [[ -n "${package_with_version}" ]]; then + if [[ "${FIPS}" == "true" ]]; then echo "Getting FIPS-compliant binary" - binary_suffix="fips-${binary_suffix}" + package_with_version=otelcol-sumo-fips + else + package_with_version=otelcol-sumo + fi fi - if [[ -n "${BINARY_BRANCH}" ]]; then - get_binary_from_branch "${BINARY_BRANCH}" "otelcol-sumo-${binary_suffix}" - else - LINK="https://github.com/SumoLogic/sumologic-otel-collector/releases/download/v${VERSION}/otelcol-sumo-${VERSION}-${binary_suffix}" - readonly LINK - - get_binary_from_url "${LINK}" + # Add -fips to the suffix if necessary + if [[ "${FIPS}" == "true" && "${package_with_version}" != *"-fips" ]]; then + package_with_version="${package_with_version}-fips" fi - echo -e "Moving otelcol-sumo to /usr/local/bin" - mv "${TMPDIR}"/otelcol-sumo "${SUMO_BINARY_PATH}" - echo -e "Setting ${SUMO_BINARY_PATH} to be executable" - chmod +x "${SUMO_BINARY_PATH}" + install_linux_package "${package_with_version}" verify_installation fi -if [[ "${DOWNLOAD_ONLY}" == "true" ]]; then - exit 0 -fi - if [[ "${SKIP_CONFIG}" == "false" ]]; then setup_config fi @@ -1795,23 +1373,6 @@ fi if [[ -n "${SUMOLOGIC_INSTALLATION_TOKEN}" && -z "${USER_TOKEN}" ]]; then echo 'Writing installation token to env file' write_installation_token_env "${SUMOLOGIC_INSTALLATION_TOKEN}" "${TOKEN_ENV_FILE}" - chmod -R 440 "${TOKEN_ENV_FILE}" -fi - -echo 'Creating user and group' -if getent passwd "${SYSTEM_USER}" > /dev/null; then - echo 'User and group already created' -else - ADDITIONAL_OPTIONS="" - if [[ -d "${HOME_DIRECTORY}" ]]; then - # do not create home directory as it already exists - ADDITIONAL_OPTIONS="-M" - else - # create home directory - ADDITIONAL_OPTIONS="-m" - fi - readonly ADDITIONAL_OPTIONS - useradd "${ADDITIONAL_OPTIONS}" -rUs /bin/false -d "${HOME_DIRECTORY}" "${SYSTEM_USER}" fi echo 'Reloading systemd' diff --git a/install-script/test/install_unix_test.go b/install-script/test/install_unix_test.go index f050d5ce..eb542256 100644 --- a/install-script/test/install_unix_test.go +++ b/install-script/test/install_unix_test.go @@ -401,15 +401,6 @@ func TestInstallScript(t *testing.T) { preChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkUserNotExists}, postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, }, - { - name: "don't keep downloads", - options: installOptions{ - skipInstallToken: true, - dontKeepDownloads: true, - }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkUserConfigNotCreated}, - }, } { t.Run(spec.name, func(t *testing.T) { runTest(t, &spec) From 5719b04dc5c167ab2d921255448bc840c97b4eff Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Thu, 29 Aug 2024 17:33:52 -0700 Subject: [PATCH 08/32] Fix infinite loop in script Remove unnecessary tests Signed-off-by: Eric Chlebek --- install-script/install.sh | 34 +++++++++++---- install-script/test/command_unix.go | 20 --------- install-script/test/common_linux.go | 1 - install-script/test/consts_common.go | 23 +++++----- .../test/install_linux_amd64_test.go | 42 ------------------- install-script/test/install_unix_test.go | 33 --------------- 6 files changed, 40 insertions(+), 113 deletions(-) delete mode 100644 install-script/test/install_linux_amd64_test.go diff --git a/install-script/install.sh b/install-script/install.sh index b9ae5173..af04a5ea 100755 --- a/install-script/install.sh +++ b/install-script/install.sh @@ -176,6 +176,10 @@ function set_defaults() { } function parse_options() { + if (( $# == 0 )); then + set -- "$@" "-${ARG_SHORT_HELP}" + fi + # Transform long options to short ones for arg in "$@"; do @@ -308,6 +312,10 @@ function parse_options() { "${ARG_SHORT_TAG}") FIELDS+=("${OPTARG}") ;; esac + # Exit loop as we iterated over all arguments + if [[ "${OPTIND}" -gt $# ]]; then + break + fi done } @@ -719,8 +727,25 @@ function uninstall_darwin() { # uninstall otelcol-sumo on linux function uninstall_linux() { - echo "linux uninstall unimplemented" - exit 1 + package_with_version="${VERSION}" + if [[ -n "${package_with_version}" ]]; then + if [[ "${FIPS}" == "true" ]]; then + echo "Getting FIPS-compliant binary" + package_with_version=otelcol-sumo-fips + else + package_with_version=otelcol-sumo + fi + fi + + case $(get_package_manager) in + yum | dnf) + yum remove "${package_with_version}" + ;; + apt-get) + apt-get update -y -o Dir::Etc::sourcelist="sources.list.d/sumologic_stable" + apt-get install "${package_with_version}" + ;; + esac } function escape_sed() { @@ -1356,11 +1381,6 @@ else fi fi - # Add -fips to the suffix if necessary - if [[ "${FIPS}" == "true" && "${package_with_version}" != *"-fips" ]]; then - package_with_version="${package_with_version}-fips" - fi - install_linux_package "${package_with_version}" verify_installation diff --git a/install-script/test/command_unix.go b/install-script/test/command_unix.go index 6747cb43..adc5d8f6 100644 --- a/install-script/test/command_unix.go +++ b/install-script/test/command_unix.go @@ -22,11 +22,7 @@ type installOptions struct { fips bool envs map[string]string uninstall bool - purge bool apiBaseURL string - configBranch string - downloadOnly bool - dontKeepDownloads bool installHostmetrics bool remotelyManaged bool ephemeral bool @@ -59,18 +55,6 @@ func (io *installOptions) string() []string { opts = append(opts, "--uninstall") } - if io.purge { - opts = append(opts, "--purge") - } - - if io.downloadOnly { - opts = append(opts, "--download-only") - } - - if !io.dontKeepDownloads { - opts = append(opts, "--keep-downloads") - } - if io.installHostmetrics { opts = append(opts, "--install-hostmetrics") } @@ -93,10 +77,6 @@ func (io *installOptions) string() []string { opts = append(opts, "--api", io.apiBaseURL) } - if io.configBranch != "" { - opts = append(opts, "--config-branch", io.configBranch) - } - if io.timeout != 0 { opts = append(opts, "--download-timeout", fmt.Sprintf("%f", io.timeout)) } diff --git a/install-script/test/common_linux.go b/install-script/test/common_linux.go index 60e3e101..f6967af0 100644 --- a/install-script/test/common_linux.go +++ b/install-script/test/common_linux.go @@ -13,7 +13,6 @@ func tearDown(t *testing.T) { test: t, installOptions: installOptions{ uninstall: true, - purge: true, autoconfirm: true, }, } diff --git a/install-script/test/consts_common.go b/install-script/test/consts_common.go index f1c64e4e..4a6c6e96 100644 --- a/install-script/test/consts_common.go +++ b/install-script/test/consts_common.go @@ -16,7 +16,7 @@ const ( ) var ( - latestAppVersion string + latestAppVersion = os.Getenv("OTELCOL_SUMO_RELEASE") ) func authenticateGithub() string { @@ -71,14 +71,17 @@ func getLatestAppReleaseVersion() (string, error) { } func init() { - latestReleaseVersion, err := getLatestAppReleaseVersion() - if err != nil { - fmt.Printf("error fetching release: %v", err) - os.Exit(1) - } - if latestReleaseVersion == "" { - fmt.Println("No app release versions found") - os.Exit(1) + latestAppVersion = os.Getenv("OTELCOL_SUMO_RELEASE") + if latestAppVersion == "" { + latestReleaseVersion, err := getLatestAppReleaseVersion() + if err != nil { + fmt.Printf("error fetching release: %v", err) + os.Exit(1) + } + if latestReleaseVersion == "" { + fmt.Println("No app release versions found") + os.Exit(1) + } + latestAppVersion = latestReleaseVersion } - latestAppVersion = latestReleaseVersion } diff --git a/install-script/test/install_linux_amd64_test.go b/install-script/test/install_linux_amd64_test.go deleted file mode 100644 index f97b7bc0..00000000 --- a/install-script/test/install_linux_amd64_test.go +++ /dev/null @@ -1,42 +0,0 @@ -//go:build linux && amd64 - -package sumologic_scripts_tests - -import ( - "os/exec" - "testing" - - "github.com/stretchr/testify/require" -) - -func checkBinaryIsFIPS(c check) { - cmd := exec.Command(binaryPath, "--version") - - output, err := cmd.Output() - require.NoError(c.test, err, "error while checking version") - require.Contains(c.test, string(output), "fips") -} - -func TestInstallScriptLinuxAmd64(t *testing.T) { - for _, spec := range []testSpec{ - { - name: "download only fips", - options: installOptions{ - downloadOnly: true, - fips: true, - }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, - postChecks: []checkFunc{ - checkBinaryCreated, - checkBinaryIsFIPS, - checkConfigNotCreated, - checkUserConfigNotCreated, - checkUserNotExists, - }, - }, - } { - t.Run(spec.name, func(t *testing.T) { - runTest(t, &spec) - }) - } -} diff --git a/install-script/test/install_unix_test.go b/install-script/test/install_unix_test.go index eb542256..bc18d7ac 100644 --- a/install-script/test/install_unix_test.go +++ b/install-script/test/install_unix_test.go @@ -15,28 +15,6 @@ func TestInstallScript(t *testing.T) { postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkAbortedDueToNoToken, checkUserNotExists}, installCode: 1, }, - { - name: "download only", - options: installOptions{ - downloadOnly: true, - }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, - }, - { - name: "download only with timeout", - options: installOptions{ - downloadOnly: true, - timeout: 1, - dontKeepDownloads: true, - }, - // Skip this test as getting binary in github actions takes less than one second - conditionalChecks: []condCheckFunc{checkSkipTest}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists, - checkDownloadTimeout}, - installCode: curlTimeoutErrorCode, - }, { name: "skip config", options: installOptions{ @@ -390,17 +368,6 @@ func TestInstallScript(t *testing.T) { preChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkUserNotExists}, postChecks: []checkFunc{checkBinaryNotCreated, checkConfigCreated, checkUserConfigCreated, checkUninstallationOutput}, }, - { - name: "purge", - options: installOptions{ - uninstall: true, - purge: true, - autoconfirm: true, - }, - preActions: []checkFunc{preActionMockStructure}, - preChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, - }, } { t.Run(spec.name, func(t *testing.T) { runTest(t, &spec) From 867c1c0b36268cebf75248e33b1a317726d7a5e4 Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Thu, 29 Aug 2024 17:38:02 -0700 Subject: [PATCH 09/32] Return 2 on incorrect usage Signed-off-by: Eric Chlebek --- install-script/install.sh | 7 ++++--- install-script/test/install_darwin_test.go | 2 +- install-script/test/install_unix_test.go | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/install-script/install.sh b/install-script/install.sh index af04a5ea..4710aed7 100755 --- a/install-script/install.sh +++ b/install-script/install.sh @@ -177,7 +177,8 @@ function set_defaults() { function parse_options() { if (( $# == 0 )); then - set -- "$@" "-${ARG_SHORT_HELP}" + usage + exit 2 fi # Transform long options to short ones @@ -260,7 +261,7 @@ function parse_options() { set -- "$@" "-${ARG_SHORT_EPHEMERAL}" ;; -*) - echo "Unknown option ${arg}"; usage; exit 1 ;; + echo "Unknown option ${arg}"; usage; exit 2 ;; *) set -- "$@" "$arg" ;; esac @@ -278,7 +279,7 @@ function parse_options() { if [[ $? != 0 && ${OPTIND} -le $# ]]; then echo "Invalid argument:" "${@:${OPTIND}:1}" usage - exit 1 + exit 2 fi # Validate opt and set arguments diff --git a/install-script/test/install_darwin_test.go b/install-script/test/install_darwin_test.go index 6eb2c3e7..9bc50cb8 100644 --- a/install-script/test/install_darwin_test.go +++ b/install-script/test/install_darwin_test.go @@ -22,7 +22,7 @@ func TestInstallScriptDarwin(t *testing.T) { options: installOptions{}, preChecks: notInstalledChecks, postChecks: append(notInstalledChecks, checkAbortedDueToNoToken), - installCode: 1, + installCode: 2, }, { name: "download only", diff --git a/install-script/test/install_unix_test.go b/install-script/test/install_unix_test.go index bc18d7ac..2a0f2cb9 100644 --- a/install-script/test/install_unix_test.go +++ b/install-script/test/install_unix_test.go @@ -13,7 +13,7 @@ func TestInstallScript(t *testing.T) { options: installOptions{}, preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkAbortedDueToNoToken, checkUserNotExists}, - installCode: 1, + installCode: 2, }, { name: "skip config", From 6130a080f386d347804a1440e68613aa58351f0b Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Thu, 29 Aug 2024 17:44:51 -0700 Subject: [PATCH 10/32] Hardcode app version for now Also fix some test bugs Signed-off-by: Eric Chlebek --- install-script/test/common_unix.go | 7 ++++--- install-script/test/consts_common.go | 3 +-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/install-script/test/common_unix.go b/install-script/test/common_unix.go index 513aaf0c..ffa012d3 100644 --- a/install-script/test/common_unix.go +++ b/install-script/test/common_unix.go @@ -40,8 +40,9 @@ func runTest(t *testing.T, spec *testSpec) { t.Log("Starting HTTP server") mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - _, err := io.WriteString(w, "200 OK\n") - require.NoError(t, err) + if _, err := io.WriteString(w, "200 OK\n"); err != nil { + panic(err) + } }) listener, err := net.Listen("tcp", ":3333") @@ -53,7 +54,7 @@ func runTest(t *testing.T, spec *testSpec) { go func() { err := httpServer.Serve(listener) if err != nil && err != http.ErrServerClosed { - require.NoError(t, err) + panic(err) } }() defer func() { diff --git a/install-script/test/consts_common.go b/install-script/test/consts_common.go index 4a6c6e96..166180ef 100644 --- a/install-script/test/consts_common.go +++ b/install-script/test/consts_common.go @@ -16,7 +16,7 @@ const ( ) var ( - latestAppVersion = os.Getenv("OTELCOL_SUMO_RELEASE") + latestAppVersion = "0.104.0" ) func authenticateGithub() string { @@ -71,7 +71,6 @@ func getLatestAppReleaseVersion() (string, error) { } func init() { - latestAppVersion = os.Getenv("OTELCOL_SUMO_RELEASE") if latestAppVersion == "" { latestReleaseVersion, err := getLatestAppReleaseVersion() if err != nil { From 8e3b87e185efd30f44ec2fba760b05c8b88ae3ce Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Fri, 30 Aug 2024 12:09:24 -0700 Subject: [PATCH 11/32] Don't try to get latest release in install script tests Signed-off-by: Eric Chlebek --- install-script/test/check.go | 8 --- install-script/test/consts_common.go | 65 ------------------------ install-script/test/install_unix_test.go | 3 +- 3 files changed, 1 insertion(+), 75 deletions(-) diff --git a/install-script/test/check.go b/install-script/test/check.go index d3eb96a6..a362d953 100644 --- a/install-script/test/check.go +++ b/install-script/test/check.go @@ -46,14 +46,6 @@ func checkBinaryIsRunning(c check) { require.Equal(c.test, 0, code, "got error code while checking version") } -func checkLatestAppVersion(c check) { - cmd := exec.Command(binaryPath, "--version") - output, err := cmd.Output() - c.test.Logf("latest app version: %s", latestAppVersion) - require.NoError(c.test, err, "error while checking version") - require.Contains(c.test, string(output), latestAppVersion, "must install latest app version") -} - func checkRun(c check) { require.Equal(c.test, c.expectedInstallCode, c.code, "unexpected installation script error code") } diff --git a/install-script/test/consts_common.go b/install-script/test/consts_common.go index 166180ef..ce994738 100644 --- a/install-script/test/consts_common.go +++ b/install-script/test/consts_common.go @@ -1,11 +1,7 @@ package sumologic_scripts_tests import ( - "encoding/json" - "fmt" "log" - "net/http" - "net/url" "os" ) @@ -15,10 +11,6 @@ const ( GithubApiBaseUrl = "https://api.github.com" ) -var ( - latestAppVersion = "0.104.0" -) - func authenticateGithub() string { githubToken := os.Getenv("GH_CI_TOKEN") if githubToken == "" { @@ -27,60 +19,3 @@ func authenticateGithub() string { } return githubToken } - -func getLatestAppReleaseVersion() (string, error) { - githubApiBaseUrl, err := url.Parse(GithubApiBaseUrl) - if err != nil { - return "", err - } - githubToken := authenticateGithub() - - githubApiLatestReleaseUrl := fmt.Sprintf("%s/repos/%s/%s/releases/latest", githubApiBaseUrl, GithubOrg, GithubAppRepository) - - req, err := http.NewRequest("GET", githubApiLatestReleaseUrl, nil) - if err != nil { - return "", err - } - - // Set Authorization header with GitHub token - req.Header.Set("Authorization", "token "+githubToken) - req.Header.Set("Accept", "application/vnd.github.v3+json") - - // Send request - client := http.Client{} - response, err := client.Do(req) - if err != nil { - return "", err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return "", fmt.Errorf("failed to get release: %s", response.Status) - } - - var release struct { - TagName string `json:"tag_name"` - } - decoder := json.NewDecoder(response.Body) - err = decoder.Decode(&release) - if err != nil { - return "", err - } - - return release.TagName, nil -} - -func init() { - if latestAppVersion == "" { - latestReleaseVersion, err := getLatestAppReleaseVersion() - if err != nil { - fmt.Printf("error fetching release: %v", err) - os.Exit(1) - } - if latestReleaseVersion == "" { - fmt.Println("No app release versions found") - os.Exit(1) - } - latestAppVersion = latestReleaseVersion - } -} diff --git a/install-script/test/install_unix_test.go b/install-script/test/install_unix_test.go index 2a0f2cb9..b5734d8b 100644 --- a/install-script/test/install_unix_test.go +++ b/install-script/test/install_unix_test.go @@ -12,7 +12,7 @@ func TestInstallScript(t *testing.T) { name: "no arguments", options: installOptions{}, preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkAbortedDueToNoToken, checkUserNotExists}, + postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, installCode: 2, }, { @@ -57,7 +57,6 @@ func TestInstallScript(t *testing.T) { postChecks: []checkFunc{ checkBinaryCreated, checkBinaryIsRunning, - checkLatestAppVersion, checkConfigCreated, checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), checkUserConfigCreated, From 69c7ee02b56f23a8a2aba5278671e3dd389d6314 Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Fri, 30 Aug 2024 12:30:46 -0700 Subject: [PATCH 12/32] Install package repositories with install.sh Signed-off-by: Eric Chlebek --- install-script/install.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/install-script/install.sh b/install-script/install.sh index 4710aed7..13e55c0e 100755 --- a/install-script/install.sh +++ b/install-script/install.sh @@ -1097,10 +1097,12 @@ function install_linux_package() { case $(get_package_manager) in yum | dnf) + curl -s https://packagecloud.io/install/repositories/sumologic/stable/script.rpm.sh | bash yum --disablerepo="*" --enablerepo="sumologic_stable" -y update yum install "${package_with_version}" ;; apt-get) + curl -s https://packagecloud.io/install/repositories/sumologic/stable/script.deb.sh | bash apt-get update -y -o Dir::Etc::sourcelist="sources.list.d/sumologic_stable" apt-get install "${package_with_version}" ;; From ebb767eb9e574d794542fb2fd236e6c6434eb917 Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Fri, 30 Aug 2024 13:06:27 -0700 Subject: [PATCH 13/32] Fix debian package uninstallation Signed-off-by: Eric Chlebek --- install-script/install.sh | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/install-script/install.sh b/install-script/install.sh index 13e55c0e..6c202fae 100755 --- a/install-script/install.sh +++ b/install-script/install.sh @@ -731,7 +731,6 @@ function uninstall_linux() { package_with_version="${VERSION}" if [[ -n "${package_with_version}" ]]; then if [[ "${FIPS}" == "true" ]]; then - echo "Getting FIPS-compliant binary" package_with_version=otelcol-sumo-fips else package_with_version=otelcol-sumo @@ -743,8 +742,7 @@ function uninstall_linux() { yum remove "${package_with_version}" ;; apt-get) - apt-get update -y -o Dir::Etc::sourcelist="sources.list.d/sumologic_stable" - apt-get install "${package_with_version}" + apt-get remove "${package_with_version}" ;; esac } From bf3405a9d79ada8629522dad540e5ec53133d037 Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Fri, 30 Aug 2024 13:18:57 -0700 Subject: [PATCH 14/32] Add -y to package removal calls Signed-off-by: Eric Chlebek --- install-script/install.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/install-script/install.sh b/install-script/install.sh index 6c202fae..f17f9880 100755 --- a/install-script/install.sh +++ b/install-script/install.sh @@ -739,10 +739,10 @@ function uninstall_linux() { case $(get_package_manager) in yum | dnf) - yum remove "${package_with_version}" + yum remove -y "${package_with_version}" ;; apt-get) - apt-get remove "${package_with_version}" + apt-get remove -y "${package_with_version}" ;; esac } From 6ec10761d830101fa216397559f503e67a83e41c Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Fri, 30 Aug 2024 13:30:25 -0700 Subject: [PATCH 15/32] Test speculative change Signed-off-by: Eric Chlebek --- install-script/install.sh | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/install-script/install.sh b/install-script/install.sh index f17f9880..1aaa4476 100755 --- a/install-script/install.sh +++ b/install-script/install.sh @@ -728,21 +728,23 @@ function uninstall_darwin() { # uninstall otelcol-sumo on linux function uninstall_linux() { - package_with_version="${VERSION}" - if [[ -n "${package_with_version}" ]]; then - if [[ "${FIPS}" == "true" ]]; then - package_with_version=otelcol-sumo-fips - else - package_with_version=otelcol-sumo - fi - fi + #package_with_version="${VERSION}" + #if [[ -n "${package_with_version}" ]]; then + # if [[ "${FIPS}" == "true" ]]; then + # package_with_version=otelcol-sumo-fips + # else + # package_with_version=otelcol-sumo + # fi + #fi case $(get_package_manager) in yum | dnf) - yum remove -y "${package_with_version}" + #yum remove -y "${package_with_version}" + yum remove -y otelcol-sumo ;; apt-get) - apt-get remove -y "${package_with_version}" + #apt-get remove -y "${package_with_version}" + apt-get remove -y otelcol-sumo ;; esac } From 38c9d60618dd90f4a1b98f1af76490b41aa8a019 Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Fri, 30 Aug 2024 13:35:19 -0700 Subject: [PATCH 16/32] Support --purge for apt-get Signed-off-by: Eric Chlebek --- install-script/install.sh | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/install-script/install.sh b/install-script/install.sh index 1aaa4476..39ed0231 100755 --- a/install-script/install.sh +++ b/install-script/install.sh @@ -728,23 +728,16 @@ function uninstall_darwin() { # uninstall otelcol-sumo on linux function uninstall_linux() { - #package_with_version="${VERSION}" - #if [[ -n "${package_with_version}" ]]; then - # if [[ "${FIPS}" == "true" ]]; then - # package_with_version=otelcol-sumo-fips - # else - # package_with_version=otelcol-sumo - # fi - #fi - case $(get_package_manager) in yum | dnf) - #yum remove -y "${package_with_version}" yum remove -y otelcol-sumo ;; apt-get) - #apt-get remove -y "${package_with_version}" - apt-get remove -y otelcol-sumo + if [[ "${PURGE}" == "true" ]]; then + apt-get purge -y otelcol-sumo + else + apt-get remove -y otelcol-sumo + fi ;; esac } @@ -1127,10 +1120,6 @@ function check_deprecated_linux_flags() { if [[ -n "${CONFIG_BRANCH}" ]]; then echo "warning: --config-branch is deprecated" fi - - if [[ "${PURGE}" == "true" ]]; then - echo "warning: purge is deprecated" - fi } ############################ Main code From bbcce55894384a28783c4261964c432c2be45d1e Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Fri, 30 Aug 2024 13:37:45 -0700 Subject: [PATCH 17/32] Use --purge in script tests Signed-off-by: Eric Chlebek --- install-script/test/command_unix.go | 1 + 1 file changed, 1 insertion(+) diff --git a/install-script/test/command_unix.go b/install-script/test/command_unix.go index adc5d8f6..8f4e70d8 100644 --- a/install-script/test/command_unix.go +++ b/install-script/test/command_unix.go @@ -53,6 +53,7 @@ func (io *installOptions) string() []string { if io.uninstall { opts = append(opts, "--uninstall") + opts = append(opts, "--purge") } if io.installHostmetrics { From 64972acccb3bd65ffa2ba852416b12c7ecfcced7 Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Fri, 30 Aug 2024 13:43:47 -0700 Subject: [PATCH 18/32] Remove autoconfirm tests on linux Signed-off-by: Eric Chlebek --- install-script/test/command_unix.go | 4 ---- install-script/test/common_linux.go | 3 +-- install-script/test/install_unix_test.go | 21 --------------------- 3 files changed, 1 insertion(+), 27 deletions(-) diff --git a/install-script/test/command_unix.go b/install-script/test/command_unix.go index 8f4e70d8..6d4674ba 100644 --- a/install-script/test/command_unix.go +++ b/install-script/test/command_unix.go @@ -167,10 +167,6 @@ func runScript(ch check) (int, []string, []string, error) { // otherwise ensure there is no error require.NoError(ch.test, err) - if ch.installOptions.autoconfirm { - continue - } - } // Handle stderr separately diff --git a/install-script/test/common_linux.go b/install-script/test/common_linux.go index f6967af0..42ee014c 100644 --- a/install-script/test/common_linux.go +++ b/install-script/test/common_linux.go @@ -12,8 +12,7 @@ func tearDown(t *testing.T) { ch := check{ test: t, installOptions: installOptions{ - uninstall: true, - autoconfirm: true, + uninstall: true, }, } diff --git a/install-script/test/install_unix_test.go b/install-script/test/install_unix_test.go index b5734d8b..064f44ea 100644 --- a/install-script/test/install_unix_test.go +++ b/install-script/test/install_unix_test.go @@ -42,7 +42,6 @@ func TestInstallScript(t *testing.T) { name: "override default config", options: installOptions{ skipInstallToken: true, - autoconfirm: true, }, preActions: []checkFunc{preActionMockConfig}, preChecks: []checkFunc{checkBinaryNotCreated, checkConfigCreated, checkUserConfigNotCreated, checkUserNotExists}, @@ -347,26 +346,6 @@ func TestInstallScript(t *testing.T) { preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkTags}, }, - { - name: "uninstallation without autoconfirm fails", - options: installOptions{ - uninstall: true, - }, - installCode: 1, - preActions: []checkFunc{preActionMockStructure}, - preChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated}, - }, - { - name: "uninstallation with autoconfirm", - options: installOptions{ - autoconfirm: true, - uninstall: true, - }, - preActions: []checkFunc{preActionMockStructure}, - preChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryNotCreated, checkConfigCreated, checkUserConfigCreated, checkUninstallationOutput}, - }, } { t.Run(spec.name, func(t *testing.T) { runTest(t, &spec) From d5107a9b66726769459700d056542dd390894965 Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Fri, 30 Aug 2024 13:50:48 -0700 Subject: [PATCH 19/32] Don't do user checks Signed-off-by: Eric Chlebek --- install-script/test/check_linux.go | 5 -- install-script/test/install_unix_test.go | 64 +++++++++--------------- 2 files changed, 25 insertions(+), 44 deletions(-) diff --git a/install-script/test/check_linux.go b/install-script/test/check_linux.go index 2b597704..23e5cf28 100644 --- a/install-script/test/check_linux.go +++ b/install-script/test/check_linux.go @@ -161,11 +161,6 @@ func checkUserExists(c check) { require.NoError(c.test, err, "user has not been created") } -func checkUserNotExists(c check) { - _, err := user.Lookup(systemUser) - require.Error(c.test, err, "user has been created") -} - func checkVarLogACL(c check) { if !checkACLAvailability(c) { return diff --git a/install-script/test/install_unix_test.go b/install-script/test/install_unix_test.go index 064f44ea..13bd0b82 100644 --- a/install-script/test/install_unix_test.go +++ b/install-script/test/install_unix_test.go @@ -11,8 +11,8 @@ func TestInstallScript(t *testing.T) { { name: "no arguments", options: installOptions{}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, - postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, + postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, installCode: 2, }, { @@ -21,7 +21,7 @@ func TestInstallScript(t *testing.T) { skipConfig: true, skipInstallToken: true, }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, postChecks: []checkFunc{checkBinaryCreated, checkConfigNotCreated, checkUserConfigNotCreated}, }, { @@ -29,7 +29,7 @@ func TestInstallScript(t *testing.T) { options: installOptions{ skipInstallToken: true, }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, postChecks: []checkFunc{ checkBinaryCreated, checkBinaryIsRunning, @@ -44,7 +44,7 @@ func TestInstallScript(t *testing.T) { skipInstallToken: true, }, preActions: []checkFunc{preActionMockConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigCreated, checkUserConfigNotCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigCreated, checkUserConfigNotCreated}, postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkConfigOverrided, checkUserConfigNotCreated}, }, { @@ -52,7 +52,7 @@ func TestInstallScript(t *testing.T) { options: installOptions{ installToken: installToken, }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, postChecks: []checkFunc{ checkBinaryCreated, checkBinaryIsRunning, @@ -61,8 +61,6 @@ func TestInstallScript(t *testing.T) { checkUserConfigCreated, checkEphemeralNotInConfig(userConfigPath), checkTokenInConfig, - - checkUserNotExists, checkHostmetricsConfigNotCreated, checkTokenEnvFileNotCreated, }, @@ -73,7 +71,7 @@ func TestInstallScript(t *testing.T) { installToken: installToken, ephemeral: true, }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, postChecks: []checkFunc{ checkBinaryCreated, checkBinaryIsRunning, @@ -82,8 +80,6 @@ func TestInstallScript(t *testing.T) { checkUserConfigCreated, checkTokenInConfig, checkEphemeralInConfig(userConfigPath), - - checkUserNotExists, checkHostmetricsConfigNotCreated, checkTokenEnvFileNotCreated, }, @@ -94,7 +90,7 @@ func TestInstallScript(t *testing.T) { installToken: installToken, installHostmetrics: true, }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, postChecks: []checkFunc{ checkBinaryCreated, checkBinaryIsRunning, @@ -103,8 +99,6 @@ func TestInstallScript(t *testing.T) { checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), checkUserConfigCreated, checkTokenInConfig, - - checkUserNotExists, checkHostmetricsConfigCreated, checkHostmetricsOwnershipAndPermissions(rootUser, rootGroup), }, @@ -115,7 +109,7 @@ func TestInstallScript(t *testing.T) { installToken: installToken, remotelyManaged: true, }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, postChecks: []checkFunc{ checkBinaryCreated, checkBinaryIsRunning, @@ -124,8 +118,6 @@ func TestInstallScript(t *testing.T) { checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), checkTokenInSumoConfig, checkEphemeralNotInConfig(configPath), - - checkUserNotExists, }, }, { @@ -135,7 +127,7 @@ func TestInstallScript(t *testing.T) { remotelyManaged: true, ephemeral: true, }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, postChecks: []checkFunc{ checkBinaryCreated, checkBinaryIsRunning, @@ -144,8 +136,6 @@ func TestInstallScript(t *testing.T) { checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), checkTokenInSumoConfig, checkEphemeralInConfig(configPath), - - checkUserNotExists, }, }, { @@ -155,7 +145,7 @@ func TestInstallScript(t *testing.T) { remotelyManaged: true, opampEndpoint: "wss://example.com", }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, postChecks: []checkFunc{ checkBinaryCreated, checkBinaryIsRunning, @@ -164,8 +154,6 @@ func TestInstallScript(t *testing.T) { checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), checkTokenInSumoConfig, checkEphemeralNotInConfig(configPath), - - checkUserNotExists, checkOpAmpEndpointSet, }, }, @@ -177,7 +165,7 @@ func TestInstallScript(t *testing.T) { "PATH": "/sbin:/bin:/usr/sbin:/usr/bin", }, }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, postChecks: []checkFunc{ checkBinaryCreated, checkBinaryIsRunning, @@ -185,8 +173,6 @@ func TestInstallScript(t *testing.T) { checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), checkUserConfigCreated, checkTokenInConfig, - - checkUserNotExists, }, }, { @@ -195,7 +181,7 @@ func TestInstallScript(t *testing.T) { installToken: installToken, }, preActions: []checkFunc{preActionMockUserConfig, preActionWriteTokenToUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkUserConfigCreated, checkTokenInConfig}, }, { @@ -204,7 +190,7 @@ func TestInstallScript(t *testing.T) { installToken: installToken, }, preActions: []checkFunc{preActionMockUserConfig, preActionWriteDifferentTokenToUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkAbortedDueToDifferentToken}, installCode: 1, }, @@ -214,7 +200,7 @@ func TestInstallScript(t *testing.T) { installToken: installToken, }, preActions: []checkFunc{preActionMockUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkTokenInConfig}, }, { @@ -224,7 +210,7 @@ func TestInstallScript(t *testing.T) { installToken: installToken, }, preActions: []checkFunc{preActionMockUserConfig, preActionWriteEmptyUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkTokenInConfig}, }, { @@ -234,7 +220,7 @@ func TestInstallScript(t *testing.T) { skipInstallToken: true, }, preActions: []checkFunc{preActionMockUserConfig, preActionWriteAPIBaseURLToUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkUserConfigCreated, checkAPIBaseURLInConfig}, }, { @@ -244,7 +230,7 @@ func TestInstallScript(t *testing.T) { skipInstallToken: true, }, preActions: []checkFunc{preActionMockUserConfig, preActionWriteDifferentAPIBaseURLToUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkAbortedDueToDifferentAPIBaseURL}, installCode: 1, @@ -256,7 +242,7 @@ func TestInstallScript(t *testing.T) { skipInstallToken: true, }, preActions: []checkFunc{preActionMockUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkAPIBaseURLInConfig}, }, { @@ -266,13 +252,13 @@ func TestInstallScript(t *testing.T) { skipInstallToken: true, }, preActions: []checkFunc{preActionMockUserConfig, preActionWriteEmptyUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkAPIBaseURLInConfig}, }, { name: "empty installation token", preActions: []checkFunc{preActionMockUserConfig, preActionWriteDifferentTokenToUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkDifferentTokenInConfig}, }, { @@ -287,7 +273,7 @@ func TestInstallScript(t *testing.T) { "numeric": "1_024", }, }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, postChecks: []checkFunc{ checkBinaryCreated, checkBinaryIsRunning, @@ -309,7 +295,7 @@ func TestInstallScript(t *testing.T) { }, }, preActions: []checkFunc{preActionMockUserConfig, preActionWriteTagsToUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkUserConfigCreated, checkTags}, }, { @@ -325,7 +311,7 @@ func TestInstallScript(t *testing.T) { }, }, preActions: []checkFunc{preActionMockUserConfig, preActionWriteDifferentTagsToUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkDifferentTags, checkAbortedDueToDifferentTags}, installCode: 1, @@ -343,7 +329,7 @@ func TestInstallScript(t *testing.T) { }, }, preActions: []checkFunc{preActionMockUserConfig, preActionWriteEmptyUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkUserNotExists}, + preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkTags}, }, } { From b1d188e819f4986a2a996787a39ad11d5abbd29f Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Fri, 30 Aug 2024 13:55:03 -0700 Subject: [PATCH 20/32] Fixes for darwin and windows Signed-off-by: Eric Chlebek --- install-script/test/command_unix.go | 2 ++ install-script/test/install_darwin_test.go | 1 - install-script/test/install_windows_test.go | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/install-script/test/command_unix.go b/install-script/test/command_unix.go index 6d4674ba..05d0c40a 100644 --- a/install-script/test/command_unix.go +++ b/install-script/test/command_unix.go @@ -28,6 +28,8 @@ type installOptions struct { ephemeral bool timeout float64 opampEndpoint string + downloadOnly bool + dontKeepDownloads bool } func (io *installOptions) string() []string { diff --git a/install-script/test/install_darwin_test.go b/install-script/test/install_darwin_test.go index 9bc50cb8..89bc973f 100644 --- a/install-script/test/install_darwin_test.go +++ b/install-script/test/install_darwin_test.go @@ -92,7 +92,6 @@ func TestInstallScriptDarwin(t *testing.T) { postChecks: []checkFunc{ checkBinaryCreated, checkBinaryIsRunning, - checkLatestAppVersion, checkConfigCreated, checkConfigFilesOwnershipAndPermissions(systemUser, systemGroup), checkUserConfigCreated, diff --git a/install-script/test/install_windows_test.go b/install-script/test/install_windows_test.go index 9fcff317..d94df1cb 100644 --- a/install-script/test/install_windows_test.go +++ b/install-script/test/install_windows_test.go @@ -27,7 +27,6 @@ func TestInstallScript(t *testing.T) { postChecks: []checkFunc{ checkBinaryCreated, checkBinaryIsRunning, - checkLatestAppVersion, checkConfigCreated, checkConfigFilesOwnershipAndPermissions(localSystemSID), checkUserConfigCreated, From c9fadf8b1245bea4e27e2cab034396dc027e2de0 Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Fri, 30 Aug 2024 13:57:40 -0700 Subject: [PATCH 21/32] Fix add-tag routine Signed-off-by: Eric Chlebek --- install-script/install.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/install-script/install.sh b/install-script/install.sh index 39ed0231..be207e6d 100755 --- a/install-script/install.sh +++ b/install-script/install.sh @@ -888,7 +888,8 @@ function write_opamp_endpoint() { # write tags to user configuration file function write_tags() { - for field in "${1[@]}" + arr=("$@") + for field in "${arr[@]}"; do otelcol-config --add-tag "$field" done From a8510d1a9044c0228eed59dcb2aaf04fe63be212 Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Fri, 30 Aug 2024 14:08:39 -0700 Subject: [PATCH 22/32] Don't show usage for no args when token env set Signed-off-by: Eric Chlebek --- install-script/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install-script/install.sh b/install-script/install.sh index be207e6d..b506997d 100755 --- a/install-script/install.sh +++ b/install-script/install.sh @@ -176,7 +176,7 @@ function set_defaults() { } function parse_options() { - if (( $# == 0 )); then + if [[ $# == 0 && -z "${SUMOLOGIC_INSTALLATION_TOKEN}" ]]; then usage exit 2 fi From 76cfe53f1e28f6df4ac5ff7a72555bd933f59a60 Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Fri, 30 Aug 2024 15:56:49 -0700 Subject: [PATCH 23/32] use --quiet for calling package managers Signed-off-by: Eric Chlebek --- install-script/install.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/install-script/install.sh b/install-script/install.sh index b506997d..c9c4dfc0 100755 --- a/install-script/install.sh +++ b/install-script/install.sh @@ -730,13 +730,13 @@ function uninstall_darwin() { function uninstall_linux() { case $(get_package_manager) in yum | dnf) - yum remove -y otelcol-sumo + yum remove --quiet -y otelcol-sumo ;; apt-get) if [[ "${PURGE}" == "true" ]]; then - apt-get purge -y otelcol-sumo + apt-get purge --quiet -y otelcol-sumo else - apt-get remove -y otelcol-sumo + apt-get remove --quiet -y otelcol-sumo fi ;; esac From be149ca97e466a5f2f99f21fd14a679c8e454cc5 Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Fri, 30 Aug 2024 16:03:02 -0700 Subject: [PATCH 24/32] Fix crash when otelcol-config is not yet installed Signed-off-by: Eric Chlebek --- install-script/install.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/install-script/install.sh b/install-script/install.sh index c9c4dfc0..ea76c84a 100755 --- a/install-script/install.sh +++ b/install-script/install.sh @@ -1151,7 +1151,9 @@ if [[ "${UNINSTALL}" == "true" ]]; then fi # Attempt to find a token from an existing installation -USER_TOKEN=$(otelcol-config --read-kv .extensions.sumologic.installation_token) +if command -v otelcol-config &> /dev/null; then + USER_TOKEN=$(otelcol-config --read-kv .extensions.sumologic.installation_token) +fi if [[ -z "${USER_TOKEN}" ]]; then USER_TOKEN="$(get_user_env_config "${TOKEN_ENV_FILE}")" fi From 2ec9db379a1cb74b31212f2f2bbf64bcb77e1d25 Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Fri, 30 Aug 2024 16:05:06 -0700 Subject: [PATCH 25/32] Use more --quiet Signed-off-by: Eric Chlebek --- install-script/install.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/install-script/install.sh b/install-script/install.sh index ea76c84a..d269fe21 100755 --- a/install-script/install.sh +++ b/install-script/install.sh @@ -1092,13 +1092,13 @@ function install_linux_package() { case $(get_package_manager) in yum | dnf) curl -s https://packagecloud.io/install/repositories/sumologic/stable/script.rpm.sh | bash - yum --disablerepo="*" --enablerepo="sumologic_stable" -y update - yum install "${package_with_version}" + yum --quiet --disablerepo="*" --enablerepo="sumologic_stable" -y update + yum install --quiet "${package_with_version}" ;; apt-get) curl -s https://packagecloud.io/install/repositories/sumologic/stable/script.deb.sh | bash - apt-get update -y -o Dir::Etc::sourcelist="sources.list.d/sumologic_stable" - apt-get install "${package_with_version}" + apt-get update --quiet -y -o Dir::Etc::sourcelist="sources.list.d/sumologic_stable" + apt-get install --quiet "${package_with_version}" ;; esac } From 845bfb8dbab6c11b56869ac66481d44281801bdd Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Fri, 30 Aug 2024 16:47:03 -0700 Subject: [PATCH 26/32] Use otelcol-config for darwin-specific subroutine Signed-off-by: Eric Chlebek --- install-script/install.sh | 38 ++++++++++---------------------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/install-script/install.sh b/install-script/install.sh index d269fe21..b0f76cf8 100755 --- a/install-script/install.sh +++ b/install-script/install.sh @@ -87,7 +87,6 @@ VERSION="" FIPS=false CONTINUE=false CONFIG_DIRECTORY="" -USER_CONFIG_DIRECTORY="" USER_ENV_DIRECTORY="" UNINSTALL="" SUMO_BINARY_PATH="" @@ -160,12 +159,10 @@ function set_defaults() { DOWNLOAD_CACHE_DIR="/var/cache/otelcol-sumo" # this is in case we want to keep downloaded binaries CONFIG_DIRECTORY="/etc/otelcol-sumo" SUMO_BINARY_PATH="/usr/local/bin/otelcol-sumo" - USER_CONFIG_DIRECTORY="${CONFIG_DIRECTORY}/conf.d" REMOTE_CONFIG_DIRECTORY="${CONFIG_DIRECTORY}/opamp.d" USER_ENV_DIRECTORY="${CONFIG_DIRECTORY}/env" TOKEN_ENV_FILE="${USER_ENV_DIRECTORY}/token.env" CONFIG_PATH="${CONFIG_DIRECTORY}/sumologic.yaml" - COMMON_CONFIG_PATH="${USER_CONFIG_DIRECTORY}/common.yaml" LAUNCHD_CONFIG="/Library/LaunchDaemons/com.sumologic.otelcol-sumo.plist" LAUNCHD_ENV_KEY="EnvironmentVariables" @@ -653,18 +650,6 @@ function setup_config() { } function setup_config_darwin() { - local config_path - config_path="${COMMON_CONFIG_PATH}" - - if [[ "${REMOTELY_MANAGED}" == "true" ]]; then - config_path="${CONFIG_PATH}" - fi - - readonly config_path - - create_user_config_file "${config_path}" - add_extension_to_config "${config_path}" - if [[ "${EPHEMERAL}" == "true" ]]; then write_ephemeral_true fi @@ -1139,7 +1124,7 @@ check_dependencies check_deprecated_linux_flags readonly SUMOLOGIC_INSTALLATION_TOKEN API_BASE_URL OPAMP_API_URL FIELDS CONTINUE CONFIG_DIRECTORY UNINSTALL -readonly USER_CONFIG_DIRECTORY USER_ENV_DIRECTORY CONFIG_DIRECTORY CONFIG_PATH COMMON_CONFIG_PATH +readonly USER_ENV_DIRECTORY CONFIG_DIRECTORY CONFIG_PATH COMMON_CONFIG_PATH readonly INSTALL_HOSTMETRICS readonly REMOTELY_MANAGED readonly CURL_MAX_TIME @@ -1173,19 +1158,16 @@ if [[ -z "${DOWNLOAD_ONLY}" ]]; then exit 1 fi - if [[ -f "${COMMON_CONFIG_PATH}" ]]; then - USER_API_URL="$(get_user_api_url "${COMMON_CONFIG_PATH}")" - if [[ -n "${USER_API_URL}" && -n "${API_BASE_URL}" && "${USER_API_URL}" != "${API_BASE_URL}" ]]; then - echo "You are trying to install with different api base url than in your configuration file!" - exit 1 - fi - - USER_OPAMP_API_URL="$(get_user_opamp_endpoint "${COMMON_CONFIG_PATH}")" - if [[ -n "${USER_OPAMP_API_URL}" && -n "${OPAMP_API_URL}" && "${USER_OPAMP_API_URL}" != "${OPAMP_API_URL}" ]]; then - echo "You are trying to install with different opamp endpoint than in your configuration file!" - exit 1 - fi + USER_API_URL="$(get_user_api_url)" + if [[ -n "${USER_API_URL}" && -n "${API_BASE_URL}" && "${USER_API_URL}" != "${API_BASE_URL}" ]]; then + echo "You are trying to install with different api base url than in your configuration file!" + exit 1 + fi + USER_OPAMP_API_URL="$(get_user_opamp_endpoint "${COMMON_CONFIG_PATH}")" + if [[ -n "${USER_OPAMP_API_URL}" && -n "${OPAMP_API_URL}" && "${USER_OPAMP_API_URL}" != "${OPAMP_API_URL}" ]]; then + echo "You are trying to install with different opamp endpoint than in your configuration file!" + exit 1 fi fi From 25fb27981a3dcecd57c8fc4ecf7df9e65d860594 Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Fri, 30 Aug 2024 16:50:40 -0700 Subject: [PATCH 27/32] Replace common.yaml with 00-otelcol-config-settings.yaml Signed-off-by: Eric Chlebek --- install-script/test/consts_darwin.go | 2 +- install-script/test/consts_unix.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/install-script/test/consts_darwin.go b/install-script/test/consts_darwin.go index d3d9b45a..5594a88e 100644 --- a/install-script/test/consts_darwin.go +++ b/install-script/test/consts_darwin.go @@ -8,7 +8,7 @@ const ( uninstallScriptPath string = appSupportDirPath + "/uninstall.sh" // TODO: fix mismatch between darwin permissions & linux binary install permissions - // common.yaml must be writable as the install scripts mutate it + // 00-otelcol-config-settings.yaml must be writable as the install scripts mutate it commonConfigPathFilePermissions uint32 = 0660 configPathDirPermissions uint32 = 0770 configPathFilePermissions uint32 = 0440 diff --git a/install-script/test/consts_unix.go b/install-script/test/consts_unix.go index 21e83984..9b21fb18 100644 --- a/install-script/test/consts_unix.go +++ b/install-script/test/consts_unix.go @@ -11,7 +11,7 @@ const ( configPath string = etcPath + "/sumologic.yaml" confDPath string = etcPath + "/conf.d" opampDPath string = etcPath + "/opamp.d" - userConfigPath string = confDPath + "/common.yaml" + userConfigPath string = confDPath + "/00-otelcol-config-settings.yaml" hostmetricsConfigPath string = confDPath + "/hostmetrics.yaml" cacheDirectory string = "/var/cache/otelcol-sumo/" logDirPath string = "/var/log/otelcol-sumo" From c80cbe499a22d9bbcad5d02c8c216e880ad2e34b Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Tue, 3 Sep 2024 11:20:19 -0700 Subject: [PATCH 28/32] Complain when installation token not provided Signed-off-by: Eric Chlebek --- install-script/install.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/install-script/install.sh b/install-script/install.sh index b0f76cf8..433a7440 100755 --- a/install-script/install.sh +++ b/install-script/install.sh @@ -174,6 +174,7 @@ function set_defaults() { function parse_options() { if [[ $# == 0 && -z "${SUMOLOGIC_INSTALLATION_TOKEN}" ]]; then + echo "Installation token has not been provided. Please set the 'SUMOLOGIC_INSTALLATION_TOKEN' environment variable." usage exit 2 fi From 5655ac883795052401573cbf351884313daa03cb Mon Sep 17 00:00:00 2001 From: Eric Chlebek Date: Tue, 3 Sep 2024 11:22:40 -0700 Subject: [PATCH 29/32] Use otelcol-config for configuration in install.sh Use `otelcol-config` in `install.sh` to perform configuration on both Linux and macOS. The `--download-only` flag will now only work for macOS as it is not applicable to other platforms. The `--skip-config` flag has been removed as it is no longer applicable to installations. The `--skip-token` flag has been deprecated and install.sh will now exit with an error if no token has been provided. Add `DARWIN_PKG_URL` for overriding the URL used to download macOS packages in `install.sh`. Move test-install-script job to build_packages workflow Ownership and permissions of files should no longer be changed by `install.sh`. Added `conf.d-available` directory to Linux & macOS packages. Added `opamp.d` directory to Linux & macOS packages. The `ci-builds` repository is now used in CI for install script tests. Set timeout for test-install-script to 15 mins. Print launchdaemon state while waiting for start. In cases where we detect an existing installation of the collector made with the previous install script (without a package manager), we clean it up, backup its config, and setup the collector using the package manager. Add .op directory to .gitignore Use service wrapper on linux & macOS to change which flags are used to start the `otelcol-sumo` service depending on the existance of the `sumologic-remote.yaml` file. Co-authored-by: Cyril Cressent Co-authored-by: Eric Chlebek Signed-off-by: Justin Kolberg --- .github/workflows/_reusable_build_package.yml | 19 +- .github/workflows/build_packages.yml | 71 ++ .github/workflows/test-install-script.yml | 50 -- .gitignore | 3 + CMakeLists.txt | 2 +- assets/.keep | 0 assets/conf.d/ephemeral.yaml | 3 + assets/productbuild/uninstall.sh | 11 +- .../launchd/com.sumologic.otelcol-sumo.plist | 11 +- assets/services/systemd/otelcol-sumo.service | 2 +- ci/verify_installer.sh | 62 +- components/otelcol-sumo.cmake | 100 ++- docker/install-deps.sh | 2 +- install-script/install.sh | 738 ++++++++---------- install-script/test/Makefile | 10 +- install-script/test/check.go | 215 ++--- install-script/test/check_darwin.go | 162 ++-- install-script/test/check_linux.go | 261 +++---- install-script/test/check_unix.go | 123 ++- install-script/test/check_windows.go | 116 ++- install-script/test/command_unix.go | 47 +- install-script/test/command_windows.go | 17 +- install-script/test/common.go | 33 + install-script/test/common_darwin.go | 123 ++- install-script/test/common_linux.go | 7 +- install-script/test/common_unix.go | 66 +- install-script/test/common_windows.go | 54 +- install-script/test/config.go | 3 + install-script/test/consts_common.go | 3 + install-script/test/consts_darwin.go | 11 +- install-script/test/consts_linux.go | 8 - install-script/test/consts_unix.go | 41 +- install-script/test/consts_windows.go | 1 + install-script/test/install_darwin_test.go | 168 +--- install-script/test/install_unix_test.go | 253 +----- install-script/test/install_windows_test.go | 4 +- packages.cmake | 39 +- settings/otc.cmake | 7 + 38 files changed, 1423 insertions(+), 1423 deletions(-) delete mode 100644 .github/workflows/test-install-script.yml create mode 100644 assets/.keep create mode 100644 assets/conf.d/ephemeral.yaml diff --git a/.github/workflows/_reusable_build_package.yml b/.github/workflows/_reusable_build_package.yml index c7b71034..85cb395d 100644 --- a/.github/workflows/_reusable_build_package.yml +++ b/.github/workflows/_reusable_build_package.yml @@ -77,18 +77,6 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Download packagecloud-go tool - run: | - baseURL="https://github.com/amdprophet/packagecloud-go/releases/download" - version="0.1.5" - file="packagecloud-go_${version}_linux_amd64.tar.gz" - curl -Lo /tmp/packagecloud-go.tar.gz $baseURL/$version/$file - - - name: Install packagecloud-go tool - run: | - tar -C /tmp -zxf /tmp/packagecloud-go.tar.gz - sudo mv /tmp/packagecloud /usr/local/bin - - name: Workflow URL for sumologic-otel-collector if: ${{ !inputs.use_release_artifacts && inputs.workflow_id != '' }} run: | @@ -235,6 +223,13 @@ jobs: target: publish-package packagecloud-token: ${{ secrets.PACKAGECLOUD_TOKEN }} + - name: Wait for Packagecloud packages to be indexed + if: runner.os == 'Linux' + uses: ./ci/github-actions/make + with: + target: wait-for-packagecloud-indexing + packagecloud-token: ${{ secrets.PACKAGECLOUD_TOKEN }} + test_package: runs-on: ${{ inputs.runs_on }} name: Test (CMake) diff --git a/.github/workflows/build_packages.yml b/.github/workflows/build_packages.yml index a7f313d0..92bfbf43 100644 --- a/.github/workflows/build_packages.yml +++ b/.github/workflows/build_packages.yml @@ -331,3 +331,74 @@ jobs: This release packages [${{ env.OTC_APP_VERSION }}](https://github.com/SumoLogic/sumologic-otel-collector/releases/tag/${{ env.OTC_APP_VERSION }}). The changelog below is for the package itself, rather than the Sumo Logic Distribution for OpenTelemetry Collector. + + test-install-script: + name: Test Install Script + runs-on: ${{ matrix.runs_on }} + timeout-minutes: 60 + needs: + - determine_version + - build_packages + strategy: + fail-fast: false + matrix: + include: + - arch_os: linux_amd64 + runs_on: ubuntu-20.04 + - arch_os: darwin_amd64 + runs_on: macos-latest + - arch_os: windows_amd64 + runs_on: windows-2022 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_CI_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OTC_VERSION: ${{ needs.determine_version.outputs.otc_version }} + OTC_BUILD_NUMBER: ${{ github.run_number }} + PACKAGECLOUD_MASTER_TOKEN: ${{ secrets.PACKAGECLOUD_MASTER_TOKEN }} + PACKAGECLOUD_REPO: ci-builds + steps: + - uses: actions/checkout@v4 + + - name: Check if test related files changed + id: changed-files + uses: tj-actions/changed-files@v44 + with: + files: | + install-script/**/* + .github/** + + - name: Setup go + if: steps.changed-files.outputs.any_changed == 'true' + uses: WillAbides/setup-go-faster@v1 + with: + go-version: stable + + - name: Download macOS package and use it for install.sh + if: ${{ steps.changed-files.outputs.any_changed == 'true' && runner.os == 'macOS' }} + uses: actions/download-artifact@v4 + with: + path: artifacts/ + pattern: otelcol-sumo_*-intel.pkg + + - name: Show packages + if: ${{ steps.changed-files.outputs.any_changed == 'true' && runner.os == 'macOS' }} + run: | + ls -l artifacts/ + ls -l artifacts/**/* + + - name: Set DARWIN_PKG_URL + if: ${{ steps.changed-files.outputs.any_changed == 'true' && runner.os == 'macOS' }} + run: | + fp="$(readlink -f artifacts/otelcol-sumo_*-intel.pkg/otelcol-sumo_*-intel.pkg)" + echo DARWIN_PKG_URL="file://${fp}" >> $GITHUB_ENV + + - name: Run install script tests (*nix) + if: steps.changed-files.outputs.any_changed == 'true' && runner.os != 'Windows' + working-directory: install-script/test + run: make test + + - name: Run install script tests (Windows) + shell: powershell + if: steps.changed-files.outputs.any_changed == 'true' && runner.os == 'Windows' + working-directory: install-script/test + run: make test diff --git a/.github/workflows/test-install-script.yml b/.github/workflows/test-install-script.yml deleted file mode 100644 index 15eefe6a..00000000 --- a/.github/workflows/test-install-script.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: 'Test install script' - -defaults: - run: - shell: bash - -on: - pull_request: - -jobs: - test-install-script: - name: Test Install Script - runs-on: ${{ matrix.runs_on }} - strategy: - fail-fast: false - matrix: - include: - - arch_os: linux_amd64 - runs_on: ubuntu-20.04 - - arch_os: darwin_amd64 - runs_on: macos-latest - - arch_os: windows_amd64 - runs_on: windows-2022 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_CI_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@v4 - - - name: Check if test related files changed - id: changed-files - uses: tj-actions/changed-files@v44 - with: - files: | - install-script/**/* - .github/** - - - name: Setup go - if: steps.changed-files.outputs.any_changed == 'true' - uses: WillAbides/setup-go-faster@v1 - with: - go-version: stable - - - name: Install otelcol-config - run: go install github.com/SumoLogic/sumologic-otel-collector/pkg/tools/otelcol-config@latest - - - name: Run install script tests - if: steps.changed-files.outputs.any_changed == 'true' - working-directory: install-script/test - run: make test diff --git a/.gitignore b/.gitignore index 11530c4c..37e3d589 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ wix/packages msi/wix/packages msi/SumoLogic.wixext/packages install-script/test/sumologic_scripts_tests.test + +# 1Password +.op/ diff --git a/CMakeLists.txt b/CMakeLists.txt index bee55ed6..7dbefff8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,7 +3,7 @@ cmake_minimum_required(VERSION 3.24.1 FATAL_ERROR) # Required and optional programs. Attempts to find required and optional # programs used to build the packages. -find_program(PACKAGECLOUD_PROGRAM packagecloud REQUIRED) +find_program(PACKAGECLOUD_PROGRAM packagecloud) # Set version information include("${CMAKE_SOURCE_DIR}/version.cmake") diff --git a/assets/.keep b/assets/.keep new file mode 100644 index 00000000..e69de29b diff --git a/assets/conf.d/ephemeral.yaml b/assets/conf.d/ephemeral.yaml new file mode 100644 index 00000000..4aa2d995 --- /dev/null +++ b/assets/conf.d/ephemeral.yaml @@ -0,0 +1,3 @@ +extensions: + sumologic: + ephemeral: true diff --git a/assets/productbuild/uninstall.sh b/assets/productbuild/uninstall.sh index c17d1b34..5cce4d9e 100755 --- a/assets/productbuild/uninstall.sh +++ b/assets/productbuild/uninstall.sh @@ -43,19 +43,19 @@ collector_files=( "/etc/otelcol-sumo/sumologic.yaml" "/etc/otelcol-sumo/conf.d" "/etc/otelcol-sumo/conf.d-available" + "/etc/otelcol-sumo/conf.d-available/ephemeral.yaml" + "/etc/otelcol-sumo/conf.d-available/hostmetrics.yaml" "/etc/otelcol-sumo" + "/etc/otelcol-sumo/opamp.d" "/usr/local/bin/otelcol-config" "/usr/local/bin/otelcol-sumo" + "/usr/local/share/otelcol-sumo/otelcol-sumo.sh" + "/usr/local/share/otelcol-sumo" "/var/lib/otelcol-sumo/file_storage" "/var/lib/otelcol-sumo" "/var/log/otelcol-sumo" ) -# A list of files & directories to remove for hostmetrics -hostmetrics_files=( - "/etc/otelcol-sumo/conf.d-available/hostmetrics.yaml" -) - function package_is_registered() { package_id="$1" pkgutil --pkg-info "$package_id" @@ -114,7 +114,6 @@ function uninstall_package() { stop_service "${service_plist_file}" uninstall_package "com.sumologic.otelcol-sumo" "${collector_files[@]}" -uninstall_package "com.sumologic.otelcol-sumo-hostmetrics" "${hostmetrics_files[@]}" # remove the directory that this script belongs to SCRIPT_DIR="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" diff --git a/assets/services/launchd/com.sumologic.otelcol-sumo.plist b/assets/services/launchd/com.sumologic.otelcol-sumo.plist index 0aaaf732..b1aec424 100644 --- a/assets/services/launchd/com.sumologic.otelcol-sumo.plist +++ b/assets/services/launchd/com.sumologic.otelcol-sumo.plist @@ -6,12 +6,13 @@ otelcol-sumo ProgramArguments - /usr/local/bin/otelcol-sumo - --config - /etc/otelcol-sumo/sumologic.yaml - --config - glob:/etc/otelcol-sumo/conf.d/*.yaml + /usr/local/share/otelcol-sumo/otelcol-sumo.sh + EnvironmentVariables + + SUMOLOGIC_INSTALLATION_TOKEN + + UserName _otelcol-sumo diff --git a/assets/services/systemd/otelcol-sumo.service b/assets/services/systemd/otelcol-sumo.service index cc56b3ba..d435184b 100644 --- a/assets/services/systemd/otelcol-sumo.service +++ b/assets/services/systemd/otelcol-sumo.service @@ -2,7 +2,7 @@ Description=Sumo Logic Distribution for OpenTelemetry Collector [Service] -ExecStart=/usr/local/bin/otelcol-sumo --config /etc/otelcol-sumo/sumologic.yaml --config "glob:/etc/otelcol-sumo/conf.d/*.yaml" +ExecStart=/usr/share/otelcol-sumo/otelcol-sumo.sh User=otelcol-sumo Group=otelcol-sumo MemoryHigh=2000M diff --git a/ci/verify_installer.sh b/ci/verify_installer.sh index 12a16d94..26a7314f 100755 --- a/ci/verify_installer.sh +++ b/ci/verify_installer.sh @@ -37,6 +37,7 @@ system_files=( "usr" "usr/local" "usr/local/bin" + "usr/local/share" "var" "var/lib" "var/log" @@ -48,23 +49,23 @@ expected_collector_files=( "etc/otelcol-sumo/conf.d" "etc/otelcol-sumo/conf.d/common.yaml" "etc/otelcol-sumo/conf.d-available" + "etc/otelcol-sumo/conf.d-available/ephemeral.yaml" + "etc/otelcol-sumo/conf.d-available/hostmetrics.yaml" "etc/otelcol-sumo/conf.d-available/examples" + "etc/otelcol-sumo/opamp.d" "etc/otelcol-sumo/sumologic.yaml" "Library/Application Support/otelcol-sumo" "Library/Application Support/otelcol-sumo/uninstall.sh" "Library/LaunchDaemons/com.sumologic.otelcol-sumo.plist" "usr/local/bin/otelcol-config" "usr/local/bin/otelcol-sumo" + "usr/local/share/otelcol-sumo" + "usr/local/share/otelcol-sumo/otelcol-sumo.sh" "var/lib/otelcol-sumo" "var/lib/otelcol-sumo/file_storage" "var/log/otelcol-sumo" ) -# a list of files that the hostmetrics package should install -expected_hostmetrics_files=( - "etc/otelcol-sumo/conf.d-available/hostmetrics.yaml" -) - function install_package() { mpkg="$1" mpkg_basename="$2" @@ -184,15 +185,9 @@ while IFS= read -r -d $'\0'; do collector_pkg+=("$REPLY") done < <(find . -name "*-otelcol-sumo.pkg" -type d -print0) -# create an array of hostmetrics packages (only one is expected) -hostmetrics_pkg=() -while IFS= read -r -d $'\0'; do - hostmetrics_pkg+=("$REPLY") -done < <(find . -name "*-otelcol-sumo-hostmetrics.pkg" -type d -print0) - # verify that the expected number of sub-packages were found pkg_count="${#all_pkgs[@]}" -expected_pkg_count=2 +expected_pkg_count=1 if [ "$pkg_count" -ne $expected_pkg_count ]; then echo "error: ${expected_pkg_count} sub-packages were expected but found ${pkg_count}" @@ -205,12 +200,6 @@ if [ "${#collector_pkg[@]}" -gt 1 ]; then exit 1 fi -# only one hostmetrics sub-package should exist -if [ "${#hostmetrics_pkg[@]}" -gt 1 ]; then - echo "error: more than one hostmetrics sub-package was found" - exit 1 -fi - # get a list of files installed by the collector sub-package excluding system # files collector_pkg_name="$(echo "${collector_pkg[0]}" | cut -d/ -f2-)" @@ -243,41 +232,6 @@ for f in "${all_collector_files[@]}"; do collector_files+=("$collector_file") done -cd "$expanded_dir" || exit - -# get a list of files installed by the hostmetrics sub-package excluding both -# system files and collector files -hostmetrics_pkg_name="$(echo "${hostmetrics_pkg[0]}" | cut -d/ -f2-)" -cd "${hostmetrics_pkg_name}/Payload" || exit -all_hostmetrics_files=() -while IFS= read -r -d $'\0'; do - all_hostmetrics_files+=("$REPLY") -done < <(find . ! -name '.' -print0) - -hostmetrics_files=() - -for f in "${all_hostmetrics_files[@]}"; do - hostmetrics_file="$(echo "$f" | cut -d/ -f2-)" - - # shellcheck disable=SC2076 - if [[ " ${system_files[*]} " =~ " ${hostmetrics_file} " ]]; then - continue - fi - - # shellcheck disable=SC2076 - if [[ " ${collector_files[*]} " =~ " ${hostmetrics_file} " ]]; then - continue - fi - - # shellcheck disable=SC2076 - if [[ ! " ${expected_collector_files[*]} " =~ " ${collector_file} " ]]; then - echo "error: unexpected file installed by hostmetrics sub-package: ${hostmetrics_file}" - exit 1 - fi - - hostmetrics_files+=("$hostmetrics_file") -done - cd "$exec_dir" || exit install_package "$mpkg" "$mpkg_basename" @@ -287,7 +241,6 @@ echo "########################################################################## echo "Verifying installation: ${mpkg}" echo "################################################################################" verify_installation "com.sumologic.otelcol-sumo" "${expected_collector_files[@]}" -verify_installation "com.sumologic.otelcol-sumo-hostmetrics" "${expected_hostmetrics_files[@]}" echo echo "################################################################################" @@ -305,6 +258,5 @@ echo "########################################################################## echo "Verifying uninstallation: ${mpkg}" echo "################################################################################" verify_uninstallation "com.sumologic.otelcol-sumo" "${expected_collector_files[@]}" -verify_uninstallation "com.sumologic.otelcol-sumo-hostmetrics" "${expected_hostmetrics_files[@]}" echo "Success!" diff --git a/components/otelcol-sumo.cmake b/components/otelcol-sumo.cmake index 147949fe..60d441e8 100644 --- a/components/otelcol-sumo.cmake +++ b/components/otelcol-sumo.cmake @@ -1,29 +1,34 @@ macro(default_otc_linux_install) - create_otc_components() install_otc_config_directory() install_otc_config_fragment_directory() + install_otc_config_fragments_available_directory() install_otc_config_examples() install_otc_user_env_directory() + install_otc_opampd_directory() install_otc_state_directory() install_otc_filestorage_state_directory() install_otc_sumologic_yaml() install_otc_common_yaml() + install_otc_linux_hostmetrics_yaml() + install_otc_ephemeral_yaml() install_otc_token_env() install_otc_binary() install_otc_config_binary() endmacro() macro(default_otc_darwin_install) - create_otc_components() install_otc_config_directory() install_otc_config_fragment_directory() + install_otc_config_fragments_available_directory() install_otc_config_examples() + install_otc_opampd_directory() install_otc_state_directory() install_otc_filestorage_state_directory() install_otc_log_directory() install_otc_sumologic_yaml() install_otc_common_yaml() install_otc_darwin_hostmetrics_yaml() + install_otc_ephemeral_yaml() install_otc_binary() install_otc_config_binary() install_otc_uninstall_script() @@ -40,12 +45,6 @@ macro(create_otc_components) REQUIRED GROUP "otelcol-sumo-group" ) - - cpack_add_component("otelcol-sumo-hostmetrics" - DISPLAY_NAME "Collect Host Metrics" - GROUP "otelcol-sumo-group" - DISABLED - ) endmacro() # e.g. /etc/otelcol-sumo @@ -58,7 +57,7 @@ macro(install_otc_config_directory) DESTINATION "${OTC_CONFIG_DIR}" DIRECTORY_PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE - GROUP_READ GROUP_EXECUTE + GROUP_READ GROUP_WRITE GROUP_EXECUTE WORLD_EXECUTE COMPONENT otelcol-sumo ) @@ -117,8 +116,8 @@ macro(install_otc_config_examples) FILES "${example}" DESTINATION "${OTC_CONFIG_FRAGMENTS_AVAILABLE_DIR}/examples" PERMISSIONS - OWNER_READ OWNER_WRITE OWNER_EXECUTE - GROUP_READ GROUP_WRITE GROUP_EXECUTE + OWNER_READ OWNER_WRITE + GROUP_READ GROUP_WRITE COMPONENT otelcol-sumo ) endforeach(example) @@ -139,6 +138,21 @@ macro(install_otc_user_env_directory) ) endmacro() +# e.g. /etc/otelcol-sumo/opamp.d +macro(install_otc_opampd_directory) + require_variables( + "OTC_OPAMPD_DIR" + ) + install( + DIRECTORY + DESTINATION "${OTC_OPAMPD_DIR}" + DIRECTORY_PERMISSIONS + OWNER_READ OWNER_WRITE OWNER_EXECUTE + GROUP_READ GROUP_WRITE GROUP_EXECUTE + COMPONENT otelcol-sumo + ) +endmacro() + # e.g. /var/lib/otelcol-sumo macro(install_otc_state_directory) require_variables( @@ -149,7 +163,7 @@ macro(install_otc_state_directory) DESTINATION "${OTC_STATE_DIR}" DIRECTORY_PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE - GROUP_READ GROUP_EXECUTE + GROUP_READ GROUP_WRITE GROUP_EXECUTE COMPONENT otelcol-sumo ) endmacro() @@ -164,7 +178,7 @@ macro(install_otc_filestorage_state_directory) DESTINATION "${OTC_FILESTORAGE_STATE_DIR}" DIRECTORY_PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE - GROUP_READ GROUP_EXECUTE + GROUP_READ GROUP_WRITE GROUP_EXECUTE COMPONENT otelcol-sumo ) endmacro() @@ -254,8 +268,8 @@ macro(install_otc_sumologic_yaml) FILES "${ASSETS_DIR}/sumologic.yaml" DESTINATION "${OTC_CONFIG_DIR}" PERMISSIONS - OWNER_READ - GROUP_READ + OWNER_READ OWNER_WRITE + GROUP_READ GROUP_WRITE RENAME "${OTC_SUMOLOGIC_CONFIG}" COMPONENT otelcol-sumo ) @@ -293,7 +307,7 @@ macro(install_otc_common_yaml) ) endmacro() -# e.g. /etc/otelcol-sumo/conf.d/hostmetrics.yaml +# e.g. /etc/otelcol-sumo/conf.d-available/hostmetrics.yaml macro(install_otc_darwin_hostmetrics_yaml) require_variables( "ASSETS_DIR" @@ -305,13 +319,12 @@ macro(install_otc_darwin_hostmetrics_yaml) RENAME "hostmetrics.yaml" PERMISSIONS OWNER_READ OWNER_WRITE - GROUP_READ - WORLD_READ - COMPONENT otelcol-sumo-hostmetrics + GROUP_READ GROUP_WRITE + COMPONENT otelcol-sumo ) endmacro() -# e.g. /etc/otelcol-sumo/conf.d/hostmetrics.yaml +# e.g. /etc/otelcol-sumo/conf.d-available/hostmetrics.yaml macro(install_otc_linux_hostmetrics_yaml) require_variables( "ASSETS_DIR" @@ -323,9 +336,24 @@ macro(install_otc_linux_hostmetrics_yaml) RENAME "hostmetrics.yaml" PERMISSIONS OWNER_READ OWNER_WRITE - GROUP_READ - WORLD_READ - COMPONENT otelcol-sumo-hostmetrics + GROUP_READ GROUP_WRITE + COMPONENT otelcol-sumo + ) +endmacro() + +# e.g. /etc/otelcol-sumo/conf.d-available/ephemeral.yaml +macro(install_otc_ephemeral_yaml) + require_variables( + "ASSETS_DIR" + "OTC_CONFIG_FRAGMENTS_AVAILABLE_DIR" + ) + install( + FILES "${ASSETS_DIR}/conf.d/ephemeral.yaml" + DESTINATION "${OTC_CONFIG_FRAGMENTS_AVAILABLE_DIR}" + PERMISSIONS + OWNER_READ OWNER_WRITE + GROUP_READ GROUP_WRITE + COMPONENT otelcol-sumo ) endmacro() @@ -333,10 +361,12 @@ endmacro() macro(install_otc_service_systemd) require_variables( "ASSETS_DIR" + "OTC_SYSTEMD_CONFIG" "OTC_SYSTEMD_DIR" ) + install_otc_service_script() install( - FILES "${ASSETS_DIR}/services/systemd/otelcol-sumo.service" + FILES "${ASSETS_DIR}/services/systemd/${OTC_SYSTEMD_CONFIG}" DESTINATION "${OTC_SYSTEMD_DIR}" PERMISSIONS OWNER_READ OWNER_WRITE @@ -350,10 +380,12 @@ endmacro() macro(install_otc_service_launchd) require_variables( "ASSETS_DIR" + "OTC_LAUNCHD_CONFIG" "OTC_LAUNCHD_DIR" ) + install_otc_service_script() install( - FILES "${ASSETS_DIR}/services/launchd/com.sumologic.otelcol-sumo.plist" + FILES "${ASSETS_DIR}/services/launchd/${OTC_LAUNCHD_CONFIG}" DESTINATION "${OTC_LAUNCHD_DIR}" PERMISSIONS OWNER_READ OWNER_WRITE @@ -361,3 +393,21 @@ macro(install_otc_service_launchd) COMPONENT otelcol-sumo ) endmacro() + +# e.g. /usr/share/otelcol-sumo/otelcol-sumo.sh +macro(install_otc_service_script) + require_variables( + "ASSETS_DIR" + "OTC_SERVICE_SCRIPT" + "OTC_SHARE_DIR" + ) + install( + FILES "${ASSETS_DIR}/${OTC_SERVICE_SCRIPT}" + DESTINATION "${OTC_SHARE_DIR}" + PERMISSIONS + OWNER_READ OWNER_WRITE OWNER_EXECUTE + GROUP_READ GROUP_EXECUTE + WORLD_READ WORLD_EXECUTE + COMPONENT otelcol-sumo + ) +endmacro() diff --git a/docker/install-deps.sh b/docker/install-deps.sh index 23ff7f82..007958cf 100755 --- a/docker/install-deps.sh +++ b/docker/install-deps.sh @@ -4,7 +4,7 @@ set -euxo pipefail targetarch="$1" -PACKAGECLOUD_GO_VERSION="0.1.5" +PACKAGECLOUD_GO_VERSION="0.2.2" # Convert between Docker CPU architecture names and other names such as Go's # GOARCH. diff --git a/install-script/install.sh b/install-script/install.sh index 433a7440..cac7adb9 100755 --- a/install-script/install.sh +++ b/install-script/install.sh @@ -21,10 +21,10 @@ ARG_SHORT_FIPS='f' ARG_LONG_FIPS='fips' ARG_SHORT_YES='y' ARG_LONG_YES='yes' -ARG_SHORT_SKIP_CONFIG='s' -ARG_LONG_SKIP_CONFIG='skip-config' ARG_SHORT_UNINSTALL='u' ARG_LONG_UNINSTALL='uninstall' +ARG_SHORT_UPGRADE='g' +ARG_LONG_UPGRADE='upgrade' ARG_SHORT_PURGE='p' ARG_LONG_PURGE='purge' ARG_SHORT_SKIP_TOKEN='k' @@ -51,22 +51,19 @@ ARG_LONG_EPHEMERAL='ephemeral' ARG_SHORT_TIMEOUT='m' ARG_LONG_TIMEOUT='download-timeout' -PACKAGE_GITHUB_ORG="SumoLogic" -PACKAGE_GITHUB_REPO="sumologic-otel-collector-packaging" - readonly ARG_SHORT_TOKEN ARG_LONG_TOKEN ARG_SHORT_HELP ARG_LONG_HELP ARG_SHORT_API ARG_LONG_API readonly ARG_SHORT_TAG ARG_LONG_TAG ARG_SHORT_VERSION ARG_LONG_VERSION ARG_SHORT_YES ARG_LONG_YES readonly ARG_SHORT_UNINSTALL ARG_LONG_UNINSTALL +readonly ARG_SHORT_UPGRADE ARG_LONG_UPGRADE readonly ARG_SHORT_PURGE ARG_LONG_PURGE ARG_SHORT_DOWNLOAD ARG_LONG_DOWNLOAD readonly ARG_SHORT_CONFIG_BRANCH ARG_LONG_CONFIG_BRANCH ARG_SHORT_BINARY_BRANCH ARG_LONG_CONFIG_BRANCH -readonly ARG_SHORT_BRANCH ARG_LONG_BRANCH ARG_SHORT_SKIP_CONFIG ARG_LONG_SKIP_CONFIG +readonly ARG_SHORT_BRANCH ARG_LONG_BRANCH readonly ARG_SHORT_SKIP_TOKEN ARG_LONG_SKIP_TOKEN ARG_SHORT_FIPS ARG_LONG_FIPS ENV_TOKEN readonly ARG_SHORT_INSTALL_HOSTMETRICS ARG_LONG_INSTALL_HOSTMETRICS readonly ARG_SHORT_REMOTELY_MANAGED ARG_LONG_REMOTELY_MANAGED readonly ARG_SHORT_EPHEMERAL ARG_LONG_EPHEMERAL readonly ARG_SHORT_TIMEOUT ARG_LONG_TIMEOUT readonly DEPRECATED_ARG_LONG_TOKEN DEPRECATED_ENV_TOKEN DEPRECATED_ARG_LONG_SKIP_TOKEN -readonly PACKAGE_GITHUB_ORG PACKAGE_GITHUB_REPO ############################ Variables (see set_defaults function for default values) @@ -89,10 +86,8 @@ CONTINUE=false CONFIG_DIRECTORY="" USER_ENV_DIRECTORY="" UNINSTALL="" +UPGRADE="" SUMO_BINARY_PATH="" -SKIP_TOKEN="" -SKIP_CONFIG=false -CONFIG_PATH="" COMMON_CONFIG_PATH="" PURGE="" DOWNLOAD_ONLY="" @@ -115,6 +110,13 @@ KEEP_DOWNLOADS=false CURL_MAX_TIME=1800 +PACKAGE_GITHUB_ORG="SumoLogic" +PACKAGE_GITHUB_REPO="sumologic-otel-collector-packaging" + +PACKAGECLOUD_ORG="${PACKAGECLOUD_ORG:-sumologic}" +PACKAGECLOUD_REPO="${PACKAGECLOUD_REPO:-stable}" +PACKAGECLOUD_MASTER_TOKEN="${PACKAGECLOUD_MASTER_TOKEN:-}" + ############################ Functions function usage() { @@ -130,6 +132,7 @@ Supported arguments: -${ARG_SHORT_TAG}, --${ARG_LONG_TAG} Sets tag for collector. This argument can be use multiple times. One per tag. -${ARG_SHORT_DOWNLOAD}, --${ARG_LONG_DOWNLOAD} Download new binary only and skip configuration part. (Mac OS only) + -${ARG_SHORT_UPGRADE}, --${ARG_LONG_UPGRADE} Upgrades the collector using the system package manager. -${ARG_SHORT_UNINSTALL}, --${ARG_LONG_UNINSTALL} Removes Sumo Logic Distribution for OpenTelemetry Collector from the system and disable Systemd service eventually. Use with '--purge' to remove all configurations as well. @@ -138,7 +141,6 @@ Supported arguments: -${ARG_SHORT_API}, --${ARG_LONG_API} API URL, forces the collector to use non-default API -${ARG_SHORT_OPAMP_API}, --${ARG_LONG_OPAMP_API} OpAmp API URL, forces the collector to use non-default OpAmp API - -${ARG_SHORT_SKIP_CONFIG}, --${ARG_LONG_SKIP_CONFIG} Do not create default configuration. -${ARG_SHORT_VERSION}, --${ARG_LONG_VERSION} Version of Sumo Logic Distribution for OpenTelemetry Collector to install, e.g. 0.57.2-sumo-1. By default it gets latest version. -${ARG_SHORT_FIPS}, --${ARG_LONG_FIPS} Install the FIPS 140-2 compliant binary on Linux. @@ -159,10 +161,8 @@ function set_defaults() { DOWNLOAD_CACHE_DIR="/var/cache/otelcol-sumo" # this is in case we want to keep downloaded binaries CONFIG_DIRECTORY="/etc/otelcol-sumo" SUMO_BINARY_PATH="/usr/local/bin/otelcol-sumo" - REMOTE_CONFIG_DIRECTORY="${CONFIG_DIRECTORY}/opamp.d" USER_ENV_DIRECTORY="${CONFIG_DIRECTORY}/env" TOKEN_ENV_FILE="${USER_ENV_DIRECTORY}/token.env" - CONFIG_PATH="${CONFIG_DIRECTORY}/sumologic.yaml" LAUNCHD_CONFIG="/Library/LaunchDaemons/com.sumologic.otelcol-sumo.plist" LAUNCHD_ENV_KEY="EnvironmentVariables" @@ -173,12 +173,6 @@ function set_defaults() { } function parse_options() { - if [[ $# == 0 && -z "${SUMOLOGIC_INSTALLATION_TOKEN}" ]]; then - echo "Installation token has not been provided. Please set the 'SUMOLOGIC_INSTALLATION_TOKEN' environment variable." - usage - exit 2 - fi - # Transform long options to short ones for arg in "$@"; do @@ -206,9 +200,6 @@ function parse_options() { "--${ARG_LONG_YES}") set -- "$@" "-${ARG_SHORT_YES}" ;; - "--${ARG_LONG_SKIP_CONFIG}") - set -- "$@" "-${ARG_SHORT_SKIP_CONFIG}" - ;; "--${ARG_LONG_VERSION}") set -- "$@" "-${ARG_SHORT_VERSION}" ;; @@ -218,10 +209,14 @@ function parse_options() { "--${ARG_LONG_UNINSTALL}") set -- "$@" "-${ARG_SHORT_UNINSTALL}" ;; + "--${ARG_LONG_UPGRADE}") + set -- "$@" "-${ARG_SHORT_UPGRADE}" + ;; "--${ARG_LONG_PURGE}") set -- "$@" "-${ARG_SHORT_PURGE}" ;; "--${ARG_LONG_SKIP_TOKEN}") + echo "--${ARG_LONG_SKIP_TOKEN}" is deprecated and no longer affects the installation. An installation token is required. set -- "$@" "-${ARG_SHORT_SKIP_TOKEN}" ;; "--${DEPRECATED_ARG_LONG_SKIP_TOKEN}") @@ -246,7 +241,7 @@ function parse_options() { "--${ARG_LONG_TIMEOUT}") set -- "$@" "-${ARG_SHORT_TIMEOUT}" ;; - "-${ARG_SHORT_TOKEN}"|"-${ARG_SHORT_HELP}"|"-${ARG_SHORT_API}"|"-${ARG_SHORT_OPAMP_API}"|"-${ARG_SHORT_TAG}"|"-${ARG_SHORT_SKIP_CONFIG}"|"-${ARG_SHORT_VERSION}"|"-${ARG_SHORT_FIPS}"|"-${ARG_SHORT_YES}"|"-${ARG_SHORT_UNINSTALL}"|"-${ARG_SHORT_PURGE}"|"-${ARG_SHORT_SKIP_TOKEN}"|"-${ARG_SHORT_DOWNLOAD}"|"-${ARG_SHORT_CONFIG_BRANCH}"|"-${ARG_SHORT_BINARY_BRANCH}"|"-${ARG_SHORT_BRANCH}"|"-${ARG_SHORT_KEEP_DOWNLOADS}"|"-${ARG_SHORT_TIMEOUT}"|"-${ARG_SHORT_INSTALL_HOSTMETRICS}"|"-${ARG_SHORT_REMOTELY_MANAGED}"|"-${ARG_SHORT_EPHEMERAL}") + "-${ARG_SHORT_TOKEN}"|"-${ARG_SHORT_HELP}"|"-${ARG_SHORT_API}"|"-${ARG_SHORT_OPAMP_API}"|"-${ARG_SHORT_TAG}"|"-${ARG_SHORT_VERSION}"|"-${ARG_SHORT_FIPS}"|"-${ARG_SHORT_YES}"|"-${ARG_SHORT_UNINSTALL}"|"-${ARG_SHORT_UPGRADE}"|"-${ARG_SHORT_PURGE}"|"-${ARG_SHORT_SKIP_TOKEN}"|"-${ARG_SHORT_DOWNLOAD}"|"-${ARG_SHORT_CONFIG_BRANCH}"|"-${ARG_SHORT_BINARY_BRANCH}"|"-${ARG_SHORT_BRANCH}"|"-${ARG_SHORT_KEEP_DOWNLOADS}"|"-${ARG_SHORT_TIMEOUT}"|"-${ARG_SHORT_INSTALL_HOSTMETRICS}"|"-${ARG_SHORT_REMOTELY_MANAGED}"|"-${ARG_SHORT_EPHEMERAL}") set -- "$@" "${arg}" ;; "--${ARG_LONG_INSTALL_HOSTMETRICS}") @@ -270,7 +265,7 @@ function parse_options() { while true; do set +e - getopts "${ARG_SHORT_HELP}${ARG_SHORT_TOKEN}:${ARG_SHORT_API}:${ARG_SHORT_OPAMP_API}:${ARG_SHORT_TAG}:${ARG_SHORT_VERSION}:${ARG_SHORT_FIPS}${ARG_SHORT_YES}${ARG_SHORT_UNINSTALL}${ARG_SHORT_PURGE}${ARG_SHORT_SKIP_TOKEN}${ARG_SHORT_SKIP_CONFIG}${ARG_SHORT_DOWNLOAD}${ARG_SHORT_KEEP_DOWNLOADS}${ARG_SHORT_CONFIG_BRANCH}:${ARG_SHORT_BINARY_BRANCH}:${ARG_SHORT_BRANCH}:${ARG_SHORT_EPHEMERAL}${ARG_SHORT_REMOTELY_MANAGED}${ARG_SHORT_INSTALL_HOSTMETRICS}${ARG_SHORT_TIMEOUT}:" opt + getopts "${ARG_SHORT_HELP}${ARG_SHORT_TOKEN}:${ARG_SHORT_API}:${ARG_SHORT_OPAMP_API}:${ARG_SHORT_TAG}:${ARG_SHORT_VERSION}:${ARG_SHORT_FIPS}${ARG_SHORT_YES}${ARG_SHORT_UPGRADE}${ARG_SHORT_UNINSTALL}${ARG_SHORT_PURGE}${ARG_SHORT_SKIP_TOKEN}${ARG_SHORT_DOWNLOAD}${ARG_SHORT_KEEP_DOWNLOADS}${ARG_SHORT_CONFIG_BRANCH}:${ARG_SHORT_BINARY_BRANCH}:${ARG_SHORT_BRANCH}:${ARG_SHORT_EPHEMERAL}${ARG_SHORT_REMOTELY_MANAGED}${ARG_SHORT_INSTALL_HOSTMETRICS}${ARG_SHORT_TIMEOUT}:" opt set -e # Invalid argument catched, print and exit @@ -286,13 +281,12 @@ function parse_options() { "${ARG_SHORT_TOKEN}") SUMOLOGIC_INSTALLATION_TOKEN="${OPTARG}" ;; "${ARG_SHORT_API}") API_BASE_URL="${OPTARG}" ;; "${ARG_SHORT_OPAMP_API}") OPAMP_API_URL="${OPTARG}" ;; - "${ARG_SHORT_SKIP_CONFIG}") SKIP_CONFIG=true ;; "${ARG_SHORT_VERSION}") VERSION="${OPTARG}" ;; "${ARG_SHORT_FIPS}") FIPS=true ;; "${ARG_SHORT_YES}") CONTINUE=true ;; "${ARG_SHORT_UNINSTALL}") UNINSTALL=true ;; + "${ARG_SHORT_UPGRADE}") UPGRADE=true ;; "${ARG_SHORT_PURGE}") PURGE=true ;; - "${ARG_SHORT_SKIP_TOKEN}") SKIP_TOKEN=true ;; "${ARG_SHORT_DOWNLOAD}") DOWNLOAD_ONLY=true ;; "${ARG_SHORT_CONFIG_BRANCH}") CONFIG_BRANCH="${OPTARG}" ;; "${ARG_SHORT_BINARY_BRANCH}") BINARY_BRANCH="${OPTARG}" ;; @@ -339,7 +333,7 @@ function check_dependencies() { error=1 fi - REQUIRED_COMMANDS=(echo sed curl head grep sort mv chmod getopts hostname touch xargs) + REQUIRED_COMMANDS=(echo sed curl head grep sort mv getopts hostname touch xargs) if [[ -n "${BINARY_BRANCH}" ]]; then # unzip is only necessary for downloading from GHA artifacts REQUIRED_COMMANDS+=(unzip) fi @@ -356,7 +350,7 @@ function check_dependencies() { fi } -function get_latest_package_version() { +function get_latest_github_package_version() { local versions readonly versions="${1}" @@ -374,47 +368,11 @@ function get_latest_package_version() { fi } -function get_latest_version() { - local versions - readonly versions="${1}" - - # get latest version directly from website if there is no versions from api - if [[ -z "${versions}" ]]; then - curl --retry 5 --connect-timeout 5 --max-time 30 --retry-delay 5 --retry-max-time 150 -s https://github.com/SumoLogic/sumologic-otel-collector/releases \ - | grep -Eo '/SumoLogic/sumologic-otel-collector/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+-sumo-[0-9]+[^-]' \ - | head -n 1 | sed 's%/SumoLogic/sumologic-otel-collector/releases/tag/v\([^"]*\)".*%\1%g' - else - # sed 's/ /\n/g' converts spaces to new lines - echo "${versions}" | sed 's/ /\n/g' | head -n 1 - fi -} - # Get available versions of otelcol-sumo # skip prerelease and draft releases # sort it from last to first # remove v from beginning of version -function get_versions() { - # returns empty in case we exceeded github rate limit - if [[ "$(github_rate_limit)" == "0" ]]; then - return - fi - - curl \ - --connect-timeout 5 \ - --max-time 30 \ - --retry 5 \ - --retry-delay 0 \ - --retry-max-time 150 \ - -sH "Accept: application/vnd.github.v3+json" \ - https://api.github.com/repos/SumoLogic/sumologic-otel-collector/releases \ - | grep -E '(tag_name|"(draft|prerelease)")' \ - | sed 'N;N;s/.*true.*//' \ - | grep -o 'v.*"' \ - | sort -rV \ - | sed 's/^v//;s/"$//' -} - -function get_package_versions() { +function get_github_package_versions() { # returns empty in case we exceeded github rate limit. This can happen if we are running this script too many times in a short period. if [[ "$(github_rate_limit)" == "0" ]]; then return @@ -435,28 +393,6 @@ function get_package_versions() { | sed 's/^v//;s/"$//' } -# Get versions from provided one to the latest -get_versions_from() { - local versions - readonly versions="${1}" - - local from - readonly from="${2}" - - # Return if there is no installed version - if [[ "${from}" == "" ]]; then - return 0 - fi - - local line - readonly line="$(( $(echo "${versions}" | sed 's/ /\n/g' | grep -n "${from}$" | sed 's/:.*//g') - 1 ))" - - if [[ "${line}" -gt "0" ]]; then - echo "${versions}" | sed 's/ /\n/g' | head -n "${line}" | sort - fi - return 0 -} - # Get OS type (linux or darwin) function get_os_type() { local os_type @@ -515,15 +451,6 @@ function verify_installation() { echo -e "Installation succeded:\t$(${otel_command} --version)" } -# Get installed version of otelcol-sumo -function get_installed_version() { - if [[ -f "${SUMO_BINARY_PATH}" ]]; then - set +o pipefail - "${SUMO_BINARY_PATH}" --version | grep -o 'v[0-9].*$' | sed 's/v//' - set -o pipefail - fi -} - # Ask to continue and abort if not function ask_to_continue() { if [[ "${CONTINUE}" == true ]]; then @@ -533,7 +460,7 @@ function ask_to_continue() { # Just fail if we're not running in uninteractive mode # TODO: Figure out a way to reliably ask for confirmation with stdin redirected - echo "Please use the --yes flag to continue" + echo "Please use the -y flag to continue" exit 1 # local choice @@ -548,47 +475,14 @@ function ask_to_continue() { } -# Print information about breaking changes -function print_breaking_changes() { - local versions - readonly versions="${1}" - - local changelog - changelog="$(echo -e "$(curl --retry 5 --connect-timeout 5 --max-time 30 --retry-delay 0 --retry-max-time 150 -sS https://raw.githubusercontent.com/SumoLogic/sumologic-otel-collector/main/CHANGELOG.md)")" - declare -r changelog - - local is_breaking_change - local message - message="" - - for version in ${versions}; do - # Print changelog for every version - is_breaking_change=$(echo -e "${changelog}" | grep -E '^## |^### Breaking|breaking changes' | sed -e '/## \[v'"${version}"'/,/## \[v/!d' | grep -E 'Breaking|breaking' || echo "") - - if [[ -n "${is_breaking_change}" ]]; then - if [[ -n "${message}" ]]; then - message="${message}, " - fi - message="${message}v${version}" - fi - done - - if [[ -n "${message}" ]]; then - echo "The following versions contain breaking changes: ${message}! Please make sure to read the linked Changelog file." - fi -} - # set up configuration function setup_config() { echo 'We are going to get and set up a default configuration for you' - echo "Generating configuration and saving as ${CONFIG_PATH}" + echo "Generating configuration and saving it in ${CONFIG_DIRECTORY}" if [[ "${REMOTELY_MANAGED}" == "true" ]]; then echo "Warning: remote management is currently in beta." - echo -e "Creating remote configurations directory (${REMOTE_CONFIG_DIRECTORY})" - mkdir -p "${REMOTE_CONFIG_DIRECTORY}" - write_opamp_extension if [[ -n "${SUMOLOGIC_INSTALLATION_TOKEN}" ]]; then @@ -607,26 +501,24 @@ function setup_config() { write_opamp_endpoint "${OPAMP_API_URL}" fi - write_tags "${FIELDS[@]}" + if [[ ${#FIELDS[@]} -gt 0 ]]; then + write_tags "${FIELDS[@]}" + fi - # Return/stop function execution + # Return/stop function execution early as remaining logic only applies + # to locally-managed installations return fi if [[ "${INSTALL_HOSTMETRICS}" == "true" ]]; then echo -e "Installing ${OS_TYPE} hostmetrics configuration" otelcol-config --enable-hostmetrics - if [[ "${OS_TYPE}" == "linux" ]]; then - echo -e "Setting the CAP_DAC_READ_SEARCH Linux capability on the collector binary to allow it to read host metrics from /proc directory: setcap 'cap_dac_read_search=ep' \"${SUMO_BINARY_PATH}\"" - echo -e "You can remove it with the following command: sudo setcap -r \"${SUMO_BINARY_PATH}\"" - echo -e "Without this capability, the collector will not be able to collect some of the host metrics." - # TODO(echlebek): remove this when it's supported in packaging - setcap 'cap_dac_read_search=ep' "${SUMO_BINARY_PATH}" - fi fi ## Check if there is anything to update in configuration if [[ -n "${SUMOLOGIC_INSTALLATION_TOKEN}" || -n "${API_BASE_URL}" || ${#FIELDS[@]} -ne 0 || "${EPHEMERAL}" == "true" ]]; then + USER_TOKEN="$(get_user_token)" + if [[ -n "${SUMOLOGIC_INSTALLATION_TOKEN}" && -z "${USER_TOKEN}" ]]; then write_installation_token "${SUMOLOGIC_INSTALLATION_TOKEN}" fi @@ -635,51 +527,54 @@ function setup_config() { write_ephemeral_true fi - # fill in api base url if [[ -n "${API_BASE_URL}" && -z "${USER_API_URL}" ]]; then write_api_url "${API_BASE_URL}" fi - # fill in opamp url - if [[ -n "${OPAMP_API_URL}" && -z "${USER_OPAMP_API_URL}" ]]; then - write_opamp_extension - write_opamp_endpoint "${OPAMP_API_URL}" + if [[ ${#FIELDS[@]} -gt 0 ]]; then + write_tags "${FIELDS[@]}" fi - - write_tags "${FIELDS[@]}" fi } function setup_config_darwin() { + echo 'We are going to get and set up a default configuration for you' + + echo "Generating configuration and saving it in ${CONFIG_DIRECTORY}" + if [[ "${REMOTELY_MANAGED}" == "true" ]]; then + echo "Warning: remote management is currently in beta." + + write_opamp_extension + + if [[ -n "${OPAMP_API_URL}" ]]; then + write_opamp_endpoint "${OPAMP_API_URL}" + fi + fi + if [[ "${EPHEMERAL}" == "true" ]]; then write_ephemeral_true fi - # fill in api base url - if [[ -n "${API_BASE_URL}" ]]; then + if [[ -n "${API_BASE_URL}" ]]; then write_api_url "${API_BASE_URL}" + elif [[ -n "${USER_API_URL}" ]]; then + write_api_url "${USER_API_URL}" fi - write_tags "${FIELDS[@]}" + if [[ ${#FIELDS[@]} -gt 0 ]]; then + write_tags "${FIELDS[@]}" + fi + # Return/stop function execution early as remaining logic only applies to + # locally-managed installations if [[ "${REMOTELY_MANAGED}" == "true" ]]; then - echo "Warning: remote management is currently in beta." - - echo -e "Creating remote configurations directory (${REMOTE_CONFIG_DIRECTORY})" - # TODO(echlebek): remove this once packaging does it - mkdir -p "${REMOTE_CONFIG_DIRECTORY}" - - write_opamp_extension - - write_remote_config_launchd "${LAUNCHD_CONFIG}" - - # Remote configuration directory must be writable - chmod 750 "${REMOTE_CONFIG_DIRECTORY}" - - # Remote configuration directory must be owned by the mac pkg service user - chown _otelcol-sumo:_otelcol-sumo "${REMOTE_CONFIG_DIRECTORY}" + return fi + if [[ "${INSTALL_HOSTMETRICS}" == "true" ]]; then + echo -e "Installing ${OS_TYPE} hostmetrics configuration" + otelcol-config --enable-hostmetrics + fi } # uninstall otelcol-sumo @@ -696,6 +591,29 @@ function uninstall() { echo "Uninstallation completed" } +function upgrade() { + case "${OS_TYPE}" in + "linux") upgrade_linux ;; + *) + echo "upgrading is not supported by this script for OS: ${OS_TYPE}" + exit 1 + ;; + esac + +} + +function upgrade_linux() { + case $(get_package_manager) in + yum | dnf) + yum update --quiet -y + ;; + apt-get) + apt-get update --quiet && apt-get upgrade --quiet -y + ;; + esac + +} + # uninstall otelcol-sumo on darwin function uninstall_darwin() { local UNINSTALL_SCRIPT_PATH @@ -716,7 +634,7 @@ function uninstall_darwin() { function uninstall_linux() { case $(get_package_manager) in yum | dnf) - yum remove --quiet -y otelcol-sumo + yum remove --quiet -yes otelcol-sumo ;; apt-get) if [[ "${PURGE}" == "true" ]]; then @@ -728,16 +646,6 @@ function uninstall_linux() { esac } -function escape_sed() { - local text - readonly text="${1}" - - # replaces `\` with `\\` and `/` with `\/` - echo "${text}" \ - | sed -e 's/\\/\\\\/g' \ - | sed -e 's|/|\\/|g' -} - function get_user_env_config() { local file readonly file="${1}" @@ -764,12 +672,37 @@ function get_user_env_config() { || echo "" } +function get_launchd_token() { + local file + readonly file="${1}" + + if [[ "${OS_TYPE}" != "darwin" ]]; then + return + fi + + if [[ ! -f "${file}" ]]; then + return + fi + + plutil_extract_key "${file}" "${LAUNCHD_TOKEN_KEY}" +} + function get_user_api_url() { - otelcol-config --read-kv .extensions.sumologic.api_base_url + if command -v otelcol-config &> /dev/null; then + KV=$(otelcol-config --read-kv .extensions.sumologic.api_base_url) + if [[ "${KV}" != "null" ]]; then + echo "${KV}" + fi + fi } function get_user_opamp_endpoint() { - otelcol-config --read-kv .extensions.opamp.endpoint + if command -v otelcol-config &> /dev/null; then + KV=$(otelcol-config --read-kv .extensions.opamp.endpoint) + if [[ "${KV}" != "null" ]]; then + echo "${KV}" + fi + fi } # write installation token to user configuration file @@ -780,27 +713,6 @@ function write_installation_token() { otelcol-config --set-installation-token "$token" } -# write ${ENV_TOKEN}" to systemd env configuration file -function write_installation_token_env() { - local token - readonly token="${1}" - - local file - readonly file="${2}" - - local token_name - token_name="${ENV_TOKEN}" - readonly token_name - - # ToDo: ensure we override only ${ENV_TOKEN}" env value - if grep "${token_name}" "${file}" > /dev/null 2>&1; then - # Do not expose token in sed command as it can be saw on processes list - echo "s/${token_name}=.*$/${token_name}=$(escape_sed "${token}")/" | sed -i.bak -f - "${file}" - else - echo "${token_name}=${token}" > "${file}" - fi -} - # write ${ENV_TOKEN} to launchd configuration file function write_installation_token_launchd() { local token @@ -824,31 +736,13 @@ function write_installation_token_launchd() { plutil_replace_key "${file}" "${LAUNCHD_ENV_KEY}" "xml" "" fi - # Create SUMOLOGIC_INSTALLATION_TOKEN key if it does not exist + # Create SUMOLOGIC_INSTALLATION_TOKEN key if it does not exist otherwise + # replace the SUMOLOGIC_INSTALLATION_TOKEN key if ! plutil_key_exists "${file}" "${LAUNCHD_TOKEN_KEY}"; then - plutil_create_key "${file}" "${LAUNCHD_TOKEN_KEY}" "string" "${SUMOLOGIC_INSTALLATION_TOKEN}" - fi - - # Replace SUMOLOGIC_INSTALLATION_TOKEN key if it has an incorrect type - if ! plutil_key_is_type "${LAUNCHD_CONFIG}" "${LAUNCHD_TOKEN_KEY}" "string"; then - plutil_replace_key "${LAUNCHD_CONFIG}" "${LAUNCHD_TOKEN_KEY}" "string" "${SUMOLOGIC_INSTALLATION_TOKEN}" - fi -} - -function write_remote_config_launchd() { - local file - readonly file="${1}" - - if [[ ! -f "${file}" ]]; then - echo "The LaunchDaemon configuration file is missing: ${file}" - exit 1 + plutil_create_key "${file}" "${LAUNCHD_TOKEN_KEY}" "string" "${token}" + else + plutil_replace_key "${file}" "${LAUNCHD_TOKEN_KEY}" "string" "${token}" fi - - # Delete existing ProgramArguments - plutil_delete_key "${file}" "ProgramArguments" - - # Create new ProgramArguments with --remote-config - plutil_create_key "${file}" "ProgramArguments" "json" "[ \"/usr/local/bin/otelcol-sumo\", \"--remote-config\", \"opamp:${CONFIG_PATH}\" ]" } # write sumologic ephemeral: true to user configuration file @@ -1005,17 +899,6 @@ function plutil_create_key() { fi } -function plutil_delete_key() { - local file key - readonly file="${1}" - readonly key="${2}" - - if ! plutil -remove "${key}" "${file}"; then - echo "plutil_delete_key error: key=${key}, file=${file}" - exit 1 - fi -} - function plutil_extract_key() { local file key output readonly file="${1}" @@ -1072,19 +955,41 @@ function get_package_manager() { } function install_linux_package() { - local package_with_version - readonly package_with_version="${1}" + local package_name + readonly package_name="${1}" + + if [[ "${PACKAGECLOUD_MASTER_TOKEN}" != "" ]]; then + base_url="https://${PACKAGECLOUD_MASTER_TOKEN}:@packages.sumologic.com" + else + base_url="https://packages.sumologic.com" + fi + base_url+="/install/repositories/${PACKAGECLOUD_ORG}/${PACKAGECLOUD_REPO}" + + repo_id="${PACKAGECLOUD_ORG}_${PACKAGECLOUD_REPO}" case $(get_package_manager) in yum | dnf) - curl -s https://packagecloud.io/install/repositories/sumologic/stable/script.rpm.sh | bash - yum --quiet --disablerepo="*" --enablerepo="sumologic_stable" -y update - yum install --quiet "${package_with_version}" + curl -s "${base_url}/script.rpm.sh" | bash + + local package_str + package_str="${package_name}" + if [[ -n "${VERSION}" ]]; then + package_str="${package_str}-${VERSION}" + fi + echo "Installing ${package_str}" + yum install --quiet -y "${package_str}" ;; apt-get) - curl -s https://packagecloud.io/install/repositories/sumologic/stable/script.deb.sh | bash - apt-get update --quiet -y -o Dir::Etc::sourcelist="sources.list.d/sumologic_stable" - apt-get install --quiet "${package_with_version}" + curl -s "${base_url}/script.deb.sh" | bash + apt-get update --quiet -y -o Dir::Etc::sourcelist="sources.list.d/${repo_id}" + + local package_str + package_str="${package_name}" + if [[ -n "${VERSION}" ]]; then + package_str="${package_str}=${VERSION}" + fi + echo "Installing ${package_str}" + apt-get install --quiet -y "${package_str}" ;; esac } @@ -1109,6 +1014,59 @@ function check_deprecated_linux_flags() { fi } +function is_package_installed() { + case $(get_package_manager) in + yum | dnf) + # TODO: refine exact command + yum --cacheonly list --installed otelcol-sumo > /dev/null 2>&1 + ;; + apt-get) + dpkg --status otelcol-sumo > /dev/null 2>&1 + ;; + esac +} + +# Try to infer if there is a binary, pre-packaging rework installation, the +# kind of installation that was performed by downloading artifacts from Github, +# before we moved to using distribution packages. +function has_prepackaging_installation() { + if command -v otelcol-sumo > /dev/null 2>&1 && ! is_package_installed; then + true + else + false + fi +} + +function backup_prepackaging_configuration() { + cp -r "${CONFIG_DIRECTORY}" "${TMPDIR}/otelcol-sumo-configuration-backup" +} + +function restore_prepackaging_configuration() { + echo "restore_prepackaging_configuration(): not implemented yet" +} + +function uninstall_prepackaging_installation() { + # Stop the service and remove its unit file + SYSTEMD_SERVICE_PATH="/etc/systemd/system/otelcol-sumo.service" + if [[ -f "${SYSTEMD_SERVICE_PATH}" ]]; then + systemctl --quiet stop otelcol-sumo || true + systemctl --quiet disable otelcol-sumo || true + rm -f "${SYSTEMD_SERVICE_PATH}" + fi + + # Remove the old binary + rm -f "${SUMO_BINARY_PATH}" + + # Remove old configuration and data + FILE_STORAGE="/var/lib/otelcol-sumo/file_storage" + rm -rf "${CONFIG_DIRECTORY}" "${FILE_STORAGE}" + + # Remove the otelcol-sumo user and group + SYSTEM_USER="otelcol-sumo" + userdel --remove --force "${SYSTEM_USER}" 2>/dev/null || true + groupdel "${SYSTEM_USER}" 2>/dev/null || true +} + ############################ Main code OS_TYPE="$(get_os_type)" @@ -1125,7 +1083,7 @@ check_dependencies check_deprecated_linux_flags readonly SUMOLOGIC_INSTALLATION_TOKEN API_BASE_URL OPAMP_API_URL FIELDS CONTINUE CONFIG_DIRECTORY UNINSTALL -readonly USER_ENV_DIRECTORY CONFIG_DIRECTORY CONFIG_PATH COMMON_CONFIG_PATH +readonly USER_ENV_DIRECTORY CONFIG_DIRECTORY COMMON_CONFIG_PATH readonly INSTALL_HOSTMETRICS readonly REMOTELY_MANAGED readonly CURL_MAX_TIME @@ -1135,49 +1093,67 @@ if [[ "${UNINSTALL}" == "true" ]]; then uninstall exit 0 fi +if [[ "${UPGRADE}" == "true" ]]; then + upgrade + exit 0 +fi + +# get_installation_token returns the value of SUMOLOGIC_INSTALLATION_TOKEN +# (set by a flag or environment variable) when it is not empty, otherwise it +# will attempt to fetch the token from an existing installation and return it. +function get_installation_token() { + local token="" + + if [[ -z "${token}" ]]; then + token="${SUMOLOGIC_INSTALLATION_TOKEN}" + fi + + if [[ -z "${token}" ]]; then + token="$(get_user_token)" + fi + + echo "${token}" +} # Attempt to find a token from an existing installation -if command -v otelcol-config &> /dev/null; then - USER_TOKEN=$(otelcol-config --read-kv .extensions.sumologic.installation_token) -fi -if [[ -z "${USER_TOKEN}" ]]; then - USER_TOKEN="$(get_user_env_config "${TOKEN_ENV_FILE}")" -fi -readonly USER_TOKEN +function get_user_token() { + local token="${USER_TOKEN}" -# Exit if installation token is not set and there is no user configuration -if [[ -z "${SUMOLOGIC_INSTALLATION_TOKEN}" && "${SKIP_TOKEN}" != "true" && -z "${USER_TOKEN}" && -z "${DOWNLOAD_ONLY}" ]]; then - echo "Installation token has not been provided. Please set the '${ENV_TOKEN}' environment variable." - echo "You can ignore this requirement by adding '--${ARG_LONG_SKIP_TOKEN} argument." - exit 1 -fi + # Attempt to find a token from an existing installation + # Check the systemd env file for a token + if [[ -f "${TOKEN_ENV_FILE}" && -z "${token}" ]]; then + token="$(get_user_env_config "${TOKEN_ENV_FILE}")" + fi -# verify if passed arguments are the same like in user's configuration -if [[ -z "${DOWNLOAD_ONLY}" ]]; then - if [[ -n "${USER_TOKEN}" && -n "${SUMOLOGIC_INSTALLATION_TOKEN}" && "${USER_TOKEN}" != "${SUMOLOGIC_INSTALLATION_TOKEN}" ]]; then - echo "You are trying to install with different token than in your configuration file!" - exit 1 - fi + # Check the launchd config for a token + if [[ -f "${LAUNCHD_CONFIG}" && -z "${token}" ]]; then + token="$(get_launchd_token "${LAUNCHD_CONFIG}")" + fi - USER_API_URL="$(get_user_api_url)" - if [[ -n "${USER_API_URL}" && -n "${API_BASE_URL}" && "${USER_API_URL}" != "${API_BASE_URL}" ]]; then - echo "You are trying to install with different api base url than in your configuration file!" - exit 1 + # Check yaml configuration for a token + if [[ -z "${token}" ]]; then + if command -v otelcol-config &> /dev/null; then + local output="" + output=$(otelcol-config --read-kv .extensions.sumologic.installation_token) + if [[ "${output}" != "null" ]]; then + token="${output}" + fi fi + fi - USER_OPAMP_API_URL="$(get_user_opamp_endpoint "${COMMON_CONFIG_PATH}")" - if [[ -n "${USER_OPAMP_API_URL}" && -n "${OPAMP_API_URL}" && "${USER_OPAMP_API_URL}" != "${OPAMP_API_URL}" ]]; then - echo "You are trying to install with different opamp endpoint than in your configuration file!" - exit 1 - fi -fi + echo "${token}" +} -set +u -if [[ -n "${BINARY_BRANCH}" && -z "${GITHUB_TOKEN}" ]]; then - echo "GITHUB_TOKEN env is required for '${ARG_LONG_BINARY_BRANCH}' option" - exit 1 +# Load & cache user token +USER_TOKEN="$(get_user_token)" + +# Exit if installation token is not set by flag, environment variable, or from +# existing installation configuration. Skip this check when DOWNLOAD_ONLY is set +# which is only possible on macOS. +if [[ -z "$(get_installation_token)" && -z "${DOWNLOAD_ONLY}" ]]; then + echo "Installation token has not been provided. Please set the '${ENV_TOKEN}' environment variable." + exit 1 fi -set -u if [ "${FIPS}" == "true" ]; then case "${OS_TYPE}" in @@ -1195,6 +1171,34 @@ if [ "${FIPS}" == "true" ]; then fi if [[ "${OS_TYPE}" == "darwin" ]]; then + # verify if passed arguments are the same like in user's configuration + if [[ -z "${DOWNLOAD_ONLY}" ]]; then + USER_TOKEN="$(get_user_token)" + if [[ -n "${USER_TOKEN}" && -n "${SUMOLOGIC_INSTALLATION_TOKEN}" && "${USER_TOKEN}" != "${SUMOLOGIC_INSTALLATION_TOKEN}" ]]; then + echo "You are trying to install with different token than in your configuration file!" + exit 1 + fi + + USER_API_URL="$(get_user_api_url)" + if [[ -n "${USER_API_URL}" && -n "${API_BASE_URL}" && "${USER_API_URL}" != "${API_BASE_URL}" ]]; then + echo "You are trying to install with different api base url than in your configuration file! (${USER_API_URL} != ${API_BASE_URL})" + exit 1 + fi + + USER_OPAMP_API_URL="$(get_user_opamp_endpoint "${COMMON_CONFIG_PATH}")" + if [[ -n "${USER_OPAMP_API_URL}" && -n "${OPAMP_API_URL}" && "${USER_OPAMP_API_URL}" != "${OPAMP_API_URL}" ]]; then + echo "You are trying to install with different opamp endpoint than in your configuration file!" + exit 1 + fi + fi + + set +u + if [[ -n "${BINARY_BRANCH}" && -z "${GITHUB_TOKEN}" ]]; then + echo "GITHUB_TOKEN env is required for '${ARG_LONG_BINARY_BRANCH}' option" + exit 1 + fi + set -u + package_arch="" case "${ARCH_TYPE}" in "amd64") package_arch="intel" ;; @@ -1206,88 +1210,52 @@ if [[ "${OS_TYPE}" == "darwin" ]]; then esac readonly package_arch - if [[ "${SKIP_CONFIG}" == "true" ]]; then - echo "SKIP_CONFIG is not supported on darwin" - exit 1 - fi - - echo -e "Getting versions..." - # Get versions, but ignore errors as we fallback to other methods later - VERSIONS="$(get_package_versions || echo "")" + if [[ -z "${DARWIN_PKG_URL}" ]]; then + echo -e "Getting versions..." + # Get versions, but ignore errors as we fallback to other methods later + VERSIONS="$(get_github_package_versions || echo "")" - # Use user's version if set, otherwise get latest version from API (or website) - if [[ -z "${VERSION}" ]]; then - VERSION="$(get_latest_package_version "${VERSIONS}")" - fi + # Use user's version if set, otherwise get latest version from API (or website) + if [[ -z "${VERSION}" ]]; then + VERSION="$(get_latest_github_package_version "${VERSIONS}")" + fi - readonly VERSIONS VERSION + readonly VERSIONS VERSION - echo -e "Version to install:\t${VERSION}" + echo -e "Version to install:\t${VERSION}" - package_suffix="${package_arch}.pkg" + package_suffix="${package_arch}.pkg" - if [[ -n "${BINARY_BRANCH}" ]]; then - artifact_name="otelcol-sumo_.*-${package_suffix}" - get_package_from_branch "${BINARY_BRANCH}" "${artifact_name}" - else - artifact_name="otelcol-sumo_${VERSION}-${package_suffix}" - readonly artifact_name + if [[ -n "${BINARY_BRANCH}" ]]; then + artifact_name="otelcol-sumo_.*-${package_suffix}" + get_package_from_branch "${BINARY_BRANCH}" "${artifact_name}" + else + artifact_name="otelcol-sumo_${VERSION}-${package_suffix}" + readonly artifact_name - LINK="https://github.com/${PACKAGE_GITHUB_ORG}/${PACKAGE_GITHUB_REPO}/releases/download/v${VERSION}/${artifact_name}" - readonly LINK + LINK="https://github.com/${PACKAGE_GITHUB_ORG}/${PACKAGE_GITHUB_REPO}/releases/download/v${VERSION}/${artifact_name}" + readonly LINK - get_package_from_url "${LINK}" + get_package_from_url "${LINK}" + fi + else + get_package_from_url "${DARWIN_PKG_URL}" fi pkg="${TMPDIR}/otelcol-sumo.pkg" - choices="${TMPDIR}/otelcol-sumo-choices.xml" - readonly pkg choices if [[ "${DOWNLOAD_ONLY}" == "true" ]]; then echo "Package downloaded to: ${pkg}" exit 0 fi - # Extract choices xml from meta package, override the choices to enable - # optional choices, and then install using the new choice selections - installer -showChoiceChangesXML -pkg "${pkg}" -target / > "${choices}" + echo "Installing otelcol-sumo package" + installer -pkg "${pkg}" -target / - # Determine how many installation choices exist - choices_count=$(plutil -convert raw -o - "${choices}") - readonly choices_count - - # Loop through each installation choice - for (( i=0; i < "${choices_count}"; i++ )); do - choice_id_key="${i}.choiceIdentifier" - choice_attr_key="${i}.choiceAttribute" - attr_setting_key="${i}.attributeSetting" - - # Skip if choiceAttribute does not equal selected - choice_attr="$(plutil_extract_key "${choices}" "${choice_attr_key}")" - if [ "$choice_attr" != "selected" ]; then - continue - fi - - # Get the choice identifier - choice_id="$(plutil_extract_key "${choices}" "${choice_id_key}")" - - # Mark the choice as selected if the feature flag is true - case "${choice_id}" in - "otelcol-sumo-hostmetricsChoice") - if [[ "${INSTALL_HOSTMETRICS}" == "true" ]]; then - echo -e "Enabling ${OS_TYPE} hostmetrics install option" - plutil_replace_key "${choices}" "${attr_setting_key}" "integer" 1 - fi - ;; - esac - done - - installer -applyChoiceChangesXML "$choices" -pkg "$pkg" -target / - - if [[ -n "${SUMOLOGIC_INSTALLATION_TOKEN}" && -z "${USER_TOKEN}" && "${SKIP_TOKEN}" != "true" ]]; then - echo "Writing installation token to launchd config" - write_installation_token_launchd "${SUMOLOGIC_INSTALLATION_TOKEN}" "${LAUNCHD_CONFIG}" - fi + # The token must be written to the launchd config on every install as + # upgrades replace the launchd config + echo "Writing installation token to launchd config" + write_installation_token_launchd "$(get_installation_token)" "${LAUNCHD_CONFIG}" setup_config_darwin @@ -1297,81 +1265,62 @@ if [[ "${OS_TYPE}" == "darwin" ]]; then echo "Waiting for otelcol to start" while ! launchctl print system/otelcol-sumo | grep -q "state = running"; do - sleep 0.1 + echo -n " otelcol service " + launchctl print system/otelcol-sumo | grep "state = " + sleep 1 done OTEL_EXITED_WITH_ERROR=false echo 'Checking otelcol status' - for i in {1..15}; do + for _ in {1..15}; do if launchctl print system/otelcol-sumo | grep -q "last exit code = 1"; then OTEL_EXITED_WITH_ERROR=true break; fi - sleep 1 + sleep 0.4 done if [[ "${OTEL_EXITED_WITH_ERROR}" == "true" ]]; then echo "Failed to launch otelcol" tail /var/log/otelcol-sumo/otelcol-sumo.log exit 1 fi + echo "Successfully started otelcol" exit 0 fi -echo -e "Getting installed version..." -INSTALLED_VERSION="$(get_installed_version)" -echo -e "Installed version:\t${INSTALLED_VERSION:-none}" - -echo -e "Getting versions..." -# Get versions, but ignore errors as we fallback to other methods later -VERSIONS="$(get_versions || echo "")" - -# Use user's version if set, otherwise get latest version from API (or website) -if [[ -z "${VERSION}" ]]; then - VERSION="$(get_latest_version "${VERSIONS}")" +package_name="" +if [[ "${FIPS}" == "true" ]]; then + echo "Getting FIPS-compliant binary" + package_name=otelcol-sumo-fips +else + package_name=otelcol-sumo fi -echo -e "Version to install:\t${VERSION}" +if has_prepackaging_installation; then + # Display a warning and information message here? + echo 'Pre-packaging installation detected' -# Check if otelcol is already in newest version -if [[ "${INSTALLED_VERSION}" == "${VERSION}" ]]; then - echo -e "OpenTelemetry collector is already in newest (${VERSION}) version" -else + # Backup current configuration + backup_prepackaging_configuration - # add newline before breaking changes and changelog - echo "" - if [[ -n "${INSTALLED_VERSION}" ]]; then - # Take versions from installed up to the newest - BETWEEN_VERSIONS="$(get_versions_from "${VERSIONS}" "${INSTALLED_VERSION}")" - readonly BETWEEN_VERSIONS - print_breaking_changes "${BETWEEN_VERSIONS}" - fi + # Remove current installation + uninstall_prepackaging_installation - echo -e "Changelog:\t\thttps://github.com/SumoLogic/sumologic-otel-collector/blob/main/CHANGELOG.md" - # add newline after breaking changes and changelog - echo "" - - package_with_version="${VERSION}" - if [[ -n "${package_with_version}" ]]; then - if [[ "${FIPS}" == "true" ]]; then - echo "Getting FIPS-compliant binary" - package_with_version=otelcol-sumo-fips - else - package_with_version=otelcol-sumo - fi - fi - - install_linux_package "${package_with_version}" - - verify_installation + # We can now proceed and install using the packages and attempt to restore + # the configuration later. + HAD_PREPACKAGING_INSTALLATION="true" fi -if [[ "${SKIP_CONFIG}" == "false" ]]; then - setup_config -fi +install_linux_package "${package_name}" +verify_installation +setup_config -if [[ -n "${SUMOLOGIC_INSTALLATION_TOKEN}" && -z "${USER_TOKEN}" ]]; then - echo 'Writing installation token to env file' - write_installation_token_env "${SUMOLOGIC_INSTALLATION_TOKEN}" "${TOKEN_ENV_FILE}" +# If an old, pre-packaging rework installation was removed during this run, +# attempt the restore the configuration that was backed up during that removal. +set +u +if [[ -n "${HAD_PREPACKAGING_INSTALLATION}" ]]; then + restore_prepackaging_configuration fi +set -u echo 'Reloading systemd' systemctl daemon-reload @@ -1382,11 +1331,4 @@ systemctl enable otelcol-sumo echo 'Starting otelcol-sumo service' systemctl restart otelcol-sumo -echo 'Waiting 10s before checking status' -sleep 10 -if ! systemctl status otelcol-sumo --no-pager; then - echo "Failed to launch otelcol" - exit 1 -fi - exit 0 diff --git a/install-script/test/Makefile b/install-script/test/Makefile index 85c2eed1..335c4041 100644 --- a/install-script/test/Makefile +++ b/install-script/test/Makefile @@ -3,18 +3,24 @@ ifeq ($(OS),Windows_NT) endif ifneq ($(OS),windows) - GOTESTPREFIX ?= sudo env PATH="${PATH}" GH_CI_TOKEN="${GITHUB_TOKEN}" + GOTESTPREFIX ?= sudo env PATH="${PATH}" GH_CI_TOKEN="${GITHUB_TOKEN}" DARWIN_PKG_URL="${DARWIN_PKG_URL}" PACKAGECLOUD_MASTER_TOKEN="${PACKAGECLOUD_MASTER_TOKEN}" PACKAGECLOUD_REPO="${PACKAGECLOUD_REPO}" OTC_VERSION="${OTC_VERSION}" OTC_BUILD_NUMBER="${OTC_BUILD_NUMBER}" endif LINT=golangci-lint GOTEST=go test GOTESTBINARY=sumologic_scripts_tests.test +GOTESTNAME ?= "" +GOTESTRUN= + +ifneq ($(GOTESTNAME),"") + GOTESTRUN=-test.run $(GOTESTNAME) +endif # We build the test binary separately to avoid downloading modules as root .PHONY: test test: $(GOTEST) -c - $(GOTESTPREFIX) ./$(GOTESTBINARY) -test.v + $(GOTESTPREFIX) ./$(GOTESTBINARY) -test.v $(GOTESTRUN) .PHONY: fmt fmt: diff --git a/install-script/test/check.go b/install-script/test/check.go index a362d953..53dbc2a6 100644 --- a/install-script/test/check.go +++ b/install-script/test/check.go @@ -7,7 +7,7 @@ import ( "path/filepath" "testing" - "github.com/stretchr/testify/require" + "github.com/stretchr/testify/assert" ) type check struct { @@ -26,197 +26,234 @@ func checkSkipTest(c check) bool { return false } -type checkFunc func(check) +type checkFunc func(check) bool -func checkBinaryCreated(c check) { - require.FileExists(c.test, binaryPath, "binary has not been created") +func checkBinaryCreated(c check) bool { + return assert.FileExists(c.test, binaryPath, "binary has not been created") } -func checkBinaryNotCreated(c check) { - require.NoFileExists(c.test, binaryPath, "binary is already created") +func checkBinaryNotCreated(c check) bool { + return assert.NoFileExists(c.test, binaryPath, "binary is already created") } -func checkBinaryIsRunning(c check) { +func checkBinaryIsRunning(c check) bool { cmd := exec.Command(binaryPath, "--version") err := cmd.Start() - require.NoError(c.test, err, "error while checking version") + if !assert.NoError(c.test, err, "error while checking version") { + return false + } code, err := exitCode(cmd) - require.NoError(c.test, err, "error while checking exit code") - require.Equal(c.test, 0, code, "got error code while checking version") + assert.NoError(c.test, err, "error while checking exit code") + assert.Equal(c.test, 0, code, "got error code while checking version") + return true } -func checkRun(c check) { - require.Equal(c.test, c.expectedInstallCode, c.code, "unexpected installation script error code") +func checkRun(c check) bool { + return assert.Equal(c.test, c.expectedInstallCode, c.code, "unexpected installation script error code") } -func checkConfigCreated(c check) { - require.FileExists(c.test, configPath, "configuration has not been created properly") +func checkConfigCreated(c check) bool { + return assert.FileExists(c.test, configPath, "configuration has not been created properly") } -func checkConfigNotCreated(c check) { - require.NoFileExists(c.test, configPath, "configuration has been created") +func checkConfigNotCreated(c check) bool { + return assert.NoFileExists(c.test, configPath, "configuration has been created") } -func checkConfigOverrided(c check) { +func checkConfigOverrided(c check) bool { conf, err := getConfig(configPath) - require.NoError(c.test, err) + if err != nil { + c.test.Error(err) + return false + } - require.Condition(c.test, func() (success bool) { - switch conf.Extensions.Sumologic.InstallationToken { - case "${SUMOLOGIC_INSTALLATION_TOKEN}": - return true - default: - return false - } - }, "invalid value for installation token") + if got, want := conf.Extensions.Sumologic.InstallationToken, "${SUMOLOGIC_INSTALLATION_TOKEN}"; got != want { + c.test.Errorf("bad installation token: got %q, want %q", got, want) + } + return true } -func checkUserConfigCreated(c check) { - require.FileExists(c.test, userConfigPath, "user configuration has not been created properly") +func checkUserConfigCreated(c check) bool { + return assert.FileExists(c.test, userConfigPath, "user configuration has not been created properly") } -func checkUserConfigNotCreated(c check) { - require.NoFileExists(c.test, userConfigPath, "user configuration has been created") +func checkUserConfigNotCreated(c check) bool { + return assert.NoFileExists(c.test, userConfigPath, "user configuration has been created") } -func checkHomeDirectoryCreated(c check) { - require.DirExists(c.test, libPath, "home directory has not been created properly") +func checkHomeDirectoryCreated(c check) bool { + return assert.DirExists(c.test, libPath, "home directory has not been created properly") } -func checkNoBakFilesPresent(c check) { +func checkNoBakFilesPresent(c check) bool { cwd, err := os.Getwd() - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } cwdGlob := filepath.Join(cwd, "*.bak") etcPathGlob := filepath.Join(etcPath, "*.bak") etcPathNestedGlob := filepath.Join(etcPath, "*", "*.bak") for _, bakGlob := range []string{cwdGlob, etcPathGlob, etcPathNestedGlob} { bakFiles, err := filepath.Glob(bakGlob) - require.NoError(c.test, err) - require.Empty(c.test, bakFiles) + if !assert.NoError(c.test, err) { + return false + } + if !assert.Empty(c.test, bakFiles) { + return false + } } + return true } -func checkOpAmpEndpointSet(c check) { - conf, err := getConfig(configPath) - require.NoError(c.test, err, "error while reading configuration") +func checkOpAmpEndpointSet(c check) bool { + conf, err := getConfig(sumoRemotePath) + if !assert.NoError(c.test, err, "error while reading configuration") { + return false + } - require.Equal(c.test, conf.Extensions.OpAmp.Endpoint, "wss://example.com") + if !assert.Equal(c.test, conf.Extensions.OpAmp.Endpoint, "wss://example.com") { + return false + } + return true } -func checkHostmetricsConfigCreated(c check) { - require.FileExists(c.test, hostmetricsConfigPath, "hostmetrics configuration has not been created properly") +func checkHostmetricsConfigCreated(c check) bool { + return assert.FileExists(c.test, hostmetricsConfigPath, "hostmetrics configuration has not been created properly") } -func checkHostmetricsConfigNotCreated(c check) { - require.NoFileExists(c.test, hostmetricsConfigPath, "hostmetrics configuration has been created") +func checkHostmetricsConfigNotCreated(c check) bool { + return assert.NoFileExists(c.test, hostmetricsConfigPath, "hostmetrics configuration has been created") } -func checkRemoteConfigDirectoryCreated(c check) { - require.DirExists(c.test, opampDPath, "remote configuration directory has not been created properly") +func checkRemoteConfigDirectoryCreated(c check) bool { + return assert.DirExists(c.test, opampDPath, "remote configuration directory has not been created properly") } -func checkRemoteConfigDirectoryNotCreated(c check) { - require.NoDirExists(c.test, opampDPath, "remote configuration directory has been created") +func checkRemoteConfigDirectoryNotCreated(c check) bool { + return assert.NoDirExists(c.test, opampDPath, "remote configuration directory has been created") } -func checkTags(c check) { +func checkTags(c check) bool { conf, err := getConfig(userConfigPath) - require.NoError(c.test, err, "error while reading configuration") + if !assert.NoError(c.test, err, "error while reading configuration") { + return false + } + errored := false for k, v := range c.installOptions.tags { - require.Equal(c.test, v, conf.Extensions.Sumologic.Tags[k], "tag is different than expected") + if !assert.Equal(c.test, v, conf.Extensions.Sumologic.Tags[k], "tag is different than expected") { + errored = true + } } + return !errored } -func checkDifferentTags(c check) { +func checkDifferentTags(c check) bool { conf, err := getConfig(userConfigPath) - require.NoError(c.test, err, "error while reading configuration") + if !assert.NoError(c.test, err, "error while reading configuration") { + return false + } - require.Equal(c.test, "tag", conf.Extensions.Sumologic.Tags["some"], "tag is different than expected") + return assert.Equal(c.test, "tag", conf.Extensions.Sumologic.Tags["some"], "tag is different than expected") } -func checkAbortedDueToDifferentToken(c check) { - require.Greater(c.test, len(c.output), 0) - require.Contains(c.test, c.output[len(c.output)-1], "You are trying to install with different token than in your configuration file!") +func checkAbortedDueToDifferentToken(c check) bool { + if !assert.Greater(c.test, len(c.output), 0) { + return false + } + return assert.Contains(c.test, c.output[len(c.output)-1], "You are trying to install with different token than in your configuration file!") } -func preActionWriteAPIBaseURLToUserConfig(c check) { +func preActionWriteAPIBaseURLToUserConfig(c check) bool { conf, err := getConfig(userConfigPath) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } conf.Extensions.Sumologic.APIBaseURL = c.installOptions.apiBaseURL err = saveConfig(userConfigPath, conf) - require.NoError(c.test, err) + return assert.NoError(c.test, err) } -func preActionWriteDifferentAPIBaseURLToUserConfig(c check) { +func preActionWriteDifferentAPIBaseURLToUserConfig(c check) bool { conf, err := getConfig(userConfigPath) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } conf.Extensions.Sumologic.APIBaseURL = "different" + c.installOptions.apiBaseURL err = saveConfig(userConfigPath, conf) - require.NoError(c.test, err) + return assert.NoError(c.test, err) } -func preActionWriteDifferentTagsToUserConfig(c check) { +func preActionWriteDifferentTagsToUserConfig(c check) bool { conf, err := getConfig(userConfigPath) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } conf.Extensions.Sumologic.Tags = map[string]string{ "some": "tag", } err = saveConfig(userConfigPath, conf) - require.NoError(c.test, err) + return assert.NoError(c.test, err) } -func preActionWriteEmptyUserConfig(c check) { +func preActionWriteEmptyUserConfig(c check) bool { conf, err := getConfig(userConfigPath) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } err = saveConfig(userConfigPath, conf) - require.NoError(c.test, err) + return assert.NoError(c.test, err) } -func preActionWriteTagsToUserConfig(c check) { +func preActionWriteTagsToUserConfig(c check) bool { conf, err := getConfig(userConfigPath) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } conf.Extensions.Sumologic.Tags = c.installOptions.tags err = saveConfig(userConfigPath, conf) - require.NoError(c.test, err) + return assert.NoError(c.test, err) } -func checkAbortedDueToDifferentAPIBaseURL(c check) { - require.Greater(c.test, len(c.output), 0) - require.Contains(c.test, c.output[len(c.output)-1], "You are trying to install with different api base url than in your configuration file!") +func checkAbortedDueToDifferentAPIBaseURL(c check) bool { + if !assert.Greater(c.test, len(c.output), 0) { + return false + } + return assert.Contains(c.test, c.output[len(c.output)-1], "You are trying to install with different api base url than in your configuration file!") } -func checkAPIBaseURLInConfig(c check) { +func checkAPIBaseURLInConfig(c check) bool { conf, err := getConfig(userConfigPath) - require.NoError(c.test, err, "error while reading configuration") - - require.Equal(c.test, c.installOptions.apiBaseURL, conf.Extensions.Sumologic.APIBaseURL, "api base url is different than expected") -} + if !assert.NoError(c.test, err, "error while reading configuration") { + return false + } -func checkAbortedDueToDifferentTags(c check) { - require.Greater(c.test, len(c.output), 0) - require.Contains(c.test, c.output[len(c.output)-1], "You are trying to install with different tags than in your configuration file!") + return assert.Equal(c.test, c.installOptions.apiBaseURL, conf.Extensions.Sumologic.APIBaseURL, "api base url is different than expected") } -func PathHasPermissions(t *testing.T, path string, perms uint32) { +func PathHasPermissions(t *testing.T, path string, perms uint32) bool { info, err := os.Stat(path) - require.NoError(t, err) + if !assert.NoError(t, err) { + return false + } expected := fs.FileMode(perms) got := info.Mode().Perm() - require.Equal(t, expected, got, "%s should have %o permissions but has %o", path, expected, got) + return assert.Equal(t, expected, got, "%s should have %o permissions but has %o", path, expected, got) } -func PathHasUserACL(t *testing.T, path string, ownerName string, perms string) { +func PathHasUserACL(t *testing.T, path string, ownerName string, perms string) bool { cmd := exec.Command("/usr/bin/getfacl", path) output, err := cmd.Output() - require.NoError(t, err, "error while checking "+path+" acl") - require.Contains(t, string(output), "user:"+ownerName+":"+perms) + if !assert.NoError(t, err, "error while checking "+path+" acl") { + return false + } + return assert.Contains(t, string(output), "user:"+ownerName+":"+perms) } diff --git a/install-script/test/check_darwin.go b/install-script/test/check_darwin.go index d874f1cc..d59df2f1 100644 --- a/install-script/test/check_darwin.go +++ b/install-script/test/check_darwin.go @@ -3,16 +3,16 @@ package sumologic_scripts_tests import ( "io/fs" "os" + "path" "path/filepath" "regexp" "strings" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func checkConfigFilesOwnershipAndPermissions(ownerName string, ownerGroup string) func(c check) { - return func(c check) { +func checkConfigFilesOwnershipAndPermissions(ownerName string, ownerGroup string) func(c check) bool { + return func(c check) bool { PathHasPermissions(c.test, etcPath, etcPathPermissions) PathHasOwner(c.test, etcPath, ownerName, ownerGroup) @@ -21,11 +21,15 @@ func checkConfigFilesOwnershipAndPermissions(ownerName string, ownerGroup string for _, glob := range []string{etcPathGlob, etcPathNestedGlob} { paths, err := filepath.Glob(glob) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } for _, path := range paths { var permissions uint32 info, err := os.Stat(path) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } if info.IsDir() { switch path { case etcPath: @@ -41,9 +45,6 @@ func checkConfigFilesOwnershipAndPermissions(ownerName string, ownerGroup string case configPath: // /etc/otelcol-sumo/sumologic.yaml permissions = configPathFilePermissions - case userConfigPath: - // /etc/otelcol-sumo/conf.d/common.yaml - permissions = commonConfigPathFilePermissions default: // /etc/otelcol-sumo/conf.d/* permissions = confDPathFilePermissions @@ -54,46 +55,57 @@ func checkConfigFilesOwnershipAndPermissions(ownerName string, ownerGroup string } } PathHasPermissions(c.test, configPath, configPathFilePermissions) + + return true } } -func checkDifferentTokenInLaunchdConfig(c check) { - require.NotEmpty(c.test, c.installOptions.installToken, "installation token has not been provided") +func checkDifferentTokenInLaunchdConfig(c check) bool { + if !assert.NotEmpty(c.test, c.installOptions.installToken, "installation token has not been provided") { + return false + } conf, err := getLaunchdConfig(launchdPath) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } - require.Equal(c.test, "different"+c.installOptions.installToken, conf.EnvironmentVariables.InstallationToken, "installation token is different than expected") + return assert.Equal(c.test, "different"+c.installOptions.installToken, conf.EnvironmentVariables.InstallationToken, "installation token is different than expected") } -func checkGroupExists(c check) { - exists := dsclKeyExistsForPath(c.test, "/Groups", systemGroup) - require.True(c.test, exists, "group has not been created") +func checkGroupExists(c check) bool { + exists, err := dsclKeyExistsForPath(c.test, "/Groups", systemGroup) + assert.NoError(c.test, err) + return assert.True(c.test, exists, "group has not been created") } -func checkGroupNotExists(c check) { - exists := dsclKeyExistsForPath(c.test, "/Groups", systemGroup) - require.False(c.test, exists, "group has been created") +func checkGroupNotExists(c check) bool { + exists, err := dsclKeyExistsForPath(c.test, "/Groups", systemGroup) + assert.NoError(c.test, err) + return assert.False(c.test, exists, "group has been created") } -func checkHostmetricsOwnershipAndPermissions(ownerName string, ownerGroup string) func(c check) { - return func(c check) { +func checkHostmetricsOwnershipAndPermissions(ownerName string, ownerGroup string) func(c check) bool { + return func(c check) bool { PathHasOwner(c.test, hostmetricsConfigPath, ownerName, ownerGroup) PathHasPermissions(c.test, hostmetricsConfigPath, confDPathFilePermissions) + return true } } -func checkLaunchdConfigCreated(c check) { - require.FileExists(c.test, launchdPath, "launchd configuration has not been created properly") +func checkLaunchdConfigCreated(c check) bool { + return assert.FileExists(c.test, launchdPath, "launchd configuration has not been created properly") } -func checkLaunchdConfigNotCreated(c check) { - require.NoFileExists(c.test, launchdPath, "launchd configuration has been created") +func checkLaunchdConfigNotCreated(c check) bool { + return assert.NoFileExists(c.test, launchdPath, "launchd configuration has been created") } -func checkPackageCreated(c check) { +func checkPackageCreated(c check) bool { re, err := regexp.Compile("Package downloaded to: .*/otelcol-sumo.pkg") - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } matchedLine := "" for _, line := range c.output { @@ -101,96 +113,100 @@ func checkPackageCreated(c check) { matchedLine = line } } - require.NotEmpty(c.test, matchedLine, "package path not in output") + if !assert.NotEmpty(c.test, matchedLine, "package path not in output") { + return false + } packagePath := strings.TrimPrefix(matchedLine, "Package downloaded to: ") - require.FileExists(c.test, packagePath, "package has not been created") + return assert.FileExists(c.test, packagePath, "package has not been created") } -func checkTokenInLaunchdConfig(c check) { - require.NotEmpty(c.test, c.installOptions.installToken, "installation token has not been provided") +func checkTokenInLaunchdConfig(c check) bool { + if !assert.NotEmpty(c.test, c.installOptions.installToken, "installation token has not been provided") { + return false + } conf, err := getLaunchdConfig(launchdPath) - require.NoError(c.test, err) - - require.Equal(c.test, c.installOptions.installToken, conf.EnvironmentVariables.InstallationToken, "installation token is different than expected") -} - -func checkEphemeralInConfig(p string) func(c check) { - return func(c check) { - assert.True(c.test, c.installOptions.ephemeral, "ephemeral was not specified") - - conf, err := getConfig(p) - require.NoError(c.test, err, "error while reading configuration") - - assert.True(c.test, conf.Extensions.Sumologic.Ephemeral, "ephemeral is not true") + if !assert.NoError(c.test, err) { + return false } -} - -func checkEphemeralNotInConfig(p string) func(c check) { - return func(c check) { - assert.False(c.test, c.installOptions.ephemeral, "ephemeral was specified") - conf, err := getConfig(p) - require.NoError(c.test, err, "error while reading configuration") - - assert.False(c.test, conf.Extensions.Sumologic.Ephemeral, "ephemeral is true") - } + return assert.Equal(c.test, c.installOptions.installToken, conf.EnvironmentVariables.InstallationToken, "installation token is different than expected") } -func checkUserExists(c check) { - exists := dsclKeyExistsForPath(c.test, "/Users", systemUser) - require.True(c.test, exists, "user has not been created") +func checkUserExists(c check) bool { + exists, err := dsclKeyExistsForPath(c.test, "/Users", systemUser) + assert.NoError(c.test, err) + return assert.True(c.test, exists, "user has not been created") } -func checkUserNotExists(c check) { - exists := dsclKeyExistsForPath(c.test, "/Users", systemUser) - require.False(c.test, exists, "user has been created") +func checkUserNotExists(c check) bool { + exists, err := dsclKeyExistsForPath(c.test, "/Users", systemUser) + assert.NoError(c.test, err) + return assert.False(c.test, exists, "user has been created") } -func preActionInstallPackage(c check) { +func preActionInstallPackage(c check) bool { + c.installOptions.installToken = installToken + c.installOptions.apiBaseURL = mockAPIBaseURL c.code, c.output, c.errorOutput, c.err = runScript(c) + return assert.NoError(c.test, c.err) } -func preActionInstallPackageWithDifferentAPIBaseURL(c check) { - c.installOptions.apiBaseURL = "different" + c.installOptions.apiBaseURL +func preActionInstallPackageWithDifferentAPIBaseURL(c check) bool { + c.installOptions.installToken = installToken + c.installOptions.apiBaseURL = path.Join(c.installOptions.apiBaseURL, "different") c.code, c.output, c.errorOutput, c.err = runScript(c) + return assert.NoError(c.test, c.err) } -func preActionInstallPackageWithDifferentTags(c check) { +func preActionInstallPackageWithDifferentTags(c check) bool { + c.installOptions.installToken = installToken c.installOptions.tags = map[string]string{ "some": "tag", } c.code, c.output, c.errorOutput, c.err = runScript(c) + return assert.NoError(c.test, c.err) } -func preActionInstallPackageWithNoAPIBaseURL(c check) { - c.installOptions.apiBaseURL = "" +func preActionInstallPackageWithNoAPIBaseURL(c check) bool { + c.installOptions.installToken = installToken + c.installOptions.apiBaseURL = emptyAPIBaseURL c.code, c.output, c.errorOutput, c.err = runScript(c) + return assert.NoError(c.test, c.err) } -func preActionInstallPackageWithNoTags(c check) { +func preActionInstallPackageWithNoTags(c check) bool { + c.installOptions.installToken = installToken c.installOptions.tags = nil c.code, c.output, c.errorOutput, c.err = runScript(c) + return assert.NoError(c.test, c.err) } -func preActionMockLaunchdConfig(c check) { +func preActionMockLaunchdConfig(c check) bool { f, err := os.Create(launchdPath) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } err = f.Chmod(fs.FileMode(launchdPathFilePermissions)) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } conf := NewLaunchdConfig() + conf.EnvironmentVariables.InstallationToken = installToken err = saveLaunchdConfig(launchdPath, conf) - require.NoError(c.test, err) + return assert.NoError(c.test, err) } -func preActionWriteDifferentTokenToLaunchdConfig(c check) { +func preActionWriteDifferentTokenToLaunchdConfig(c check) bool { conf, err := getLaunchdConfig(launchdPath) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } conf.EnvironmentVariables.InstallationToken = "different" + c.installOptions.installToken err = saveLaunchdConfig(launchdPath, conf) - require.NoError(c.test, err) + return assert.NoError(c.test, err) } diff --git a/install-script/test/check_linux.go b/install-script/test/check_linux.go index 23e5cf28..03e9364f 100644 --- a/install-script/test/check_linux.go +++ b/install-script/test/check_linux.go @@ -6,264 +6,251 @@ import ( "os" "os/exec" "os/user" - "path/filepath" "strconv" "strings" "testing" "github.com/joho/godotenv" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func checkACLAvailability(c check) bool { return assert.FileExists(&testing.T{}, "/usr/bin/getfacl", "File ACLS is not supported") } -func checkConfigFilesOwnershipAndPermissions(ownerName string, ownerGroup string) func(c check) { - return func(c check) { - etcPathGlob := filepath.Join(etcPath, "*") - etcPathNestedGlob := filepath.Join(etcPath, "*", "*") - - for _, glob := range []string{etcPathGlob, etcPathNestedGlob} { - paths, err := filepath.Glob(glob) - require.NoError(c.test, err) - for _, path := range paths { - var permissions uint32 - info, err := os.Stat(path) - require.NoError(c.test, err) - if info.IsDir() { - if path == opampDPath { - permissions = opampDPermissions - } else { - permissions = configPathDirPermissions - } - } else { - permissions = configPathFilePermissions - } - PathHasPermissions(c.test, path, permissions) - PathHasOwner(c.test, configPath, ownerName, ownerGroup) - } - } - PathHasPermissions(c.test, configPath, configPathFilePermissions) - } -} - -func checkDifferentTokenInConfig(c check) { +func checkDifferentTokenInConfig(c check) bool { conf, err := getConfig(userConfigPath) - require.NoError(c.test, err, "error while reading configuration") + if !assert.NoError(c.test, err, "error while reading configuration") { + return false + } - require.Equal(c.test, "different"+c.installOptions.installToken, conf.Extensions.Sumologic.InstallationToken, "installation token is different than expected") + return assert.Equal(c.test, "different"+c.installOptions.installToken, conf.Extensions.Sumologic.InstallationToken, "installation token is different than expected") } -func checkDifferentTokenInEnvFile(c check) { - require.NotEmpty(c.test, c.installOptions.installToken, "installation token has not been provided") +func checkDifferentTokenInEnvFile(c check) bool { + if !assert.NotEmpty(c.test, c.installOptions.installToken, "installation token has not been provided") { + return false + } envs, err := godotenv.Read(tokenEnvFilePath) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } if _, ok := envs["SUMOLOGIC_INSTALL_TOKEN"]; ok { - require.Equal(c.test, "different"+c.installOptions.installToken, envs["SUMOLOGIC_INSTALL_TOKEN"], "installation token is different than expected") + if !assert.Equal(c.test, "different"+c.installOptions.installToken, envs["SUMOLOGIC_INSTALL_TOKEN"], "installation token is different than expected") { + return false + } } else { - require.Equal(c.test, "different"+c.installOptions.installToken, envs["SUMOLOGIC_INSTALLATION_TOKEN"], "installation token is different than expected") + if !assert.Equal(c.test, "different"+c.installOptions.installToken, envs["SUMOLOGIC_INSTALLATION_TOKEN"], "installation token is different than expected") { + return false + } } + return true } -func checkDownloadTimeout(c check) { +func checkDownloadTimeout(c check) bool { output := strings.Join(c.errorOutput, "\n") count := strings.Count(output, "Operation timed out after") - require.Equal(c.test, 6, count) + return assert.Equal(c.test, 6, count) } -func checkHostmetricsOwnershipAndPermissions(ownerName string, ownerGroup string) func(c check) { - return func(c check) { +func checkHostmetricsOwnershipAndPermissions(ownerName string, ownerGroup string) func(c check) bool { + return func(c check) bool { PathHasOwner(c.test, hostmetricsConfigPath, ownerName, ownerGroup) PathHasPermissions(c.test, hostmetricsConfigPath, configPathFilePermissions) + return true } } -func checkOutputUserAddWarnings(c check) { +func checkOutputUserAddWarnings(c check) bool { output := strings.Join(c.output, "\n") - require.NotContains(c.test, output, "useradd", "unexpected useradd output") + if !assert.NotContains(c.test, output, "useradd", "unexpected useradd output") { + return false + } errOutput := strings.Join(c.errorOutput, "\n") - require.NotContains(c.test, errOutput, "useradd", "unexpected useradd output") -} - -func checkTokenEnvFileCreated(c check) { - require.FileExists(c.test, tokenEnvFilePath, "env token file has not been created") + return assert.NotContains(c.test, errOutput, "useradd", "unexpected useradd output") } -func checkTokenEnvFileNotCreated(c check) { - require.NoFileExists(c.test, tokenEnvFilePath, "env token file not been created") +func checkTokenEnvFileCreated(c check) bool { + return assert.FileExists(c.test, tokenEnvFilePath, "env token file has not been created") } -func checkTokenInConfig(c check) { - require.NotEmpty(c.test, c.installOptions.installToken, "installation token has not been provided") - - conf, err := getConfig(userConfigPath) - require.NoError(c.test, err, "error while reading configuration") - - require.Equal(c.test, c.installOptions.installToken, conf.Extensions.Sumologic.InstallationToken, "installation token is different than expected") -} - -func checkTokenInSumoConfig(c check) { - require.NotEmpty(c.test, c.installOptions.installToken, "installation token has not been provided") - - conf, err := getConfig(configPath) - require.NoError(c.test, err, "error while reading configuration") - - require.Equal(c.test, c.installOptions.installToken, conf.Extensions.Sumologic.InstallationToken, "installation token is different than expected") +func checkTokenEnvFileNotCreated(c check) bool { + return assert.NoFileExists(c.test, tokenEnvFilePath, "env token file has been created") } -func checkTokenInEnvFile(c check) { - require.NotEmpty(c.test, c.installOptions.installToken, "installation token has not been provided") +func checkTokenInEnvFile(c check) bool { + if !assert.NotEmpty(c.test, c.installOptions.installToken, "installation token has not been provided") { + return false + } envs, err := godotenv.Read(tokenEnvFilePath) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } if _, ok := envs["SUMOLOGIC_INSTALL_TOKEN"]; ok { - require.Equal(c.test, c.installOptions.installToken, envs["SUMOLOGIC_INSTALL_TOKEN"], "installation token is different than expected") + if !assert.Equal(c.test, c.installOptions.installToken, envs["SUMOLOGIC_INSTALL_TOKEN"], "installation token is different than expected") { + return false + } } else { - require.Equal(c.test, c.installOptions.installToken, envs["SUMOLOGIC_INSTALLATION_TOKEN"], "installation token is different than expected") - } -} - -func checkEphemeralInConfig(p string) func(c check) { - return func(c check) { - assert.True(c.test, c.installOptions.ephemeral, "ephemeral was not specified") - - conf, err := getConfig(p) - require.NoError(c.test, err, "error while reading configuration") - - assert.True(c.test, conf.Extensions.Sumologic.Ephemeral, "ephemeral is not true") + if !assert.Equal(c.test, c.installOptions.installToken, envs["SUMOLOGIC_INSTALLATION_TOKEN"], "installation token is different than expected") { + return false + } } + return true } -func checkEphemeralNotInConfig(p string) func(c check) { - return func(c check) { - assert.False(c.test, c.installOptions.ephemeral, "ephemeral was specified") - - conf, err := getConfig(p) - require.NoError(c.test, err, "error while reading configuration") - - assert.False(c.test, conf.Extensions.Sumologic.Ephemeral, "ephemeral is true") +func checkUninstallationOutput(c check) bool { + if !assert.Greater(c.test, len(c.output), 1) { + return false } + return assert.Contains(c.test, c.output[len(c.output)-1], "Uninstallation completed") } -func checkUninstallationOutput(c check) { - require.Greater(c.test, len(c.output), 1) - require.Contains(c.test, c.output[len(c.output)-1], "Uninstallation completed") -} - -func checkUserExists(c check) { +func checkUserExists(c check) bool { _, err := user.Lookup(systemUser) - require.NoError(c.test, err, "user has not been created") + return assert.NoError(c.test, err, "user has not been created") } -func checkVarLogACL(c check) { +func checkVarLogACL(c check) bool { if !checkACLAvailability(c) { - return + return true } PathHasUserACL(c.test, "/var/log", systemUser, "r-x") + return true } -func preActionCreateHomeDirectory(c check) { +func preActionCreateHomeDirectory(c check) bool { err := os.MkdirAll(libPath, fs.FileMode(etcPathPermissions)) - require.NoError(c.test, err) + return assert.NoError(c.test, err) } // preActionCreateUser creates the system user and then set it as owner of configPath -func preActionCreateUser(c check) { - preActionMockUserConfig(c) +func preActionCreateUser(c check) bool { + if !preActionMockUserConfig(c) { + return false + } cmd := exec.Command("useradd", systemUser) _, err := cmd.CombinedOutput() - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } f, err := os.Open(configPath) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } user, err := user.Lookup(systemUser) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } uid, err := strconv.Atoi(user.Uid) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } gid, err := strconv.Atoi(user.Gid) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } err = f.Chown(uid, gid) - require.NoError(c.test, err) + return assert.NoError(c.test, err) } -func preActionMockConfigs(c check) { - preActionMockConfig(c) - preActionMockUserConfig(c) +func preActionMockConfigs(c check) bool { + if !preActionMockConfig(c) { + return false + } + return preActionMockUserConfig(c) } -func preActionMockEnvFiles(c check) { +func preActionMockEnvFiles(c check) bool { err := os.MkdirAll(envDirectoryPath, fs.FileMode(etcPathPermissions)) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } f, err := os.Create(configPath) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } err = f.Chmod(fs.FileMode(configPathFilePermissions)) - require.NoError(c.test, err) + return assert.NoError(c.test, err) } -func preActionMockStructure(c check) { - preActionMockConfigs(c) +func preActionMockStructure(c check) bool { + if !preActionMockConfigs(c) { + return false + } err := os.MkdirAll(fileStoragePath, os.ModePerm) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } content := []byte("#!/bin/sh\necho hello world\n") err = os.WriteFile(binaryPath, content, 0755) - require.NoError(c.test, err) + return assert.NoError(c.test, err) } -func preActionWriteDefaultAPIBaseURLToUserConfig(c check) { +func preActionWriteDefaultAPIBaseURLToUserConfig(c check) bool { conf, err := getConfig(userConfigPath) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } conf.Extensions.Sumologic.APIBaseURL = apiBaseURL err = saveConfig(userConfigPath, conf) - require.NoError(c.test, err) + return assert.NoError(c.test, err) } -func preActionWriteDifferentDeprecatedTokenToEnvFile(c check) { - preActionMockEnvFiles(c) +func preActionWriteDifferentDeprecatedTokenToEnvFile(c check) bool { + if !preActionMockEnvFiles(c) { + return false + } content := fmt.Sprintf("SUMOLOGIC_INSTALL_TOKEN=different%s", c.installOptions.installToken) err := os.WriteFile(tokenEnvFilePath, []byte(content), fs.FileMode(etcPathPermissions)) - require.NoError(c.test, err) + return assert.NoError(c.test, err) } -func preActionWriteDifferentTokenToEnvFile(c check) { - preActionMockEnvFiles(c) +func preActionWriteDifferentTokenToEnvFile(c check) bool { + if !preActionMockEnvFiles(c) { + return false + } content := fmt.Sprintf("SUMOLOGIC_INSTALLATION_TOKEN=different%s", c.installOptions.installToken) err := os.WriteFile(tokenEnvFilePath, []byte(content), fs.FileMode(etcPathPermissions)) - require.NoError(c.test, err) + return assert.NoError(c.test, err) } -func preActionWriteDifferentTokenToUserConfig(c check) { +func preActionWriteDifferentTokenToUserConfig(c check) bool { conf, err := getConfig(userConfigPath) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } conf.Extensions.Sumologic.InstallationToken = "different" + c.installOptions.installToken err = saveConfig(userConfigPath, conf) - require.NoError(c.test, err) + return assert.NoError(c.test, err) } -func preActionWriteTokenToUserConfig(c check) { +func preActionWriteTokenToUserConfig(c check) bool { conf, err := getConfig(userConfigPath) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } conf.Extensions.Sumologic.InstallationToken = c.installOptions.installToken err = saveConfig(userConfigPath, conf) - require.NoError(c.test, err) + return assert.NoError(c.test, err) } diff --git a/install-script/test/check_unix.go b/install-script/test/check_unix.go index 71e3fad7..f1683b39 100644 --- a/install-script/test/check_unix.go +++ b/install-script/test/check_unix.go @@ -10,43 +10,116 @@ import ( "syscall" "testing" - "github.com/stretchr/testify/require" + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" ) -func checkAbortedDueToNoToken(c check) { - require.Greater(c.test, len(c.output), 1) - require.Contains(c.test, c.output[len(c.output)-2], "Installation token has not been provided. Please set the 'SUMOLOGIC_INSTALLATION_TOKEN' environment variable.") - require.Contains(c.test, c.output[len(c.output)-1], "You can ignore this requirement by adding '--skip-installation-token argument.") +type configRoot struct { + Extensions *configExtensions `yaml:"extensions,omitempty"` } -func preActionMockConfig(c check) { +type configExtensions struct { + Sumologic *sumologicExt `yaml:"sumologic,omitempty"` +} + +type sumologicExt struct { + Ephemeral bool `yaml:"ephemeral,omitempty"` +} + +func checkAbortedDueToNoToken(c check) bool { + if !assert.Greater(c.test, len(c.output), 1) { + return false + } + return assert.Contains(c.test, c.output, "Installation token has not been provided. Please set the 'SUMOLOGIC_INSTALLATION_TOKEN' environment variable.") +} + +func checkEphemeralConfigFileCreated(p string) func(c check) bool { + return func(c check) bool { + return assert.FileExists(c.test, p, "ephemeral config file has not been created") + } +} + +func checkEphemeralConfigFileNotCreated(p string) func(c check) bool { + return func(c check) bool { + return assert.NoFileExists(c.test, p, "ephemeral config file has been created") + } +} + +func checkEphemeralEnabledInRemote(p string) func(c check) bool { + return func(c check) bool { + yamlFile, err := os.ReadFile(p) + if assert.NoError(c.test, err, "sumologic remote config file could not be read") { + return false + } + + var config configRoot + + if assert.NoError(c.test, yaml.Unmarshal(yamlFile, &config), "could not parse yaml") { + return false + } + + return config.Extensions.Sumologic.Ephemeral + } +} + +func checkEphemeralNotEnabledInRemote(p string) func(c check) bool { + return func(c check) bool { + yamlFile, err := os.ReadFile(p) + if err != nil { + // assume the error is due to the file not existing, which is valid + return true + } + + var config configRoot + + if assert.NoError(c.test, yaml.Unmarshal(yamlFile, &config), "could not parse yaml") { + return false + } + + return !config.Extensions.Sumologic.Ephemeral + } +} + +func preActionMockConfig(c check) bool { err := os.MkdirAll(etcPath, fs.FileMode(etcPathPermissions)) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } f, err := os.Create(configPath) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } err = f.Chmod(fs.FileMode(configPathFilePermissions)) - require.NoError(c.test, err) + return assert.NoError(c.test, err) } -func preActionMockUserConfig(c check) { +func preActionMockUserConfig(c check) bool { err := os.MkdirAll(etcPath, fs.FileMode(etcPathPermissions)) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } err = os.MkdirAll(confDPath, fs.FileMode(configPathDirPermissions)) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } f, err := os.Create(userConfigPath) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } - err = f.Chmod(fs.FileMode(commonConfigPathFilePermissions)) - require.NoError(c.test, err) + err = f.Chmod(fs.FileMode(confDPathFilePermissions)) + return assert.NoError(c.test, err) } -func PathHasOwner(t *testing.T, path string, ownerName string, groupName string) { +func PathHasOwner(t *testing.T, path string, ownerName string, groupName string) bool { info, err := os.Stat(path) - require.NoError(t, err) + if !assert.NoError(t, err) { + return false + } // get the owning user and group stat := info.Sys().(*syscall.Stat_t) @@ -54,11 +127,17 @@ func PathHasOwner(t *testing.T, path string, ownerName string, groupName string) gid := strconv.FormatUint(uint64(stat.Gid), 10) usr, err := user.LookupId(uid) - require.NoError(t, err) + if !assert.NoError(t, err) { + return false + } group, err := user.LookupGroupId(gid) - require.NoError(t, err) - - require.Equal(t, ownerName, usr.Username, "%s should be owned by user '%s'", path, ownerName) - require.Equal(t, groupName, group.Name, "%s should be owned by group '%s'", path, groupName) + if !assert.NoError(t, err) { + return false + } + + if !assert.Equal(t, ownerName, usr.Username, "%s should be owned by user '%s'", path, ownerName) { + return false + } + return assert.Equal(t, groupName, group.Name, "%s should be owned by group '%s'", path, groupName) } diff --git a/install-script/test/check_windows.go b/install-script/test/check_windows.go index 1a406db5..6b9246a3 100644 --- a/install-script/test/check_windows.go +++ b/install-script/test/check_windows.go @@ -12,7 +12,6 @@ import ( "unsafe" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "golang.org/x/sys/windows" ) @@ -29,79 +28,109 @@ type ACLRecord struct { AccessMode windows.ACCESS_MODE } -func checkAbortedDueToNoToken(c check) { - require.Greater(c.test, len(c.output), 1) - require.Greater(c.test, len(c.errorOutput), 1) +func checkAbortedDueToNoToken(c check) bool { + if !assert.Greater(c.test, len(c.output), 1) { + return false + } + if !assert.Greater(c.test, len(c.errorOutput), 1) { + return false + } // The exact formatting of the error message can be different depending on Powershell version errorOutput := strings.Join(c.errorOutput, " ") - require.Contains(c.test, errorOutput, "Installation token has not been provided.") - require.Contains(c.test, errorOutput, "Please set the SUMOLOGIC_INSTALLATION_TOKEN environment variable.") + if !assert.Contains(c.test, errorOutput, "Installation token has not been provided.") { + return false + } + return assert.Contains(c.test, errorOutput, "Please set the SUMOLOGIC_INSTALLATION_TOKEN environment variable.") } -func checkBinaryFipsError(c check) { +func checkBinaryFipsError(c check) bool { cmd := exec.Command(binaryPath, "--version") _, err := cmd.Output() - require.Error(c.test, err, "running on a non-FIPS system must error") + if !assert.Error(c.test, err, "running on a non-FIPS system must error") { + return false + } exitErr, ok := err.(*exec.ExitError) - require.True(c.test, ok, "returned error must be of type ExitError") + if !assert.True(c.test, ok, "returned error must be of type ExitError") { + return false + } - require.Equal(c.test, 2, exitErr.ExitCode(), "got error code while checking version") - require.Contains(c.test, string(exitErr.Stderr), "not in FIPS mode") + if !assert.Equal(c.test, 2, exitErr.ExitCode(), "got error code while checking version") { + return false + } + return assert.Contains(c.test, string(exitErr.Stderr), "not in FIPS mode") } -func checkEphemeralNotInConfig(p string) func(c check) { - return func(c check) { +func checkEphemeralNotInConfig(p string) func(c check) bool { + return func(c check) bool { assert.False(c.test, c.installOptions.ephemeral, "ephemeral was specified") conf, err := getConfig(p) - require.NoError(c.test, err, "error while reading configuration") + if !assert.NoError(c.test, err, "error while reading configuration") { + return false + } assert.False(c.test, conf.Extensions.Sumologic.Ephemeral, "ephemeral is true") + return true } } -func checkEphemeralInConfig(p string) func(c check) { - return func(c check) { +func checkEphemeralInConfig(p string) func(c check) bool { + return func(c check) bool { assert.True(c.test, c.installOptions.ephemeral, "ephemeral was not specified") conf, err := getConfig(p) - require.NoError(c.test, err, "error while reading configuration") + if !assert.NoError(c.test, err, "error while reading configuration") { + return false + } assert.True(c.test, conf.Extensions.Sumologic.Ephemeral, "ephemeral is not true") + return true } } -func checkTokenInConfig(c check) { - require.NotEmpty(c.test, c.installOptions.installToken, "installation token has not been provided") +func checkTokenInConfig(c check) bool { + if !assert.NotEmpty(c.test, c.installOptions.installToken, "installation token has not been provided") { + return false + } conf, err := getConfig(userConfigPath) - require.NoError(c.test, err, "error while reading configuration") + if !assert.NoError(c.test, err, "error while reading configuration") { + return false + } - require.Equal(c.test, c.installOptions.installToken, conf.Extensions.Sumologic.InstallationToken, "installation token is different than expected") + return assert.Equal(c.test, c.installOptions.installToken, conf.Extensions.Sumologic.InstallationToken, "installation token is different than expected") } -func checkTokenInSumoConfig(c check) { - require.NotEmpty(c.test, c.installOptions.installToken, "installation token has not been provided") +func checkTokenInSumoConfig(c check) bool { + if !assert.NotEmpty(c.test, c.installOptions.installToken, "installation token has not been provided") { + return false + } conf, err := getConfig(configPath) - require.NoError(c.test, err, "error while reading configuration") + if !assert.NoError(c.test, err, "error while reading configuration") { + return false + } - require.Equal(c.test, c.installOptions.installToken, conf.Extensions.Sumologic.InstallationToken, "installation token is different than expected") + return assert.Equal(c.test, c.installOptions.installToken, conf.Extensions.Sumologic.InstallationToken, "installation token is different than expected") } -func checkConfigFilesOwnershipAndPermissions(ownerSid string) func(c check) { - return func(c check) { +func checkConfigFilesOwnershipAndPermissions(ownerSid string) func(c check) bool { + return func(c check) bool { etcPathGlob := filepath.Join(etcPath, "*") etcPathNestedGlob := filepath.Join(etcPath, "*", "*") for _, glob := range []string{etcPathGlob, etcPathNestedGlob} { paths, err := filepath.Glob(glob) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } for _, path := range paths { var aclRecords []ACLRecord info, err := os.Stat(path) - require.NoError(c.test, err) + if !assert.NoError(c.test, err) { + return false + } if info.IsDir() { if path == opampDPath { aclRecords = opampDPermissions @@ -115,38 +144,48 @@ func checkConfigFilesOwnershipAndPermissions(ownerSid string) func(c check) { PathHasOwner(c.test, path, ownerSid) } } + return true } } -func PathHasOwner(t *testing.T, path string, ownerSID string) { +func PathHasOwner(t *testing.T, path string, ownerSID string) bool { securityDescriptor, err := windows.GetNamedSecurityInfo( path, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION, ) - require.NoError(t, err) + if !assert.NoError(t, err) { + return false + } // get the owning user owner, _, err := securityDescriptor.Owner() - require.NoError(t, err) + if !assert.NoError(t, err) { + return false + } - require.Equal(t, ownerSID, owner.String(), "%s should be owned by user '%s'", path, ownerSID) + return assert.Equal(t, ownerSID, owner.String(), "%s should be owned by user '%s'", path, ownerSID) } -func PathHasWindowsACLs(t *testing.T, path string, expectedACLs []ACLRecord) { +func PathHasWindowsACLs(t *testing.T, path string, expectedACLs []ACLRecord) bool { securityDescriptor, err := windows.GetNamedSecurityInfo( path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, ) - require.NoError(t, err) + if !assert.NoError(t, err) { + return false + } // get the ACL entries acl, _, err := securityDescriptor.DACL() - require.NoError(t, err) - require.NotNil(t, acl) + if !assert.NoError(t, err) || !assert.NotNil(t, acl) { + return false + } entries, err := GetExplicitEntriesFromACL(acl) - require.NoError(t, err) + if !assert.NoError(t, err) { + return false + } aclRecords := []ACLRecord{} for _, entry := range entries { aclRecord := ExplicitEntryToACLRecord(entry) @@ -155,6 +194,7 @@ func PathHasWindowsACLs(t *testing.T, path string, expectedACLs []ACLRecord) { } } assert.Equal(t, expectedACLs, aclRecords, "invalid ACLs for %s", path) + return true } // GetExplicitEntriesFromACL gets a list of explicit entries from an ACL diff --git a/install-script/test/command_unix.go b/install-script/test/command_unix.go index 05d0c40a..3ac98f76 100644 --- a/install-script/test/command_unix.go +++ b/install-script/test/command_unix.go @@ -9,15 +9,12 @@ import ( "os" "os/exec" "strings" - - "github.com/stretchr/testify/require" ) type installOptions struct { installToken string autoconfirm bool tags map[string]string - skipConfig bool skipInstallToken bool fips bool envs map[string]string @@ -30,6 +27,7 @@ type installOptions struct { opampEndpoint string downloadOnly bool dontKeepDownloads bool + version string } func (io *installOptions) string() []string { @@ -45,8 +43,8 @@ func (io *installOptions) string() []string { opts = append(opts, "--fips") } - if io.skipConfig { - opts = append(opts, "--skip-config") + if io.downloadOnly { + opts = append(opts, "--download-only") } if io.skipInstallToken { @@ -76,8 +74,19 @@ func (io *installOptions) string() []string { } } - if io.apiBaseURL != "" { - opts = append(opts, "--api", io.apiBaseURL) + // 1. If the apiBaseURL is empty, replace it with the mock API's URL. + // 2. If the apiBaseURL is equal to the emptyAPIBaseURL constant, don't set + // the --api flag. + // 3. If none of the above are true, set the --api flag to the value of + // apiBaseURL. + apiBaseURL := "" + if io.apiBaseURL == "" { + apiBaseURL = mockAPIBaseURL + } else if io.apiBaseURL != emptyAPIBaseURL { + apiBaseURL = io.apiBaseURL + } + if apiBaseURL != "" { + opts = append(opts, "--api", apiBaseURL) } if io.timeout != 0 { @@ -88,6 +97,15 @@ func (io *installOptions) string() []string { opts = append(opts, "--opamp-api", io.opampEndpoint) } + otc_version := os.Getenv("OTC_VERSION") + otc_build_number := os.Getenv("OTC_BUILD_NUMBER") + + if io.version != "" { + opts = append(opts, "--version", io.version) + } else if otc_version != "" && otc_build_number != "" { + opts = append(opts, "--version", fmt.Sprintf("%s-%s", otc_version, otc_build_number)) + } + return opts } @@ -124,22 +142,24 @@ func runScript(ch check) (int, []string, []string, error) { cmd.Env = ch.installOptions.buildEnvs() output := []string{} + ch.test.Logf("Running command: %s", strings.Join(ch.installOptions.string(), " ")) + in, err := cmd.StdinPipe() if err != nil { - require.NoError(ch.test, err) + return 0, nil, nil, err } defer in.Close() out, err := cmd.StdoutPipe() if err != nil { - require.NoError(ch.test, err) + return 0, nil, nil, err } defer out.Close() errOut, err := cmd.StderrPipe() if err != nil { - require.NoError(ch.test, err) + return 0, nil, nil, err } defer errOut.Close() @@ -148,7 +168,7 @@ func runScript(ch check) (int, []string, []string, error) { // Start the process if err = cmd.Start(); err != nil { - require.NoError(ch.test, err) + return 0, nil, nil, err } // Read the results from the process @@ -167,8 +187,9 @@ func runScript(ch check) (int, []string, []string, error) { } // otherwise ensure there is no error - require.NoError(ch.test, err) - + if err != nil { + return 0, nil, nil, err + } } // Handle stderr separately diff --git a/install-script/test/command_windows.go b/install-script/test/command_windows.go index 47ad5b86..cea52c45 100644 --- a/install-script/test/command_windows.go +++ b/install-script/test/command_windows.go @@ -7,8 +7,6 @@ import ( "os" "os/exec" "strings" - - "github.com/stretchr/testify/require" ) type installOptions struct { @@ -50,6 +48,8 @@ func (io *installOptions) string() []string { if io.apiBaseURL != "" { opts = append(opts, "-Api", io.apiBaseURL) + } else { + opts = append(opts, "-Api", mockAPIBaseURL) } return opts @@ -90,20 +90,20 @@ func runScript(ch check) (int, []string, []string, error) { in, err := cmd.StdinPipe() if err != nil { - require.NoError(ch.test, err) + return 0, nil, nil, err } defer in.Close() out, err := cmd.StdoutPipe() if err != nil { - require.NoError(ch.test, err) + return 0, nil, nil, err } defer out.Close() errOut, err := cmd.StderrPipe() if err != nil { - require.NoError(ch.test, err) + return 0, nil, nil, err } defer errOut.Close() @@ -112,7 +112,7 @@ func runScript(ch check) (int, []string, []string, error) { // Start the process if err = cmd.Start(); err != nil { - require.NoError(ch.test, err) + return 0, nil, nil, err } // Read the results from the process @@ -131,8 +131,9 @@ func runScript(ch check) (int, []string, []string, error) { } // otherwise ensure there is no error - require.NoError(ch.test, err) - + if err != nil { + return 0, nil, nil, err + } } // Handle stderr separately diff --git a/install-script/test/common.go b/install-script/test/common.go index f82b3aa8..3af4b9c0 100644 --- a/install-script/test/common.go +++ b/install-script/test/common.go @@ -1,5 +1,12 @@ package sumologic_scripts_tests +import ( + "io" + "net" + "net/http" + "testing" +) + type testSpec struct { name string options installOptions @@ -9,3 +16,29 @@ type testSpec struct { conditionalChecks []condCheckFunc installCode int } + +func startMockAPI(t *testing.T) (*http.Server, error) { + t.Log("Starting HTTP server") + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if _, err := io.WriteString(w, "200 OK\n"); err != nil { + panic(err) + } + }) + + listener, err := net.Listen("tcp", ":3333") + if err != nil { + return nil, err + } + + httpServer := &http.Server{ + Handler: mux, + } + go func() { + err := httpServer.Serve(listener) + if err != nil && err != http.ErrServerClosed { + panic(err) + } + }() + return httpServer, nil +} diff --git a/install-script/test/common_darwin.go b/install-script/test/common_darwin.go index a4f1311c..7197c49f 100644 --- a/install-script/test/common_darwin.go +++ b/install-script/test/common_darwin.go @@ -9,14 +9,16 @@ import ( "strings" "testing" - "github.com/stretchr/testify/require" + "github.com/stretchr/testify/assert" ) -func dsclDeletePath(t *testing.T, path string) { +func dsclDeletePath(t *testing.T, path string) bool { cmd := exec.Command("dscl", ".", "-delete", path) output, err := cmd.CombinedOutput() - require.NoErrorf(t, err, "error while using dscl to delete path: %s, path: %s", output, path) - require.Empty(t, string(output)) + if !assert.NoErrorf(t, err, "error while using dscl to delete path: %s, path: %s", output, path) { + return false + } + return assert.Empty(t, string(output)) } // The user.Lookup() and user.LookupGroup() functions do not appear to work @@ -24,25 +26,25 @@ func dsclDeletePath(t *testing.T, path string) { // has been deleted. There are several GitHub issues in github.com/golang/go // that describe similar or related behaviour. To work around this issue we use // the dscl command to determine if a user or group exists. -func dsclKeyExistsForPath(t *testing.T, path, key string) bool { +func dsclKeyExistsForPath(t *testing.T, path, key string) (bool, error) { cmd := exec.Command("dscl", ".", "-list", path) out, err := cmd.StdoutPipe() if err != nil { - require.NoError(t, err) + return false, err } defer out.Close() bufOut := bufio.NewReader(out) if err := cmd.Start(); err != nil { - require.NoError(t, err) + return false, err } for { line, _, err := bufOut.ReadLine() if string(line) == key { - return true + return true, nil } // exit if script finished @@ -51,82 +53,131 @@ func dsclKeyExistsForPath(t *testing.T, path, key string) bool { } // otherwise ensure there is no error - require.NoError(t, err) + if err != nil { + return false, err + } } - return false + return false, nil } -func forgetPackage(t *testing.T, name string) { +func forgetPackage(t *testing.T, name string) error { noReceiptMsg := fmt.Sprintf("No receipt for '%s' found at '/'.", name) output, err := exec.Command("pkgutil", "--forget", name).CombinedOutput() if err != nil && !strings.Contains(string(output), noReceiptMsg) { - require.NoErrorf(t, err, "error forgetting package: %s", string(output)) + return fmt.Errorf("error forgetting package: %s", string(output)) } + return nil } -func removeFileIfExists(t *testing.T, path string) { +func removeFileIfExists(t *testing.T, path string) error { if _, err := os.Stat(path); err != nil { - return + if os.IsNotExist(err) { + return nil + } + return err } - require.NoErrorf(t, os.Remove(path), "error removing file: %s", path) + if err := os.Remove(path); err != nil { + return fmt.Errorf("error removing file: %s", path) + } + return nil } -func removeDirectoryIfExists(t *testing.T, path string) { +func removeDirectoryIfExists(t *testing.T, path string) error { info, err := os.Stat(path) if err != nil { - return + if os.IsNotExist(err) { + return nil + } + return err } - require.Truef(t, info.IsDir(), "path is not a directory: %s", path) - require.NoErrorf(t, os.RemoveAll(path), "error removing directory: %s", path) + if !info.IsDir() { + return fmt.Errorf("path is not a directory: %s", path) + } + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("error removing directory: %s", path) + } + return nil } func tearDown(t *testing.T) { // Stop service - unloadLaunchdService(t) + if err := unloadLaunchdService(t); err != nil { + t.Log(err) + } // Remove files - removeFileIfExists(t, binaryPath) - removeFileIfExists(t, launchdPath) + if err := removeFileIfExists(t, binaryPath); err != nil { + t.Log(err) + } + if err := removeFileIfExists(t, launchdPath); err != nil { + t.Log(err) + } // Remove configuration & data - removeDirectoryIfExists(t, etcPath) - removeDirectoryIfExists(t, fileStoragePath) - removeDirectoryIfExists(t, logDirPath) - removeDirectoryIfExists(t, appSupportDirPath) + if err := removeDirectoryIfExists(t, etcPath); err != nil { + t.Log(err) + } + if err := removeDirectoryIfExists(t, fileStoragePath); err != nil { + t.Log(err) + } + if err := removeDirectoryIfExists(t, logDirPath); err != nil { + t.Log(err) + } + if err := removeDirectoryIfExists(t, appSupportDirPath); err != nil { + t.Log(err) + } // Remove user & group - if dsclKeyExistsForPath(t, "/Users", systemUser) { + if exists, err := dsclKeyExistsForPath(t, "/Users", systemUser); err != nil { + t.Log(err) + } else if exists { dsclDeletePath(t, fmt.Sprintf("/Users/%s", systemUser)) } - if dsclKeyExistsForPath(t, "/Groups", systemGroup) { + if exists, err := dsclKeyExistsForPath(t, "/Groups", systemGroup); err != nil { + t.Log(err) + } else if exists { dsclDeletePath(t, fmt.Sprintf("/Groups/%s", systemGroup)) } - if dsclKeyExistsForPath(t, "/Users", systemUser) { + if exists, err := dsclKeyExistsForPath(t, "/Users", systemUser); err != nil { + t.Log(err) + } else if exists { panic(fmt.Sprintf("user exists after deletion: %s", systemUser)) } - if dsclKeyExistsForPath(t, "/Groups", systemGroup) { + + if exists, err := dsclKeyExistsForPath(t, "/Groups", systemGroup); err != nil { + t.Log(err) + } else if exists { panic(fmt.Sprintf("group exists after deletion: %s", systemGroup)) } // Remove packages - forgetPackage(t, "com.sumologic.otelcol-sumo-hostmetrics") - forgetPackage(t, "com.sumologic.otelcol-sumo") + if err := forgetPackage(t, "com.sumologic.otelcol-sumo"); err != nil { + t.Log(err) + } } -func unloadLaunchdService(t *testing.T) { +func unloadLaunchdService(t *testing.T) error { info, err := os.Stat(launchdPath) if err != nil { - return + if os.IsNotExist(err) { + return nil + } + return err } - require.Falsef(t, info.IsDir(), "launchd config is not a file: %s", launchdPath) + if info.IsDir() { + return fmt.Errorf("launchd config is a directory: %s", launchdPath) + } output, err := exec.Command("launchctl", "unload", "-w", "otelcol-sumo").Output() - require.NoErrorf(t, err, "error stopping service: %s", string(output)) + if err != nil { + fmt.Errorf("error stopping service: %s", string(output)) + } + return nil } diff --git a/install-script/test/common_linux.go b/install-script/test/common_linux.go index 42ee014c..0c5731c2 100644 --- a/install-script/test/common_linux.go +++ b/install-script/test/common_linux.go @@ -4,8 +4,6 @@ package sumologic_scripts_tests import ( "testing" - - "github.com/stretchr/testify/require" ) func tearDown(t *testing.T) { @@ -17,5 +15,8 @@ func tearDown(t *testing.T) { } _, _, _, err := runScript(ch) - require.NoError(t, err) + if err != nil { + t.Log(err) + } + return } diff --git a/install-script/test/common_unix.go b/install-script/test/common_unix.go index ffa012d3..960f0dbc 100644 --- a/install-script/test/common_unix.go +++ b/install-script/test/common_unix.go @@ -4,24 +4,19 @@ package sumologic_scripts_tests import ( "context" - "io" - "net" - "net/http" + "fmt" "os" "testing" - - "github.com/stretchr/testify/require" ) // These checks always have to be true after a script execution var commonPostChecks = []checkFunc{checkNoBakFilesPresent} -func cleanCache(t *testing.T) { - err := os.RemoveAll(cacheDirectory) - require.NoError(t, err) +func cleanCache(t *testing.T) error { + return os.RemoveAll(cacheDirectory) } -func runTest(t *testing.T, spec *testSpec) { +func runTest(t *testing.T, spec *testSpec) (fErr error) { ch := check{ test: t, installOptions: spec.options, @@ -37,56 +32,59 @@ func runTest(t *testing.T, spec *testSpec) { defer tearDown(t) - t.Log("Starting HTTP server") - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - if _, err := io.WriteString(w, "200 OK\n"); err != nil { - panic(err) - } - }) - - listener, err := net.Listen("tcp", ":3333") - require.NoError(t, err) - - httpServer := &http.Server{ - Handler: mux, + mockAPI, err := startMockAPI(t) + if err != nil { + return fmt.Errorf("Failed to start mock API: %s", err) } - go func() { - err := httpServer.Serve(listener) - if err != nil && err != http.ErrServerClosed { - panic(err) - } - }() + defer func() { - require.NoError(t, httpServer.Shutdown(context.Background())) + if err := mockAPI.Shutdown(context.Background()); err != nil { + fErr = fmt.Errorf("Failed to shutdown API: %s", err) + return + } }() t.Log("Running pre actions") for _, a := range spec.preActions { - a(ch) + if ok := a(ch); !ok { + return nil + } } t.Log("Running pre checks") for _, c := range spec.preChecks { - c(ch) + if ok := c(ch); !ok { + return nil + } } + t.Log("Running script") ch.code, ch.output, ch.errorOutput, ch.err = runScript(ch) + if ch.err != nil { + return ch.err + } // Remove cache in case of curl issue if ch.code == curlTimeoutErrorCode { - cleanCache(t) + if err := cleanCache(t); err != nil { + return err + } } checkRun(ch) t.Log("Running common post checks") for _, c := range commonPostChecks { - c(ch) + if ok := c(ch); !ok { + return nil + } } t.Log("Running post checks") for _, c := range spec.postChecks { - c(ch) + if ok := c(ch); !ok { + return nil + } } + return nil } diff --git a/install-script/test/common_windows.go b/install-script/test/common_windows.go index 58a5c585..a6ee0673 100644 --- a/install-script/test/common_windows.go +++ b/install-script/test/common_windows.go @@ -5,19 +5,14 @@ package sumologic_scripts_tests import ( "context" "fmt" - "io" - "net" - "net/http" "os/exec" "testing" - - "github.com/stretchr/testify/require" ) // These checks always have to be true after a script execution var commonPostChecks = []checkFunc{checkNoBakFilesPresent} -func runTest(t *testing.T, spec *testSpec) { +func runTest(t *testing.T, spec *testSpec) (fErr error) { ch := check{ test: t, installOptions: spec.options, @@ -33,52 +28,53 @@ func runTest(t *testing.T, spec *testSpec) { defer tearDown(t) - t.Log("Starting HTTP server") - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - _, err := io.WriteString(w, "200 OK\n") - require.NoError(t, err) - }) - - listener, err := net.Listen("tcp", ":3333") - require.NoError(t, err) - - httpServer := &http.Server{ - Handler: mux, + mockAPI, err := startMockAPI(t) + if err != nil { + return fmt.Errorf("Failed to start mock API: %s", err) } - go func() { - err := httpServer.Serve(listener) - if err != nil && err != http.ErrServerClosed { - require.NoError(t, err) - } - }() + defer func() { - require.NoError(t, httpServer.Shutdown(context.Background())) + if err := mockAPI.Shutdown(context.Background()); err != nil { + fErr = fmt.Errorf("Failed to shutdown API: %s", err) + return + } }() t.Log("Running pre actions") for _, a := range spec.preActions { - a(ch) + if ok := a(ch); !ok { + return nil + } } t.Log("Running pre checks") for _, c := range spec.preChecks { - c(ch) + if ok := c(ch); !ok { + return nil + } } ch.code, ch.output, ch.errorOutput, ch.err = runScript(ch) + if err != nil { + return err + } checkRun(ch) t.Log("Running common post checks") for _, c := range commonPostChecks { - c(ch) + if ok := c(ch); !ok { + return nil + } } t.Log("Running post checks") for _, c := range spec.postChecks { - c(ch) + if ok := c(ch); !ok { + return nil + } } + return nil } func tearDown(t *testing.T) { diff --git a/install-script/test/config.go b/install-script/test/config.go index 66abeb17..98a3b854 100644 --- a/install-script/test/config.go +++ b/install-script/test/config.go @@ -31,6 +31,9 @@ func getConfig(path string) (config, error) { yamlFile, err := os.ReadFile(path) if err != nil { + if err == os.ErrNotExist { + return config{}, nil + } return config{}, err } diff --git a/install-script/test/consts_common.go b/install-script/test/consts_common.go index ce994738..86c71a12 100644 --- a/install-script/test/consts_common.go +++ b/install-script/test/consts_common.go @@ -9,6 +9,9 @@ const ( GithubOrg = "SumoLogic" GithubAppRepository = "sumologic-otel-collector" GithubApiBaseUrl = "https://api.github.com" + + mockAPIBaseURL = "http://127.0.0.1:3333" + emptyAPIBaseURL = "empty" ) func authenticateGithub() string { diff --git a/install-script/test/consts_darwin.go b/install-script/test/consts_darwin.go index 5594a88e..296fb9bd 100644 --- a/install-script/test/consts_darwin.go +++ b/install-script/test/consts_darwin.go @@ -4,18 +4,9 @@ const ( appSupportDirPath string = "/Library/Application Support/otelcol-sumo" packageName string = "otelcol-sumo.pkg" launchdPath string = "/Library/LaunchDaemons/com.sumologic.otelcol-sumo.plist" - launchdPathFilePermissions uint32 = 0640 + launchdPathFilePermissions uint32 = 0600 uninstallScriptPath string = appSupportDirPath + "/uninstall.sh" - // TODO: fix mismatch between darwin permissions & linux binary install permissions - // 00-otelcol-config-settings.yaml must be writable as the install scripts mutate it - commonConfigPathFilePermissions uint32 = 0660 - configPathDirPermissions uint32 = 0770 - configPathFilePermissions uint32 = 0440 - confDPathFilePermissions uint32 = 0644 - etcPathPermissions uint32 = 0751 - opampDPermissions uint32 = 0750 - rootGroup string = "wheel" rootUser string = "root" systemGroup string = "_otelcol-sumo" diff --git a/install-script/test/consts_linux.go b/install-script/test/consts_linux.go index 0221221b..450818f4 100644 --- a/install-script/test/consts_linux.go +++ b/install-script/test/consts_linux.go @@ -4,14 +4,6 @@ const ( envDirectoryPath string = etcPath + "/env" tokenEnvFilePath string = envDirectoryPath + "/token.env" - // TODO: fix mismatch between package permissions & expected permissions - commonConfigPathFilePermissions uint32 = 0550 - configPathDirPermissions uint32 = 0550 - configPathFilePermissions uint32 = 0440 - confDPathFilePermissions uint32 = 0644 - etcPathPermissions uint32 = 0551 - opampDPermissions uint32 = 0750 - rootGroup string = "root" rootUser string = "root" systemGroup string = "otelcol-sumo" diff --git a/install-script/test/consts_unix.go b/install-script/test/consts_unix.go index 9b21fb18..9cd65ddf 100644 --- a/install-script/test/consts_unix.go +++ b/install-script/test/consts_unix.go @@ -3,22 +3,31 @@ package sumologic_scripts_tests const ( - binaryPath string = "/usr/local/bin/otelcol-sumo" - libPath string = "/var/lib/otelcol-sumo" - fileStoragePath string = libPath + "/file_storage" - etcPath string = "/etc/otelcol-sumo" - scriptPath string = "../install.sh" - configPath string = etcPath + "/sumologic.yaml" - confDPath string = etcPath + "/conf.d" - opampDPath string = etcPath + "/opamp.d" - userConfigPath string = confDPath + "/00-otelcol-config-settings.yaml" - hostmetricsConfigPath string = confDPath + "/hostmetrics.yaml" - cacheDirectory string = "/var/cache/otelcol-sumo/" - logDirPath string = "/var/log/otelcol-sumo" + binaryPath = "/usr/local/bin/otelcol-sumo" + libPath = "/var/lib/otelcol-sumo" + fileStoragePath = libPath + "/file_storage" + etcPath = "/etc/otelcol-sumo" + scriptPath = "../install.sh" + configPath = etcPath + "/sumologic.yaml" + confDPath = etcPath + "/conf.d" + confDAvailablePath = etcPath + "/conf.d-available" + opampDPath = etcPath + "/opamp.d" + userConfigPath = confDPath + "/00-otelcol-config-settings.yaml" + hostmetricsConfigPath = confDPath + "/hostmetrics.yaml" + cacheDirectory = "/var/cache/otelcol-sumo/" + logDirPath = "/var/log/otelcol-sumo" + sumoRemotePath = "/etc/otelcol-sumo/sumologic-remote.yaml" - installToken string = "token" - installTokenEnv string = "SUMOLOGIC_INSTALLATION_TOKEN" - apiBaseURL string = "https://open-collectors.sumologic.com" + installToken = "token" + installTokenEnv = "SUMOLOGIC_INSTALLATION_TOKEN" + apiBaseURL = "https://open-collectors.sumologic.com" + ephemeralConfigPath = confDPath + "/ephemeral.yaml" - curlTimeoutErrorCode int = 28 + curlTimeoutErrorCode = 28 + + configPathDirPermissions uint32 = 0770 + configPathFilePermissions uint32 = 0660 + confDPathFilePermissions uint32 = 0660 + etcPathPermissions uint32 = 0771 + opampDPermissions uint32 = 0770 ) diff --git a/install-script/test/consts_windows.go b/install-script/test/consts_windows.go index 6257288a..1253414c 100644 --- a/install-script/test/consts_windows.go +++ b/install-script/test/consts_windows.go @@ -20,6 +20,7 @@ const ( opampDPath = etcPath + `\opamp.d` userConfigPath = confDPath + `\common.yaml` hostmetricsConfigPath = confDPath + `\hostmetrics.yaml` + sumoRemotePath = etcPath + `\sumologic-remote.yaml` installToken string = "token" installTokenEnv string = "SUMOLOGIC_INSTALLATION_TOKEN" diff --git a/install-script/test/install_darwin_test.go b/install-script/test/install_darwin_test.go index 89bc973f..af765c0e 100644 --- a/install-script/test/install_darwin_test.go +++ b/install-script/test/install_darwin_test.go @@ -22,7 +22,7 @@ func TestInstallScriptDarwin(t *testing.T) { options: installOptions{}, preChecks: notInstalledChecks, postChecks: append(notInstalledChecks, checkAbortedDueToNoToken), - installCode: 2, + installCode: 1, }, { name: "download only", @@ -56,33 +56,14 @@ func TestInstallScriptDarwin(t *testing.T) { installCode: 1, }, { - // Skip config is not supported on Darwin - name: "skip config", + name: "skip installation token", options: installOptions{ - skipConfig: true, skipInstallToken: true, }, preChecks: notInstalledChecks, postChecks: notInstalledChecks, installCode: 1, }, - { - name: "skip installation token", - options: installOptions{ - skipInstallToken: true, - }, - preChecks: notInstalledChecks, - postChecks: []checkFunc{ - checkBinaryCreated, - checkBinaryIsRunning, - checkConfigCreated, - checkConfigFilesOwnershipAndPermissions(systemUser, systemGroup), - checkUserConfigCreated, - checkLaunchdConfigCreated, - checkHomeDirectoryCreated, - }, - installCode: 1, // because of invalid installation token - }, { name: "installation token only", options: installOptions{ @@ -95,7 +76,8 @@ func TestInstallScriptDarwin(t *testing.T) { checkConfigCreated, checkConfigFilesOwnershipAndPermissions(systemUser, systemGroup), checkUserConfigCreated, - checkEphemeralNotInConfig(userConfigPath), + checkEphemeralConfigFileNotCreated(ephemeralConfigPath), + checkEphemeralNotEnabledInRemote(sumoRemotePath), checkLaunchdConfigCreated, checkTokenInLaunchdConfig, checkUserExists, @@ -103,7 +85,6 @@ func TestInstallScriptDarwin(t *testing.T) { checkHostmetricsConfigNotCreated, checkHomeDirectoryCreated, }, - installCode: 1, // because of invalid installation token }, { name: "installation token and ephemeral", @@ -118,7 +99,8 @@ func TestInstallScriptDarwin(t *testing.T) { checkConfigCreated, checkConfigFilesOwnershipAndPermissions(systemUser, systemGroup), checkUserConfigCreated, - checkEphemeralInConfig(userConfigPath), + checkEphemeralConfigFileCreated(ephemeralConfigPath), + checkEphemeralNotEnabledInRemote(sumoRemotePath), checkLaunchdConfigCreated, checkTokenInLaunchdConfig, checkUserExists, @@ -126,13 +108,12 @@ func TestInstallScriptDarwin(t *testing.T) { checkHostmetricsConfigNotCreated, checkHomeDirectoryCreated, }, - installCode: 1, // because of invalid installation token }, { name: "override default config", options: installOptions{ - skipInstallToken: true, - autoconfirm: true, + autoconfirm: true, + installToken: installToken, }, preActions: []checkFunc{preActionMockConfig}, preChecks: []checkFunc{ @@ -149,7 +130,7 @@ func TestInstallScriptDarwin(t *testing.T) { checkUserConfigCreated, checkLaunchdConfigCreated, }, - installCode: 1, // because of invalid installation token + installCode: 0, }, { name: "installation token and hostmetrics", @@ -172,7 +153,7 @@ func TestInstallScriptDarwin(t *testing.T) { checkHostmetricsOwnershipAndPermissions(systemUser, systemGroup), checkHomeDirectoryCreated, }, - installCode: 1, // because of invalid installation token + installCode: 0, }, { name: "installation token and remotely-managed", @@ -187,15 +168,18 @@ func TestInstallScriptDarwin(t *testing.T) { checkConfigCreated, checkRemoteConfigDirectoryCreated, checkConfigFilesOwnershipAndPermissions(systemUser, systemGroup), - checkUserConfigCreated, - checkEphemeralNotInConfig(configPath), + checkUserConfigNotCreated, + checkEphemeralConfigFileNotCreated(ephemeralConfigPath), + checkEphemeralNotEnabledInRemote(sumoRemotePath), checkLaunchdConfigCreated, checkTokenInLaunchdConfig, checkUserExists, checkGroupExists, checkHomeDirectoryCreated, }, - installCode: 1, // because of invalid installation token + // TODO(JK): this succeeds when testing locally but fails in CI, + // I need to determine why this is the case + installCode: 1, }, { name: "installation token, remotely-managed, and ephemeral", @@ -211,38 +195,18 @@ func TestInstallScriptDarwin(t *testing.T) { checkConfigCreated, checkRemoteConfigDirectoryCreated, checkConfigFilesOwnershipAndPermissions(systemUser, systemGroup), - checkUserConfigCreated, - checkEphemeralInConfig(configPath), - checkLaunchdConfigCreated, - checkTokenInLaunchdConfig, - checkUserExists, - checkGroupExists, - checkHomeDirectoryCreated, - }, - installCode: 1, // because of invalid installation token - }, - { - name: "installation token only, binary not in PATH", - options: installOptions{ - installToken: installToken, - envs: map[string]string{ - "PATH": "/sbin:/bin:/usr/sbin:/usr/bin", - }, - }, - preChecks: notInstalledChecks, - postChecks: []checkFunc{ - checkBinaryCreated, - checkBinaryIsRunning, - checkConfigCreated, - checkConfigFilesOwnershipAndPermissions(systemUser, systemGroup), - checkUserConfigCreated, + checkUserConfigNotCreated, + checkEphemeralConfigFileNotCreated(ephemeralConfigPath), + checkEphemeralEnabledInRemote(sumoRemotePath), checkLaunchdConfigCreated, checkTokenInLaunchdConfig, checkUserExists, checkGroupExists, checkHomeDirectoryCreated, }, - installCode: 1, // because of invalid installation token + // TODO(JK): this succeeds when testing locally but fails in CI, + // I need to determine why this is the case + installCode: 1, }, { name: "same installation token in launchd config", @@ -267,7 +231,7 @@ func TestInstallScriptDarwin(t *testing.T) { checkTokenInLaunchdConfig, checkHomeDirectoryCreated, }, - installCode: 1, // because of invalid installation token + installCode: 0, }, { name: "different installation token in launchd config", @@ -301,8 +265,7 @@ func TestInstallScriptDarwin(t *testing.T) { { name: "same api base url", options: installOptions{ - apiBaseURL: apiBaseURL, - skipInstallToken: true, + apiBaseURL: mockAPIBaseURL, }, preActions: []checkFunc{ preActionInstallPackage, @@ -320,13 +283,12 @@ func TestInstallScriptDarwin(t *testing.T) { checkUserConfigCreated, checkAPIBaseURLInConfig, }, - installCode: 1, // because of invalid installation token + installCode: 0, }, { name: "different api base url", options: installOptions{ - apiBaseURL: apiBaseURL, - skipInstallToken: true, + apiBaseURL: apiBaseURL, }, preActions: []checkFunc{ preActionInstallPackageWithDifferentAPIBaseURL, @@ -349,37 +311,16 @@ func TestInstallScriptDarwin(t *testing.T) { { name: "adding api base url", options: installOptions{ - apiBaseURL: apiBaseURL, - skipInstallToken: true, + apiBaseURL: mockAPIBaseURL, }, preActions: []checkFunc{preActionInstallPackageWithNoAPIBaseURL}, preChecks: []checkFunc{ checkBinaryCreated, checkConfigCreated, - checkUserConfigCreated, - checkUserExists, - }, - postChecks: []checkFunc{ - checkBinaryCreated, - checkConfigCreated, - checkUserConfigCreated, - checkAPIBaseURLInConfig, - }, - installCode: 1, // because of invalid installation token - }, - { - name: "editing api base url", - options: installOptions{ - apiBaseURL: apiBaseURL, - skipInstallToken: true, - }, - preActions: []checkFunc{ - preActionInstallPackageWithNoAPIBaseURL, - }, - preChecks: []checkFunc{ - checkBinaryCreated, - checkConfigCreated, - checkUserConfigCreated, + // The user config file will only exist if non-default values + // are used for otelcol-config managed settings such as the + // API URL or tags + checkUserConfigNotCreated, checkUserExists, }, postChecks: []checkFunc{ @@ -388,12 +329,12 @@ func TestInstallScriptDarwin(t *testing.T) { checkUserConfigCreated, checkAPIBaseURLInConfig, }, - installCode: 1, // because of invalid installation token + installCode: 0, }, { name: "configuration with tags", options: installOptions{ - skipInstallToken: true, + installToken: installToken, tags: map[string]string{ "lorem": "ipsum", "foo": "bar", @@ -411,12 +352,11 @@ func TestInstallScriptDarwin(t *testing.T) { checkTags, checkLaunchdConfigCreated, }, - installCode: 1, // because of invalid installation token + installCode: 0, }, { name: "same tags", options: installOptions{ - skipInstallToken: true, tags: map[string]string{ "lorem": "ipsum", "foo": "bar", @@ -442,43 +382,11 @@ func TestInstallScriptDarwin(t *testing.T) { checkTags, checkLaunchdConfigCreated, }, - installCode: 1, // because of invalid installation token - }, - { - name: "different tags", - options: installOptions{ - skipInstallToken: true, - tags: map[string]string{ - "lorem": "ipsum", - "foo": "bar", - "escape_me": "'\\/", - "slash": "a/b", - "numeric": "1_024", - }, - }, - preActions: []checkFunc{ - preActionInstallPackageWithDifferentTags, - }, - preChecks: []checkFunc{ - checkBinaryCreated, - checkConfigCreated, - checkUserConfigCreated, - checkUserExists, - }, - postChecks: []checkFunc{ - checkBinaryCreated, - checkConfigCreated, - checkUserConfigCreated, - checkDifferentTags, - checkLaunchdConfigCreated, - checkAbortedDueToDifferentTags, - }, - installCode: 1, + installCode: 0, }, { name: "editing tags", options: installOptions{ - skipInstallToken: true, tags: map[string]string{ "lorem": "ipsum", "foo": "bar", @@ -503,11 +411,13 @@ func TestInstallScriptDarwin(t *testing.T) { checkTags, checkLaunchdConfigCreated, }, - installCode: 1, // because of invalid installation token + installCode: 0, }, } { t.Run(spec.name, func(t *testing.T) { - runTest(t, &spec) + if err := runTest(t, &spec); err != nil { + t.Error(err) + } }) } } diff --git a/install-script/test/install_unix_test.go b/install-script/test/install_unix_test.go index 13bd0b82..5b2565b3 100644 --- a/install-script/test/install_unix_test.go +++ b/install-script/test/install_unix_test.go @@ -7,62 +7,42 @@ import ( ) func TestInstallScript(t *testing.T) { + notInstalledChecks := []checkFunc{ + checkBinaryNotCreated, + checkConfigNotCreated, + checkUserConfigNotCreated, + } + for _, spec := range []testSpec{ { name: "no arguments", options: installOptions{}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, - postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, - installCode: 2, - }, - { - name: "skip config", - options: installOptions{ - skipConfig: true, - skipInstallToken: true, - }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, - postChecks: []checkFunc{checkBinaryCreated, checkConfigNotCreated, checkUserConfigNotCreated}, + preChecks: notInstalledChecks, + postChecks: notInstalledChecks, + installCode: 1, }, { name: "skip installation token", options: installOptions{ skipInstallToken: true, }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, - postChecks: []checkFunc{ - checkBinaryCreated, - checkBinaryIsRunning, - checkConfigCreated, - checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), - checkUserConfigNotCreated, - }, - }, - { - name: "override default config", - options: installOptions{ - skipInstallToken: true, - }, - preActions: []checkFunc{preActionMockConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigCreated, checkUserConfigNotCreated}, - postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkConfigOverrided, checkUserConfigNotCreated}, + preChecks: notInstalledChecks, + postChecks: notInstalledChecks, + installCode: 1, }, { name: "installation token only", options: installOptions{ installToken: installToken, }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, + preChecks: notInstalledChecks, postChecks: []checkFunc{ checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, - checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), - checkUserConfigCreated, - checkEphemeralNotInConfig(userConfigPath), - checkTokenInConfig, + checkEphemeralConfigFileNotCreated(ephemeralConfigPath), checkHostmetricsConfigNotCreated, - checkTokenEnvFileNotCreated, + checkTokenEnvFileCreated, }, }, { @@ -71,17 +51,15 @@ func TestInstallScript(t *testing.T) { installToken: installToken, ephemeral: true, }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, + preChecks: notInstalledChecks, postChecks: []checkFunc{ checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, - checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), - checkUserConfigCreated, - checkTokenInConfig, - checkEphemeralInConfig(userConfigPath), + checkEphemeralConfigFileCreated(ephemeralConfigPath), + checkEphemeralNotEnabledInRemote(sumoRemotePath), checkHostmetricsConfigNotCreated, - checkTokenEnvFileNotCreated, + checkTokenEnvFileCreated, }, }, { @@ -90,17 +68,14 @@ func TestInstallScript(t *testing.T) { installToken: installToken, installHostmetrics: true, }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, + preChecks: notInstalledChecks, postChecks: []checkFunc{ checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, - checkRemoteConfigDirectoryNotCreated, - checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), - checkUserConfigCreated, - checkTokenInConfig, + checkRemoteConfigDirectoryCreated, checkHostmetricsConfigCreated, - checkHostmetricsOwnershipAndPermissions(rootUser, rootGroup), + checkHostmetricsOwnershipAndPermissions(systemUser, systemGroup), }, }, { @@ -109,15 +84,14 @@ func TestInstallScript(t *testing.T) { installToken: installToken, remotelyManaged: true, }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, + preChecks: notInstalledChecks, postChecks: []checkFunc{ checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkRemoteConfigDirectoryCreated, - checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), - checkTokenInSumoConfig, - checkEphemeralNotInConfig(configPath), + checkEphemeralConfigFileNotCreated(ephemeralConfigPath), + checkEphemeralNotEnabledInRemote(sumoRemotePath), }, }, { @@ -127,15 +101,14 @@ func TestInstallScript(t *testing.T) { remotelyManaged: true, ephemeral: true, }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, + preChecks: notInstalledChecks, postChecks: []checkFunc{ checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkRemoteConfigDirectoryCreated, - checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), - checkTokenInSumoConfig, - checkEphemeralInConfig(configPath), + checkEphemeralConfigFileNotCreated(ephemeralConfigPath), + checkEphemeralEnabledInRemote(sumoRemotePath), }, }, { @@ -145,126 +118,21 @@ func TestInstallScript(t *testing.T) { remotelyManaged: true, opampEndpoint: "wss://example.com", }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, + preChecks: notInstalledChecks, postChecks: []checkFunc{ checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkRemoteConfigDirectoryCreated, - checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), - checkTokenInSumoConfig, - checkEphemeralNotInConfig(configPath), + checkEphemeralConfigFileNotCreated(ephemeralConfigPath), + checkEphemeralNotEnabledInRemote(sumoRemotePath), checkOpAmpEndpointSet, }, }, - { - name: "installation token only, binary not in PATH", - options: installOptions{ - installToken: installToken, - envs: map[string]string{ - "PATH": "/sbin:/bin:/usr/sbin:/usr/bin", - }, - }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, - postChecks: []checkFunc{ - checkBinaryCreated, - checkBinaryIsRunning, - checkConfigCreated, - checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), - checkUserConfigCreated, - checkTokenInConfig, - }, - }, - { - name: "same installation token", - options: installOptions{ - installToken: installToken, - }, - preActions: []checkFunc{preActionMockUserConfig, preActionWriteTokenToUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, - postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkUserConfigCreated, checkTokenInConfig}, - }, - { - name: "different installation token", - options: installOptions{ - installToken: installToken, - }, - preActions: []checkFunc{preActionMockUserConfig, preActionWriteDifferentTokenToUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, - postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkAbortedDueToDifferentToken}, - installCode: 1, - }, - { - name: "adding installation token", - options: installOptions{ - installToken: installToken, - }, - preActions: []checkFunc{preActionMockUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, - postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkTokenInConfig}, - }, - { - name: "editing installation token", - options: installOptions{ - apiBaseURL: apiBaseURL, - installToken: installToken, - }, - preActions: []checkFunc{preActionMockUserConfig, preActionWriteEmptyUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, - postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkTokenInConfig}, - }, - { - name: "same api base url", - options: installOptions{ - apiBaseURL: apiBaseURL, - skipInstallToken: true, - }, - preActions: []checkFunc{preActionMockUserConfig, preActionWriteAPIBaseURLToUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, - postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkUserConfigCreated, checkAPIBaseURLInConfig}, - }, - { - name: "different api base url", - options: installOptions{ - apiBaseURL: apiBaseURL, - skipInstallToken: true, - }, - preActions: []checkFunc{preActionMockUserConfig, preActionWriteDifferentAPIBaseURLToUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, - postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, - checkAbortedDueToDifferentAPIBaseURL}, - installCode: 1, - }, - { - name: "adding api base url", - options: installOptions{ - apiBaseURL: apiBaseURL, - skipInstallToken: true, - }, - preActions: []checkFunc{preActionMockUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, - postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkAPIBaseURLInConfig}, - }, - { - name: "editing api base url", - options: installOptions{ - apiBaseURL: apiBaseURL, - skipInstallToken: true, - }, - preActions: []checkFunc{preActionMockUserConfig, preActionWriteEmptyUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, - postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkAPIBaseURLInConfig}, - }, - { - name: "empty installation token", - preActions: []checkFunc{preActionMockUserConfig, preActionWriteDifferentTokenToUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, - postChecks: []checkFunc{checkBinaryCreated, checkConfigCreated, checkUserConfigCreated, checkDifferentTokenInConfig}, - }, { name: "configuration with tags", options: installOptions{ - skipInstallToken: true, + installToken: installToken, tags: map[string]string{ "lorem": "ipsum", "foo": "bar", @@ -273,68 +141,19 @@ func TestInstallScript(t *testing.T) { "numeric": "1_024", }, }, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigNotCreated}, + preChecks: notInstalledChecks, postChecks: []checkFunc{ checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, - checkConfigFilesOwnershipAndPermissions(rootUser, rootGroup), checkTags, }, }, - { - name: "same tags", - options: installOptions{ - skipInstallToken: true, - tags: map[string]string{ - "lorem": "ipsum", - "foo": "bar", - "escape_me": "'\\/", - "slash": "a/b", - "numeric": "1_024", - }, - }, - preActions: []checkFunc{preActionMockUserConfig, preActionWriteTagsToUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, - postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkUserConfigCreated, checkTags}, - }, - { - name: "different tags", - options: installOptions{ - skipInstallToken: true, - tags: map[string]string{ - "lorem": "ipsum", - "foo": "bar", - "escape_me": "'\\/", - "slash": "a/b", - "numeric": "1_024", - }, - }, - preActions: []checkFunc{preActionMockUserConfig, preActionWriteDifferentTagsToUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, - postChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated, checkDifferentTags, - checkAbortedDueToDifferentTags}, - installCode: 1, - }, - { - name: "editing tags", - options: installOptions{ - skipInstallToken: true, - tags: map[string]string{ - "lorem": "ipsum", - "foo": "bar", - "escape_me": "'\\/", - "slash": "a/b", - "numeric": "1_024", - }, - }, - preActions: []checkFunc{preActionMockUserConfig, preActionWriteEmptyUserConfig}, - preChecks: []checkFunc{checkBinaryNotCreated, checkConfigNotCreated, checkUserConfigCreated}, - postChecks: []checkFunc{checkBinaryCreated, checkBinaryIsRunning, checkConfigCreated, checkTags}, - }, } { t.Run(spec.name, func(t *testing.T) { - runTest(t, &spec) + if err := runTest(t, &spec); err != nil { + t.Error(err) + } }) } } diff --git a/install-script/test/install_windows_test.go b/install-script/test/install_windows_test.go index d94df1cb..67d9ff44 100644 --- a/install-script/test/install_windows_test.go +++ b/install-script/test/install_windows_test.go @@ -147,7 +147,9 @@ func TestInstallScript(t *testing.T) { }, } { t.Run(spec.name, func(t *testing.T) { - runTest(t, &spec) + if err := runTest(t, &spec); err != nil { + t.Error(err) + } }) } } diff --git a/packages.cmake b/packages.cmake index 65f091ad..fd3f8011 100644 --- a/packages.cmake +++ b/packages.cmake @@ -125,6 +125,9 @@ macro(build_cpack_config) # Build CPackConfig include(CPack) + # Create components + create_otc_components() + # Add a target for each packagecloud distro the package should be published to set(_pc_user "sumologic") set(_pc_repo "ci-builds") @@ -136,21 +139,49 @@ macro(build_cpack_config) get_property(_all_publish_targets GLOBAL PROPERTY _all_publish_targets) add_custom_target(publish-package DEPENDS ${_all_publish_targets}) + + # Add a wait-for-packagecloud-indexing target to wait for Packagecloud to finish indexing + create_wait_for_packagecloud_indexing_target(${_pc_user} ${_pc_repo} ${_package_output}) endmacro() # Create a Packagecloud publish target for uploading a package to a specific # repository for a specific distribution. -function(create_packagecloud_publish_target _pc_user _pc_repo _pc_distro _pkg_name) - set(_pc_output "${_pkg_name}-${_pc_repo}/${_pc_distro}") - separate_arguments(_packagecloud_push_cmd UNIX_COMMAND "packagecloud push --skip-exists ${_pc_user}/${_pc_repo}/${_pc_distro} ${_pkg_name}") +function(create_packagecloud_publish_target _pc_user _pc_repo _pc_distro _pkg_path) + set(_pc_output "${_pkg_path}-${_pc_repo}/${_pc_distro}") + separate_arguments(_packagecloud_push_cmd + UNIX_COMMAND "packagecloud push --skip-exists ${_pc_user}/${_pc_repo}/${_pc_distro} ${_pkg_path}") add_custom_command(OUTPUT ${_pc_output} COMMAND ${_packagecloud_push_cmd} - DEPENDS ${_pkg_name} + DEPENDS ${_pkg_path} WORKING_DIRECTORY ${CMAKE_BINARY_DIR} VERBATIM) append_to_publish_targets(${_pc_output}) endfunction() +# Create a Packagecloud wait for indexing target that will block until +# Packagecloud has finished indexing packages with the given package name. +function(create_wait_for_packagecloud_indexing_target _pc_user _pc_repo _pkg_path) + set(_pc_output "${_pkg_path}-${_pc_repo}-wait-for-indexing") + cmake_path(GET _pkg_path FILENAME _pkg_name) + set(_repo_id "${_pc_user}/${_pc_repo}") + set(_base_cmd "packagecloud search") + set(_query_arg "--query ${_pkg_name}") + set(_wait_args "--wait-for-indexing --wait-seconds 30 --wait-max-retries 12") + set(_cmd "${_base_cmd} ${_repo_id} ${_query_arg} ${_wait_args}") + separate_arguments(_packagecloud_search_cmd UNIX_COMMAND "${_cmd}") + + message(STATUS "wait for indexing command: ${_cmd}") + + add_custom_command(OUTPUT ${_pc_output} + COMMAND ${_packagecloud_search_cmd} + DEPENDS ${_pkg_name} + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + VERBATIM) + + add_custom_target(wait-for-packagecloud-indexing + DEPENDS ${_pc_output}) +endfunction() + # Sets a GitHub output parameter by appending a statement to the file defined by # the GITHUB_OUTPUT environment variable. It enables the passing of data from # this CMake project to GitHub Actions. diff --git a/settings/otc.cmake b/settings/otc.cmake index 348cac56..b30c4f80 100644 --- a/settings/otc.cmake +++ b/settings/otc.cmake @@ -22,6 +22,8 @@ macro(set_otc_settings) # File names set(OTC_BINARY "otelcol-sumo") set(OTC_CONFIG_BINARY "otelcol-config") + set(OTC_LAUNCHD_CONFIG "com.sumologic.otelcol-sumo.plist") + set(OTC_SERVICE_SCRIPT "otelcol-sumo.sh") set(OTC_SUMOLOGIC_CONFIG "sumologic.yaml") set(OTC_SYSTEMD_CONFIG "otelcol-sumo.service") @@ -31,11 +33,16 @@ macro(set_otc_settings) set(OTC_CONFIG_FRAGMENTS_DIR "${OTC_CONFIG_DIR}/conf.d") set(OTC_CONFIG_FRAGMENTS_AVAILABLE_DIR "${OTC_CONFIG_DIR}/conf.d-available") set(OTC_USER_ENV_DIR "${OTC_CONFIG_DIR}/env") + set(OTC_OPAMPD_DIR "${OTC_CONFIG_DIR}/opamp.d") set(OTC_STATE_DIR "var/lib/otelcol-sumo") set(OTC_FILESTORAGE_STATE_DIR "${OTC_STATE_DIR}/file_storage") set(OTC_LAUNCHD_DIR "Library/LaunchDaemons") set(OTC_SYSTEMD_DIR "lib/systemd/system") set(OTC_LOG_DIR "var/log/otelcol-sumo") + set(OTC_SHARE_DIR "usr/share/otelcol-sumo") + if("${goos}" STREQUAL "darwin") + set(OTC_SHARE_DIR "usr/local/share/otelcol-sumo") + endif() # File paths set(OTC_BIN_PATH "${OTC_BIN_DIR}/${OTC_BINARY}") From bd4d24efa091ac3995663a8f013bbc330d0bc27c Mon Sep 17 00:00:00 2001 From: Justin Kolberg Date: Thu, 31 Oct 2024 19:37:22 -0700 Subject: [PATCH 30/32] upload packages & install scripts to s3 Signed-off-by: Justin Kolberg --- .github/workflows/_reusable_build_package.yml | 35 ++++++++++++++++++- .github/workflows/build_packages.yml | 33 +++++++++++++++++ Dockerfile | 3 +- Makefile | 2 ++ ci/github-actions/make/action.yml | 8 +++++ distributions.cmake | 3 ++ packages.cmake | 19 ++++++++++ 7 files changed, 101 insertions(+), 2 deletions(-) diff --git a/.github/workflows/_reusable_build_package.yml b/.github/workflows/_reusable_build_package.yml index 85cb395d..7abee012 100644 --- a/.github/workflows/_reusable_build_package.yml +++ b/.github/workflows/_reusable_build_package.yml @@ -38,6 +38,10 @@ on: required: false type: boolean default: false + packagecloud_go_version: + required: false + type: string + default: "0.2.2" secrets: gh_artifacts_token: required: true @@ -61,6 +65,14 @@ on: required: true packagecloud_token: required: true + aws_access_key_id: + required: true + aws_secret_access_key: + required: true + outputs: + otc_build_number: + description: "The build number of the package" + value: ${{ jobs.build_package.outputs.otc_build_number }} defaults: run: @@ -72,6 +84,7 @@ jobs: name: Build (CMake) if: inputs.build_tool == 'cmake' outputs: + otc_build_number: ${{ steps.get-build-number.outputs.otc_build_number }} package_path: ${{ steps.package.outputs.path }} steps: - name: Checkout @@ -85,6 +98,15 @@ jobs: workflow_id="${{ inputs.workflow_id }}" echo "https://github.com/${org}/${repo}/actions/runs/${workflow_id}" + # Only output build number on one target so that it can be read by other + # jobs + - name: Output Build Number + if: inputs.cmake_target == 'otc_linux_amd64_deb' + id: get-build-number + run: | + build_number="${{ inputs.otc_build_number }}" + echo "otc_build_number=${build_number}" >> $GITHUB_OUTPUT + - name: Determine if MacOS package should be signed if: runner.os == 'macOS' env: @@ -216,12 +238,23 @@ jobs: path: ./build/${{ steps.package.outputs.path }} if-no-files-found: error - - name: Publish package to Packagecloud + - name: Publish packages if: runner.os == 'Linux' uses: ./ci/github-actions/make with: target: publish-package packagecloud-token: ${{ secrets.PACKAGECLOUD_TOKEN }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + - name: Publish packages + if: runner.os != 'Linux' + working-directory: build + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + PACKAGECLOUD_TOKEN: ${{ secrets.PACKAGECLOUD_TOKEN }} + run: make publish-package - name: Wait for Packagecloud packages to be indexed if: runner.os == 'Linux' diff --git a/.github/workflows/build_packages.yml b/.github/workflows/build_packages.yml index 92bfbf43..0011b2c1 100644 --- a/.github/workflows/build_packages.yml +++ b/.github/workflows/build_packages.yml @@ -75,6 +75,10 @@ on: type: boolean required: false default: false + is_latest: + type: boolean + required: false + default: false jobs: determine_workflow: @@ -199,6 +203,8 @@ jobs: microsoft_description: ${{ secrets.MICROSOFT_DESCRIPTION }} gh_ci_token: ${{ secrets.GH_CI_TOKEN }} packagecloud_token: ${{ secrets.PACKAGECLOUD_TOKEN }} + aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} strategy: matrix: @@ -249,6 +255,16 @@ jobs: install-script: name: Store install script runs-on: ubuntu-latest + needs: + - determine_version + - build_packages + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: us-east-1 + AWS_S3_BUCKET: sumologic-osc-ci-builds + OTC_VERSION: ${{ needs.determine_version.outputs.otc_version }} + BUILD_NUMBER: ${{ needs.build_packages.outputs.otc_build_number }} steps: - uses: actions/checkout@v4 @@ -266,6 +282,23 @@ jobs: path: ./install-script/install.ps1 if-no-files-found: error + - name: Store install scripts on S3 + run: | + version="${OTC_VERSION}-${BUILD_NUMBER}" + s3_path="${version}/" + aws s3 cp install-script/install.ps1 s3://${AWS_S3_BUCKET}/${s3_path} + aws s3 cp install-script/install.sh s3://${AWS_S3_BUCKET}/${s3_path} + + - name: Create latest_version file + if: inputs.is_latest + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + run: | + version="${OTC_VERSION}-${BUILD_NUMBER}" + echo "${version}" >> latest_version + aws s3 cp --content-type "text/plain" latest_version s3://${AWS_S3_BUCKET}/ + publish_release: name: Publish Release runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile index cc7607cc..baa2e385 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,8 @@ RUN apk add --no-cache \ curl \ bash \ tar \ - gzip + gzip \ + aws-cli COPY docker/install-deps.sh /install-deps.sh diff --git a/Makefile b/Makefile index 5dce5e65..0cccb15a 100644 --- a/Makefile +++ b/Makefile @@ -42,6 +42,8 @@ publish-package: -v "$(mkfile_dir):/src" \ -v "$(build_dir):/build" \ -e PACKAGECLOUD_TOKEN="$(PACKAGECLOUD_TOKEN)" \ + -e AWS_ACCESS_KEY_ID="$(AWS_ACCESS_KEY_ID)" \ + -e AWS_SECRET_ACCESS_KEY="$(AWS_SECRET_ACCESS_KEY)" otelcol-sumo/cmake \ make publish-package diff --git a/ci/github-actions/make/action.yml b/ci/github-actions/make/action.yml index 85391fd1..0c348636 100644 --- a/ci/github-actions/make/action.yml +++ b/ci/github-actions/make/action.yml @@ -7,12 +7,20 @@ inputs: packagecloud-token: required: false type: string + aws-access-key-id: + required: false + type: string + aws-secret-access-key: + required: false + type: string runs: using: 'docker' image: '../../../Dockerfile' env: PACKAGECLOUD_TOKEN: ${{ inputs.packagecloud-token }} + AWS_ACCESS_KEY_ID: ${{ inputs.aws-access-key-id }} + AWS_SECRET_ACCESS_KEY: ${{ inputs.aws-secret-access-key }} args: - make - ${{ inputs.target }} diff --git a/distributions.cmake b/distributions.cmake index 98758fa5..a517f18c 100644 --- a/distributions.cmake +++ b/distributions.cmake @@ -1,3 +1,6 @@ +# Checks if the Packagecloud distro supports the package architecture and if it +# does it will be added to the list of Packagecloud distributions to upload the +# package to. macro(check_architecture_support) if(NOT ${package_arch} IN_LIST _supported_architectures) message(FATAL_ERROR "${_distro_name} does not support architecture: ${package_arch}") diff --git a/packages.cmake b/packages.cmake index fd3f8011..4a25ec0e 100644 --- a/packages.cmake +++ b/packages.cmake @@ -135,6 +135,13 @@ macro(build_cpack_config) create_packagecloud_publish_target(${_pc_user} ${_pc_repo} ${_pc_distro} ${_package_output}) endforeach() + # Add a target for uploading the package to Amazon S3 + set(_s3_channel "ci-builds") + set(_version "${OTC_VERSION}-${BUILD_NUMBER}") + set(_s3_bucket "sumologic-osc-${_s3_channel}") + set(_s3_path "${_version}/") + create_s3_cp_target(${_s3_bucket} ${_s3_path} ${_package_output}) + # Add a publish-package target to publish the package built above get_property(_all_publish_targets GLOBAL PROPERTY _all_publish_targets) add_custom_target(publish-package @@ -182,6 +189,18 @@ function(create_wait_for_packagecloud_indexing_target _pc_user _pc_repo _pkg_pat DEPENDS ${_pc_output}) endfunction() +# Create an Amazon S3 publish target for uploading a package to an S3 bucket. +function(create_s3_cp_target _s3_bucket _s3_path _pkg_path) + set(_s3_output "${_pkg_path}-s3-${_s3_bucket}") + separate_arguments(_s3_cp_cmd UNIX_COMMAND "aws s3 cp ${_pkg_path} s3://${_s3_bucket}/${_s3_path}") + add_custom_command(OUTPUT ${_s3_output} + COMMAND ${_s3_cp_cmd} + DEPENDS ${_pkg_path} + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + VERBATIM) + append_to_publish_targets(${_s3_output}) +endfunction() + # Sets a GitHub output parameter by appending a statement to the file defined by # the GITHUB_OUTPUT environment variable. It enables the passing of data from # this CMake project to GitHub Actions. From a994849228a1a11b288fbb4cd52e39e566d4c1e6 Mon Sep 17 00:00:00 2001 From: Justin Kolberg Date: Mon, 4 Nov 2024 17:40:25 -0800 Subject: [PATCH 31/32] Add docs/release.md (#121) Signed-off-by: Justin Kolberg --- docs/release.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/release.md diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 00000000..e69de29b From eff559cd2dd36a379ecfce8a4d5c48627a361f70 Mon Sep 17 00:00:00 2001 From: Justin Kolberg Date: Tue, 5 Nov 2024 13:44:41 -0800 Subject: [PATCH 32/32] add new release workflow & docs Signed-off-by: Justin Kolberg --- .github/workflows/releases.yml | 168 +++++++++++++++++++++++++++++++++ docs/release.md | 118 +++++++++++++++++++++++ images/release_0.png | Bin 0 -> 42381 bytes images/release_1.png | Bin 0 -> 44581 bytes images/release_2.png | Bin 0 -> 66553 bytes 5 files changed, 286 insertions(+) create mode 100644 .github/workflows/releases.yml create mode 100644 images/release_0.png create mode 100644 images/release_1.png create mode 100644 images/release_2.png diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml new file mode 100644 index 00000000..326e05f7 --- /dev/null +++ b/.github/workflows/releases.yml @@ -0,0 +1,168 @@ +name: 'Publish release' + +run-name: > + ${{ format('Publish Release for Workflow: {0}', inputs.workflow_id) }} + +on: + workflow_dispatch: + inputs: + workflow_id: + description: | + Workflow Run ID from this repository to fetch artifacts from for this + release. + required: true + type: string + +defaults: + run: + shell: bash + +jobs: + get-version: + name: Get application version for this revision + runs-on: ubuntu-latest + outputs: + git-sha: ${{ steps.get-version.outputs.git-sha }} + otc-version: ${{ steps.get-version.outputs.otc-version }} + sumo-version: ${{ steps.get-version.outputs.sumo-version }} + binary-version: ${{ steps.get-version.outputs.binary-version }} + version: ${{ steps.get-version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Outuput Workflow ID + run: echo ::notice title=Workflow ID::${{ inputs.workflow_id }} + + - name: Output Workflow URL + run: | + repo_url="https://github.com/SumoLogic/sumologic-otel-collector-packaging" + url="${repo_url}/actions/runs/${{ inputs.workflow_id }}" + echo ::notice title=Workflow URL::${url} + + - name: Determine Workflow Run ID from workflow + id: get-run-number + run: | + workflow="11673248730" + run_number=$(gh run view "${workflow}" --json number -t '{{.number}}') + echo "run-number=$run_number" >> $GITHUB_OUTPUT + + - name: Output Workflow Run Number + run: | + run_number=${{ steps.get-run-number.outputs.run-number }} + echo ::notice title=Workflow Run Number::${run_number} + + - name: Download otelcol-sumo artifact from workflow + uses: actions/download-artifact@v4 + with: + name: otelcol-sumo-linux_amd64 + path: artifacts/ + merge-multiple: true + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ inputs.workflow_id }} + + - name: Determine version from artifact + id: get-version + run: | + artifact="artifacts/otelcol-sumo-linux_amd64" + chmod +x "${artifact}" + script="ci/get_version_from_binary.sh" + core="$("$script" core "${artifact}")" + sumo="$("$script" sumo "${artifact}")" + run_number=${{ steps.get-run-number.outputs.run-number }} + echo "otc-version=$core" >> $GITHUB_OUTPUT + echo "sumo-version=$sumo" >> $GITHUB_OUTPUT + echo "binary-version=${core}-sumo-${sumo}" >> $GITHUB_OUTPUT + echo "version=${core}-${run_number}" >> $GITHUB_OUTPUT + + - name: Output Binary Version + run: | + binary_version=${{ steps.get-version.outputs.binary-version }} + echo ::notice title=Binary Version::${binary_version} + + - name: Output Package Version + run: | + package_version=${{ steps.get-version.outputs.version }} + echo ::notice title=Package Version::${package_version} + + - name: Determine Git SHA of workflow + id: get-sha + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + workflow=${{ inputs.workflow_id }} + sha="$(gh run view ${workflow} --json headSha -t '{{.headSha}}')" + echo "git-sha=$sha" >> $GITHUB_OUTPUT + + - name: Output Git SHA + run: | + echo ::notice title=Git SHA::${{ steps.get-sha.outputs.git-sha }} + + # Store the install script from the packaging repository as a release + # artifact. + install-script: + name: Store install script + runs-on: ubuntu-latest + needs: + - get-version + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.get-version.outputs.git-sha }} + + - name: Store Linux install script as action artifact + uses: actions/upload-artifact@v4 + with: + name: install.sh + path: ./install-script/install.sh + if-no-files-found: error + + - name: Store Windows install script as action artifact + uses: actions/upload-artifact@v4 + with: + name: install.ps1 + path: ./install-script/install.ps1 + if-no-files-found: error + + create-release: + name: Create Github release + runs-on: ubuntu-20.04 + needs: + - get-version + permissions: + contents: write + steps: + - name: Download all artifacts from workflow + uses: actions/download-artifact@v4 + with: + path: artifacts/ + merge-multiple: true + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ inputs.workflow_id }} + + - uses: ncipollo/release-action@v1 + with: + name: v${{ needs.get-version.outputs.version }} + commit: ${{ needs.get-version.outputs.git-sha }} + tag: v${{ needs.get-version.outputs.version }} + + draft: true + generateReleaseNotes: true + prerelease: false + + allowUpdates: true + omitBodyDuringUpdate: true + omitNameDuringUpdate: true + + artifacts: "artifacts/*/*" + artifactErrorsFailBuild: true + replacesArtifacts: true + + body: | + This release packages + [${{ needs.get-version.outputs.version }}](https://github.com/SumoLogic/sumologic-otel-collector/releases/tag/v${{ needs.get-version.outputs.binary-version }}). + + The changelog below is for the package itself, rather than the Sumo + Logic Distribution for OpenTelemetry Collector. The changelog for + the Sumo Logic Distribution for OpenTelemetry Collector can be found + on the collector's + [release page](https://github.com/SumoLogic/sumologic-otel-collector/releases/tag/v${{ needs.get-version.outputs.binary-version }}). diff --git a/docs/release.md b/docs/release.md index e69de29b..1634db56 100644 --- a/docs/release.md +++ b/docs/release.md @@ -0,0 +1,118 @@ +# Releasing + +## How to release + +### Check end-to-end tests + +Check if the Sumo internal e2e tests are passing. + +### Determine the Workflow Run ID to release + +We can begin the process of creating a release once QE has given a thumbs up for +a given package version and the [collector release steps][collector_release] +have already been performed. We can determine the Workflow Run ID to use for a +release using the following steps: + +#### Find the package build number + +Each package has a build number and it's included in the package version & +filename. For example, if the package version that QE validates is 0.108.0-1790 +then the build number is 1790. + +#### Find the collector workflow run + +We can find the workflow used to build the packages by using the package build +number. + +The build number corresponds directly to the GitHub Run Number for a packaging +workflow run in GitHub Actions. Unfortunately, there does not currently appear to +be a way to reference a workflow run using the run number. Instead, we can use +one of two methods to find the workflow run: + +#### Option 1 - Use the `gh` cli tool to find the workflow + +```shell +PAGER="" +BUILD_NUMBER="1790" +gh run list -R sumologic/sumologic-otel-collector-packaging -s success \ +-w build_packages.yml -L 200 -b main --json displayTitle,databaseId,number,url \ +-q ".[] | select(.number == ${BUILD_NUMBER})" +``` + +This will output a number of fields, for example: + +```json +{ + "databaseId": 11673248730, + "displayTitle": "Build for Remote Workflow: 11672946742, Version: 0.108.0-sumo-1\n", + "number": 1790, + "url": "https://github.com/SumoLogic/sumologic-otel-collector-packaging/actions/runs/11673248730" +} +``` + +The number in the `databaseId` field is the ID for the workflow run that built +the packages. + +The workflow run can be viewed by visiting the URL in the `url` field. + +#### Option 2 - Search the GitHub website manually + +Manually search for the run number on the +[Build packages workflow][build_workflow] page. Search for the build number +(e.g. 1790) until you find the corresponding workflow. + +![Finding the packaging workflow run][release_0] + +Once you've found the packaging workflow run, click it to navigate to the +details of the workflow run. The Workflow Run ID can be found in the last part +of the URL in the address bar: + +![Finding the packaging workflow ID][release_1] + +### Trigger the release + +Now that we have the Workflow Run ID we can trigger a release. There are two +methods of doing this. + +#### Option 1 - Use the `gh` cli tool to trigger the release + +A release can be triggered by using the following command (be sure to replace +`WORKFLOW_ID` with the Workflow Run ID from the previous step): + +```shell +PAGER="" +WORKFLOW_ID="11673248730" +gh workflow run build_packages.yml \ +-R sumologic/sumologic-otel-collector-packaging -f workflow_id=${WORKFLOW_ID} +``` + +#### Option 2 - Use the GitHub website to trigger the release + +Navigate to the [Publish release][releases_workflow] workflow in GitHub Actions. +Find and click the `Run workflow` button on the right-hand side of the page. +Fill in the Workflow Run ID from the previous step. If the release should be +considered to be the latest version, click the checkbox for `Latest version`. +Click the `Run workflow` button to trigger the release. + +![Triggering a release][release_2] + +### Publish GitHub release + +The GitHub release is created as draft by the +[releases](../.github/workflows/releases.yml) GitHub Action. + +After the release draft is created, go to [GitHub releases](https://github.com/SumoLogic/sumologic-otel-collector-packaging/releases), +edit the release draft and fill in missing information: + +- Specify versions for upstream OT core and contrib releases +- Copy and paste the Changelog entry for this release from [CHANGELOG.md][changelog] + +After verifying that the release text and all links are good, publish the release. + +[build_workflow]: https://github.com/SumoLogic/sumologic-otel-collector-packaging/actions/workflows/build_packages.yml?query=branch%3Amain +[changelog]: https://github.com/SumoLogic/sumologic-otel-collector/blob/main/CHANGELOG.md +[collector_release]: https://github.com/SumoLogic/sumologic-otel-collector/blob/main/docs/release.md +[release_0]: ../images/release_0.png +[release_1]: ../images/release_1.png +[release_1]: ../images/release_2.png +[releases_workflow]: https://github.com/SumoLogic/sumologic-otel-collector-packaging/actions/workflows/releases.yml diff --git a/images/release_0.png b/images/release_0.png new file mode 100644 index 0000000000000000000000000000000000000000..2847d3ecd1fcc48e3fe1311482d5b050c058cbb5 GIT binary patch literal 42381 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 DIhTs` literal 0 HcmV?d00001 diff --git a/images/release_1.png b/images/release_1.png new file mode 100644 index 0000000000000000000000000000000000000000..6e365b97e1276e20ebe43e7bea37688a66649147 GIT binary patch literal 44581 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 E0iQ^n>;M1& literal 0 HcmV?d00001 diff --git a/images/release_2.png b/images/release_2.png new file mode 100644 index 0000000000000000000000000000000000000000..8961b7f50432c389726b643ee273ca512b5d7d14 GIT binary patch literal 66553 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 WzDy19ci@})UD5>`YF4P*hW;P2;-vuq literal 0 HcmV?d00001