Compare commits

..

15 Commits

Author SHA1 Message Date
144d8ae6a3 accept more media types 2023-08-18 23:02:36 -07:00
176ce2b913 logging 2023-08-18 22:59:59 -07:00
3b98ba922b logging 2023-08-18 22:53:41 -07:00
4ec5cfd721 use buffer for content 2023-08-18 22:52:25 -07:00
efaa2aa24d typo; add content length header 2023-08-18 22:49:59 -07:00
ac5141c79a switch to http basic auth 2023-08-18 22:47:09 -07:00
09228f4f08 typo 2023-08-18 22:39:53 -07:00
a976260e5e working on auth issues 2023-08-18 22:38:46 -07:00
46de33be5d fixes for updated inputs 2023-08-18 22:07:08 -07:00
fdcefbf5fa fixes for updated inputs 2023-08-18 22:06:16 -07:00
8ff608d867 fixes for updated inputs 2023-08-18 22:05:22 -07:00
3697f3078d update inputs; readme 2023-08-18 22:01:39 -07:00
2b8c0ec844 fix reference error 2023-08-18 21:48:02 -07:00
dc3b78f911 fix secondary get with token 2023-08-18 21:46:52 -07:00
acab3bc116 fix secondary get with token 2023-08-18 21:46:45 -07:00
5 changed files with 334 additions and 238 deletions

View File

@@ -1,4 +1,4 @@
name: Docker Tag
name: Tag Manifest
description: Tags an existing container manifest in a remote registry without pulling/pushing any images
runs:
using: node16
@@ -7,21 +7,18 @@ inputs:
registry:
description: URL to the container registry
required: true
insecure-registry:
insecure:
description: If "true", will use HTTP rather than HTTPS
required: false
image:
description: The name of the image in the registry
required: true
old-tag:
description: The tag of the existing image to be re-tagged
required: true
new-tags:
description: Newline delimited list of new tags to apply to the image
required: true
username:
description: Username to use to authenticate to the container registry
required: false
password:
description: Password to use to authenticate to the container registry
required: false
manifest:
description: The name/tag of the existing manifest in the registry
required: true
tags:
description: Newline or comma delimited list of new tags to apply to the manifest
required: true

253
dist/index.js vendored
View File

