From bf06bfa961ce819ac28021296d329285b2b49226 Mon Sep 17 00:00:00 2001 From: yclee Date: Fri, 4 Sep 2026 11:12:31 +0900 Subject: [PATCH] feat: bootstrap Jenkins object storage credentials securely --- bin/installAll.sh | 24 +- conf/docker/docker-compose.yaml | 1 + .../tool/init.groovy.d/basic-security.groovy | 2 + .../tool/jenkins/jenkins-credentials.groovy | 78 +++++++ .../tool/jenkins/register-credentials.sh | 209 ++++++++++++++++++ 5 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 conf/docker/tool/jenkins/jenkins-credentials.groovy create mode 100755 conf/docker/tool/jenkins/register-credentials.sh diff --git a/bin/installAll.sh b/bin/installAll.sh index 7f0434d..1df61c2 100755 --- a/bin/installAll.sh +++ b/bin/installAll.sh @@ -161,6 +161,7 @@ STARTUP_WAVES=( "mc-infra-connector mc-infra-manager mc-iam-manager mc-iam-manager-post-initial" "mc-data-manager mc-web-console-api mc-web-console-front" "mc-observability-manager mc-observability-front mc-observability-insight mc-observability-insight-scheduler mc-observability-mcp-grafana mc-observability-mcp-maria mc-observability-mcp-influx mc-observability-log-collector" + "mc-workflow-manager-jenkins" "mc-application-manager mc-workflow-manager mc-cost-optimizer-fe" ) @@ -190,6 +191,17 @@ check_post_initial() { fi } +register_jenkins_credentials() { + local bootstrap_script="$PROJECT_ROOT_ABS/tool/jenkins/register-credentials.sh" + + echo "" + echo "Registering Jenkins credentials..." + if ! bash "$bootstrap_script"; then + echo "Error: Jenkins credential registration failed." >&2 + return 1 + fi +} + # ============================================================================= # Save current directory at script start @@ -544,12 +556,19 @@ case $RUN_MODE in wave_num=$((wave_num + 1)) echo "" echo "---- Wave $wave_num/${#STARTUP_WAVES[@]}: $wave_services ----" - ./mcc infra run -s "$wave_services" + if [ "$wave_num" -lt "${#STARTUP_WAVES[@]}" ]; then + ./mcc infra run -d -s "$wave_services" + else + ./mcc infra run -s "$wave_services" + fi run_exit=$? if [ $run_exit -ne 0 ]; then report_run_failure "$run_exit" "Wave $wave_num ($wave_services)" exit 1 fi + if [ "$wave_services" = "mc-workflow-manager-jenkins" ]; then + register_jenkins_credentials || exit 1 + fi done check_post_initial @@ -586,6 +605,9 @@ case $RUN_MODE in report_run_failure "$run_exit" "Wave $wave_num ($wave_services)" exit 1 fi + if [ "$wave_services" = "mc-workflow-manager-jenkins" ]; then + register_jenkins_credentials || exit 1 + fi done echo "" diff --git a/conf/docker/docker-compose.yaml b/conf/docker/docker-compose.yaml index 7424e9c..4a44b6e 100644 --- a/conf/docker/docker-compose.yaml +++ b/conf/docker/docker-compose.yaml @@ -1014,6 +1014,7 @@ services: - /usr/bin/docker:/usr/bin/docker - ./container-volume/mc-workflow-manager/jenkins/:/var/jenkins_home:rw - ./tool/init.groovy.d:/usr/share/jenkins/ref/init.groovy.d + - ./tool/jenkins:/opt/mcmp/jenkins-credentials:ro environment: JENKINS_USERNAME: ${MC_WORKFLOW_MANAGER_JENKINS_USERNAME:-admin} JENKINS_PASSWORD: ${MC_WORKFLOW_MANAGER_JENKINS_PASSWORD:-123456} diff --git a/conf/docker/tool/init.groovy.d/basic-security.groovy b/conf/docker/tool/init.groovy.d/basic-security.groovy index 2c8560e..26d2f66 100644 --- a/conf/docker/tool/init.groovy.d/basic-security.groovy +++ b/conf/docker/tool/init.groovy.d/basic-security.groovy @@ -5,6 +5,8 @@ import hudson.security.* // 설치할 플러그인 목록 def pluginParameter = """ workflow-api +credentials +snakeyaml-api swarm authorize-project antisamy-markup-formatter diff --git a/conf/docker/tool/jenkins/jenkins-credentials.groovy b/conf/docker/tool/jenkins/jenkins-credentials.groovy new file mode 100644 index 0000000..8a5b1cd --- /dev/null +++ b/conf/docker/tool/jenkins/jenkins-credentials.groovy @@ -0,0 +1,78 @@ +if (!binding.hasVariable("encodedCredentialsYaml")) { + throw new IllegalStateException("encodedCredentialsYaml must be provided by the external bootstrap script") +} + +byte[] decryptedBytes = null +String yamlText = null +try { + decryptedBytes = Base64.decoder.decode(encodedCredentialsYaml.toString()) + if (decryptedBytes.length == 0) { + throw new IllegalStateException("CSP credentials are empty") + } + yamlText = new String(decryptedBytes, java.nio.charset.StandardCharsets.UTF_8) +} finally { + if (decryptedBytes != null) { + java.util.Arrays.fill(decryptedBytes, (byte) 0) + } +} + +def yamlRoot = new org.yaml.snakeyaml.Yaml().load(yamlText) +def adminCredentials = yamlRoot?.credentialholder?.admin + +if (!(adminCredentials instanceof Map)) { + throw new IllegalStateException("credentialholder.admin is required in credentials.yaml.enc") +} + +def objectStorageKeys = [ + aws: ["aws_access_key_id", "aws_secret_access_key"], + gcp: ["S3AccessKey", "S3SecretKey"], + alibaba: ["AccessKeyId", "AccessKeySecret"], + tencent: ["SecretId", "SecretKey"], + ibm: ["S3AccessKey", "S3SecretKey"], + ncp: ["ncloud_access_key", "ncloud_secret_key"], + nhn: ["S3AccessKey", "S3SecretKey"] +] + +def store = com.cloudbees.plugins.credentials.SystemCredentialsProvider.instance.store +def domain = com.cloudbees.plugins.credentials.domains.Domain.global() +def registered = [] +def skipped = [] + +objectStorageKeys.each { provider, keys -> + def providerCredentials = adminCredentials[provider] + def accessKey = providerCredentials instanceof Map ? providerCredentials[keys[0]]?.toString()?.trim() : "" + def secretKey = providerCredentials instanceof Map ? providerCredentials[keys[1]]?.toString()?.trim() : "" + + if (!accessKey || !secretKey) { + skipped << provider + return + } + if (accessKey.contains("\n") || accessKey.contains("\r") || secretKey.contains("\n") || secretKey.contains("\r")) { + throw new IllegalStateException("Object Storage credential for ${provider} must not contain line breaks") + } + + def credentialId = "object-storage-credential-${provider}" + def credential = new com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl( + com.cloudbees.plugins.credentials.CredentialsScope.GLOBAL, + credentialId, + "M-CMP Object Storage credential for ${provider.toUpperCase()}", + accessKey, + secretKey + ) + def existing = store.getCredentials(domain).find { it.id == credentialId } + def saved = existing ? store.updateCredentials(domain, existing, credential) : store.addCredentials(domain, credential) + + if (!saved) { + throw new IllegalStateException("Failed to save Jenkins credential ${credentialId}") + } + registered << credentialId +} + +registered.each { println "MCMP_OBJECT_STORAGE_CREDENTIAL_REGISTERED ${it}" } +skipped.each { println "MCMP_OBJECT_STORAGE_CREDENTIAL_SKIPPED ${it}" } +new File(jenkins.model.Jenkins.get().rootDir, ".mcmp-object-storage-credentials-initialized").text = "ready\n" +println "MCMP_OBJECT_STORAGE_CREDENTIALS_COMPLETE" + +yamlText = null +yamlRoot = null +adminCredentials = null diff --git a/conf/docker/tool/jenkins/register-credentials.sh b/conf/docker/tool/jenkins/register-credentials.sh new file mode 100755 index 0000000..c3790a9 --- /dev/null +++ b/conf/docker/tool/jenkins/register-credentials.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash + +set -euo pipefail +umask 077 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly JENKINS_CONTAINER="${MCMP_JENKINS_CONTAINER_NAME:-mc-workflow-manager-jenkins}" +readonly ENCRYPTED_CREDENTIALS_FILE="${MCMP_CREDENTIALS_FILE:-$HOME/.cloud-barista/credentials.yaml.enc}" +readonly DECRYPT_KEY_FILE="${MCMP_CREDENTIALS_KEY_FILE:-$HOME/.cloud-barista/.tmp_enc_key}" +readonly GROOVY_SCRIPT="$SCRIPT_DIR/jenkins-credentials.groovy" +readonly REGISTRATION_MARKER="/var/jenkins_home/.mcmp-object-storage-credentials-initialized" +readonly MAX_PASSWORD_ATTEMPTS=3 + +MCMP_JENKINS_COOKIE_FILE="" + +cleanup_jenkins_cookie() { + if [ -n "$MCMP_JENKINS_COOKIE_FILE" ]; then + rm -f -- "$MCMP_JENKINS_COOKIE_FILE" + fi +} + +run_inside_container() { + : "${JENKINS_USERNAME:?JENKINS_USERNAME must not be empty}" + : "${JENKINS_PASSWORD:?JENKINS_PASSWORD must not be empty}" + + local jenkins_url="http://localhost:8080" + local crumb_header + + MCMP_JENKINS_COOKIE_FILE=$(mktemp /tmp/mcmp-jenkins-cookie.XXXXXX) + trap cleanup_jenkins_cookie EXIT + + crumb_header=$(curl -fsS \ + --user "$JENKINS_USERNAME:$JENKINS_PASSWORD" \ + --cookie-jar "$MCMP_JENKINS_COOKIE_FILE" \ + "$jenkins_url/crumbIssuer/api/xml?xpath=concat(//crumbRequestField,%22:%22,//crumb)") + + if [ -z "$crumb_header" ]; then + echo "Error: Jenkins returned an empty CSRF crumb." >&2 + return 1 + fi + + curl -fsS \ + --user "$JENKINS_USERNAME:$JENKINS_PASSWORD" \ + --cookie "$MCMP_JENKINS_COOKIE_FILE" \ + --header "$crumb_header" \ + --data-urlencode "script@-" \ + "$jenkins_url/scriptText" +} + +if [ "${1:-}" = "--inside-container" ]; then + if [ "$#" -ne 1 ]; then + echo "Error: --inside-container does not accept additional arguments." >&2 + exit 1 + fi + run_inside_container + exit $? +fi + +force_registration=false +case "${1:-}" in + "") + ;; + --force) + force_registration=true + ;; + *) + echo "Usage: $0 [--force]" >&2 + exit 1 + ;; +esac + +for command_name in docker openssl; do + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "Error: $command_name is required to register Jenkins credentials." >&2 + exit 1 + fi +done + +if [ ! -f "$GROOVY_SCRIPT" ]; then + echo "Error: Jenkins credential script not found: $GROOVY_SCRIPT" >&2 + exit 1 +fi + +container_running=$(docker inspect --format '{{.State.Running}}' "$JENKINS_CONTAINER" 2>/dev/null || true) +if [ "$container_running" != "true" ]; then + echo "Error: Jenkins container is not running: $JENKINS_CONTAINER" >&2 + exit 1 +fi + +echo "Waiting for Jenkins initialization to complete..." +jenkins_ready=false +for ((attempt = 1; attempt <= 120; attempt++)); do + health_status=$(docker inspect \ + --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' \ + "$JENKINS_CONTAINER" 2>/dev/null || true) + if [ "$health_status" = "healthy" ]; then + jenkins_ready=true + break + fi + sleep 5 +done + +if [ "$jenkins_ready" != "true" ]; then + echo "Error: Jenkins did not become healthy within 10 minutes." >&2 + exit 1 +fi + +if [ "$force_registration" != "true" ] && \ + docker exec "$JENKINS_CONTAINER" test -f "$REGISTRATION_MARKER"; then + echo "Jenkins credentials are already initialized; skipping registration." + exit 0 +fi + +if [ ! -f "$ENCRYPTED_CREDENTIALS_FILE" ] || [ ! -s "$ENCRYPTED_CREDENTIALS_FILE" ]; then + echo "Encrypted CSP credentials not found or empty; skipping Jenkins credential registration:" + echo " $ENCRYPTED_CREDENTIALS_FILE" + echo "Register the credentials manually in the Jenkins UI." + exit 0 +fi + +encoded_credentials_yaml="" +credentials_decrypted=false +if [ -s "$DECRYPT_KEY_FILE" ]; then + echo "Using the host key file for this one-time registration: $DECRYPT_KEY_FILE" + if encoded_credentials_yaml=$( + openssl enc -aes-256-cbc -d -pbkdf2 \ + -in "$ENCRYPTED_CREDENTIALS_FILE" \ + -pass "file:$DECRYPT_KEY_FILE" 2>/dev/null | + openssl base64 -A + ); then + credentials_decrypted=true + else + encoded_credentials_yaml="" + echo "Warning: failed to decrypt credentials.yaml.enc with .tmp_enc_key." >&2 + fi +fi + +if [ "$credentials_decrypted" != "true" ] && [ -t 0 ]; then + for ((attempt = 1; attempt <= MAX_PASSWORD_ATTEMPTS; attempt++)); do + credential_password="" + IFS= read -r -s -p "Enter the credentials.yaml.enc password ($attempt/$MAX_PASSWORD_ATTEMPTS): " credential_password || credential_password="" + printf '\n' + + if [ -z "$credential_password" ]; then + echo "Warning: a decryption password is required ($attempt/$MAX_PASSWORD_ATTEMPTS)." >&2 + continue + fi + + if encoded_credentials_yaml=$( + printf '%s\n' "$credential_password" | + openssl enc -aes-256-cbc -d -pbkdf2 \ + -in "$ENCRYPTED_CREDENTIALS_FILE" \ + -pass stdin 2>/dev/null | + openssl base64 -A + ); then + credentials_decrypted=true + unset credential_password + break + fi + + encoded_credentials_yaml="" + unset credential_password + echo "Warning: failed to decrypt credentials.yaml.enc with the entered password ($attempt/$MAX_PASSWORD_ATTEMPTS)." >&2 + done +fi + +if [ "$credentials_decrypted" != "true" ]; then + echo "Unable to decrypt credentials.yaml.enc; skipping Jenkins credential registration." + echo "Register the credentials manually in the Jenkins UI." + exit 0 +fi + +if [ -z "$encoded_credentials_yaml" ]; then + echo "Error: decrypted CSP credentials are empty." >&2 + exit 1 +fi + +if ! registration_output=$( + { + printf 'encodedCredentialsYaml = "%s"\n' "$encoded_credentials_yaml" + sed -n '1,$p' "$GROOVY_SCRIPT" + } | docker exec -i "$JENKINS_CONTAINER" \ + /opt/mcmp/jenkins-credentials/register-credentials.sh --inside-container +); then + unset encoded_credentials_yaml + echo "Error: failed to send credentials to Jenkins." >&2 + exit 1 +fi +unset encoded_credentials_yaml + +registration_complete=false +while IFS= read -r output_line; do + case "$output_line" in + MCMP_OBJECT_STORAGE_CREDENTIAL_REGISTERED\ *|MCMP_OBJECT_STORAGE_CREDENTIAL_SKIPPED\ *) + printf '%s\n' "$output_line" + ;; + MCMP_OBJECT_STORAGE_CREDENTIALS_COMPLETE) + registration_complete=true + ;; + esac +done <<< "$registration_output" + +if [ "$registration_complete" != "true" ]; then + echo "Error: Jenkins did not confirm credential registration." >&2 + exit 1 +fi + +echo "Jenkins credential registration is complete."