|
| 1 | +import jenkins.model.Jenkins |
| 2 | +import hudson.model.User |
| 3 | +import java.nio.file.* |
| 4 | +import java.nio.charset.StandardCharsets |
| 5 | +import groovy.transform.Field |
| 6 | + |
| 7 | +// ===== SSO enablement check ===== |
| 8 | +// Exit early if SSO is disabled via SPRYKER_SCHEDULER_SSO_ENABLED environment variable |
| 9 | +if (System.getenv('SPRYKER_SCHEDULER_SSO_ENABLED') != 'true') { |
| 10 | + println "[bootstrap] SSO is disabled (SPRYKER_SCHEDULER_SSO_ENABLED != true); skipping token generation" |
| 11 | + return |
| 12 | +} |
| 13 | + |
| 14 | +// ===== env / dynamic path ===== |
| 15 | +@Field final String REGION = System.getenv('AWS_REGION') ?: 'unknown-region' |
| 16 | +@Field final String PROJECT = System.getenv('SPRYKER_PROJECT_NAME') ?: 'unknown-project' |
| 17 | +@Field final List<String> SSM_PARAMS = [ |
| 18 | + "/${PROJECT}/custom-secrets/SPRYKER_SCHEDULER_PASSWORD", |
| 19 | +] |
| 20 | + |
| 21 | +// Jenkins user to mint token for |
| 22 | +@Field final String USERNAME = System.getenv('SPRYKER_SCHEDULER_USER') ?: 'svc-spryker' |
| 23 | +@Field final String LABEL = "bootstrap-" + System.currentTimeMillis() |
| 24 | + |
| 25 | +// Jenkins home as a Path |
| 26 | +// [CHANGED] Always use the *actual running* Jenkins root dir; no env/default fallback. |
| 27 | +@Field final Path HOME = Jenkins.get().getRootDir().toPath() // [CHANGED] |
| 28 | +@Field final Path OUT_DIR = HOME.resolve('secrets').resolve('bootstrap') |
| 29 | +@Field final Path OUT_FILE = OUT_DIR.resolve("${USERNAME}.token") |
| 30 | +Files.createDirectories(OUT_DIR) |
| 31 | + |
| 32 | +// ---- marker to signal completion to entrypoint ---- |
| 33 | +@Field final Path MARKER_DIR = OUT_DIR |
| 34 | +@Field final Path MARKER_FILE = MARKER_DIR.resolve(".token_ready") |
| 35 | + |
| 36 | +def writeMarker() { |
| 37 | + try { |
| 38 | + Files.createDirectories(this.@MARKER_DIR) |
| 39 | + Files.write( |
| 40 | + this.@MARKER_FILE, |
| 41 | + ("ready@" + new Date().toString() + "\n").getBytes("UTF-8"), |
| 42 | + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING |
| 43 | + ) |
| 44 | + try { |
| 45 | + def perms = [ |
| 46 | + java.nio.file.attribute.PosixFilePermission.OWNER_READ, |
| 47 | + java.nio.file.attribute.PosixFilePermission.OWNER_WRITE |
| 48 | + ] as Set |
| 49 | + Files.setPosixFilePermissions(this.@MARKER_FILE, perms) |
| 50 | + } catch (Throwable ignore) {} |
| 51 | + println "[bootstrap] wrote marker: ${this.@MARKER_FILE}" |
| 52 | + } catch (Throwable t) { |
| 53 | + println "[bootstrap] WARNING: failed to write marker ${this.@MARKER_FILE}: ${t.message}" |
| 54 | + } |
| 55 | +} |
| 56 | + |
| 57 | +// ---- resolve ApiTokenProperty class safely (handles package differences) |
| 58 | +Class apiTokenPropertyClass |
| 59 | +try { |
| 60 | + apiTokenPropertyClass = Class.forName('jenkins.security.apitoken.ApiTokenProperty') |
| 61 | +} catch (Throwable ignore) { |
| 62 | + apiTokenPropertyClass = Class.forName('jenkins.security.ApiTokenProperty') // older cores |
| 63 | +} |
| 64 | + |
| 65 | +// ===== helpers ===== |
| 66 | +def sh(Map<String,String> env, String cmd) { |
| 67 | + def pb = new ProcessBuilder(["bash","-lc", cmd]).redirectErrorStream(true) |
| 68 | + if (env) pb.environment().putAll(env) |
| 69 | + def p = pb.start() |
| 70 | + String out = p.inputStream.getText(StandardCharsets.UTF_8.name()).trim() |
| 71 | + int rc = p.waitFor() |
| 72 | + [rc: rc, out: out] |
| 73 | +} |
| 74 | + |
| 75 | +def writeTokenFile(Path outFile, String uuid, String value) { |
| 76 | + String content = (uuid ? "tokenUuid=${uuid}\n" : "") + "tokenValue=${value}\n" |
| 77 | + Files.write(outFile, content.getBytes('UTF-8'), |
| 78 | + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) |
| 79 | + try { |
| 80 | + def perms = [ |
| 81 | + java.nio.file.attribute.PosixFilePermission.OWNER_READ, |
| 82 | + java.nio.file.attribute.PosixFilePermission.OWNER_WRITE |
| 83 | + ] as Set |
| 84 | + Files.setPosixFilePermissions(outFile, perms) |
| 85 | + } catch (Throwable ignore) {} |
| 86 | +} |
| 87 | + |
| 88 | +// --- DEBUG HELPERS (logs only; no behavior change) --- |
| 89 | +def rightN(String s, int n) { |
| 90 | + if (s == null) return "" |
| 91 | + int len = s.length() |
| 92 | + int start = Math.max(0, len - n) |
| 93 | + return s.substring(start, len) |
| 94 | +} |
| 95 | + |
| 96 | +def mask(String s, int head=6, int tail=4) { |
| 97 | + if (!s) return "(null)" |
| 98 | + if (s.length() <= head + tail) return s |
| 99 | + return s.take(head) + "…" + rightN(s, tail) |
| 100 | +} |
| 101 | + |
| 102 | +def dbgToken(String where, String uuid, String value) { |
| 103 | + println "[debug] token@${where}: len=${value?.size() ?: 0}, masked=${mask(value)}, uuid=${uuid ?: '(none)'}" |
| 104 | + try { |
| 105 | + def basic = "${USERNAME}:${value ?: ''}".bytes.encodeBase64().toString() |
| 106 | + println "[debug] basicHeaderSample: Authorization: Basic " + mask(basic, 8, 6) |
| 107 | + } catch (Throwable ignore) {} |
| 108 | +} |
| 109 | + |
| 110 | +// [NEW] Validate token against the running Jenkins before reuse |
| 111 | +def isTokenValid(String user, String token) { // [NEW] |
| 112 | + if (!token) return false // [NEW] |
| 113 | + try { // [NEW] |
| 114 | + String base = System.getenv('JENKINS_URL') ?: (Jenkins.get().getRootUrl() ?: 'http://127.0.0.1:8080/') |
| 115 | + base = base.replaceAll('/+$','') |
| 116 | + def cmd = """curl -s -o /dev/null -w '%{http_code}' \ |
| 117 | + -H "Authorization: Basic ${("${user}:${token}").bytes.encodeBase64().toString()}" \ |
| 118 | + "${base}/whoAmI/api/json" """ |
| 119 | + def r = sh([:], cmd) |
| 120 | + return r.rc == 0 && r.out == '200' |
| 121 | + } catch (Throwable t) { |
| 122 | + return false |
| 123 | + } |
| 124 | +} // [NEW] |
| 125 | + |
| 126 | +// publish to all target params |
| 127 | +def publishToSSM(String value) { |
| 128 | + if (!value) return |
| 129 | + final String region = this.@REGION |
| 130 | + final String project = this.@PROJECT |
| 131 | + if (!region || project == 'unknown-project') { |
| 132 | + println "[bootstrap] Skipping SSM (REGION/PROJECT not set): REGION=${region} PROJECT=${project}" |
| 133 | + return |
| 134 | + } |
| 135 | + this.@SSM_PARAMS.each { String param -> |
| 136 | + def r = sh([TOKEN_VALUE: value, AWS_REGION: region], |
| 137 | + 'aws ssm put-parameter --region "$AWS_REGION" --name "'+param+'" --type SecureString --overwrite --value "$TOKEN_VALUE"') |
| 138 | + println "[bootstrap] SSM put-parameter rc=${r.rc} -> ${param}" |
| 139 | + if (r.rc != 0) { |
| 140 | + println "[bootstrap] WARNING: aws cli returned non-zero; output:\n${r.out}" |
| 141 | + } |
| 142 | + } |
| 143 | +} |
| 144 | + |
| 145 | +// ===== main flow ===== |
| 146 | +String tokenValue |
| 147 | +String tokenUuid = null |
| 148 | + |
| 149 | +// If local file already exists, validate and reuse if still good; otherwise re-mint |
| 150 | +if (Files.exists(OUT_FILE)) { |
| 151 | + def text = Files.readString(OUT_FILE, StandardCharsets.UTF_8) |
| 152 | + def m = (text =~ /(?m)^tokenValue=(.+)$/) |
| 153 | + def mu = (text =~ /(?m)^tokenUuid=(.+)$/) |
| 154 | + if (m.find()) { |
| 155 | + tokenValue = m.group(1).trim() |
| 156 | + tokenUuid = (mu.find() ? mu.group(1).trim() : null) |
| 157 | + dbgToken("disk", tokenUuid, tokenValue) |
| 158 | + |
| 159 | + // [NEW] validate-before-reuse (auto self-heal if secrets rotated) |
| 160 | + if (isTokenValid(USERNAME, tokenValue)) { // [NEW] |
| 161 | + println "[bootstrap] token file exists and is valid; skipping generation" // [NEW] |
| 162 | + publishToSSM(tokenValue) // [NEW] |
| 163 | + writeMarker() // [NEW] |
| 164 | + return // [NEW] |
| 165 | + } else { // [NEW] |
| 166 | + println "[bootstrap] existing token is NOT valid; minting a new one" // [NEW] |
| 167 | + } |
| 168 | + } else { |
| 169 | + println "[bootstrap] token file exists but tokenValue not found; will generate a new token" |
| 170 | + } |
| 171 | +} |
| 172 | + |
| 173 | +// Create or load user |
| 174 | +def u = User.getById(USERNAME, true) |
| 175 | + |
| 176 | +// Ensure the ApiTokenProperty exists |
| 177 | +def p = u.getProperty(apiTokenPropertyClass as Class) |
| 178 | +if (p == null) { |
| 179 | + p = apiTokenPropertyClass.getDeclaredConstructor().newInstance() |
| 180 | + u.addProperty(p) |
| 181 | + u.save() |
| 182 | +} |
| 183 | + |
| 184 | +// ===== generate & extract token (getter-or-field tolerant) ===== |
| 185 | +def tokenStore = p.getClass().getMethod('getTokenStore').invoke(p) |
| 186 | +def t = tokenStore.getClass().getMethod('generateNewToken', String).invoke(tokenStore, LABEL) |
| 187 | + |
| 188 | +// Helper: try getter, Groovy property, then direct field |
| 189 | +def readProp = { obj, List<String> names -> |
| 190 | + for (String n : names) { |
| 191 | + try { def m = obj.getClass().getMethod(n); def v = m.invoke(obj); if (v != null) return v.toString() } catch (Throwable ignore) {} |
| 192 | + try { def v = obj."$n"; if (v != null) return v.toString() } catch (Throwable ignore) {} |
| 193 | + try { |
| 194 | + def fName = n.startsWith('get') ? n.substring(3,4).toLowerCase() + n.substring(4) : n |
| 195 | + def f = obj.getClass().getDeclaredField(fName); f.setAccessible(true) |
| 196 | + def v = f.get(obj); if (v != null) return v.toString() |
| 197 | + } catch (Throwable ignore) {} |
| 198 | + } |
| 199 | + return null |
| 200 | +} |
| 201 | + |
| 202 | +tokenValue = readProp(t, ['getPlainValue','plainValue','getValue','value']) |
| 203 | +tokenUuid = readProp(t, ['getTokenUuid','tokenUuid','getUuid','uuid']) |
| 204 | + |
| 205 | +if (!tokenValue) { |
| 206 | + try { |
| 207 | + def legacy = p.getClass().getMethod('getApiToken').invoke(p) |
| 208 | + if (legacy) tokenValue = legacy.toString() |
| 209 | + } catch (Throwable ignore) {} |
| 210 | +} |
| 211 | + |
| 212 | +dbgToken("generated", tokenUuid, tokenValue) |
| 213 | + |
| 214 | +// [NEW] Ensure token store changes are flushed to disk |
| 215 | +try { u.save() } catch (Throwable ignore) {} // [NEW] |
| 216 | + |
| 217 | +// Write local file and update SSM |
| 218 | +writeTokenFile(OUT_FILE, tokenUuid, tokenValue) |
| 219 | +println "[bootstrap] generated API token for ${USERNAME}; wrote ${OUT_FILE}" |
| 220 | +println "[bootstrap] updating SSM params: ${this.@SSM_PARAMS.join(', ')}" |
| 221 | +publishToSSM(tokenValue) |
| 222 | + |
| 223 | +// mark completion so entrypoint can proceed |
| 224 | +writeMarker() |
0 commit comments