const core = require('@actions/core'); const http = require('http'); const https = require('https'); const querystring = require('querystring'); const req_timeout_ms = 30_000; const manifest_media_type = 'application/vnd.docker.distribution.manifest.v2+json'; main(); async function main() { try { const input = { registry: core.getInput('registry'), registry_url: null, insecure: core.getInput('insecure') === 'true', username: core.getInput('username'), password: core.getInput('password'), manifest: parse_manifest_tag(core.getInput('manifest')), tags: parse_tags(core.getInput('tags').trim()), token: null, }; input.registry_url = `http${input.insecure ? '' : 's'}://${input.registry}`; console.log('Tagging %s/%s:%s with new tags', input.registry, input.manifest.image, input.manifest.tag, input.tags); const manifest = await get_manifest(input); const promises = input.tags.map((tag) => { return put_manifest(tag, manifest, input); }); await Promise.all(promises); } catch (error) { console.error(error); core.setFailed(error.message); } } function parse_manifest_tag(full_tag) { const colon_index = full_tag.lastIndexOf(':'); const image = full_tag.slice(0, colon_index); const tag = full_tag.slice(colon_index + 1); return { image, tag }; } function parse_tags(tags) { if (tags.indexOf('\n') < 0) { return tags.split(',').map((tag) => tag.trim()); } return tags.split('\n').map((tag) => tag.trim()); } async function get_manifest(input) { const path = `/v2/${input.manifest.image}/manifests/${input.manifest.tag}`; const req_headers = { accept: manifest_media_type, }; http_basic_auth(req_headers, input); let { status, headers, body } = await http_req('GET', input.registry_url + path, req_headers); // if (status === 401 && headers['www-authenticate']) { // await get_token(headers['www-authenticate'], input); // ({ status, headers, body } = await http_req('GET', input.registry_url + path, { // accept: manifest_media_type, // authorization: `Bearer ${input.token}`, // })); // } if (status !== 200) { console.error('get manifest response', { status, headers, body }); throw new Error('failed to fetch existing manifest'); } return body; } async function put_manifest(new_tag, manifest, input) { const path = `/v2/${input.manifest.image}/manifests/${new_tag}`; const req_headers = { 'content-type': manifest_media_type, 'content-length': Buffer.byteLength(manifest), }; http_basic_auth(req_headers, input); // if (input.token) { // req_headers.authorization = `Bearer ${input.token}`; // } const { status, headers, body } = await http_req('PUT', input.registry_url + path, req_headers, manifest); if (status >= 400) { console.error('put manifest response', { status, headers, body }); throw new Error('failed to put manifest'); } } function http_basic_auth(headers, input) { if (input.username) { const credentials = Buffer.from(`${input.username}:${input.password}`, 'utf8').toString('base64'); headers.authorization = `Basic ${credentials}`; } } // async function get_token(www_authenticate, input) { // const params = parse_www_authenticate(www_authenticate); // if (! input.username || ! input.password) { // throw new Error('registry requested authentication, but not credentials were provided'); // } // const query_params = { // service: params.service, // scope: `repository:${input.manifest.image}:pull,push`, // grant_type: 'password', // username: input.username, // password: input.password, // }; // const { status, headers, body } = await http_req('GET', params.realm + '?' + querystring.stringify(query_params), { // 'content-type': 'application/x-www-form-urlencoded', // }); // if (status !== 200) { // console.error('get token response', { status, headers, body }); // throw new Error('error response from get token request'); // } // try { // const { token } = JSON.parse(body); // input.token = token; // } // catch (error) { // throw new Error('invalid response from get token request'); // } // } async function http_req(method, url_str, headers, body) { const url = new URL(url_str); const make_request = url.protocol === 'https:' ? https.request : url.protocol === 'http:' ? http.request : null; if (! make_request) { throw new Error('registry URL protocol not http(s)'); } if (url.username || url.password) { throw new Error('urls containing user credentials not allowed'); } const result = { url, status: null, body: null, headers: null, }; const path = url.pathname + (url.search || ''); const port = url.port ? parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80); return new Promise((resolve, reject) => { const req = make_request({ method: method, protocol: url.protocol, hostname: url.hostname, port: port, path: path, headers: headers }, (res) => { result.status = res.statusCode; result.body = ''; res.on('data', (chunk) => { result.body += chunk; }); res.on('end', () => { result.headers = res.headers; resolve(result); }); }); req.setTimeout(req_timeout_ms, () => { req.destroy(); reject(new Error('request timeout')); }); req.on('error', (error) => { reject(error); }); if (body) { req.write(body); } req.end(); }); } // function parse_www_authenticate(www_authenticate) { // if (! www_authenticate.startsWith('Bearer ')) { // throw new Error('invalid www-authenticate header (no "Bearer " prefix)'); // } // let remaining = www_authenticate.slice(7); // const parameters = Object.create(null); // while (remaining) { // const eq_index = remaining.indexOf('='); // if (eq_index < 0) { // parameters[remaining] = ''; // break; // } // const key = remaining.slice(0, eq_index); // remaining = remaining.slice(eq_index + 1); // if (! remaining) { // parameters[key] = ''; // break; // } // if (remaining[0] === '"') { // const close_index = remaining.slice(1).indexOf('"'); // if (close_index < 0) { // throw new Error('invalid www-authenticate header (unclosed quotes)'); // } // parameters[key] = remaining.slice(1, close_index + 1); // remaining = remaining.slice(close_index + 2); // if (remaining && remaining[0] !== ',') { // throw new Error('invalid www-authenticate header (expected comma after closing quote)'); // } // remaining = remaining.slice(1); // } // else { // const comma_index = remaining.indexOf(','); // if (comma_index < 0) { // parameters[key] = remaining; // break; // } // parameters[key] = remaining.slice(1, comma_index); // remaining = remaining.slice(comma_index + 1); // } // } // const { realm, service, scope } = parameters; // if (! realm || ! service || ! scope) { // throw new Error('invalid www-authenticate header (missing "realm", "service", or "scope")'); // } // return parameters; // }