@@ -2794,14 +2794,6 @@ module.exports = require("path");
/***/ }),
/***/ 477:
/***/ ((module) => {
"use strict";
module.exports = require("querystring");
/***/ }),
/***/ 404:
/***/ ((module) => {
@@ -2863,10 +2855,11 @@ var __webpack_exports__ = {};
const core = __nccwpck_require__(186);
const http = __nccwpck_require__(685);
const https = __nccwpck_require__(687);
const querystring = __nccwpck_require__(477);
// const querystring = require('querystring');
const req_timeout_ms = 30_000;
const manifest_media_type = 'application/vnd.docker.distribution.manifest.v2+json';
const image_index_media_type = 'application/vnd.oci.image.index.v1+json';
main();
@@ -2875,22 +2868,21 @@ async function main() {
const input = {
registry: core.getInput('registry'),
registry_url: null,
insecure_registry: core.getInput('insecure-registry') === 'true',
image: core.getInput('image'),
old_tag: core.getInput('old-tag'),
new_tags: core.getInput('new-tags').trim().split('\n'),
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_registry ? '' : 's'}://${input.registry}`;
console.log('Tagging %s/%s:%s with new tags', input.registry, input.image, input.old_tag, input.new_tags);
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.new_tags.map((new_tag) => {
return put_manifest(new_tag, manifest, input);
const promises = input.tags.map((tag) => {
return put_manifest(tag, manifest, input);
});
await Promise.all(promises);
@@ -2902,81 +2894,114 @@ async function main() {
}
}
async function get_manifest(input) {
const path = `/v2/${input.image}/manifests/${input.old_tag}`;
let { status, headers, body } = await http_req('GET', input.registry_url + path, {
accept: manifest_media_type,
});
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 };
}
if (status === 401 && headers['www-authenticate']) {
await get_token(headers['www-authenticate'], input);
({ status, headers, body } = await http_req(input.insecure_registry, 'GET', input.registry_url + path, {
accept: manifest_media_type,
authorization: `Bearer ${input.token}`,
}));
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}, ${image_index_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;
return {
content: body,
type: headers['content-type'],
length: headers['content-length'],
};
}
async function put_manifest(new_tag, manifest, input) {
const path = `/v2/${input.image}/manifests/${new_tag}`;
const path = `/v2/${input.manifest.image}/manifests/${new_tag}`;
const req_headers = {
'content-type': manifest_media_type,
'content-type': manifest.type,
'content-length': manifest.length,
};
if (token) {
req_headers.authorization = `Bearer ${input.token}`;
}
http_basic_auth(req_headers, input);
const { status, headers, body } = await http_req('PUT', input.registry_url + path, req_headers, manifest);
// if (input.token) {
// req_headers.authorization = `Bearer ${input.token}`;
// }
const { status, headers, body } = await http_req('PUT', input.registry_url + path, req_headers, manifest.content);
if (status >= 400) {
console.error('get manifest response', { status, headers, body });
console.error('put manifest response', { status, headers, body });
throw new Error('failed to put manifest');
}
}
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: params.scope,
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');
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
@@ -3041,68 +3066,68 @@ async function http_req(method, url_str, headers, body) {
});
}
function parse_www_authenticate(www_authenticate) {
if (! www_authenticate.startsWith('Bearer ')) {
throw new Error('invalid www-authenticate header (no "Bearer " prefix)');
}
// 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);
// let remaining = www_authenticate.slice(7);
// const parameters = Object.create(null);
while (remaining) {
const eq_index = remaining.indexOf('=');
// while (remaining) {
// const eq_index = remaining.indexOf('=');
if (eq_index < 0) {
parameters[remaining] = '';
break;
}
// if (eq_index < 0) {
// parameters[remaining] = '';
// break;
// }
const key = remaining.slice(0, eq_index);
remaining = remaining.slice(eq_index + 1);
// const key = remaining.slice(0, eq_index);
// remaining = remaining.slice(eq_index + 1);
if (! remaining) {
parameters[key] = '';
break;
}
// if (! remaining) {
// parameters[key] = '';
// break;
// }
if (remaining[0] === '"') {
const close_index = remaining.slice(1).indexOf('"');
// if (remaining[0] === '"') {
// const close_index = remaining.slice(1).indexOf('"');
if (close_index < 0) {
throw new Error('invalid www-authenticate header (unclosed quotes)');
}
// 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);
// 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)');
}
// if (remaining && remaining[0] !== ',') {
// throw new Error('invalid www-authenticate header (expected comma after closing quote)');
// }
remaining = remaining.slice(1);
}
// remaining = remaining.slice(1);
// }
else {
const comma_index = remaining.indexOf(',');
// else {
// const comma_index = remaining.indexOf(',');
if (comma_index < 0) {
parameters[key] = remaining;
break;
}
// if (comma_index < 0) {
// parameters[key] = remaining;
// break;
// }
parameters[key] = remaining.slice(1, comma_index);
remaining = remaining.slice(comma_index + 1);
}
}
// parameters[key] = remaining.slice(1, comma_index);
// remaining = remaining.slice(comma_index + 1);
// }
// }
const { realm, service, scope } = parameters;
// const { realm, service, scope } = parameters;
if (! realm || ! service || ! scope) {
throw new Error('invalid www-authenticate header (missing "realm", "service", or "scope")');
}
// if (! realm || ! service || ! scope) {
// throw new Error('invalid www-authenticate header (missing "realm", "service", or "scope")');
// }
return parameters;
}
// return parameters;
// }
})();

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

245
index.js
View File

