502 lines
17 KiB
Ruby
502 lines
17 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "digest"
|
|
require "json"
|
|
require "pathname"
|
|
require "tempfile"
|
|
require "time"
|
|
|
|
module OpenCodeCompat
|
|
class PromotionError < StandardError; end
|
|
|
|
class RuntimeTuplePromoter
|
|
FULL_COMMIT = /\A[0-9a-f]{40}\z/
|
|
DIGEST = /\Asha256:[0-9a-f]{64}\z/
|
|
IMMUTABLE_IMAGE = /\A[^@\s]+@sha256:[0-9a-f]{64}\z/
|
|
UTC_TIMESTAMP = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\z/
|
|
CERTIFICATION_STATUS = "pass"
|
|
NON_CERTIFIABLE_TUPLE_STATUSES = %w[observed-production-contract-failed].freeze
|
|
TUPLE_METADATA_KEYS = %w[
|
|
certification
|
|
certified_at
|
|
compatibility_certified_at
|
|
promoted_at
|
|
status
|
|
].freeze
|
|
EXACT_IMAGE_REFERENCE_KEYS = %w[
|
|
base_image
|
|
image
|
|
registry_ref
|
|
source_image
|
|
upstream_base
|
|
].freeze
|
|
|
|
def initialize(root:, manifest_path: File.join(root, "manifests/runtime-tuples.json"))
|
|
@root = File.realpath(root)
|
|
@manifest_path = File.expand_path(manifest_path)
|
|
end
|
|
|
|
def fingerprints(consumer:, consumer_commit:)
|
|
validate_full_commit!(consumer_commit, "consumer commit")
|
|
manifest = read_manifest
|
|
consumer_entry = fetch_consumer!(manifest, consumer)
|
|
profile = fetch_profile!(consumer_entry, consumer)
|
|
candidate = prepare_candidate!(consumer_entry, consumer_commit)
|
|
current = consumer_entry.fetch("current") do
|
|
raise PromotionError, "#{consumer} has no current tuple to preserve as previous"
|
|
end
|
|
validate_tuple!(current, "#{consumer} current")
|
|
|
|
{
|
|
"consumer" => consumer,
|
|
"profile" => profile,
|
|
"candidate_tuple_sha256" => tuple_fingerprint(candidate, consumer: consumer, profile: profile),
|
|
"current_tuple_sha256" => tuple_fingerprint(current, consumer: consumer, profile: profile)
|
|
}
|
|
end
|
|
|
|
def promote(consumer:, consumer_commit:, certification:, previous_certification: nil, dry_run: false)
|
|
validate_full_commit!(consumer_commit, "consumer commit")
|
|
|
|
if dry_run
|
|
manifest = read_manifest
|
|
return promote_manifest(
|
|
manifest,
|
|
consumer: consumer,
|
|
consumer_commit: consumer_commit,
|
|
certification: certification,
|
|
previous_certification: previous_certification
|
|
)
|
|
end
|
|
|
|
with_current_manifest_lock do |manifest|
|
|
promoted = promote_manifest(
|
|
manifest,
|
|
consumer: consumer,
|
|
consumer_commit: consumer_commit,
|
|
certification: certification,
|
|
previous_certification: previous_certification
|
|
)
|
|
atomic_write(promoted)
|
|
promoted
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def read_manifest
|
|
parse_json(File.read(@manifest_path), @manifest_path)
|
|
rescue Errno::ENOENT
|
|
raise PromotionError, "runtime tuple manifest does not exist: #{@manifest_path}"
|
|
end
|
|
|
|
def promote_manifest(manifest, consumer:, consumer_commit:, certification:, previous_certification:)
|
|
consumer_entry = fetch_consumer!(manifest, consumer)
|
|
profile = fetch_profile!(consumer_entry, consumer)
|
|
candidate = prepare_candidate!(consumer_entry, consumer_commit)
|
|
current = consumer_entry.fetch("current") do
|
|
raise PromotionError, "#{consumer} has no current tuple to preserve as previous"
|
|
end
|
|
validate_tuple!(current, "#{consumer} current")
|
|
|
|
candidate_fingerprint = tuple_fingerprint(candidate, consumer: consumer, profile: profile)
|
|
current_fingerprint = tuple_fingerprint(current, consumer: consumer, profile: profile)
|
|
if candidate_fingerprint == current_fingerprint
|
|
raise PromotionError, "#{consumer} candidate is identical to its current tuple"
|
|
end
|
|
|
|
certified_candidate = certify_tuple!(
|
|
candidate,
|
|
consumer: consumer,
|
|
profile: profile,
|
|
supplied: certification,
|
|
expected_fingerprint: candidate_fingerprint,
|
|
label: "candidate"
|
|
)
|
|
certified_previous = certify_previous!(
|
|
current,
|
|
consumer: consumer,
|
|
profile: profile,
|
|
supplied: previous_certification,
|
|
expected_fingerprint: current_fingerprint
|
|
)
|
|
|
|
consumer_entry["previous"] = certified_previous
|
|
consumer_entry["current"] = certified_candidate
|
|
consumer_entry["candidate"] = nil
|
|
manifest["migration_state"] = all_consumers_certified?(manifest) ? "certified" : "candidate"
|
|
manifest
|
|
end
|
|
|
|
def fetch_consumer!(manifest, consumer)
|
|
unless manifest.is_a?(Hash) && manifest["schema_version"] == 1 && manifest["consumers"].is_a?(Hash)
|
|
raise PromotionError, "runtime tuple manifest must use schema_version 1 and contain consumers"
|
|
end
|
|
|
|
manifest.fetch("consumers").fetch(consumer) do
|
|
raise PromotionError, "unknown consumer #{consumer.inspect}"
|
|
end
|
|
end
|
|
|
|
def prepare_candidate!(consumer_entry, consumer_commit)
|
|
candidate = consumer_entry["candidate"]
|
|
unless candidate.is_a?(Hash)
|
|
raise PromotionError, "consumer has no candidate tuple to promote"
|
|
end
|
|
unless candidate["status"] == "compatibility-certified"
|
|
raise PromotionError, "candidate status must be compatibility-certified"
|
|
end
|
|
validate_timestamp!(candidate["certified_at"], "candidate compatibility certification timestamp")
|
|
|
|
if candidate.key?("consumer_commit") && candidate["consumer_commit"] != consumer_commit
|
|
raise PromotionError, "explicit consumer commit does not match the candidate"
|
|
end
|
|
|
|
prepared = deep_copy(candidate)
|
|
prepared["consumer_commit"] = consumer_commit
|
|
validate_tuple!(prepared, "candidate")
|
|
prepared
|
|
end
|
|
|
|
def fetch_profile!(consumer_entry, consumer)
|
|
profile = consumer_entry["profile"]
|
|
unless profile.is_a?(String) && profile.match?(/\A[a-z0-9][a-z0-9-]*\z/)
|
|
raise PromotionError, "#{consumer} must declare a valid compatibility profile"
|
|
end
|
|
|
|
profile_path = File.join(@root, "profiles", "#{profile}.json")
|
|
unless File.file?(profile_path)
|
|
raise PromotionError, "#{consumer} compatibility profile does not exist: profiles/#{profile}.json"
|
|
end
|
|
|
|
profile
|
|
end
|
|
|
|
def certify_previous!(tuple, consumer:, profile:, supplied:, expected_fingerprint:)
|
|
if NON_CERTIFIABLE_TUPLE_STATUSES.include?(tuple["status"])
|
|
raise PromotionError,
|
|
"current tuple is known to fail the compatibility contract and cannot become a certified rollback"
|
|
end
|
|
|
|
if tuple["status"] == "certified"
|
|
validate_recorded_certification!(
|
|
tuple,
|
|
consumer: consumer,
|
|
profile: profile,
|
|
expected_fingerprint: expected_fingerprint,
|
|
label: "current rollback"
|
|
)
|
|
return deep_copy(tuple)
|
|
end
|
|
|
|
unless supplied
|
|
raise PromotionError,
|
|
"current tuple is not certified; explicit previous certification evidence, timestamp, and status are required"
|
|
end
|
|
|
|
certify_tuple!(
|
|
tuple,
|
|
consumer: consumer,
|
|
profile: profile,
|
|
supplied: supplied,
|
|
expected_fingerprint: expected_fingerprint,
|
|
label: "previous"
|
|
)
|
|
end
|
|
|
|
def certify_tuple!(tuple, consumer:, profile:, supplied:, expected_fingerprint:, label:)
|
|
certification = validate_supplied_certification!(
|
|
supplied,
|
|
consumer: consumer,
|
|
profile: profile,
|
|
tuple: tuple,
|
|
expected_fingerprint: expected_fingerprint,
|
|
label: label
|
|
)
|
|
|
|
certified = deep_copy(tuple)
|
|
if certified.key?("certified_at")
|
|
certified["compatibility_certified_at"] = certified.delete("certified_at")
|
|
end
|
|
certified["status"] = "certified"
|
|
certified["certification"] = certification
|
|
certified
|
|
end
|
|
|
|
def validate_supplied_certification!(supplied, consumer:, profile:, tuple:, expected_fingerprint:, label:)
|
|
unless supplied.is_a?(Hash)
|
|
raise PromotionError, "#{label} certification evidence, timestamp, and status are required"
|
|
end
|
|
|
|
status = supplied["status"]
|
|
certified_at = supplied["certified_at"]
|
|
evidence = Array(supplied["evidence"])
|
|
unless status == CERTIFICATION_STATUS
|
|
raise PromotionError, "#{label} certification status must be #{CERTIFICATION_STATUS.inspect}"
|
|
end
|
|
validate_timestamp!(certified_at, "#{label} certification timestamp")
|
|
raise PromotionError, "#{label} certification requires at least one evidence file" if evidence.empty?
|
|
raise PromotionError, "#{label} certification evidence files must be unique" unless evidence.uniq == evidence
|
|
|
|
consumer_commit = tuple.fetch("consumer_commit")
|
|
normalized_evidence = evidence.map do |reference|
|
|
validate_evidence!(
|
|
reference,
|
|
consumer: consumer,
|
|
profile: profile,
|
|
consumer_commit: consumer_commit,
|
|
status: status,
|
|
certified_at: certified_at,
|
|
tuple_fingerprint: expected_fingerprint,
|
|
label: label
|
|
)
|
|
end
|
|
|
|
{
|
|
"status" => status,
|
|
"certified_at" => certified_at,
|
|
"tuple_sha256" => expected_fingerprint,
|
|
"evidence" => normalized_evidence
|
|
}
|
|
end
|
|
|
|
def validate_recorded_certification!(tuple, consumer:, profile:, expected_fingerprint:, label:)
|
|
certification = tuple["certification"]
|
|
unless certification.is_a?(Hash) && certification["tuple_sha256"] == expected_fingerprint
|
|
raise PromotionError, "#{label} tuple has invalid or stale certification metadata"
|
|
end
|
|
|
|
validate_supplied_certification!(
|
|
certification,
|
|
consumer: consumer,
|
|
profile: profile,
|
|
tuple: tuple,
|
|
expected_fingerprint: expected_fingerprint,
|
|
label: label
|
|
)
|
|
end
|
|
|
|
def validate_evidence!(
|
|
reference,
|
|
consumer:,
|
|
profile:,
|
|
consumer_commit:,
|
|
status:,
|
|
certified_at:,
|
|
tuple_fingerprint:,
|
|
label:
|
|
)
|
|
unless reference.is_a?(String) && !reference.empty?
|
|
raise PromotionError, "#{label} evidence references must be non-empty strings"
|
|
end
|
|
|
|
relative = Pathname.new(reference).cleanpath
|
|
if relative.absolute? || relative.each_filename.first != "evidence"
|
|
raise PromotionError, "#{label} evidence must be a repository-relative path under evidence/"
|
|
end
|
|
|
|
full_path = File.join(@root, relative.to_s)
|
|
evidence_root = File.realpath(File.join(@root, "evidence"))
|
|
real_path = File.realpath(full_path)
|
|
unless real_path.start_with?("#{evidence_root}#{File::SEPARATOR}")
|
|
raise PromotionError, "#{label} evidence resolves outside evidence/"
|
|
end
|
|
|
|
document = parse_json(File.read(real_path), relative.to_s)
|
|
unless document.is_a?(Hash) && document["schema_version"] == 1
|
|
raise PromotionError, "#{label} evidence #{relative} must use schema_version 1"
|
|
end
|
|
expected = {
|
|
"consumer" => consumer,
|
|
"profile" => profile,
|
|
"consumer_commit" => consumer_commit,
|
|
"status" => status,
|
|
"certified_at" => certified_at,
|
|
"tuple_sha256" => tuple_fingerprint
|
|
}
|
|
expected.each do |key, value|
|
|
next if document[key] == value
|
|
|
|
raise PromotionError,
|
|
"#{label} evidence #{relative} has #{key}=#{document[key].inspect}; expected #{value.inspect}"
|
|
end
|
|
|
|
relative.to_s
|
|
rescue Errno::ENOENT
|
|
raise PromotionError, "#{label} evidence does not exist: #{reference}"
|
|
end
|
|
|
|
def validate_tuple!(tuple, label)
|
|
raise PromotionError, "#{label} tuple must be an object" unless tuple.is_a?(Hash)
|
|
|
|
validate_full_commit!(tuple["consumer_commit"], "#{label} consumer commit")
|
|
validate_client!(tuple["opencode_ruby"], "#{label} opencode_ruby", required: true)
|
|
validate_client!(tuple["opencode_rails"], "#{label} opencode_rails", required: false)
|
|
validate_runtime!(tuple["runtime"], "#{label} runtime")
|
|
end
|
|
|
|
def validate_client!(client, label, required:)
|
|
if client.nil?
|
|
raise PromotionError, "#{label} is required" if required
|
|
|
|
return
|
|
end
|
|
unless client.is_a?(Hash) && client["version"].is_a?(String) && !client["version"].empty?
|
|
raise PromotionError, "#{label} must include a non-empty version"
|
|
end
|
|
|
|
validate_full_commit!(client["git_commit"], "#{label} git commit")
|
|
end
|
|
|
|
def validate_runtime!(runtime, label)
|
|
raise PromotionError, "#{label} must be an object" unless runtime.is_a?(Hash)
|
|
unless runtime["reported_version"].is_a?(String) && !runtime["reported_version"].empty?
|
|
raise PromotionError, "#{label} must include the reported OpenCode server version"
|
|
end
|
|
|
|
exact_execution_coordinate = false
|
|
runtime.each do |key, value|
|
|
if key == "docker_image_id" || key.end_with?("_image_id")
|
|
unless value.is_a?(String) && value.match?(DIGEST)
|
|
raise PromotionError, "#{label} #{key} must be an exact sha256 image ID"
|
|
end
|
|
exact_execution_coordinate = true if key == "docker_image_id"
|
|
elsif image_reference_key?(key)
|
|
unless value.is_a?(String) && value.match?(IMMUTABLE_IMAGE)
|
|
raise PromotionError, "#{label} #{key} must be an immutable image@sha256 digest, not a tag"
|
|
end
|
|
exact_execution_coordinate = true if %w[image registry_ref].include?(key)
|
|
elsif key == "source_commit"
|
|
validate_full_commit!(value, "#{label} source commit")
|
|
end
|
|
end
|
|
|
|
unless exact_execution_coordinate
|
|
raise PromotionError, "#{label} must include an immutable image, registry_ref, or docker_image_id"
|
|
end
|
|
end
|
|
|
|
def image_reference_key?(key)
|
|
EXACT_IMAGE_REFERENCE_KEYS.include?(key) ||
|
|
(key.end_with?("_image") && key != "tag_provenance") ||
|
|
(key.end_with?("_ref") && key != "tag_provenance")
|
|
end
|
|
|
|
def validate_full_commit!(value, label)
|
|
return if value.is_a?(String) && value.match?(FULL_COMMIT)
|
|
|
|
raise PromotionError, "#{label} must be a full 40-character lowercase Git commit"
|
|
end
|
|
|
|
def validate_timestamp!(value, label)
|
|
unless value.is_a?(String) && value.match?(UTC_TIMESTAMP)
|
|
raise PromotionError, "#{label} must be an explicit RFC 3339 UTC timestamp"
|
|
end
|
|
|
|
Time.iso8601(value)
|
|
rescue ArgumentError
|
|
raise PromotionError, "#{label} is not a valid timestamp"
|
|
end
|
|
|
|
def tuple_fingerprint(tuple, consumer:, profile:)
|
|
payload = deep_copy(tuple)
|
|
TUPLE_METADATA_KEYS.each { |key| payload.delete(key) }
|
|
envelope = {
|
|
"consumer" => consumer,
|
|
"profile" => profile,
|
|
"tuple" => payload
|
|
}
|
|
"sha256:#{Digest::SHA256.hexdigest(JSON.generate(canonicalize(envelope)))}"
|
|
end
|
|
|
|
def canonicalize(value)
|
|
case value
|
|
when Hash
|
|
value.keys.sort.to_h { |key| [key, canonicalize(value.fetch(key))] }
|
|
when Array
|
|
value.map { |item| canonicalize(item) }
|
|
else
|
|
value
|
|
end
|
|
end
|
|
|
|
def deep_copy(value)
|
|
JSON.parse(JSON.generate(value))
|
|
end
|
|
|
|
def parse_json(contents, label)
|
|
JSON.parse(contents)
|
|
rescue JSON::ParserError => e
|
|
raise PromotionError, "invalid JSON in #{label}: #{e.message}"
|
|
end
|
|
|
|
def all_consumers_certified?(manifest)
|
|
manifest.fetch("consumers").all? do |consumer, entry|
|
|
next false unless entry["candidate"].nil?
|
|
|
|
profile = fetch_profile!(entry, consumer)
|
|
|
|
%w[current previous].all? do |slot|
|
|
tuple = entry[slot]
|
|
next false unless tuple.is_a?(Hash) && tuple["status"] == "certified"
|
|
|
|
validate_tuple!(tuple, "#{consumer} #{slot}")
|
|
validate_recorded_certification!(
|
|
tuple,
|
|
consumer: consumer,
|
|
profile: profile,
|
|
expected_fingerprint: tuple_fingerprint(tuple, consumer: consumer, profile: profile),
|
|
label: "#{consumer} #{slot}"
|
|
)
|
|
true
|
|
rescue PromotionError
|
|
false
|
|
end
|
|
end
|
|
end
|
|
|
|
def with_current_manifest_lock
|
|
loop do
|
|
retry_with_new_inode = false
|
|
result = nil
|
|
|
|
File.open(@manifest_path, File::RDONLY) do |locked_file|
|
|
locked_file.flock(File::LOCK_EX)
|
|
unless File.identical?(locked_file, @manifest_path)
|
|
retry_with_new_inode = true
|
|
next
|
|
end
|
|
|
|
result = yield parse_json(locked_file.read, @manifest_path)
|
|
end
|
|
|
|
return result unless retry_with_new_inode
|
|
end
|
|
rescue Errno::ENOENT
|
|
raise PromotionError, "runtime tuple manifest does not exist: #{@manifest_path}"
|
|
end
|
|
|
|
def atomic_write(manifest)
|
|
directory = File.dirname(@manifest_path)
|
|
mode = File.stat(@manifest_path).mode & 0o777
|
|
payload = JSON.pretty_generate(manifest) + "\n"
|
|
|
|
Tempfile.create([".runtime-tuples-", ".tmp"], directory) do |temporary|
|
|
temporary.chmod(mode)
|
|
temporary.write(payload)
|
|
temporary.flush
|
|
temporary.fsync
|
|
temporary.close
|
|
File.rename(temporary.path, @manifest_path)
|
|
fsync_directory(directory)
|
|
end
|
|
end
|
|
|
|
def fsync_directory(directory)
|
|
File.open(directory, File::RDONLY, &:fsync)
|
|
rescue Errno::EINVAL, Errno::ENOTSUP
|
|
# Some filesystems do not support directory fsync; rename is still atomic.
|
|
end
|
|
end
|
|
end
|