Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions cli/deploy.js
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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]
}
Expand Down Expand Up @@ -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 })
Expand All @@ -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
Expand Down
16 changes: 11 additions & 5 deletions lib/deploy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
55 changes: 53 additions & 2 deletions tests/deploy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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_<base64url>` 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 })
})