@@ -2,10 +2,11 @@
const core = require('@actions/core');
const http = require('http');
const https = require('https');
const querystring = require('querystring');
// const querystring = require('querystring');
const req_timeout_ms = 30_000;
const manifest_media_type = 'application/vnd.docker.distribution.manifest.v2+json';
const image_index_media_type = 'application/vnd.oci.image.index.v1+json';
main();
@@ -14,22 +15,21 @@ async function main() {
const input = {
registry: core.getInput('registry'),
registry_url: null,
insecure_registry: core.getInput('insecure-registry') === 'true',
image: core.getInput('image'),
old_tag: core.getInput('old-tag'),
new_tags: core.getInput('new-tags').trim().split('\n'),
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_registry ? '' : 's'}://${input.registry}`;
console.log('Tagging %s/%s:%s with new tags', input.registry, input.image, input.old_tag, input.new_tags);
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.new_tags.map((new_tag) => {
return put_manifest(new_tag, manifest, input);
const promises = input.tags.map((tag) => {
return put_manifest(tag, manifest, input);
});
await Promise.all(promises);
@@ -41,81 +41,114 @@ async function main() {
}
}
async function get_manifest(input) {
const path = `/v2/${input.image}/manifests/${input.old_tag}`;
let { status, headers, body } = await http_req('GET', input.registry_url + path, {
accept: manifest_media_type,
});
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 };
}
if (status === 401 && headers['www-authenticate']) {
await get_token(headers['www-authenticate'], input);
({ status, headers, body } = await http_req(input.insecure_registry, 'GET', input.registry_url + path, {
accept: manifest_media_type,
authorization: `Bearer ${input.token}`,
}));
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}, ${image_index_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;
return {
content: body,
type: headers['content-type'],
length: headers['content-length'],
};
}
async function put_manifest(new_tag, manifest, input) {
const path = `/v2/${input.image}/manifests/${new_tag}`;
const path = `/v2/${input.manifest.image}/manifests/${new_tag}`;
const req_headers = {
'content-type': manifest_media_type,
'content-type': manifest.type,
'content-length': manifest.length,
};
if (token) {
req_headers.authorization = `Bearer ${input.token}`;
}
http_basic_auth(req_headers, input);
const { status, headers, body } = await http_req('PUT', input.registry_url + path, req_headers, manifest);
// if (input.token) {
// req_headers.authorization = `Bearer ${input.token}`;
// }
const { status, headers, body } = await http_req('PUT', input.registry_url + path, req_headers, manifest.content);
if (status >= 400) {
console.error('get manifest response', { status, headers, body });
console.error('put manifest response', { status, headers, body });
throw new Error('failed to put manifest');
}
}
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: params.scope,
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');
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
@@ -180,65 +213,65 @@ async function http_req(method, url_str, headers, body) {
});
}
function parse_www_authenticate(www_authenticate) {
if (! www_authenticate.startsWith('Bearer ')) {
throw new Error('invalid www-authenticate header (no "Bearer " prefix)');
}
// 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);
// let remaining = www_authenticate.slice(7);
// const parameters = Object.create(null);
while (remaining) {
const eq_index = remaining.indexOf('=');
// while (remaining) {
// const eq_index = remaining.indexOf('=');
if (eq_index < 0) {
parameters[remaining] = '';
break;
}
// if (eq_index < 0) {
// parameters[remaining] = '';
// break;
// }
const key = remaining.slice(0, eq_index);
remaining = remaining.slice(eq_index + 1);
// const key = remaining.slice(0, eq_index);
// remaining = remaining.slice(eq_index + 1);
if (! remaining) {
parameters[key] = '';
break;
}
// if (! remaining) {
// parameters[key] = '';
// break;
// }
if (remaining[0] === '"') {
const close_index = remaining.slice(1).indexOf('"');
// if (remaining[0] === '"') {
// const close_index = remaining.slice(1).indexOf('"');
if (close_index < 0) {
throw new Error('invalid www-authenticate header (unclosed quotes)');
}
// 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);
// 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)');
}
// if (remaining && remaining[0] !== ',') {
// throw new Error('invalid www-authenticate header (expected comma after closing quote)');
// }
remaining = remaining.slice(1);
}
// remaining = remaining.slice(1);
// }
else {
const comma_index = remaining.indexOf(',');
// else {
// const comma_index = remaining.indexOf(',');
if (comma_index < 0) {
parameters[key] = remaining;
break;
}
// if (comma_index < 0) {
// parameters[key] = remaining;
// break;
// }
parameters[key] = remaining.slice(1, comma_index);
remaining = remaining.slice(comma_index + 1);
}
}
// parameters[key] = remaining.slice(1, comma_index);
// remaining = remaining.slice(comma_index + 1);
// }
// }
const { realm, service, scope } = parameters;
// const { realm, service, scope } = parameters;
if (! realm || ! service || ! scope) {
throw new Error('invalid www-authenticate header (missing "realm", "service", or "scope")');
}
// if (! realm || ! service || ! scope) {
// throw new Error('invalid www-authenticate header (missing "realm", "service", or "scope")');
// }
return parameters;
}
// return parameters;
// }

View File

@@ -3,7 +3,42 @@ Action for re-tagging an existing docker multi-arch manifest
## Inputs
<!-- -->
### `registry`
URL to the container registry
required: true
### `insecure`
If "true", will use HTTP rather than HTTPS
required: false
### `username`
Username to use to authenticate to the container registry
required: false
### `password`
Password to use to authenticate to the container registry
required: false
### `manifest`
The name/tag of the existing manifest in the registry
required: true
### `tags`
Newline or comma delimited list of new tags to apply to the manifest
required: true
## Example usage
@@ -13,15 +48,21 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Tag the "gitea.example.com/owner/package:latest" image with some new tags
uses: https://gitea.jbrumond.me/actions/docker-tag@v0.1
uses: https://gitea.jbrumond.me/actions/tag-manifest@v0.1
with:
# Container registry where the manifest is hosted
registry: gitea.example.com
# Credentials to access the registry
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
insecure-registry: false
image: owner/package
old-tag: latest
new-tags: |
# Existing manifest to apply tags to
manifest: owner/package:latest
# New tags to apply (either of these works):
tags: tag1,tag2,tag3
tags: |
tag1
tag2
tag3