diff --git a/cli/deploy.js b/cli/deploy.js index 0a9b15d..2540582 100644 --- a/cli/deploy.js +++ b/cli/deploy.js @@ -1,5 +1,6 @@ import { resolve, sep, basename, join } from 'node:path' import { readFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' import minimist from 'minimist' import dotenv from 'dotenv' import { loadContext } from '../lib/context.js' @@ -29,6 +30,39 @@ export function deployBuildArgs (version) { return version ? { PLT_DEPLOYMENT_ID: version } : {} } +// Is this profile's ICC going to version what we deploy? Read from the profile +// rather than asked of the cluster: the version has to be decided before the +// image is built, which is before anything is deployed. +export function skewProtectionEnabled (context) { + return context?.platformatic?.services?.icc?.features?.skew_protection?.enable === true +} + +const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' + +function base62 (buf) { + let num = BigInt('0x' + buf.toString('hex')) + let out = '' + while (num > 0n) { + out = BASE62[Number(num % 62n)] + out + num /= 62n + } + return out +} + +// A version label for a deploy that did not name one, in the same `plt_` + 24 +// base62 shape ICC mints, derived the same way: sha256 of the image reference. +// Deriving rather than randomising means rebuilding the same tag names the same +// version, and desk's tag already carries a timestamp so each deploy differs. +// +// This is not the value ICC would derive for the same image -- ICC hashes +// `tag@sha256:digest`, and the digest does not exist until after the build that +// this id has to be baked into. It does not need to match: a declared +// plt.dev/version wins over derivation, so ICC never derives one here. +export function generateVersion (imageRef) { + const hash = createHash('sha256').update(String(imageRef)).digest() + return 'plt_' + base62(hash).slice(0, 24) +} + export function imageName (image) { return image.split('/').at(-1).split('@')[0].split(':')[0] } @@ -109,7 +143,18 @@ export default async function cli (argv) { const isWorkflow = detectWorkflow(dockerfile, envVars) - const buildArgs = deployBuildArgs(args.version) + // Mint a version when skew protection is on and none was named. It has to be + // decided here, before the build, because the id is baked into the client + // assets -- a version assigned afterwards is one ICC cannot pin. + // + // Only when building from --dir. A prebuilt --image already carries whatever + // id it was built with, and a label we invent here would contradict it: ICC + // would see the mismatch and refuse to route by query anyway. + const version = args.version || + (directory && skewProtectionEnabled(context) ? generateVersion(appImage) : undefined) + if (version && !args.version) info(`No --version given; using generated version ${version}`) + + const buildArgs = deployBuildArgs(version) if (directory) await registry.buildFromDirectory(directory, appImage, { npmrc: args.npmrc, buildArgs }) const clusterStatus = await getClusterStatus({ context }) @@ -121,7 +166,6 @@ export default async function cli (argv) { envVars.REGINA_VALKEY_CONNECTION_STRING = clusterStatus.valkeyRegina.connectionString } - const version = args.version const hostname = args.hostname let minReplicas diff --git a/lib/deploy.js b/lib/deploy.js index b62d601..c23ef76 100644 --- a/lib/deploy.js +++ b/lib/deploy.js @@ -2,11 +2,17 @@ import { spawn } from './utils.js' import { addToRun } from './run-directory.js' // The Deployment/Service name must be unique per version so versions coexist for -// skew protection. With an explicit --version use it; otherwise fall back to the -// image tag (unique per build), sanitized to a k8s-safe segment. This is only the -// resource name -- ICC still mints the routing version id (plt_...) from the image. -function resourceVersion (version, imageNameTag) { - if (version) return version +// skew protection. Use the version when it is already a legal name segment; +// otherwise fall back to the image tag (unique per build), sanitized. This is +// only the resource name -- the routing version id is the plt.dev/version label, +// which keeps the original value. +// +// The guard is not optional: a `plt_`-shaped id (the shape ICC mints and desk +// generates) has an underscore and capitals, and the API server rejects it as a +// resource name. Same rule as ICC's own deployment-builder, so a workload desk +// creates is named exactly as one ICC would create for the same version. +export function resourceVersion (version, imageNameTag) { + if (version && /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(version)) return version const nameAndTag = imageNameTag.slice(imageNameTag.lastIndexOf('/') + 1) const colon = nameAndTag.lastIndexOf(':') const tag = colon >= 0 ? nameAndTag.slice(colon + 1) : 'latest' diff --git a/tests/deploy.test.js b/tests/deploy.test.js index 8dc0fae..49d77be 100644 --- a/tests/deploy.test.js +++ b/tests/deploy.test.js @@ -3,8 +3,8 @@ import { test } from 'node:test' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { createDeployment } from '../lib/deploy.js' -import { imageName, deployBuildArgs } from '../cli/deploy.js' +import { createDeployment, resourceVersion } from '../lib/deploy.js' +import { imageName, deployBuildArgs, generateVersion, skewProtectionEnabled } from '../cli/deploy.js' test('imageName handles registry ports, tags, and digests', () => { assert.equal(imageName('localhost:5000/orders:v2'), 'orders') @@ -67,3 +67,54 @@ test('a deploy without --version passes no build args', () => { assert.deepEqual(deployBuildArgs(null), {}) assert.deepEqual(deployBuildArgs(''), {}) }) + +test('a generated version matches the plt_ convention and is a legal label value', () => { + // Same shape ICC mints: `plt_` + 24 base62. base62 is alphanumeric only, so the + // id always starts and ends alphanumeric -- which is what a k8s label value + // requires, and what the `dpl_` shape used on ECS cannot promise + // (its tail can land on `-` or `_`). + const labelValue = /^[A-Za-z0-9](?:[-A-Za-z0-9_.]{0,61}[A-Za-z0-9])?$/ + + for (let i = 0; i < 200; i++) { + const version = generateVersion(`registry/app:${i}`) + assert.match(version, /^plt_[0-9A-Za-z]{24}$/, `${version} is not plt_ + 24 base62`) + assert.match(version, labelValue, `${version} is not a valid label value`) + } +}) + +test('a generated version is derived from the image reference', () => { + // Deterministic per tag: the same build produces the same id. desk's tag + // carries a timestamp, so consecutive deploys still get distinct versions. + assert.equal(generateVersion('registry/app:123'), generateVersion('registry/app:123')) + assert.notEqual(generateVersion('registry/app:123'), generateVersion('registry/app:124')) +}) + +test('a generated version does not become the resource name', () => { + // The plt_ shape is a legal label value but NOT a legal resource name: the API + // server rejects the underscore and the capitals. The name must fall back to + // the image tag while plt.dev/version keeps the generated id. + const version = generateVersion('plt.localreg/plt-local/orders:1786546018096') + assert.equal(resourceVersion(version, 'plt.localreg/plt-local/orders:1786546018096'), '1786546018096') + // An explicit --version that IS a legal segment is still used verbatim. + assert.equal(resourceVersion('v1.2.3', 'registry/orders:abc'), 'v1.2.3') +}) + +test('skew protection is read from the profile', () => { + const on = { platformatic: { services: { icc: { features: { skew_protection: { enable: true } } } } } } + const off = { platformatic: { services: { icc: { features: { skew_protection: { enable: false } } } } } } + + assert.equal(skewProtectionEnabled(on), true) + assert.equal(skewProtectionEnabled(off), false) + // A profile that never mentions the feature must not deploy versioned + // workloads: the deploy would name versions ICC is not tracking. + assert.equal(skewProtectionEnabled({ platformatic: { services: { icc: {} } } }), false) + assert.equal(skewProtectionEnabled({}), false) + assert.equal(skewProtectionEnabled(undefined), false) +}) + +test('a generated version reaches the image build', () => { + // The generated value is only useful if it is baked in: skipping the build arg + // would produce a version label whose assets carry no matching ?dpl. + const version = generateVersion('plt.localreg/plt-local/orders:1786546018096') + assert.deepEqual(deployBuildArgs(version), { PLT_DEPLOYMENT_ID: version }) +})