From ed550eee0a603f7e543dcd794142b1deebc9a14d Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 29 Oct 2026 23:33:29 +0000 Subject: [PATCH] Normalize VM image registry and repository casing --- packages/api/src/flow-schemas.ts | 23 +++++++++++++++++-- packages/api/src/flow-service.ts | 8 ++++--- packages/api/src/protected-api.ts | 7 +++++- packages/api/test/api-contract.test.ts | 11 +++++++-- packages/api/test/loading.postgres.test.ts | 3 ++- .../app/src/features/jobs/editor-state.ts | 7 +++++- packages/app/src/features/jobs/editor.tsx | 6 ++--- packages/app/test/editor.browser.test.mjs | 3 +++ packages/docs/openapi.yaml | 6 ++--- 9 files changed, 58 insertions(+), 16 deletions(-) diff --git a/packages/api/src/flow-schemas.ts b/packages/api/src/flow-schemas.ts index 953b62a..db452d6 100644 --- a/packages/api/src/flow-schemas.ts +++ b/packages/api/src/flow-schemas.ts @@ -34,6 +34,25 @@ export const triggerSchema = z.discriminatedUnion("kind", [ }).strict() }).strict(), z.object({ id: z.string().min(1).optional(), slug: z.string().regex(/^trigger-[1-9][0-9]*$/).optional(), kind: z.literal("jobIdle"), enabled: z.boolean().default(true), config: z.object({ sourceJobIds: z.array(z.string().min(1)).min(1) }).strict() }).strict(), ]); + +/** Lower registry and repository names while keeping the case-sensitive tag. */ +export function canonicalizeVmImage(value: string): string { + const trimmed = value.trim(); + const digestAt = trimmed.indexOf("@"); + const reference = digestAt < 0 ? trimmed : trimmed.slice(0, digestAt); + const digest = digestAt < 0 ? "" : trimmed.slice(digestAt); + const slash = reference.lastIndexOf("/"); + const colon = reference.lastIndexOf(":"); + const tag = colon > slash ? reference.slice(colon) : ""; + const name = tag ? reference.slice(0, -tag.length) : reference; + return `${name.toLowerCase()}${tag}${digest}`; +} + +const imageReferencePattern = String.raw`(?:(?:[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*\/)?[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*(?:\/[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?)`; +const publicVmImage = (allowBlank: boolean) => (allowBlank ? z.string().trim().max(512) : z.string().trim().min(1).max(512)) + .regex(new RegExp(allowBlank ? `^(?:${imageReferencePattern})?$` : `^${imageReferencePattern}$`), + "Enter a public Docker/OCI image reference without a URL scheme (for example, ghcr.io/owner/image:tag).") + .transform(canonicalizeVmImage); export const jobInputSchema = z.object({ projectId: z.guid().optional(), name: z.string().trim().min(1).max(120), @@ -43,7 +62,7 @@ export const jobInputSchema = z.object({ concurrencyLimit: z.number().int().min(1).max(50).default(1), executionConfigId: z.string().min(1), tags: z.array(z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/)).max(20).optional(), - vmImage: z.string().trim().max(512).regex(/^(?:(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?)?$/, "Enter a public Docker/OCI image reference.").optional(), + vmImage: publicVmImage(true).optional(), model: z.string().trim().max(120).optional(), effort: z.string().trim().max(40).optional(), triggers: z.array(triggerSchema).max(50).default([]), @@ -51,7 +70,7 @@ export const jobInputSchema = z.object({ }).strict(); export const vmImageVerificationInput = z.object({ connectionId: z.string().min(1).max(200), - image: z.string().trim().min(1).max(512).regex(/^(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?$/), + image: publicVmImage(false), tags: z.array(z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/)).max(20), agentKind: z.enum(["codex", "claude", "pi"]), }).strict(); diff --git a/packages/api/src/flow-service.ts b/packages/api/src/flow-service.ts index d9c8340..9fd97d1 100644 --- a/packages/api/src/flow-service.ts +++ b/packages/api/src/flow-service.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import { runPageResponse, fullRunResponse } from "./run-read-contracts"; import { triggerContextCatalog } from "./trigger-context"; import type { Env, OAuthProps } from "./types"; -import { vmImageVerificationInput, type JobInput, type ManualInvocationInput } from "./flow-schemas"; +import { canonicalizeVmImage, vmImageVerificationInput, type JobInput, type ManualInvocationInput } from "./flow-schemas"; import { nextOccurrence, validateScheduleConfig } from "./schedule"; import { databaseFor } from "./postgres/database"; import { IdentityRepository } from "./postgres/identity-repository"; @@ -228,19 +228,21 @@ export class ApiService { async testJobConditions(input: unknown) { await this.authorize("jobs:write"); const parsed = jobConditionsTestSchema.parse(input); return evaluateWebhookConditions(parsed.conditions, parsed.webhook); } async createJob(input: JobInput) { await this.authorize("jobs:write"); const timestamp = new Date().toISOString(), id = crypto.randomUUID(); + const vmImage = input.vmImage ? canonicalizeVmImage(input.vmImage) : input.vmImage; await this.validateExecutionOptions(input.executionConfigId, input.model ?? "", input.effort ?? "", input.tags ?? []); const triggers = this.normalizedTriggers(input.triggers).map(trigger => ({ ...trigger, jobId: id })); const projectId = input.projectId ?? await this.projects().defaultProjectId(); const selectedSkills = input.skillIds ? await this.skillSelections(input.skillIds) : []; - const job: Job = { id, projectId, name: input.name, slug: input.slug, promptTemplate: input.promptTemplate, runNameTemplate: input.runNameTemplate ?? "", tags: input.tags ?? [], model: input.model ?? "", ...(input.effort ? { effort: input.effort } : {}), executionConfig: { ...await this.executionConfig(input.executionConfigId), ...(input.vmImage ? { image: input.vmImage } : {}) }, concurrencyLimit: input.concurrencyLimit, enabled: true, triggers, selectedSkills, createdAt: timestamp, updatedAt: timestamp }; + const job: Job = { id, projectId, name: input.name, slug: input.slug, promptTemplate: input.promptTemplate, runNameTemplate: input.runNameTemplate ?? "", tags: input.tags ?? [], model: input.model ?? "", ...(input.effort ? { effort: input.effort } : {}), executionConfig: { ...await this.executionConfig(input.executionConfigId), ...(vmImage ? { image: vmImage } : {}) }, concurrencyLimit: input.concurrencyLimit, enabled: true, triggers, selectedSkills, createdAt: timestamp, updatedAt: timestamp }; try { return this.presentJob(await this.jobs().create(job)); } catch (error: any) { if (error?.code === "23505") throw new ServiceError(409, "conflict", "Job slug is already in use"); throw error; } } async updateJob(jobId: string, input: JobInput & { expectedUpdatedAt?: string }) { await this.authorize("jobs:write"); const repository = this.jobs(), current = await repository.getJob(jobId); if (!current) throw new ServiceError(404, "not_found", "Job not found"); + const vmImage = input.vmImage ? canonicalizeVmImage(input.vmImage) : input.vmImage; await this.validateExecutionOptions(input.executionConfigId, input.model ?? "", input.effort ?? "", input.tags ?? []); const triggers = this.normalizedTriggers(input.triggers, current.triggers).map(trigger => ({ ...trigger, jobId })); const selectedSkills = input.skillIds === undefined ? current.selectedSkills : await this.skillSelections(input.skillIds); - const updated = await repository.update({ ...current, projectId: input.projectId ?? current.projectId, name: input.name, slug: input.slug, promptTemplate: input.promptTemplate, runNameTemplate: input.runNameTemplate ?? "", tags: input.tags ?? [], model: input.model ?? "", effort: input.effort, executionConfig: { ...await this.executionConfig(input.executionConfigId), ...(input.vmImage ? { image: input.vmImage } : {}) }, concurrencyLimit: input.concurrencyLimit, triggers, selectedSkills, updatedAt: new Date().toISOString() }, input.expectedUpdatedAt).catch(error => { if (error instanceof StaleJobEdit) throw new ServiceError(409, "stale_job", "This job changed. Reload its configuration before saving."); throw error; }); + const updated = await repository.update({ ...current, projectId: input.projectId ?? current.projectId, name: input.name, slug: input.slug, promptTemplate: input.promptTemplate, runNameTemplate: input.runNameTemplate ?? "", tags: input.tags ?? [], model: input.model ?? "", effort: input.effort, executionConfig: { ...await this.executionConfig(input.executionConfigId), ...(vmImage ? { image: vmImage } : {}) }, concurrencyLimit: input.concurrencyLimit, triggers, selectedSkills, updatedAt: new Date().toISOString() }, input.expectedUpdatedAt).catch(error => { if (error instanceof StaleJobEdit) throw new ServiceError(409, "stale_job", "This job changed. Reload its configuration before saving."); throw error; }); await this.wakeScheduler(); return this.presentJob(updated); } diff --git a/packages/api/src/protected-api.ts b/packages/api/src/protected-api.ts index f4abd0b..0d8b2ca 100644 --- a/packages/api/src/protected-api.ts +++ b/packages/api/src/protected-api.ts @@ -17,7 +17,12 @@ function errorResponse(error: unknown): Response { if (error instanceof ExeIntegrationError) return Response.json({ error: { code: "invalid_request", message: error.message } }, { status: 400 }); if (error instanceof URIError) return Response.json({ error: { code: "invalid_request", message: "Invalid path encoding" } }, { status: 400 }); if (error instanceof InvocationError && error.code === "queue_full") return Response.json({ error: { code: error.code, message: error.message } }, { status: 409 }); - if (error instanceof z.ZodError) return Response.json({ error: { code: "invalid_request", message: "Request validation failed", details: error.issues } }, { status: 400 }); + if (error instanceof z.ZodError) { + const imageIssue = error.issues.find(issue => issue.path.at(-1) === "vmImage" || issue.path.at(-1) === "image"); + const field = imageIssue?.path.join("."); + const message = imageIssue && field ? `Invalid ${field}: ${imageIssue.message}` : "Request validation failed"; + return Response.json({ error: { code: "invalid_request", message, details: error.issues } }, { status: 400 }); + } if (error instanceof ServiceError) { const headers = error.code === "insufficient_scope" ? { "WWW-Authenticate": `Bearer error="insufficient_scope"` } : undefined; return Response.json({ error: { code: error.code, message: error.message } }, { status: error.status, headers }); diff --git a/packages/api/test/api-contract.test.ts b/packages/api/test/api-contract.test.ts index c64ba24..02c2372 100644 --- a/packages/api/test/api-contract.test.ts +++ b/packages/api/test/api-contract.test.ts @@ -36,8 +36,15 @@ describe("versioned API contract", () => { it("accepts public image references and publishes tenant-scoped verification operations", () => { const job = { name: "Image job", slug: "image-job", promptTemplate: "Run", executionConfigId: "exe", vmImage: "ghcr.io/org/image:tag", triggers: [] }; expect(jobInputSchema.safeParse(job).success).toBe(true); - expect(jobInputSchema.safeParse({ ...job, vmImage: "https://private.example/image" }).success).toBe(false); - expect(vmImageVerificationInput.safeParse({ connectionId: "exe", image: "ghcr.io/org/image:tag", tags: [], agentKind: "codex" }).success).toBe(true); + expect(jobInputSchema.parse({ ...job, vmImage: "ghcr.io/GenerationSoftware/anarack:ReleaseA" }).vmImage).toBe("ghcr.io/generationsoftware/anarack:ReleaseA"); + expect(jobInputSchema.parse({ ...job, vmImage: "" }).vmImage).toBe(""); + const digest = `ghcr.io/GenerationSoftware/anarack:ReleaseA@sha256:${"a".repeat(64)}`; + expect(jobInputSchema.parse({ ...job, vmImage: digest }).vmImage).toBe(`ghcr.io/generationsoftware/anarack:ReleaseA@sha256:${"a".repeat(64)}`); + const invalid = jobInputSchema.safeParse({ ...job, vmImage: "https://private.example/image" }); + expect(invalid.success).toBe(false); + if (!invalid.success) expect(invalid.error.issues[0].message).toContain("without a URL scheme"); + expect(vmImageVerificationInput.parse({ connectionId: "exe", image: "ghcr.io/GenerationSoftware/anarack:ReleaseA", tags: [], agentKind: "codex" }).image).toBe("ghcr.io/generationsoftware/anarack:ReleaseA"); + expect(vmImageVerificationInput.safeParse({ connectionId: "exe", image: "bad image", tags: [], agentKind: "codex" }).success).toBe(false); const post = API_ROUTES.find(route => route.path === "/api/v1/vm-image-verifications" && route.method === "POST"); const get = API_ROUTES.find(route => route.path === "/api/v1/vm-image-verifications/{verificationId}" && route.method === "GET"); expect(post?.scope).toBe("jobs:write"); expect(get?.scope).toBe("jobs:read"); diff --git a/packages/api/test/loading.postgres.test.ts b/packages/api/test/loading.postgres.test.ts index 2e804eb..99ba77d 100644 --- a/packages/api/test/loading.postgres.test.ts +++ b/packages/api/test/loading.postgres.test.ts @@ -80,9 +80,10 @@ describe.skipIf(!url)("request loading with PostgreSQL", () => { it("creates a job with a deterministic migrated execution integration UUID", async () => { const target = "local"; try { - const job = await new ApiService(env, auth).createJob({ name: "Migrated target job", slug: "migrated-target-job", promptTemplate: "Do work", executionConfigId: target, concurrencyLimit: 1, triggers: [{ kind: "manual", enabled: true, config: {} }] }); + const job = await new ApiService(env, auth).createJob({ name: "Migrated target job", slug: "migrated-target-job", promptTemplate: "Do work", executionConfigId: target, vmImage: "ghcr.io/GenerationSoftware/anarack:ReleaseA", concurrencyLimit: 1, triggers: [{ kind: "manual", enabled: true, config: {} }] }); expect(job.executionConfigId).toBe(target); expect(job.executionConfig).toMatchObject({ connectionId: "local", agentKind: "codex" }); + expect(job.executionConfig.image).toBe("ghcr.io/generationsoftware/anarack:ReleaseA"); } finally { await db.pool.query("DELETE FROM app.jobs WHERE execution_config->>'targetId'=$1", [target]); } diff --git a/packages/app/src/features/jobs/editor-state.ts b/packages/app/src/features/jobs/editor-state.ts index 18e2dbd..d740be3 100644 --- a/packages/app/src/features/jobs/editor-state.ts +++ b/packages/app/src/features/jobs/editor-state.ts @@ -3,6 +3,11 @@ export type JobInput = components["schemas"]["JobInput"]; export type Job = operations["get_api_v1_jobs_jobId"]["responses"][200]["content"]["application/json"]; export type Trigger = JobInput["triggers"][number]; export const emptyJob = (): JobInput => ({ name: "", slug: "", promptTemplate: "", runNameTemplate: "", executionConfigId: "", concurrencyLimit: 1, tags: [], vmImage: "", model: "", effort: "", skillIds: [], triggers: [{ kind: "manual", slug: "trigger-1", enabled: true, config: {} }] }); +export function canonicalizeVmImage(value: string): string { + const trimmed = value.trim(), digestAt = trimmed.indexOf("@"), reference = digestAt < 0 ? trimmed : trimmed.slice(0, digestAt), digest = digestAt < 0 ? "" : trimmed.slice(digestAt); + const slash = reference.lastIndexOf("/"), colon = reference.lastIndexOf(":"), tag = colon > slash ? reference.slice(colon) : "", name = tag ? reference.slice(0, -tag.length) : reference; + return `${name.toLowerCase()}${tag}${digest}`; +} export function editableJob(job: Job): JobInput { return { projectId: job.projectId, name: job.name, slug: job.slug, promptTemplate: job.promptTemplate, runNameTemplate: job.runNameTemplate, executionConfigId: job.executionConfigId, concurrencyLimit: job.concurrencyLimit, tags: job.tags ?? [], vmImage: job.executionConfig?.image ?? "", model: job.model, effort: job.effort ?? "", skillIds: job.skills?.map(skill => skill.id) ?? [], triggers: job.triggers.map(trigger => { const { id, slug, kind, enabled } = trigger; @@ -34,7 +39,7 @@ export function validateJobInput(input: JobInput): string | undefined { if (!Number.isInteger(input.concurrencyLimit) || input.concurrencyLimit < 1 || input.concurrencyLimit > 50) return "Concurrency limit must be between 1 and 50."; if (!input.executionConfigId) return "Choose an execution integration."; if ((input.tags?.length ?? 0) > 20 || input.tags?.some(tag => !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(tag))) return "VM tags are invalid."; - if (input.vmImage && !/^(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?$/.test(input.vmImage.trim())) return "Enter a public Docker/OCI image reference."; + if (input.vmImage && !/^(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?$/.test(canonicalizeVmImage(input.vmImage))) return input.vmImage.trim().startsWith("https://") ? "Enter an image reference without a URL scheme." : "Enter a public Docker/OCI image reference."; if (input.triggers.length > 50) return "A job can have at most 50 triggers."; for (const trigger of input.triggers) { if (!trigger.slug || !/^trigger-[1-9][0-9]*$/.test(trigger.slug)) return "Each trigger needs a valid stable identifier."; diff --git a/packages/app/src/features/jobs/editor.tsx b/packages/app/src/features/jobs/editor.tsx index 088ee6d..0f52027 100644 --- a/packages/app/src/features/jobs/editor.tsx +++ b/packages/app/src/features/jobs/editor.tsx @@ -9,7 +9,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "factorize-api-client"; import { jobQuery } from "./queries"; import { metadataQuery, targetsQuery } from "./editor-queries"; -import { editableJob, emptyJob, reconcile, validateJobInput, writeInput, type Job, type JobInput } from "./editor-state"; +import { canonicalizeVmImage, editableJob, emptyJob, reconcile, validateJobInput, writeInput, type Job, type JobInput } from "./editor-state"; import { TemplateField } from "./template-field"; import { TriggerEditor } from "./trigger-editor"; import { messageOf } from "../auth/session"; @@ -37,7 +37,7 @@ function EditorForm({ initial }: { initial?: Job }) { const project = useQuery({ ...projectQuery(initial?.projectId ?? ""), enabled: !!initial?.projectId }); const projects = useQuery({ ...projectsQuery, enabled: !initial }); const target = targets.data?.find(t => t.id === draft.executionConfigId); - const verificationFingerprint = JSON.stringify([draft.executionConfigId, draft.vmImage?.trim() ?? "", draft.tags ?? [], target?.agentKind ?? ""]); + const verificationFingerprint = JSON.stringify([draft.executionConfigId, canonicalizeVmImage(draft.vmImage ?? ""), draft.tags ?? [], target?.agentKind ?? ""]); const verificationKey = `factorize-vm-image-verification:${initial?.id ?? "new"}`; const [verificationRef, setVerificationRef] = useState<{ fingerprint: string; id: string } | null>(() => { try { return JSON.parse(localStorage.getItem(verificationKey) ?? "null"); } catch { return null; } }); const currentVerificationFingerprint = useRef(verificationFingerprint); currentVerificationFingerprint.current = verificationFingerprint; @@ -50,7 +50,7 @@ function EditorForm({ initial }: { initial?: Job }) { const [projectDialog, setProjectDialog] = useState(false); const onlyProject = projects.data?.length === 1 ? projects.data[0] : undefined; useEffect(() => { if (!initial && !draft.projectId && !projectId && onlyProject) setDraft(previous => ({ ...previous, projectId: onlyProject.id })); }, [initial, draft.projectId, projectId, onlyProject]); - const update = (key: K, value: JobInput[K]) => { setSaved(false); if (["vmImage", "executionConfigId", "tags"].includes(key)) { verificationGeneration.current++; setVerificationRef(null); localStorage.removeItem(verificationKey); } setDraft(previous => ({ ...previous, [key]: value })); }; + const update = (key: K, value: JobInput[K]) => { setSaved(false); const sameImage = key === "vmImage" && canonicalizeVmImage(draft.vmImage ?? "") === canonicalizeVmImage(typeof value === "string" ? value : ""); if (["executionConfigId", "tags"].includes(key) || (key === "vmImage" && !sameImage)) { verificationGeneration.current++; setVerificationRef(null); localStorage.removeItem(verificationKey); } setDraft(previous => ({ ...previous, [key]: value })); }; const refreshConflict = useMutation({ retry: false, mutationFn: async () => { if (!initial) throw new Error("No job selected"); const { data, error } = await api.GET("/api/v1/jobs/{jobId}", { params: { path: { jobId: initial.id } } }); diff --git a/packages/app/test/editor.browser.test.mjs b/packages/app/test/editor.browser.test.mjs index 6e9f2e1..9fe3a39 100644 --- a/packages/app/test/editor.browser.test.mjs +++ b/packages/app/test/editor.browser.test.mjs @@ -70,6 +70,9 @@ test("image verification is explicit, autosave never provisions, and stale verif await page.getByRole("status").filter({ hasText: "Not verified" }).waitFor(); assert.equal(createCount, 1); await page.getByRole("button", { name: "Verify image" }).click(); await page.getByRole("status").filter({ hasText: "Verification passed" }).waitFor(); assert.equal(createCount, 2); + await page.getByLabel("VM image").fill("GHCR.IO/ACME/base:two"); + await page.getByRole("status").filter({ hasText: "Job settings saved." }).waitFor(); + await page.getByRole("status").filter({ hasText: "Verified: Verification passed" }).waitFor(); await page.getByLabel("VM tags").selectOption(["github"]); await page.getByRole("status").filter({ hasText: "Not verified" }).waitFor(); await context.close(); }); diff --git a/packages/docs/openapi.yaml b/packages/docs/openapi.yaml index 8aecd78..61834c3 100644 --- a/packages/docs/openapi.yaml +++ b/packages/docs/openapi.yaml @@ -842,7 +842,7 @@ components: vmImage: type: string maxLength: 512 - pattern: ^(?:(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?)?$ + pattern: ^(?:(?:(?:[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*\/)?[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*(?:\/[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?))?$ model: type: string maxLength: 120 @@ -6518,7 +6518,7 @@ paths: type: string minLength: 1 maxLength: 512 - pattern: ^(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?$ + pattern: ^(?:(?:[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*\/)?[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*(?:\/[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?)$ tags: maxItems: 20 type: array @@ -9135,7 +9135,7 @@ paths: vmImage: type: string maxLength: 512 - pattern: ^(?:(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?)?$ + pattern: ^(?:(?:(?:[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*\/)?[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*(?:\/[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?))?$ model: type: string maxLength: 120