From b8612a8918b6c7df661a437fb71113f57eb7e137 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Mon, 5 Oct 2026 02:03:07 +0000 Subject: [PATCH 01/15] Oracle OCI Always Free: cited allowance (extdeps), allowance fit + fabric offer binding (product.supplier) Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/extdeps/cloud/oracle_oci.dag | 205 +++++++++++ dag/gunbc/product/supplier/oracle_oci.dag | 329 ++++++++++++++++++ .../oracle_oci_always_free_witness_test.dag | 188 ++++++++++ 3 files changed, 722 insertions(+) create mode 100644 dag/extdeps/cloud/oracle_oci.dag create mode 100644 dag/gunbc/product/supplier/oracle_oci.dag create mode 100644 dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag diff --git a/dag/extdeps/cloud/oracle_oci.dag b/dag/extdeps/cloud/oracle_oci.dag new file mode 100644 index 00000000000..d909fb3140f --- /dev/null +++ b/dag/extdeps/cloud/oracle_oci.dag @@ -0,0 +1,205 @@ +module extdeps.cloud.oracle_oci + +import extdeps.external_authority { ExternalAuthority } +import extdeps.uri { Uri, Https } +import extdeps.toolchain.types { Architecture, Aarch64, X86_64 } +import std.decl_ref { DeclarationRef, decl_ref } +import std.measure { Gigabyte, gigabyte } +import std.types { Bool, NonEmptyStr } +import std.nat { Nat } + +// ORACLE CLOUD INFRASTRUCTURE COMPUTE, AS ITS ALWAYS FREE ALLOWANCE AND ITS TWO FREE SHAPES. +// +// Every figure below was read on 2026-10-05 from the three Oracle pages cited by the authority rows +// in this module, and each row names the page it came from. This module holds the vendor's facts +// only. Whether a planned set of instances fits the allowance, and how an instance becomes a fabric +// offer, are OUR questions, and they live in product.supplier.oracle_oci. +// +// THE ALLOWANCE WAS HALVED IN 2026, AND THE OLD FIGURE IS STILL WIDELY REPEATED. The Always Free page +// read on 2026-10-05 gives 1,500 OCPU hours and 9,000 GB hours a month, "equivalent to 2 OCPUs and +// 12 GB of memory". The 4 OCPU / 24 GB figure that many guides still give is the previous allowance. +// Only the current page is cited here. The secondary reports of the cut (heise, linuxiac) are not +// cited, because the vendor page states the current number directly. + +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "docs.oracle.com/en-us/iaas/Content/FreeTier/freetier_topic-Always_Free_Resources.htm" + } +} + +// A named handle on the Always Free page, for consumers that cite where an allowance figure came from +// (the same reason extdeps.cloud.ubicloud gives for ubicloud_runner_types_authority). +data oci_always_free_resources_authority: ExternalAuthority = extdeps_external_authority_anchor + +data oci_compute_shapes_authority: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "docs.oracle.com/en-us/iaas/Content/Compute/References/computeshapes.htm" + } +} + +data oci_preemptible_authority: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "docs.oracle.com/en-us/iaas/Content/Compute/Concepts/preemptible.htm" + } +} + +// ── THE TWO ALWAYS FREE SHAPES ──────────────────────────────────────────────────────────────── +// +// There is no "Ampere Micro". The Arm option is one flexible shape whose size the tenant chooses, and +// the AMD option is one fixed micro shape. These are separate allowances, so a tenancy can use both +// at once. +type OciAlwaysFreeShape + = VmStandardA1Flex + | VmStandardE21Micro + +fn oci_shape_wire_name(shape: OciAlwaysFreeShape) -> NonEmptyStr { + match shape { + VmStandardA1Flex => "VM.Standard.A1.Flex" as NonEmptyStr + VmStandardE21Micro => "VM.Standard.E2.1.Micro" as NonEmptyStr + } +} + +fn oci_shape_architecture(shape: OciAlwaysFreeShape) -> Architecture { + match shape { + VmStandardA1Flex => Aarch64 + VmStandardE21Micro => X86_64 + } +} + +// From the compute shapes page: "1 OCPU on Arm A1 (Compute) = 1 core on Arm A1 (Compute) or 1 vCPU", +// and "1 OCPU on x86 (AMD and Intel) = 2 vCPUs". This is the denominator the allowance is written in. +// Without it, "2 OCPUs" on A1 and on E2 would look like the same amount of compute, and they are not. +fn oci_vcpus_per_ocpu(shape: OciAlwaysFreeShape) -> Nat { + match shape { + VmStandardA1Flex => 1 + VmStandardE21Micro => 2 + } +} + +// The compute shapes page names the A1.Flex processor as Ampere Altra Q80-30. That part is already a +// catalog row, which is cited here rather than restated. +data oci_a1_flex_processor: DeclarationRef = decl_ref(module_path: "extdeps.cpu.ampere", decl_name: "altra_q8030_catalog") + +// ── THE ALLOWANCE ───────────────────────────────────────────────────────────────────────────── +// +// The page writes memory as "GB" and does not say whether that is decimal or binary. The figures are +// kept as the page writes them, as Gigabyte counts, and are not converted to bytes. A byte figure +// would assert a basis the vendor never stated. +// +// The hour figures: "All tenancies get the first 1,500 OCPU hours and 9,000 GB hours per month for +// free for VM instances using the VM.Standard.A1.Flex shape". The always_free figures: "For Always +// Free tenancies, this is equivalent to 2 OCPUs and 12 GB of memory." +type OciA1FlexAllowance { + ocpu_hours_per_month: Nat + gb_hours_per_month: Nat + always_free_ocpus: Nat + always_free_memory: Gigabyte +} + +data oci_a1_flex_allowance: OciA1FlexAllowance = OciA1FlexAllowance { + ocpu_hours_per_month: 1500, + gb_hours_per_month: 9000, + always_free_ocpus: 2, + always_free_memory: gigabyte(count: 12), +} + +// "All tenancies get up to two Always Free VM instances using the VM.Standard.E2.1.Micro shape". +// Each one: "1/8th of an OCPU with the ability to use additional CPU resources", 1 GB of memory, and +// "up to 50 Mbps network bandwidth via the internet". +type OciE2MicroAllowance { + max_instances: Nat + memory_per_instance: Gigabyte + internet_bandwidth_megabits_per_second: Nat +} + +data oci_e2_micro_allowance: OciE2MicroAllowance = OciE2MicroAllowance { + max_instances: 2, + memory_per_instance: gigabyte(count: 1), + internet_bandwidth_megabits_per_second: 50, +} + +// "All tenancies receive a total of 200 GB of Block Volume storage, and five volume backups". Every +// instance's boot volume is a block volume, so this one pool is shared by every free instance. +type OciBlockVolumeAllowance { + total: Gigabyte + volume_backups: Nat +} + +data oci_block_volume_allowance: OciBlockVolumeAllowance = OciBlockVolumeAllowance { + total: gigabyte(count: 200), + volume_backups: 5, +} + +// ── PLACEMENT RULES ─────────────────────────────────────────────────────────────────────────── +// +// "You must create the Always Free compute instances in your home region." The home region is a fact +// about one tenancy, not about Oracle, so it is never written here. The consumer has to supply it. +// +// "Instances using the VM.Standard.E2.1.Micro shape can only be created in one availability domain", +// while A1 instances may use any availability domain, except in South Korea North (Chuncheon). +type OciAvailabilityDomainRule + = AnyAvailabilityDomain + | SingleEligibleAvailabilityDomain + +fn oci_availability_domain_rule(shape: OciAlwaysFreeShape) -> OciAvailabilityDomainRule { + match shape { + VmStandardA1Flex => AnyAvailabilityDomain + VmStandardE21Micro => SingleEligibleAvailabilityDomain + } +} + +// The five capacity types the instance-creation console offers. The Always Free page describes only +// ordinary instances. It says nothing about the other four, so the free-eligibility of those four is +// UNREAD, which is different from refused. The preemptible page states that preemptible capacity +// "costs 50% less than on-demand capacity", that A1.Flex supports it, and that E2.1.Micro does not. +// It also says nothing about Always Free. +type OciCapacityType + = OnDemandCapacity + | PreemptibleCapacity + | CapacityReservation + | DedicatedVirtualMachineHost + | ComputeCluster + +// ── IDLE RECLAMATION ────────────────────────────────────────────────────────────────────────── +// +// "Oracle will deem virtual machine and bare metal compute instances as idle if, during a 7-day +// period, the following are true: CPU utilization for the 95th percentile is less than 20%, Network +// utilization is less than 20%, Memory utilization is less than 20% (applies to A1 shapes only)". +// All the conditions must hold together, so an instance that is busy on any one axis is not idle. +type OciIdleReclamationRule { + window_days: Nat + cpu_p95_percent_below: Nat + network_percent_below: Nat + memory_percent_below: Nat +} + +data oci_idle_reclamation_rule: OciIdleReclamationRule = OciIdleReclamationRule { + window_days: 7, + cpu_p95_percent_below: 20, + network_percent_below: 20, + memory_percent_below: 20, +} + +// The memory condition "applies to A1 shapes only", so on E2.1.Micro the instance can be judged idle +// on CPU and network alone. +fn oci_idle_rule_reads_memory(shape: OciAlwaysFreeShape) -> Bool { + match shape { + VmStandardA1Flex => true + VmStandardE21Micro => false + } +} + +// ── ACCOUNT TIERS ───────────────────────────────────────────────────────────────────────────── +// +// The allowance is stated for "all tenancies", so a Pay As You Go tenancy gets the same free amounts. +// What differs is what happens above them. On an Always Free tenancy, resources beyond the allowance +// are simply not available. The out-of-capacity guidance says that upgrading "gives you access to +// more types of Compute resources". On a paid tenancy, usage above the allowance is billed at that +// resource's rate. No rate is read into this module, so a consumer that needs the overage price must +// refuse rather than assume one. +type OciTenancyTier + = AlwaysFreeTenancy + | PayAsYouGoTenancy diff --git a/dag/gunbc/product/supplier/oracle_oci.dag b/dag/gunbc/product/supplier/oracle_oci.dag new file mode 100644 index 00000000000..3216e03cbb7 --- /dev/null +++ b/dag/gunbc/product/supplier/oracle_oci.dag @@ -0,0 +1,329 @@ +module product.supplier.oracle_oci + +import std.types { Bool, List, NonEmptyStr } +import std.nat { Nat } +import std.decl_ref { DeclarationRef, decl_ref } +import std.measure { + Gigabyte, gigabyte, gigabyte_count, Second, second, + byte_size, hardware_thread_count, money_amount_micro, MoneyPerHour, +} +import extdeps.currency.currency { Usd } +import product.fabric.identity { FabricIdentity } +import product.fabric.work { Shape, HardRequirements, CapabilityManifestRef, TrustDomainRef } +import product.fabric.envelope { cpu_memory_envelope } +import product.fabric.isolation { IsolationProfile } +import product.fabric.supply { SupplierOffer, OfferQuote, QuotedPerHour, UnobservedSupply } +import product.fabric.provider_route { + ProviderRegionObservation, ProviderRegionUnread, ProviderRouteObserved, ProviderRouteRegionUnobserved, + provider_route_from_region, +} +import product.supplier.ubicloud { + SupplierOfferProjection, UnexpressedSupplyFact, IsolationNotObserved, + ReadinessObservation, ReadinessMeasured, ReadinessUnmeasured, + OfferBinding, OfferBound, OfferBindingRefused, +} +import extdeps.cloud.oracle_oci { + OciAlwaysFreeShape, VmStandardA1Flex, VmStandardE21Micro, + OciCapacityType, OnDemandCapacity, PreemptibleCapacity, CapacityReservation, + DedicatedVirtualMachineHost, ComputeCluster, + OciTenancyTier, AlwaysFreeTenancy, PayAsYouGoTenancy, + oci_a1_flex_allowance, oci_e2_micro_allowance, oci_block_volume_allowance, + oci_shape_wire_name, oci_shape_architecture, oci_vcpus_per_ocpu, +} + +// ORACLE'S ALWAYS FREE ALLOWANCE AS A FABRIC SUPPLIER: TWO QUESTIONS, ASKED IN ORDER. +// +// First: does a planned set of instances fit the allowance? oci_always_free_fit answers that, and it +// is what makes a $0 price true. A zero quote is honest only inside the allowance, so no offer from +// this module carries one unless the plan it belongs to has fit. Second: how does one planned +// instance become a SupplierOffer? bind_oci_always_free_offer answers that, using the same refusals +// as product.supplier.ubicloud. A route needs the tenancy's home region to have been read, and a +// ranked offer needs a measured readiness. Those types are imported from that module rather than +// re-minted, because a second OfferBinding beside the first would be the fork DESIGN section 3 +// forbids. +// +// Every vendor figure comes from extdeps.cloud.oracle_oci. Nothing here restates it. + +// ── THE PLAN ────────────────────────────────────────────────────────────────────────────────── + +type OciInstanceSize + = A1FlexSize { ocpus: Nat, memory: Gigabyte } + | E2MicroSize + +fn oci_instance_shape(size: OciInstanceSize) -> OciAlwaysFreeShape { + match size { + A1FlexSize { ocpus, memory } => VmStandardA1Flex + E2MicroSize => VmStandardE21Micro + } +} + +// Home region and the micro availability domain are facts about THIS tenancy, so the planner states +// them on each instance. The vendor module cannot know them. +type OciRegionPlacement + = InHomeRegion + | OutsideHomeRegion + +type OciMicroDomainPlacement + = InMicroEligibleDomain + | OutsideMicroEligibleDomain + +type OciPlannedInstance { + name: NonEmptyStr + size: OciInstanceSize + boot_volume: Gigabyte + capacity: OciCapacityType + region: OciRegionPlacement + micro_domain: OciMicroDomainPlacement +} + +// ── THE FIT ─────────────────────────────────────────────────────────────────────────────────── +// +// Every breach is collected rather than stopping at the first one, so a planner sees the whole +// distance from a free plan in one reading. +// +// CapacityTypeFreeEligibilityUnread: on-demand is the only capacity type the Always Free page +// describes. For the other four, free eligibility is unread rather than refused, and this arm names +// the obligation. It must never be read as "Oracle charges for this". +type OciAllowanceBreach + = A1OcpusOverAllowance { planned: Nat, allowed: Nat } + | A1MemoryOverAllowance { planned: Nat, allowed: Nat } + | E2MicroCountOverAllowance { planned: Nat, allowed: Nat } + | BlockVolumeOverAllowance { planned: Nat, allowed: Nat } + | NotInHomeRegion { instance: NonEmptyStr } + | MicroOutsideEligibleDomain { instance: NonEmptyStr } + | CapacityTypeFreeEligibilityUnread { instance: NonEmptyStr, obligation: DeclarationRef } + +type OciAllowanceFit + = FitsAlwaysFree + | ExceedsAlwaysFree { breaches: List } + +data oci_capacity_type_free_eligibility_obligation: NonEmptyStr = "Whether preemptible capacity, a capacity reservation, a dedicated virtual machine host or a compute cluster can host an Always Free instance is UNREAD. extdeps.cloud.oracle_oci records that the Always Free page (read 2026-10-05) describes only ordinary instances, and that the preemptible page prices preemptible capacity at 50% of on-demand and says nothing about Always Free. RESOLVED BY an Oracle statement covering Always Free eligibility for that capacity type, in either direction." + +fn oci_capacity_breach(i: OciPlannedInstance) -> List { + let unread = [CapacityTypeFreeEligibilityUnread { + instance: i.name, + obligation: decl_ref(module_path: "product.supplier.oracle_oci", decl_name: "oci_capacity_type_free_eligibility_obligation"), + }] + match i.capacity { + OnDemandCapacity => [] + PreemptibleCapacity => unread + CapacityReservation => unread + DedicatedVirtualMachineHost => unread + ComputeCluster => unread + } +} + +fn oci_region_breach(i: OciPlannedInstance) -> List { + match i.region { + InHomeRegion => [] + OutsideHomeRegion => [NotInHomeRegion { instance: i.name }] + } +} + +// The single-domain rule constrains the micro shape only. An A1 instance's micro_domain field is +// not read. +fn oci_micro_domain_breach(i: OciPlannedInstance) -> List { + match i.size { + A1FlexSize { ocpus, memory } => [] + E2MicroSize => + match i.micro_domain { + InMicroEligibleDomain => [] + OutsideMicroEligibleDomain => [MicroOutsideEligibleDomain { instance: i.name }] + } + } +} + +fn oci_a1_ocpus(i: OciPlannedInstance) -> Nat { + match i.size { + A1FlexSize { ocpus, memory } => ocpus + E2MicroSize => 0 + } +} + +fn oci_a1_memory_gb(i: OciPlannedInstance) -> Nat { + match i.size { + A1FlexSize { ocpus, memory } => gigabyte_count(g: memory) + E2MicroSize => 0 + } +} + +fn oci_micro_count(i: OciPlannedInstance) -> Nat { + match i.size { + A1FlexSize { ocpus, memory } => 0 + E2MicroSize => 1 + } +} + +fn oci_over(planned: Nat, allowed: Nat) -> Bool { + planned > allowed +} + +fn oci_aggregate_breaches(plan: List) -> List { + let ocpus = fold(plan, init: 0, f: (acc, i) => acc + oci_a1_ocpus(i: i)) + let memory = fold(plan, init: 0, f: (acc, i) => acc + oci_a1_memory_gb(i: i)) + let micros = fold(plan, init: 0, f: (acc, i) => acc + oci_micro_count(i: i)) + let block = fold(plan, init: 0, f: (acc, i) => acc + gigabyte_count(g: i.boot_volume)) + let ocpu_cap = oci_a1_flex_allowance.always_free_ocpus + let memory_cap = gigabyte_count(g: oci_a1_flex_allowance.always_free_memory) + let micro_cap = oci_e2_micro_allowance.max_instances + let block_cap = gigabyte_count(g: oci_block_volume_allowance.total) + let b1 = if oci_over(planned: ocpus, allowed: ocpu_cap) { [A1OcpusOverAllowance { planned: ocpus, allowed: ocpu_cap }] } else { [] } + let b2 = if oci_over(planned: memory, allowed: memory_cap) { [A1MemoryOverAllowance { planned: memory, allowed: memory_cap }] } else { [] } + let b3 = if oci_over(planned: micros, allowed: micro_cap) { [E2MicroCountOverAllowance { planned: micros, allowed: micro_cap }] } else { [] } + let b4 = if oci_over(planned: block, allowed: block_cap) { [BlockVolumeOverAllowance { planned: block, allowed: block_cap }] } else { [] } + concat(concat(b1, b2), concat(b3, b4)) +} + +fn oci_instance_breaches(i: OciPlannedInstance) -> List { + concat(concat(oci_capacity_breach(i: i), oci_region_breach(i: i)), oci_micro_domain_breach(i: i)) +} + +fn oci_always_free_fit(plan: List) -> OciAllowanceFit { + let per_instance = fold(plan, init: [], f: (acc, i) => concat(acc, oci_instance_breaches(i: i))) + let breaches = concat(oci_aggregate_breaches(plan: plan), per_instance) + if breaches.length() == 0 { FitsAlwaysFree } else { ExceedsAlwaysFree { breaches: breaches } } +} + +// What a breach costs depends on the tenancy tier. On an Always Free tenancy the vendor refuses the +// excess. On Pay As You Go the excess is billed, and its rate is unread, so the consequence names the +// obligation and no number. Neither tier makes a breaching plan free. +type OciBreachConsequence + = ExcessRefusedByVendor + | ExcessBilledAtUnreadRate { obligation: DeclarationRef } + +data oci_overage_rate_obligation: NonEmptyStr = "The Pay As You Go price of OCI compute and block storage above the Always Free allowance is UNREAD; extdeps.cloud.oracle_oci carries no price row. RESOLVED BY citing Oracle's price list for VM.Standard.A1.Flex OCPU and memory hours and for block volume storage into an extdeps.pricing module." + +fn oci_breach_consequence(tier: OciTenancyTier) -> OciBreachConsequence { + match tier { + AlwaysFreeTenancy => ExcessRefusedByVendor + PayAsYouGoTenancy => ExcessBilledAtUnreadRate { + obligation: decl_ref(module_path: "product.supplier.oracle_oci", decl_name: "oci_overage_rate_obligation"), + } + } +} + +// ── THE OFFER ───────────────────────────────────────────────────────────────────────────────── + +fn oci_provider_ref() -> DeclarationRef { + decl_ref(module_path: "extdeps.cloud.oracle_oci", decl_name: "extdeps_external_authority_anchor") +} + +fn oci_service_ref() -> DeclarationRef { + decl_ref(module_path: "extdeps.cloud.oracle_oci", decl_name: "oci_always_free_resources_authority") +} + +// Nobody has read this tenancy's home region into the tree. The console the operator was using +// suggested Ashburn, but a guess is not a reading, so the standing observation is the unread arm and +// every offer refuses on it. +data oci_home_region_unread_obligation: NonEmptyStr = "The home region of the operator's OCI tenancy has not been read into this tree. Always Free compute must be created there (extdeps.cloud.oracle_oci), so it is the region of every offer this supplier can make. RESOLVED BY the tenancy's home region as shown in the OCI console (Tenancy details), cited as a region row." + +fn oci_home_region_unread() -> ProviderRegionObservation { + ProviderRegionUnread { + obligation: decl_ref(module_path: "product.supplier.oracle_oci", decl_name: "oci_home_region_unread_obligation"), + } +} + +data oci_readiness_unread_obligation: NonEmptyStr = "Provisioning delay from an OCI launch request to an Always Free instance accepting work has not been measured. Out-of-capacity refusals are common on A1 in many home regions, so the delay may be unbounded. RESOLVED BY a launch receipt recording request and first-heartbeat timestamps per shape." + +data oci_readiness: ReadinessObservation = ReadinessUnmeasured { + obligation: decl_ref(module_path: "product.supplier.oracle_oci", decl_name: "oci_readiness_unread_obligation"), +} + +// An instance that fits the allowance is not yet supply. Until one is launched and observed, its +// evidence is the unobserved arm, which product.fabric.failure_scenario_admission refuses by name. +data oci_instance_unlaunched_obligation: NonEmptyStr = "No Always Free instance has been launched and observed for this offer. RESOLVED BY a launch receipt naming the instance OCID, shape and home region." + +// The quote is zero, and that is only true inside a plan that fit. The binding takes the fit as an +// argument and refuses when the fit is not FitsAlwaysFree, so a zero cannot outlive the plan that +// earned it. +fn oci_zero_quote() -> OfferQuote { + QuotedPerHour(MoneyPerHour { amount: money_amount_micro(count: 0), currency: Usd }) +} + +// Oracle meters the allowance in OCPU-hours and GB-hours. Above the allowance a free tenancy never +// bills, so the quantum is never consulted on a zero quote. It is stated as an hour because that is +// the unit the allowance is denominated in. +data oci_billing_quantum: Second = second(count: 3600) + +// E2MicroSize is 1/8 OCPU with burst, carried as one hardware thread. The fractional baseline +// cannot be written in a thread count. +fn oci_vcpus(size: OciInstanceSize) -> Nat { + match size { + A1FlexSize { ocpus, memory } => ocpus * oci_vcpus_per_ocpu(shape: VmStandardA1Flex) + E2MicroSize => 1 + } +} + +fn oci_memory(size: OciInstanceSize) -> Gigabyte { + match size { + A1FlexSize { ocpus, memory } => memory + E2MicroSize => oci_e2_micro_allowance.memory_per_instance + } +} + +// Gigabytes are held as the vendor writes them, with no stated basis. The envelope needs bytes, so +// the DECIMAL reading is taken, which is the smaller of the two possible readings. An offer that +// promises less memory than the instance has can only refuse work it could have run. It can never +// admit work the instance cannot hold. +fn oci_shape(size: OciInstanceSize) -> Shape { + Shape { + hard: HardRequirements { threads: hardware_thread_count(count: oci_vcpus(size: size)) }, + envelope: cpu_memory_envelope( + min_threads: hardware_thread_count(count: oci_vcpus(size: size)), + architecture: oci_shape_architecture(shape: oci_instance_shape(size: size)), + min_bytes: byte_size(count: gigabyte_count(g: oci_memory(size: size)) * 1000000000), + ), + } +} + +data oci_always_free_fit_refusal: NonEmptyStr = "This instance belongs to a plan that does not fit the Always Free allowance (see oci_always_free_fit), so a zero quote for it would be false. Bind it only after the plan fits, or bind it through a priced supplier once the overage rate is read." + +fn bind_oci_always_free_offer

( + principal: P, + executor: P, + instance: OciPlannedInstance, + plan_fit: OciAllowanceFit, + capabilities: CapabilityManifestRef, + trust_domain: TrustDomainRef, + readiness: ReadinessObservation, + region: ProviderRegionObservation, +) -> OfferBinding

{ + match plan_fit { + ExceedsAlwaysFree { breaches } => OfferBindingRefused { + runs_on_label: instance.name, + cause: decl_ref(module_path: "product.supplier.oracle_oci", decl_name: "oci_always_free_fit_refusal"), + }, + FitsAlwaysFree => + match provider_route_from_region(provider: oci_provider_ref(), service: oci_service_ref(), region: region) { + ProviderRouteRegionUnobserved { obligation } => + OfferBindingRefused { runs_on_label: instance.name, cause: obligation }, + ProviderRouteObserved { route: bound_route } => + match readiness { + ReadinessUnmeasured { obligation } => + OfferBindingRefused { runs_on_label: instance.name, cause: obligation }, + ReadinessMeasured { ready_at, receipt } => + OfferBound { + bound: SupplierOfferProjection { + offer: SupplierOffer { + id: FabricIdentity { principal: principal, key: instance.name }, + executor: executor, + route: bound_route, + shape: oci_shape(size: instance.size), + capabilities: capabilities, + trust_domain: trust_domain, + isolation: IsolationProfile { guarantees: [] }, + quantity_bound: 1, + ready_at: ready_at, + quote: oci_zero_quote(), + billing_quantum: oci_billing_quantum, + evidence: UnobservedSupply { + obligation: decl_ref(module_path: "product.supplier.oracle_oci", decl_name: "oci_instance_unlaunched_obligation"), + }, + }, + unexpressed: [IsolationNotObserved], + }, + }, + }, + }, + } +} diff --git a/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag b/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag new file mode 100644 index 00000000000..48e06472a82 --- /dev/null +++ b/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag @@ -0,0 +1,188 @@ +module test.claim.supplier.oracle_oci_always_free_witness + +import std.types { Bool, List, NonEmptyStr } +import std.nat { Nat } +import std.decl_ref { DeclarationRef, decl_ref, declaration_ref_eq } +import std.measure { gigabyte, money_amount_micro_count } +import product.fabric.provider_route { ProviderRegionObservation, ProviderRegionRead } +import product.fabric.supply { offer_quote_amount, UnobservedSupply, ObservedSupply, QuotedSupply } +import product.supplier.ubicloud { ReadinessObservation, ReadinessMeasured, OfferBound, OfferBindingRefused } +import extdeps.cloud.oracle_oci { + OnDemandCapacity, PreemptibleCapacity, AlwaysFreeTenancy, PayAsYouGoTenancy, +} +import product.supplier.oracle_oci { + OciPlannedInstance, OciInstanceSize, A1FlexSize, E2MicroSize, + InHomeRegion, OutsideHomeRegion, InMicroEligibleDomain, OutsideMicroEligibleDomain, + OciAllowanceFit, FitsAlwaysFree, ExceedsAlwaysFree, OciAllowanceBreach, + A1OcpusOverAllowance, A1MemoryOverAllowance, E2MicroCountOverAllowance, + BlockVolumeOverAllowance, NotInHomeRegion, MicroOutsideEligibleDomain, + CapacityTypeFreeEligibilityUnread, + ExcessRefusedByVendor, ExcessBilledAtUnreadRate, + oci_always_free_fit, oci_breach_consequence, bind_oci_always_free_offer, + oci_home_region_unread, oci_readiness, +} + +// The subject is the allowance fit and the offer seam, at the vendor figures in +// extdeps.cloud.oracle_oci. Each plan below is built here as a supplied value. The figures it is +// checked against are the real rows, so moving the allowance moves these verdicts. +fn a1(name: NonEmptyStr, ocpus: Nat, gb: Nat, boot: Nat) -> OciPlannedInstance { + OciPlannedInstance { + name: name, + size: A1FlexSize { ocpus: ocpus, memory: gigabyte(count: gb) }, + boot_volume: gigabyte(count: boot), + capacity: OnDemandCapacity, + region: InHomeRegion, + micro_domain: InMicroEligibleDomain, + } +} + +fn micro(name: NonEmptyStr, boot: Nat) -> OciPlannedInstance { + OciPlannedInstance { + name: name, + size: E2MicroSize, + boot_volume: gigabyte(count: boot), + capacity: OnDemandCapacity, + region: InHomeRegion, + micro_domain: InMicroEligibleDomain, + } +} + +fn fits(plan: List) -> Bool { + match oci_always_free_fit(plan: plan) { + FitsAlwaysFree => true + ExceedsAlwaysFree { breaches } => false + } +} + +fn breaches_of(plan: List) -> List { + match oci_always_free_fit(plan: plan) { + FitsAlwaysFree => [] + ExceedsAlwaysFree { breaches } => breaches + } +} + +// The vendor's own example: two AMD micros alongside one 2-OCPU Ampere instance, all at once. +test fn two_micros_and_one_full_a1_fit_together() -> Bool { + fits(plan: [micro(name: "m1", boot: 47), micro(name: "m2", boot: 47), a1(name: "a", ocpus: 2, gb: 12, boot: 47)]) +} + +// The pre-2026 allowance, 4 OCPUs and 24 GB, no longer fits. It refuses on exactly those two axes. +test fn the_old_four_ocpu_allowance_refuses_on_ocpus_and_memory() -> Bool { + breaches_of(plan: [a1(name: "a", ocpus: 4, gb: 24, boot: 47)]) == [A1OcpusOverAllowance { planned: 4, allowed: 2 }, A1MemoryOverAllowance { planned: 24, allowed: 12 }] +} + +// The maximum instance count is limited by storage. Four 50 GB boot volumes use exactly 200 GB and +// fit. One more gigabyte on any of them refuses on block volume alone. +test fn four_instances_fit_at_exactly_two_hundred_gigabytes() -> Bool { + fits(plan: [micro(name: "m1", boot: 50), micro(name: "m2", boot: 50), a1(name: "a1", ocpus: 1, gb: 6, boot: 50), a1(name: "a2", ocpus: 1, gb: 6, boot: 50)]) +} + +test fn one_more_boot_gigabyte_refuses_on_block_volume_alone() -> Bool { + breaches_of(plan: [micro(name: "m1", boot: 51), micro(name: "m2", boot: 50), a1(name: "a1", ocpus: 1, gb: 6, boot: 50), a1(name: "a2", ocpus: 1, gb: 6, boot: 50)]) == [BlockVolumeOverAllowance { planned: 201, allowed: 200 }] +} + +test fn a_third_micro_refuses_on_micro_count() -> Bool { + breaches_of(plan: [micro(name: "m1", boot: 40), micro(name: "m2", boot: 40), micro(name: "m3", boot: 40)]) == [E2MicroCountOverAllowance { planned: 3, allowed: 2 }] +} + +// Preemptible capacity is UNREAD for Always Free, which is different from refused. The breach is the +// unread arm, so it names an obligation and asserts no price. +test fn preemptible_capacity_is_unread_not_priced() -> Bool { + let p = OciPlannedInstance { + name: "p", size: A1FlexSize { ocpus: 1, memory: gigabyte(count: 6) }, boot_volume: gigabyte(count: 47), + capacity: PreemptibleCapacity, region: InHomeRegion, micro_domain: InMicroEligibleDomain, + } + breaches_of(plan: [p]) == [CapacityTypeFreeEligibilityUnread { + instance: "p", + obligation: decl_ref(module_path: "product.supplier.oracle_oci", decl_name: "oci_capacity_type_free_eligibility_obligation"), + }] +} + +// The single-domain rule binds the micro and not the A1. The same misplacement refuses on one shape +// and not the other. +test fn the_micro_domain_rule_binds_the_micro_only() -> Bool { + let m = OciPlannedInstance { + name: "m", size: E2MicroSize, boot_volume: gigabyte(count: 47), + capacity: OnDemandCapacity, region: InHomeRegion, micro_domain: OutsideMicroEligibleDomain, + } + let a = OciPlannedInstance { + name: "a", size: A1FlexSize { ocpus: 1, memory: gigabyte(count: 6) }, boot_volume: gigabyte(count: 47), + capacity: OnDemandCapacity, region: OutsideHomeRegion, micro_domain: OutsideMicroEligibleDomain, + } + breaches_of(plan: [m, a]) == [MicroOutsideEligibleDomain { instance: "m" }, NotInHomeRegion { instance: "a" }] +} + +test fn a_breach_is_refused_on_free_and_billed_at_an_unread_rate_on_paid() -> Bool { + let free = match oci_breach_consequence(tier: AlwaysFreeTenancy) { + ExcessRefusedByVendor => true + ExcessBilledAtUnreadRate { obligation } => false + } + let paid = match oci_breach_consequence(tier: PayAsYouGoTenancy) { + ExcessRefusedByVendor => false + ExcessBilledAtUnreadRate { obligation } => true + } + free && paid +} + +// ── THE OFFER SEAM ──────────────────────────────────────────────────────────────────────────── +data oci_witness_home_region: NonEmptyStr = "oci-witness-home-region" + +fn read_region() -> ProviderRegionObservation { + ProviderRegionRead { + region: decl_ref(module_path: "test.claim.supplier.oracle_oci_always_free_witness", decl_name: "oci_witness_home_region"), + } +} + +fn measured() -> ReadinessObservation { + ReadinessMeasured { ready_at: "2026-10-05T00:00:00Z", receipt: "gunbc.observation.oci-witness-readiness" } +} + +fn refused_with(instance: OciPlannedInstance, fit: OciAllowanceFit, region: ProviderRegionObservation, readiness: ReadinessObservation, expected: NonEmptyStr) -> Bool { + match bind_oci_always_free_offer( + principal: "gunbc", executor: "gunbc", instance: instance, plan_fit: fit, + capabilities: "cap-manifest-oci", trust_domain: "trust-domain-oci", + readiness: readiness, region: region, + ) { + OfferBound { bound } => false + OfferBindingRefused { runs_on_label, cause } => + declaration_ref_eq(a: cause, b: decl_ref(module_path: "product.supplier.oracle_oci", decl_name: expected)) + } +} + +// RED: the state production is in. The home region is unread, so the offer refuses on that +// obligation, even with the plan fitting and readiness measured. +test fn production_state_refuses_on_the_unread_home_region() -> Bool { + let i = a1(name: "a", ocpus: 2, gb: 12, boot: 47) + refused_with(instance: i, fit: oci_always_free_fit(plan: [i]), region: oci_home_region_unread(), readiness: measured(), expected: "oci_home_region_unread_obligation") +} + +test fn production_readiness_refuses_once_the_region_is_read() -> Bool { + let i = a1(name: "a", ocpus: 2, gb: 12, boot: 47) + refused_with(instance: i, fit: oci_always_free_fit(plan: [i]), region: read_region(), readiness: oci_readiness, expected: "oci_readiness_unread_obligation") +} + +// A plan over the allowance cannot carry a zero quote, even when region and readiness are both read. +test fn an_over_allowance_plan_refuses_before_any_route() -> Bool { + let i = a1(name: "a", ocpus: 4, gb: 24, boot: 47) + refused_with(instance: i, fit: oci_always_free_fit(plan: [i]), region: read_region(), readiness: measured(), expected: "oci_always_free_fit_refusal") +} + +// POSITIVE CONTROL: with everything read, the same call binds a zero-quoted offer. Its evidence is +// still the unobserved arm, because nothing has been launched yet. +test fn a_fitting_plan_with_region_and_readiness_binds_at_zero_unobserved() -> Bool { + let i = a1(name: "a", ocpus: 2, gb: 12, boot: 47) + match bind_oci_always_free_offer( + principal: "gunbc", executor: "gunbc", instance: i, plan_fit: oci_always_free_fit(plan: [i]), + capabilities: "cap-manifest-oci", trust_domain: "trust-domain-oci", + readiness: measured(), region: read_region(), + ) { + OfferBound { bound } => + money_amount_micro_count(m: offer_quote_amount(q: bound.offer.quote)) == 0 + && match bound.offer.evidence { + UnobservedSupply { obligation } => true + ObservedSupply { observed_at, receipt } => false + QuotedSupply { quoted_at, valid_until, receipt } => false + } + OfferBindingRefused { runs_on_label, cause } => false + } +} From 2375ae9adbd17e745e8cb10f6788dbde4c498948 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Mon, 5 Oct 2026 02:16:02 +0000 Subject: [PATCH 02/15] OCI: tenancy home region us-ashburn-1 (operator statement 2026-10-05) + cited region row Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/extdeps/cloud/oracle_oci.dag | 27 +++++++++++++++++++ dag/gunbc/product/supplier/oracle_oci.dag | 17 ++++++------ .../oracle_oci_always_free_witness_test.dag | 22 ++++++++++----- 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/dag/extdeps/cloud/oracle_oci.dag b/dag/extdeps/cloud/oracle_oci.dag index d909fb3140f..c785a049f94 100644 --- a/dag/extdeps/cloud/oracle_oci.dag +++ b/dag/extdeps/cloud/oracle_oci.dag @@ -203,3 +203,30 @@ fn oci_idle_rule_reads_memory(shape: OciAlwaysFreeShape) -> Bool { type OciTenancyTier = AlwaysFreeTenancy | PayAsYouGoTenancy + +// ── REGIONS ─────────────────────────────────────────────────────────────────────────────────── +// +// One row per region a consumer has needed. Read 2026-10-05 from the regions page cited below: +// US East (Ashburn), identifier us-ashburn-1, region key IAD, realm OC1, three availability domains. +// The domain count matters for the micro rule above, because exactly one of the three can host an +// E2.1.Micro. +data oci_regions_authority: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "docs.oracle.com/en-us/iaas/Content/General/Concepts/regions.htm" + } +} + +type OciRegion { + identifier: NonEmptyStr + region_key: NonEmptyStr + realm_key: NonEmptyStr + availability_domain_count: Nat +} + +data oci_region_us_ashburn_1: OciRegion = OciRegion { + identifier: "us-ashburn-1", + region_key: "IAD", + realm_key: "OC1", + availability_domain_count: 3, +} diff --git a/dag/gunbc/product/supplier/oracle_oci.dag b/dag/gunbc/product/supplier/oracle_oci.dag index 3216e03cbb7..12bf5887d74 100644 --- a/dag/gunbc/product/supplier/oracle_oci.dag +++ b/dag/gunbc/product/supplier/oracle_oci.dag @@ -14,7 +14,7 @@ import product.fabric.envelope { cpu_memory_envelope } import product.fabric.isolation { IsolationProfile } import product.fabric.supply { SupplierOffer, OfferQuote, QuotedPerHour, UnobservedSupply } import product.fabric.provider_route { - ProviderRegionObservation, ProviderRegionUnread, ProviderRouteObserved, ProviderRouteRegionUnobserved, + ProviderRegionObservation, ProviderRegionRead, ProviderRouteObserved, ProviderRouteRegionUnobserved, provider_route_from_region, } import product.supplier.ubicloud { @@ -212,14 +212,15 @@ fn oci_service_ref() -> DeclarationRef { decl_ref(module_path: "extdeps.cloud.oracle_oci", decl_name: "oci_always_free_resources_authority") } -// Nobody has read this tenancy's home region into the tree. The console the operator was using -// suggested Ashburn, but a guess is not a reading, so the standing observation is the unread arm and -// every offer refuses on it. -data oci_home_region_unread_obligation: NonEmptyStr = "The home region of the operator's OCI tenancy has not been read into this tree. Always Free compute must be created there (extdeps.cloud.oracle_oci), so it is the region of every offer this supplier can make. RESOLVED BY the tenancy's home region as shown in the OCI console (Tenancy details), cited as a region row." +// THE OPERATOR'S TENANCY HOME REGION IS US EAST (ASHBURN), as the operator stated on 2026-10-05. +// It is a fact about our tenancy and not about Oracle, so it lives here, as an attributable operator +// statement, and cites the vendor's region row for what us-ashburn-1 is. Always Free compute can be +// created only in the home region, so this is the region of every offer this supplier makes. +data oci_tenancy_home_region_statement: NonEmptyStr = "Operator statement, 2026-10-05: the gunbc OCI tenancy's home region is US East (Ashburn), us-ashburn-1. Re-read it from the OCI console's Tenancy details if the tenancy is ever recreated." -fn oci_home_region_unread() -> ProviderRegionObservation { - ProviderRegionUnread { - obligation: decl_ref(module_path: "product.supplier.oracle_oci", decl_name: "oci_home_region_unread_obligation"), +fn oci_home_region() -> ProviderRegionObservation { + ProviderRegionRead { + region: decl_ref(module_path: "extdeps.cloud.oracle_oci", decl_name: "oci_region_us_ashburn_1"), } } diff --git a/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag b/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag index 48e06472a82..86544cfc359 100644 --- a/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag +++ b/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag @@ -19,7 +19,7 @@ import product.supplier.oracle_oci { CapacityTypeFreeEligibilityUnread, ExcessRefusedByVendor, ExcessBilledAtUnreadRate, oci_always_free_fit, oci_breach_consequence, bind_oci_always_free_offer, - oci_home_region_unread, oci_readiness, + oci_home_region, oci_readiness, } // The subject is the allowance fit and the offer seam, at the vendor figures in @@ -149,16 +149,24 @@ fn refused_with(instance: OciPlannedInstance, fit: OciAllowanceFit, region: Prov } } -// RED: the state production is in. The home region is unread, so the offer refuses on that -// obligation, even with the plan fitting and readiness measured. -test fn production_state_refuses_on_the_unread_home_region() -> Bool { +// RED: the state production is in. The home region is read (Ashburn) and readiness is not, so the +// offer refuses on the readiness obligation, even with a plan that fits. +test fn production_state_refuses_on_unmeasured_readiness() -> Bool { let i = a1(name: "a", ocpus: 2, gb: 12, boot: 47) - refused_with(instance: i, fit: oci_always_free_fit(plan: [i]), region: oci_home_region_unread(), readiness: measured(), expected: "oci_home_region_unread_obligation") + refused_with(instance: i, fit: oci_always_free_fit(plan: [i]), region: oci_home_region(), readiness: oci_readiness, expected: "oci_readiness_unread_obligation") } -test fn production_readiness_refuses_once_the_region_is_read() -> Bool { +// The production home region yields a route: with readiness measured, the same call binds. +test fn the_production_home_region_binds_once_readiness_is_measured() -> Bool { let i = a1(name: "a", ocpus: 2, gb: 12, boot: 47) - refused_with(instance: i, fit: oci_always_free_fit(plan: [i]), region: read_region(), readiness: oci_readiness, expected: "oci_readiness_unread_obligation") + match bind_oci_always_free_offer( + principal: "gunbc", executor: "gunbc", instance: i, plan_fit: oci_always_free_fit(plan: [i]), + capabilities: "cap-manifest-oci", trust_domain: "trust-domain-oci", + readiness: measured(), region: oci_home_region(), + ) { + OfferBound { bound } => declaration_ref_eq(a: bound.offer.route.region, b: decl_ref(module_path: "extdeps.cloud.oracle_oci", decl_name: "oci_region_us_ashburn_1")) + OfferBindingRefused { runs_on_label, cause } => false + } } // A plan over the allowance cannot carry a zero quote, even when region and readiness are both read. From 70c7a848605475d4d2bd61a607c37970a8a65884 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Mon, 5 Oct 2026 02:38:05 +0000 Subject: [PATCH 03/15] OCI request signing (pure): signing string, body digest, HTTP date, Authorization header; pinned to Oracle's GET vector Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cloud/oracle_oci_request_signing.dag | 224 ++++++++++++++++++ ...racle_oci_request_signing_witness_test.dag | 107 +++++++++ 2 files changed, 331 insertions(+) create mode 100644 dag/extdeps/cloud/oracle_oci_request_signing.dag create mode 100644 dag/test/claim/supplier/oracle_oci_request_signing_witness_test.dag diff --git a/dag/extdeps/cloud/oracle_oci_request_signing.dag b/dag/extdeps/cloud/oracle_oci_request_signing.dag new file mode 100644 index 00000000000..dfefdd0e571 --- /dev/null +++ b/dag/extdeps/cloud/oracle_oci_request_signing.dag @@ -0,0 +1,224 @@ +module extdeps.cloud.oracle_oci_request_signing + +import extdeps.external_authority { ExternalAuthority } +import extdeps.uri { Uri, Https } +import extdeps.crypto.sha2 { sha256 } +import std.encoding { base64_encode, Standard } +import std.integer { QualifiedOctetsResult, QualifiedOctetsReady, QualifiedOctetsRefused, uint8_octets_of_ints } +import std.types { Int, List, NonEmptyStr, String } + +// OCI API REQUEST SIGNING, the upstream specification: what is signed and how the Authorization +// header is spelled. It is read from the signing-requests page cited below on 2026-10-05. +// +// This module is PURE. It builds the signing string and the header from a signature it is handed. +// Producing the RSA-SHA256 signature is realization, done by whichever signer holds the key (today +// gunbc.jws_signer_realize, which hands a host key file to openssl), and the key never enters here. +// +// The page's rules, as they are used below. A GET or DELETE signs at least (request-target), host +// and date. A POST or PUT also signs x-content-sha256, content-type and content-length. Header +// names are lowercase in the signing string, and each line is ": ". The +// (request-target) line is the lowercase method, one space, then the path with its query string. +// x-content-sha256 is the Base64 of the SHA-256 of the body. The signature is +// Base64(RSA-SHA256(signing string)). The date is an HTTP date such as "Thu, 05 Jan 2014 21:31:40 GMT", +// and it must be within 5 minutes of the server's clock. +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "docs.oracle.com/en-us/iaas/Content/API/Concepts/signingrequests.htm" + } +} + +// keyId is "//". All three are identifiers, not secrets. +type OciApiKeyId { + tenancy: NonEmptyStr + user: NonEmptyStr + fingerprint: NonEmptyStr +} + +fn oci_key_id_wire(key: OciApiKeyId) -> String { + join([key.tenancy as String, "/", key.user as String, "/", key.fingerprint as String], "") +} + +// A body is signed by its exact bytes. This module only admits an ASCII body, so its length in bytes +// is its length in characters and its octets are its code points. A body with any non-ASCII +// character refuses rather than being hashed under a byte encoding this module does not carry. +type OciRequestBody + = OciNoBody + | OciJsonBody { text: String } + +type OciBodyOctets + = OciBodyOctetsReady { octets: List } + | OciBodyNotAscii { index: Int } + +fn oci_ascii_octets_from(text: String, index: Int, acc: List) -> OciBodyOctets { + if index >= string_length(text) { + OciBodyOctetsReady { octets: acc } + } else { + let cp = code_point(char_at(text, index)) + if cp > 127 { + OciBodyNotAscii { index: index } + } else { + oci_ascii_octets_from(text: text, index: index + 1, acc: concat(acc, [cp])) + } + } +} + +fn oci_ascii_octets(text: String) -> OciBodyOctets { + oci_ascii_octets_from(text: text, index: 0, acc: []) +} + +// The digest is 32 octets each in [0, 255], so the octet admission cannot refuse it. The refused arm +// is the admission's own vocabulary and returns the empty string, which no OCI endpoint accepts as a +// digest, so the request is refused by the server rather than signed over an invented hash. +fn oci_sha256_base64(octets: List) -> String { + match uint8_octets_of_ints(values: sha256(message: octets)) { + QualifiedOctetsReady { value } => base64_encode(octets: value, variant: Standard) + QualifiedOctetsRefused { observed } => "" + } +} + +// ── THE HTTP DATE ───────────────────────────────────────────────────────────────────────────── +// +// The date is derived from epoch seconds, the magnitude extdeps.clock produces, using the +// days-to-civil-date conversion of the proleptic Gregorian calendar. 1970-01-01 was a Thursday. + +// index 0 is the weekday of day 0, 1970-01-01, which was a Thursday. +fn oci_weekday_name(index: Int) -> String { + if index == 0 { "Thu" } else if index == 1 { "Fri" } else if index == 2 { "Sat" } + else if index == 3 { "Sun" } else if index == 4 { "Mon" } else if index == 5 { "Tue" } else { "Wed" } +} + +fn oci_month_name(month: Int) -> String { + if month == 1 { "Jan" } else if month == 2 { "Feb" } else if month == 3 { "Mar" } + else if month == 4 { "Apr" } else if month == 5 { "May" } else if month == 6 { "Jun" } + else if month == 7 { "Jul" } else if month == 8 { "Aug" } else if month == 9 { "Sep" } + else if month == 10 { "Oct" } else if month == 11 { "Nov" } else { "Dec" } +} + +fn oci_two_digits(n: Int) -> String { + if n < 10 { join(["0", to_string(n)], "") } else { to_string(n) } +} + +type OciCivilDate { + year: Int + month: Int + day: Int +} + +fn oci_civil_from_days(days: Int) -> OciCivilDate { + let z = days + 719468 + let era = z / 146097 + let doe = z - era * 146097 + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365 + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100) + let mp = (5 * doy + 2) / 153 + let day = doy - (153 * mp + 2) / 5 + 1 + let month = if mp < 10 { mp + 3 } else { mp - 9 } + let year = yoe + era * 400 + (if month <= 2 { 1 } else { 0 }) + OciCivilDate { year: year, month: month, day: day } +} + +fn oci_http_date(epoch_seconds: Int) -> String { + let days = epoch_seconds / 86400 + let secs = epoch_seconds - days * 86400 + let civil = oci_civil_from_days(days: days) + join([ + oci_weekday_name(index: days % 7), ", ", + oci_two_digits(n: civil.day), " ", + oci_month_name(month: civil.month), " ", + to_string(civil.year), " ", + oci_two_digits(n: secs / 3600), ":", + oci_two_digits(n: (secs % 3600) / 60), ":", + oci_two_digits(n: secs % 60), " GMT", + ], "") +} + +// ── THE SIGNING STRING ──────────────────────────────────────────────────────────────────────── + +type OciHttpMethod + = OciGet + | OciPost + | OciDelete + +fn oci_method_lower(method: OciHttpMethod) -> String { + match method { + OciGet => "get" + OciPost => "post" + OciDelete => "delete" + } +} + +// The request as it will be sent. path_and_query must already be URL-encoded (RFC 3986), in the order +// it will appear on the wire. The page requires the signed target and the sent target to be the same +// bytes. +type OciRequest { + method: OciHttpMethod + host: NonEmptyStr + path_and_query: NonEmptyStr + date: String + body: OciRequestBody +} + +// What a signer must sign, and the headers that must accompany the request. headers_list is the +// value of the Authorization header's headers="..." field, in the same order as the signing-string +// lines. +type OciSigningPlan + = OciSigningReady { + signing_string: String, + headers_list: String, + content_sha256: String, + content_length: Int, + } + | OciSigningRefused { cause: NonEmptyStr } + +fn oci_request_target_line(req: OciRequest) -> String { + join(["(request-target): ", oci_method_lower(method: req.method), " ", req.path_and_query as String], "") +} + +fn oci_bodiless_signing_string(req: OciRequest) -> String { + join([ + join(["date: ", req.date], ""), + oci_request_target_line(req: req), + join(["host: ", req.host as String], ""), + ], "\n") +} + +fn oci_signing_plan(req: OciRequest) -> OciSigningPlan { + match req.body { + OciNoBody => OciSigningReady { + signing_string: oci_bodiless_signing_string(req: req), + headers_list: "date (request-target) host", + content_sha256: "", + content_length: 0, + } + OciJsonBody { text } => + match oci_ascii_octets(text: text) { + OciBodyNotAscii { index } => OciSigningRefused { + cause: join(["OCI request body has a non-ASCII character at index ", to_string(index), "; only an ASCII body is signed, so its byte length is its character length"], "") as NonEmptyStr, + } + OciBodyOctetsReady { octets } => { + let digest = oci_sha256_base64(octets: octets) + let length = string_length(text) + OciSigningReady { + signing_string: join([ + oci_bodiless_signing_string(req: req), + join(["content-length: ", to_string(length)], ""), + "content-type: application/json", + join(["x-content-sha256: ", digest], ""), + ], "\n"), + headers_list: "date (request-target) host content-length content-type x-content-sha256", + content_sha256: digest, + content_length: length, + } + } + } + } +} + +fn oci_authorization_header(key: OciApiKeyId, headers_list: String, signature_base64: String) -> String { + join([ + "Signature version=\"1\",headers=\"", headers_list, + "\",keyId=\"", oci_key_id_wire(key: key), + "\",algorithm=\"rsa-sha256\",signature=\"", signature_base64, "\"", + ], "") +} diff --git a/dag/test/claim/supplier/oracle_oci_request_signing_witness_test.dag b/dag/test/claim/supplier/oracle_oci_request_signing_witness_test.dag new file mode 100644 index 00000000000..03eb9540ae7 --- /dev/null +++ b/dag/test/claim/supplier/oracle_oci_request_signing_witness_test.dag @@ -0,0 +1,107 @@ +module test.claim.supplier.oracle_oci_request_signing_witness + +import std.types { Bool, Int, String } +import extdeps.cloud.oracle_oci_request_signing { + OciRequest, OciGet, OciPost, OciNoBody, OciJsonBody, + OciSigningPlan, OciSigningReady, OciSigningRefused, + OciApiKeyId, + oci_signing_plan, oci_http_date, oci_authorization_header, +} + +// Oracle's own test values on the signing-requests page (read 2026-10-05) are the oracle here. +// The GET signing string below is the page's, character for character. The page's POST example +// elides its body, so the body digest is checked against SHA-256 values computed independently. +// The empty-body digest is the well-known SHA-256 of zero bytes, and the {"a":1} digest was +// computed with Python's hashlib. + +data oracle_vector_path: String = "/20160918/instances?availabilityDomain=Pjwf%3A%20PHX-AD-1&compartmentId=ocid1.compartment.oc1..aaaaaaaam3we6vgnherjq5q2idnccdflvjsnog7mlr6rtdb25gilchfeyjxa&displayName=TeamXInstances&volumeId=ocid1.volume.oc1.phx.abyhqljrgvttnlx73nmrwfaux7kcvzfs3s66izvxf2h4lgvyndsdsnoiwr5q" + +fn plan_of(req: OciRequest) -> OciSigningPlan { + oci_signing_plan(req: req) +} + +fn signing_string_of(p: OciSigningPlan) -> String { + match p { + OciSigningReady { signing_string, headers_list, content_sha256, content_length } => signing_string + OciSigningRefused { cause } => "" + } +} + +// Oracle's example date is "Thu, 05 Jan 2014 21:31:40 GMT", and its weekday is wrong: 5 January 2014 +// was a Sunday. The page's signing strings use the page's text verbatim, and the date header is +// opaque to the signing string. So the GET vector below keeps "Thu", while the date this module +// derives from the clock carries the true weekday. +test fn the_http_date_derives_the_true_weekday_of_oracles_example_instant() -> Bool { + oci_http_date(epoch_seconds: 1388957500) == "Sun, 05 Jan 2014 21:31:40 GMT" +} + +// A leap-day date in a different weekday and month, so the calendar arithmetic is not a single-point fit. +test fn the_http_date_handles_a_leap_day() -> Bool { + oci_http_date(epoch_seconds: 1709210096) == "Thu, 29 Feb 2024 12:34:56 GMT" +} + +test fn the_get_signing_string_is_oracles_vector() -> Bool { + let req = OciRequest { + method: OciGet, + host: "iaas.us-phoenix-1.oraclecloud.com", + path_and_query: oracle_vector_path, + date: "Thu, 05 Jan 2014 21:31:40 GMT", + body: OciNoBody, + } + signing_string_of(p: plan_of(req: req)) == join([ + "date: Thu, 05 Jan 2014 21:31:40 GMT", + join(["(request-target): get ", oracle_vector_path], ""), + "host: iaas.us-phoenix-1.oraclecloud.com", + ], "\n") +} + +fn post_plan(body: String) -> OciSigningPlan { + plan_of(req: OciRequest { + method: OciPost, + host: "iaas.us-phoenix-1.oraclecloud.com", + path_and_query: "/20160918/volumeAttachments", + date: "Thu, 05 Jan 2014 21:31:40 GMT", + body: OciJsonBody { text: body }, + }) +} + +test fn a_post_signs_length_type_and_body_digest_in_oracles_order() -> Bool { + match post_plan(body: "{\"a\":1}") { + OciSigningReady { signing_string, headers_list, content_sha256, content_length } => + content_sha256 == "AVq9f1zFei3ZS3WQ8ErYCEJzkF7jPsXOvq5iJ2qX+GI=" + && content_length == 7 + && headers_list == "date (request-target) host content-length content-type x-content-sha256" + && signing_string == join([ + "date: Thu, 05 Jan 2014 21:31:40 GMT", + "(request-target): post /20160918/volumeAttachments", + "host: iaas.us-phoenix-1.oraclecloud.com", + "content-length: 7", + "content-type: application/json", + "x-content-sha256: AVq9f1zFei3ZS3WQ8ErYCEJzkF7jPsXOvq5iJ2qX+GI=", + ], "\n") + OciSigningRefused { cause } => false + } +} + +test fn an_empty_body_digests_to_the_sha256_of_zero_bytes() -> Bool { + match post_plan(body: "") { + OciSigningReady { signing_string, headers_list, content_sha256, content_length } => + content_sha256 == "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=" && content_length == 0 + OciSigningRefused { cause } => false + } +} + +test fn a_non_ascii_body_refuses_rather_than_signing() -> Bool { + match post_plan(body: "{\"name\":\"caf\u{e9}\"}") { + OciSigningReady { signing_string, headers_list, content_sha256, content_length } => false + OciSigningRefused { cause } => true + } +} + +test fn the_authorization_header_has_oracles_shape() -> Bool { + oci_authorization_header( + key: OciApiKeyId { tenancy: "ocid1.tenancy.oc1..t", user: "ocid1.user.oc1..u", fingerprint: "aa:bb" }, + headers_list: "date (request-target) host", + signature_base64: "SIG=", + ) == "Signature version=\"1\",headers=\"date (request-target) host\",keyId=\"ocid1.tenancy.oc1..t/ocid1.user.oc1..u/aa:bb\",algorithm=\"rsa-sha256\",signature=\"SIG=\"" +} From ceccc347489805db0ebba362416d06467d47f789 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Mon, 5 Oct 2026 03:05:21 +0000 Subject: [PATCH 04/15] OCI compartment converge: signed curl transport, custody + accessor rows for the API key, gunbc-always-free compartment ensure - extdeps.tools.curl HttpSignature: exact-bytes signed GET/POST (body on stdin, fixed signed-header slots) - gunbc.oracle_oci.compartment_ensure: key as a custodied host file signed by the existing openssl handler; ListCompartments/CreateCompartment; readback decides - host_credential_custody_converge: OracleOciApiSigningKey (srv1, root 0400); fleet-converge.yml choice option - fleet_secret_accessor_roster: accessor row for oracle-oci-api-signing-key - gcp_secret_access witness: named rows now include fabric_state_key_accessor_row (the identity join was red on main without it) Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/fleet-converge.yml | 2 +- dag/extdeps/tools/curl.dag | 84 ++++- .../auth/fleet_secret_accessor_roster.dag | 6 + .../host_credential_custody_converge.dag | 18 +- dag/gunbc/oracle_oci/compartment_ensure.dag | 320 ++++++++++++++++++ .../claim/gcp_secret_access_witness_test.dag | 4 +- ...le_oci_compartment_ensure_witness_test.dag | 75 ++++ 7 files changed, 504 insertions(+), 5 deletions(-) create mode 100644 dag/gunbc/oracle_oci/compartment_ensure.dag create mode 100644 dag/test/claim/supplier/oracle_oci_compartment_ensure_witness_test.dag diff --git a/.github/workflows/fleet-converge.yml b/.github/workflows/fleet-converge.yml index 679f03f9648..91080aa51e1 100644 --- a/.github/workflows/fleet-converge.yml +++ b/.github/workflows/fleet-converge.yml @@ -53,7 +53,7 @@ on: credential: description: "host_credential_custody_converge only: which rostered custody row to deliver (gunbc.host_credential_custody_converge). A closed choice, never a free-text secret name: each option carries its own SecretRef, path, owner, group, mode and directory, and a row scoped to one host refuses any other" required: false - options: [controller_app_key, approval_ntfy_publisher_token, fabric_state_writer_key] + options: [controller_app_key, approval_ntfy_publisher_token, fabric_state_writer_key, oracle_oci_api_signing_key] type: choice d0_consent: description: "pair_serving_d0 only (Cut D, Group A on srv1; expected_revision is required and frozen with the filing): the escalation id the consent is filed under. It names the transaction, and a rerun after a crash MUST name the same id to find its frozen filing and held claim -- a fresh id is a new consent" diff --git a/dag/extdeps/tools/curl.dag b/dag/extdeps/tools/curl.dag index e8210c34cfb..fadc7eed945 100644 --- a/dag/extdeps/tools/curl.dag +++ b/dag/extdeps/tools/curl.dag @@ -2,8 +2,9 @@ module extdeps.tools.curl import extdeps.exec.program { ProgramIdentity, cataloged_program } -import std.types { String, NonEmptyStr, Bool, List, FilePath, Int, HttpStatus, http_status_of } +import std.types { String, NonEmptyStr, Bool, List, FilePath, Int, HttpStatus, http_status_of, Unit } import std.algebra { trim } +import std.resources { Network } import std.measure { Second, second, second_count } import extdeps.version { VersionConstraint } import extdeps.external_authority { ExternalAuthority } @@ -385,3 +386,84 @@ fn curl_exit_outcome_name(outcome: CurlExitOutcome) -> String { CurlExitUnnamed { code: c } => join(["curl exit ", to_string(c)], "") } } + +// ── REQUESTS WHOSE AUTHORIZATION IS AN HTTP SIGNATURE OVER THE EXACT BYTES SENT ───────────────── +// +// Some APIs authenticate each request with a signature over its date, its target and, for a request +// with a body, a digest of the body's bytes (Oracle Cloud Infrastructure is the consumer: +// extdeps.cloud.oracle_oci_request_signing). The signature can only be checked if the bytes it covers +// are the bytes on the wire. So the body here is a caller-supplied STRING sent verbatim on stdin with +// --data-binary, never a structure the transport re-serializes. curl sets Content-Length from those +// bytes, which is the value the caller signed. The headers are fixed slots, because the set of +// signed headers is the protocol and not a caller's choice. +// +// The Authorization value appears in argv. It is a signature bound to one request and one date +// (Oracle rejects a date more than 5 minutes off), not the key, which never leaves the signer. +// The response body comes back followed by a newline and the status, read by +// classify_curl_http_code_after_newline. +service curl.HttpSignature { + operation SignedGet { + requires Network + input { url: NonEmptyStr, date: NonEmptyStr, authorization: NonEmptyStr } + output { + exit_code: Int from "exit_code" + stdout: String from "stdout" + stderr: String from "stderr" + } + readonly + transport shell { + argv: [curl_program().invocation, "-sS", "--max-time", "60", "-H", "date: {date}", "-H", "authorization: {authorization}", "-w", "\n%\{http_code\}", "{url}"] + } + exit { + 0 => Unit + nonzero => Unit + } + } + + operation SignedPostJson { + requires Network + input { + url: NonEmptyStr, + date: NonEmptyStr, + authorization: NonEmptyStr, + content_sha256: NonEmptyStr, + body: String, + } + output { + exit_code: Int from "exit_code" + stdout: String from "stdout" + stderr: String from "stderr" + } + transport shell { + argv: [curl_program().invocation, "-sS", "--max-time", "60", "-X", "POST", "-H", "date: {date}", "-H", "authorization: {authorization}", "-H", "content-type: application/json", "-H", "x-content-sha256: {content_sha256}", "--data-binary", "@-", "-w", "\n%\{http_code\}", "{url}"] + stdin: body + } + exit { + 0 => Unit + nonzero => Unit + } + } +} + +// The body is everything before the final newline that the write-out format adds. +fn curl_body_before_http_code(stdout: String) -> String { + let lines = split(s: stdout, delimiter: "\n") + let n = lines.length() + if n <= 1 { "" } else { join(lines |> take(n: n - 1), "\n") } +} + +type CurlSignedRun { + exit_code: Int + stdout: String + stderr: String +} + +fn curl_http_signature_get(url: NonEmptyStr, date: NonEmptyStr, authorization: NonEmptyStr) -> CurlSignedRun uses net: Network { + let run = curl.HttpSignature.SignedGet(url: url, date: date, authorization: authorization) + CurlSignedRun { exit_code: run.exit_code, stdout: run.stdout, stderr: run.stderr } +} + +fn curl_http_signature_post_json(url: NonEmptyStr, date: NonEmptyStr, authorization: NonEmptyStr, content_sha256: NonEmptyStr, body: String) -> CurlSignedRun uses net: Network { + let run = curl.HttpSignature.SignedPostJson(url: url, date: date, authorization: authorization, content_sha256: content_sha256, body: body) + CurlSignedRun { exit_code: run.exit_code, stdout: run.stdout, stderr: run.stderr } +} diff --git a/dag/gunbc/auth/fleet_secret_accessor_roster.dag b/dag/gunbc/auth/fleet_secret_accessor_roster.dag index 8ed42728813..7454da8d2ee 100644 --- a/dag/gunbc/auth/fleet_secret_accessor_roster.dag +++ b/dag/gunbc/auth/fleet_secret_accessor_roster.dag @@ -19,6 +19,7 @@ import gunbc.auth.approval_decision_store { approval_mac_key_secret_ref } import gunbc.auth.approval_request_submission { approval_submission_mac_key_secret_ref } import gunbc.auth.approval_store_receipt { approval_store_receipt_mac_key_secret_ref } import gunbc.auth.approval_ntfy_deployment { approval_ntfy_publisher_token_secret_ref } +import gunbc.oracle_oci.compartment_ensure { oci_api_signing_key_secret_ref } import gunbc.auth.gcp_secret_access { SecretAccessGrant, secret_accessor_grant, secret_access_ensure_for } import gunbc.auth.access_token_source { AccessTokenSource, OperatorSuppliedToken, read_supplied_access_token, SuppliedTokenReady, SuppliedTokenUnavailable, @@ -58,6 +59,10 @@ data approval_ntfy_publisher_token_accessor_row: FleetAccessorGrantRow = FleetAc data fabric_state_key_accessor_row: FleetAccessorGrantRow = FleetAccessorGrantRow { lane: "fabric state service credential (controller and website custody)", target: fabric_state_key_secret_ref } +// The OCI API signing key, read on srv1 by the custody converge (gunbc.host_credential_custody_converge +// OracleOciApiSigningKey). +data oracle_oci_api_signing_key_accessor_row: FleetAccessorGrantRow = FleetAccessorGrantRow { lane: "oracle oci api signing key (srv1 custody)", target: oci_api_signing_key_secret_ref } + data fleet_accessor_grant_roster: List = [ spark_accessor_row, mtcollins1_bmc_accessor_row, @@ -66,6 +71,7 @@ data fleet_accessor_grant_roster: List = [ approval_store_receipt_mac_key_accessor_row, approval_ntfy_publisher_token_accessor_row, fabric_state_key_accessor_row, + oracle_oci_api_signing_key_accessor_row, ] fn fleet_accessor_grant(row: FleetAccessorGrantRow) -> SecretAccessGrant { diff --git a/dag/gunbc/fleet/host_credential_custody_converge.dag b/dag/gunbc/fleet/host_credential_custody_converge.dag index 655b3ddf7c8..3decea994d3 100644 --- a/dag/gunbc/fleet/host_credential_custody_converge.dag +++ b/dag/gunbc/fleet/host_credential_custody_converge.dag @@ -42,6 +42,7 @@ import gunbc.auth.secret_ref_credential { SecretCredentialResolvedVersionMismatch, } import gunbc.github_effect_perform { lifecycle_controller_app_key } +import gunbc.oracle_oci.compartment_ensure { oci_api_signing_key, oci_api_signing_key_secret_ref } import extdeps.auth.jws { JwsSigningKeyRef, HostKeyFile } import gunbc.fabric_cell_converge { fabric_cell_base_dir } import gunbc.typed_remote_file_write { @@ -166,14 +167,16 @@ type HostCustodyCredential = ControllerAppKey | ApprovalNtfyPublisherToken | FabricStateWriterKey + | OracleOciApiSigningKey -data host_custody_credential_roster: List = [ControllerAppKey, ApprovalNtfyPublisherToken, FabricStateWriterKey] +data host_custody_credential_roster: List = [ControllerAppKey, ApprovalNtfyPublisherToken, FabricStateWriterKey, OracleOciApiSigningKey] fn host_custody_credential_wire(c: HostCustodyCredential) -> String { match c { ControllerAppKey => "controller_app_key" ApprovalNtfyPublisherToken => "approval_ntfy_publisher_token" FabricStateWriterKey => "fabric_state_writer_key" + OracleOciApiSigningKey => "oracle_oci_api_signing_key" } } @@ -300,6 +303,9 @@ data custody_root_owned_ntfy_group: PosixOwnerNames = PosixOwnerNames { group: approval_ntfy_principal_name, } +// OracleOciApiSigningKey signs every OCI request gunbc.oracle_oci.compartment_ensure makes, through +// the signer's own key reference, never re-spelled. It is delivered to srv1 only, root-owned and +// owner-read, because srv1 is where the OCI converge runs and nothing else signs with it. fn host_credential_custody_row(c: HostCustodyCredential) -> HostCredentialCustodyRow { match c { FabricStateWriterKey => HostCredentialCustodyRow { @@ -321,6 +327,16 @@ fn host_credential_custody_row(c: HostCustodyCredential) -> HostCredentialCustod dir_ownership: custody_root_only, dir_mode: DirectoryOwnerOnly, } + OracleOciApiSigningKey => HostCredentialCustodyRow { + credential: c, + secret: oci_api_signing_key_secret_ref, + path: signing_key_file_path(key: oci_api_signing_key), + host_scope: CustodyOnlyOnHost { host: operator_host_srv1 }, + file_ownership: custody_root_only, + file_mode: CustodyFileOwnerRead, + dir_ownership: custody_root_only, + dir_mode: DirectoryOwnerOnly, + } ApprovalNtfyPublisherToken => HostCredentialCustodyRow { credential: c, secret: approval_ntfy_publisher_token_secret_ref, diff --git a/dag/gunbc/oracle_oci/compartment_ensure.dag b/dag/gunbc/oracle_oci/compartment_ensure.dag new file mode 100644 index 00000000000..28ec1a75942 --- /dev/null +++ b/dag/gunbc/oracle_oci/compartment_ensure.dag @@ -0,0 +1,320 @@ +module gunbc.oracle_oci.compartment_ensure + +import std.types { Bool, Int, List, NonEmptyStr, String, HttpStatus } +import std.algebra { trim } +import std.resources { Network } +import std.process { ProcessExit, ExitSuccess, exit_failure } +import std.encoding { base64_encode, Standard } +import std.integer { uint8_octets_of_ints, QualifiedOctetsReady, QualifiedOctetsRefused } +import std.upsert_decision { + ObservationVerdict, Converged, Absent, Conflict, Inaccessible, UnknownRefused, Drifted, + Noop, Apply, Refuse, UpsertClassification, upsert_decision_label, observation_verdict_label, +} +import extdeps.clock { Clock } +import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.auth.jws { JwsSigningKeyRef, HostKeyFile, JwsSigned, JwsSignRefused } +import extdeps.cloud.gcp.secret_ref { SecretRef, HashPending } +import extdeps.tools.curl { curl_http_signature_get, curl_http_signature_post_json, CurlWroteHttpStatus, CurlWroteNoHttpStatus, classify_curl_http_code_after_newline, curl_body_before_http_code } +import extdeps.languages.json.emit { JsonValue, JsonString, JsonArray, JsonObject, json_object, json_kv, json_string, serialize_json } +import extdeps.languages.json.parse { parse_json_document, JsonDocumentParsed, JsonDocumentUnreadable, json_document_gap_text, json_member_presence, JsonMemberValue, JsonMemberMissing, JsonMemberNull, JsonMemberUnreadable } +import extdeps.cloud.oracle_oci_request_signing { + OciApiKeyId, OciRequest, OciHttpMethod, OciGet, OciPost, OciRequestBody, OciNoBody, OciJsonBody, + OciSigningReady, OciSigningRefused, oci_signing_plan, oci_http_date, oci_authorization_header, +} +import gunbc.jws_signer_realize { jws_rs256_sign } +import gunbc.fleet_secrets_config { fleet_secrets_gcp_project } + +// THE OPERATOR'S OCI TENANCY: CREDENTIAL, IDENTITY AND THE COMPARTMENT THE FREE INSTANCES LIVE IN. +// +// The key is a host file, never a value in the evaluator. gunbc.host_credential_custody_converge +// delivers the Secret Manager secret below to that path on srv1, root-owned and owner-read, and the +// openssl handler in gunbc.jws_signer_realize signs from the file. Until custody has delivered it, +// openssl cannot open the path and every exchange refuses at the signer. Nothing falls back. +// +// The OCIDs and the fingerprint are identifiers, given by the operator on 2026-10-05. They are +// public in the sense that matters here: possessing them grants nothing without the private key. + +data oci_api_signing_key_secret_ref: SecretRef = SecretRef { + project: fleet_secrets_gcp_project, + secret: "oracle-oci-api-signing-key", + version: "1", + hash_state: HashPending, +} + +data oci_api_signing_key: JwsSigningKeyRef = HostKeyFile { + path: "/etc/gunbc/oracle-oci/api-signing-key.pem", +} + +data oci_tenancy_ocid: NonEmptyStr = "ocid1.tenancy.oc1..aaaaaaaaxarkm67vc5fiaqayhea33y2k6j3zdhnt5a4e434ddrrsxipmcf7a" + +data oci_api_key_id: OciApiKeyId = OciApiKeyId { + tenancy: oci_tenancy_ocid, + user: "ocid1.user.oc1..aaaaaaaasu4qfbdfzezarj6e6pylvhxwk7eqvfmvpkqvcrirazgrjw3ga5qa", + fingerprint: "00:46:05:c5:42:80:08:ec:48:3a:e1:7c:34:bc:e1:03", +} + +// Identity calls must go to the home region (us-ashburn-1, product.supplier.oracle_oci). The host +// template is Oracle's own, from its SDK's identity client: identity.{region}.oci.{secondLevelDomain}. +data oci_identity_host: NonEmptyStr = "identity.us-ashburn-1.oci.oraclecloud.com" + +// ── ONE SIGNED EXCHANGE ─────────────────────────────────────────────────────────────────────── + +type OciExchange + = OciAnswered { status: Int, body: String } + | OciExchangeRefused { cause: NonEmptyStr } + +fn oci_refused(cause: String) -> OciExchange { + OciExchangeRefused { cause: cause as NonEmptyStr } +} + +fn oci_signature_base64(signature: List) -> String { + match uint8_octets_of_ints(values: signature) { + QualifiedOctetsReady { value } => base64_encode(octets: value, variant: Standard) + QualifiedOctetsRefused { observed } => "" + } +} + +fn oci_send(method: OciHttpMethod, url: NonEmptyStr, date: NonEmptyStr, authorization: NonEmptyStr, content_sha256: String, body: OciRequestBody) -> OciExchange uses net: Network { + let run = match body { + OciNoBody => curl_http_signature_get(url: url, date: date, authorization: authorization) + OciJsonBody { text } => curl_http_signature_post_json(url: url, date: date, authorization: authorization, content_sha256: content_sha256 as NonEmptyStr, body: text) + } + if run.exit_code != 0 { + oci_refused(cause: join(["curl exit ", to_string(run.exit_code), ": ", trim(s: run.stderr)], "")) + } else { + match classify_curl_http_code_after_newline(stdout: run.stdout) { + CurlWroteNoHttpStatus { cause } => oci_refused(cause: cause as String) + CurlWroteHttpStatus { status } => OciAnswered { status: status as Int, body: curl_body_before_http_code(stdout: run.stdout) } + } + } +} + +// Read the clock, build the signing plan, sign it with the custodied key, send it. Every step that +// cannot complete refuses with its own cause. The date is read immediately before signing, because +// Oracle rejects a date more than 5 minutes from its own clock. +fn oci_signed_exchange(method: OciHttpMethod, host: NonEmptyStr, path_and_query: NonEmptyStr, body: OciRequestBody) -> OciExchange uses net: Network { + match parse_int(s: trim(s: Clock.UnixSecs().unix_secs)) { + Absent => oci_refused(cause: "the host clock did not print epoch seconds") + Present { value: now } => { + let date = oci_http_date(epoch_seconds: now) + match oci_signing_plan(req: OciRequest { method: method, host: host, path_and_query: path_and_query, date: date, body: body }) { + OciSigningRefused { cause } => OciExchangeRefused { cause: cause } + OciSigningReady { signing_string, headers_list, content_sha256, content_length } => + match jws_rs256_sign(signing_input: signing_string as NonEmptyStr, key: oci_api_signing_key) { + JwsSignRefused { detail } => oci_refused(cause: concat("THE REQUEST WAS NOT SIGNED, NOTHING SENT: ", detail)) + JwsSigned { signature } => + oci_send( + method: method, + url: join(["https://", host as String, path_and_query as String], "") as NonEmptyStr, + date: date as NonEmptyStr, + authorization: oci_authorization_header(key: oci_api_key_id, headers_list: headers_list, signature_base64: oci_signature_base64(signature: map(signature, o => o as Int))) as NonEmptyStr, + content_sha256: content_sha256, + body: body, + ) + } + } + } + } +} + +// ── THE COMPARTMENT ─────────────────────────────────────────────────────────────────────────── +// +// One compartment, directly under the root (the tenancy), holding everything this repository +// creates on the free tier. Its name is unique among its siblings, so the name is its identity for +// this converge. The description is required by CreateCompartment. +data oci_free_compartment_name: NonEmptyStr = "gunbc-always-free" + +data oci_free_compartment_description: NonEmptyStr = "gunbc: Always Free compute, created and converged by gunbc.oracle_oci.compartment_ensure" + +// Oracle's lifecycle states for a compartment, from its SDK's Compartment model. +type OciCompartmentState + = CompartmentCreating + | CompartmentActive + | CompartmentInactive + | CompartmentDeleting + | CompartmentDeleted + | CompartmentStateUnknown { raw: String } + +fn oci_compartment_state(raw: String) -> OciCompartmentState { + if raw == "CREATING" { CompartmentCreating } + else if raw == "ACTIVE" { CompartmentActive } + else if raw == "INACTIVE" { CompartmentInactive } + else if raw == "DELETING" { CompartmentDeleting } + else if raw == "DELETED" { CompartmentDeleted } + else { CompartmentStateUnknown { raw: raw } } +} + +type OciCompartment { + id: String + name: String + state: OciCompartmentState +} + +type OciCompartmentRead + = CompartmentsListed { compartments: List } + | CompartmentReadInaccessible { status: Int, body: String } + | CompartmentReadRefused { cause: NonEmptyStr } + +fn oci_string_member(obj: JsonValue, key: String) -> String { + match json_member_presence(obj: obj, key: key) { + JsonMemberValue { value } => match value { + JsonString { value: s } => s + _ => "" + } + JsonMemberMissing => "" + JsonMemberNull => "" + JsonMemberUnreadable { cause } => "" + } +} + +fn oci_compartment_of_json(v: JsonValue) -> OciCompartment { + OciCompartment { + id: oci_string_member(obj: v, key: "id"), + name: oci_string_member(obj: v, key: "name"), + state: oci_compartment_state(raw: oci_string_member(obj: v, key: "lifecycleState")), + } +} + +// ListCompartments answers a bare JSON array of Compartment (Oracle's SDK: list[Compartment]). A 401 +// or 404 is the API refusing this identity, and its body is kept, because Oracle's error body names +// the reason. Anything else that is not a 200 array refuses with what it was. +fn classify_oci_compartment_list(exchange: OciExchange) -> OciCompartmentRead { + match exchange { + OciExchangeRefused { cause } => CompartmentReadRefused { cause: cause } + OciAnswered { status, body } => + if status == 401 || status == 404 { + CompartmentReadInaccessible { status: status, body: body } + } else if status != 200 { + CompartmentReadRefused { cause: join(["ListCompartments answered ", to_string(status), ": ", body], "") as NonEmptyStr } + } else { + match parse_json_document(s: body) { + JsonDocumentUnreadable { gap } => CompartmentReadRefused { cause: concat("ListCompartments body is not JSON: ", json_document_gap_text(gap: gap)) as NonEmptyStr } + JsonDocumentParsed { value } => match value { + JsonArray { elements } => CompartmentsListed { compartments: map(elements, e => oci_compartment_of_json(v: e)) } + _ => CompartmentReadRefused { cause: "ListCompartments answered 200 with a body that is not an array" } + } + } + } + } +} + +fn oci_compartment_list_path() -> NonEmptyStr { + join(["/20160918/compartments?compartmentId=", oci_tenancy_ocid as String, "&name=", oci_free_compartment_name as String], "") as NonEmptyStr +} + +fn read_oci_free_compartment() -> OciCompartmentRead uses net: Network { + classify_oci_compartment_list(exchange: oci_signed_exchange(method: OciGet, host: oci_identity_host, path_and_query: oci_compartment_list_path(), body: OciNoBody)) +} + +type OciCompartmentCreatePlan { + body: String +} + +fn oci_compartment_create_body() -> String { + serialize_json(v: json_object(members: [ + json_kv(key: "compartmentId", value: json_string(s: oci_tenancy_ocid as String)), + json_kv(key: "name", value: json_string(s: oci_free_compartment_name as String)), + json_kv(key: "description", value: json_string(s: oci_free_compartment_description as String)), + ])) +} + +fn oci_compartment_refuse(verdict: ObservationVerdict, reason: String) -> UpsertClassification { + UpsertClassification { verdict: verdict, decision: Refuse { reason: reason as NonEmptyStr } } +} + +fn oci_compartment_is_live(c: OciCompartment) -> Bool { + match c.state { + CompartmentDeleted => false + CompartmentCreating => true + CompartmentActive => true + CompartmentInactive => true + CompartmentDeleting => true + CompartmentStateUnknown { raw } => true + } +} + +// PURE, SO EVERY ARM IS WITNESSABLE OVER A SUPPLIED READ. A deleted compartment of the same name is +// history, not a holder of the name for this decision. One live compartment that is ACTIVE is the +// goal. One that is still CREATING refuses with "re-run", because readback has not reached the goal +// yet. Two live compartments of one name is a conflict this converge does not resolve. +fn classify_oci_compartment_ensure(read: OciCompartmentRead) -> UpsertClassification { + match read { + CompartmentReadRefused { cause } => oci_compartment_refuse(verdict: UnknownRefused, reason: concat("THE COMPARTMENT READ WAS REFUSED, NOTHING CREATED: ", cause as String)) + CompartmentReadInaccessible { status, body } => oci_compartment_refuse(verdict: Inaccessible, reason: join(["OCI refused this API key (", to_string(status), "), NOTHING CREATED. Check that the key with fingerprint ", oci_api_key_id.fingerprint as String, " is still on the user, and that the user may inspect compartments: ", body], "")) + CompartmentsListed { compartments } => { + let live = filter(compartments, c => c.name == (oci_free_compartment_name as String) && oci_compartment_is_live(c: c)) + if live.length() == 0 { + UpsertClassification { verdict: Absent, decision: Apply { plan: OciCompartmentCreatePlan { body: oci_compartment_create_body() } } } + } else if live.length() > 1 { + oci_compartment_refuse(verdict: Conflict, reason: join(["more than one live compartment is named ", oci_free_compartment_name as String, " under the tenancy"], "")) + } else { + match live.first() { + Absent => oci_compartment_refuse(verdict: UnknownRefused, reason: "the live compartment list was nonempty and had no first element") + Present { value: c } => match c.state { + CompartmentActive => UpsertClassification { verdict: Converged, decision: Noop } + CompartmentCreating => oci_compartment_refuse(verdict: Drifted, reason: join(["compartment ", c.id, " is still CREATING; run this entry again"], "")) + CompartmentInactive => oci_compartment_refuse(verdict: Conflict, reason: join(["compartment ", c.id, " is INACTIVE; an operator must reactivate or replace it"], "")) + CompartmentDeleting => oci_compartment_refuse(verdict: Conflict, reason: join(["compartment ", c.id, " is DELETING; wait for the deletion, then run this entry again"], "")) + CompartmentDeleted => oci_compartment_refuse(verdict: UnknownRefused, reason: "a deleted compartment passed the live filter") + CompartmentStateUnknown { raw } => oci_compartment_refuse(verdict: UnknownRefused, reason: join(["compartment ", c.id, " reports lifecycle state ", raw, ", which is not in Oracle's published set"], "")) + } + } + } + } + } +} + +// ── THE EFFECTFUL HALF ──────────────────────────────────────────────────────────────────────── + +fn oci_compartment_line(classified: UpsertClassification, detail: String) -> String { + join(["oci compartment ", oci_free_compartment_name as String, " verdict=", observation_verdict_label(verdict: classified.verdict), " decision=", upsert_decision_label(decision: classified.decision), " ", detail], "") +} + +type OciCompartmentEnsureOutcome { + line: String + held: Bool +} + +// Create is not trusted as the answer. After a 200, the compartment is read back through the same +// list, and only that read decides the outcome. A just-created compartment is normally CREATING, so +// the first run ends "re-run" and the second ends converged. +fn apply_oci_compartment_create(plan: OciCompartmentCreatePlan) -> OciCompartmentEnsureOutcome uses net: Network { + match oci_signed_exchange(method: OciPost, host: oci_identity_host, path_and_query: "/20160918/compartments", body: OciJsonBody { text: plan.body }) { + OciExchangeRefused { cause } => OciCompartmentEnsureOutcome { line: concat("oci compartment create refused before an answer: ", cause as String), held: false } + OciAnswered { status, body } => + if status != 200 { + OciCompartmentEnsureOutcome { line: join(["oci compartment create answered ", to_string(status), ": ", body], ""), held: false } + } else { + let after = classify_oci_compartment_ensure(read: read_oci_free_compartment()) + OciCompartmentEnsureOutcome { + line: oci_compartment_line(classified: after, detail: "(readback after create)"), + held: match after.decision { Noop => true Apply { plan } => false Refuse { reason } => false }, + } + } + } +} + +fn ensure_oci_free_compartment() -> OciCompartmentEnsureOutcome uses net: Network { + let classified = classify_oci_compartment_ensure(read: read_oci_free_compartment()) + match classified.decision { + Noop => OciCompartmentEnsureOutcome { line: oci_compartment_line(classified: classified, detail: ""), held: true } + Refuse { reason } => OciCompartmentEnsureOutcome { line: oci_compartment_line(classified: classified, detail: reason as String), held: false } + Apply { plan } => apply_oci_compartment_create(plan: plan) + } +} + +data oci_compartment_ensure_receipt_path: String = "target/oci-compartment-ensure-receipt.txt" + +// THE ENTRY. It runs on the host that holds the custodied key (srv1). The receipt is written whatever +// the outcome, and the run fails unless the compartment ended converged. +fn ensure() -> ProcessExit uses net: Network { + let outcome = ensure_oci_free_compartment() + let written = Filesystem.Write(path: oci_compartment_ensure_receipt_path, content: outcome.line) + if !written.success { + exit_failure(reason: concat("THE RECEIPT COULD NOT BE WRITTEN: ", written.error)) + } else if outcome.held { + ExitSuccess + } else { + exit_failure(reason: outcome.line) + } +} diff --git a/dag/test/claim/gcp_secret_access_witness_test.dag b/dag/test/claim/gcp_secret_access_witness_test.dag index 5131203852e..da5c870650f 100644 --- a/dag/test/claim/gcp_secret_access_witness_test.dag +++ b/dag/test/claim/gcp_secret_access_witness_test.dag @@ -24,7 +24,7 @@ import gunbc.auth.gcp_secret_access { import std.process { ExitSuccess, ExitFailure } import gunbc.spark.credential_workflow { spark_administrator_password_secret_ref } import gunbc.auth.fleet_secret_accessor_roster { - FleetAccessorGrantRow, fleet_accessor_grant_roster, fleet_accessor_grant, spark_accessor_row, mtcollins1_bmc_accessor_row, + FleetAccessorGrantRow, fleet_accessor_grant_roster, fleet_accessor_grant, spark_accessor_row, mtcollins1_bmc_accessor_row, oracle_oci_api_signing_key_accessor_row, fabric_state_key_accessor_row, approval_capability_mac_key_accessor_row, approval_submission_mac_key_accessor_row, approval_store_receipt_mac_key_accessor_row, approval_ntfy_publisher_token_accessor_row, } @@ -478,7 +478,7 @@ fn roster_has_secret(row: FleetAccessorGrantRow) -> Bool { data named_accessor_rows: List = [ spark_accessor_row, mtcollins1_bmc_accessor_row, approval_capability_mac_key_accessor_row, approval_submission_mac_key_accessor_row, approval_store_receipt_mac_key_accessor_row, - approval_ntfy_publisher_token_accessor_row, + approval_ntfy_publisher_token_accessor_row, fabric_state_key_accessor_row, oracle_oci_api_signing_key_accessor_row, ] test fn the_roster_is_the_single_authority_for_the_spark_and_mtcollins1_grants() -> Bool { let bmc_cells = secret_access_desired_cells(member: convergence_member, grant: fleet_accessor_grant(row: mtcollins1_bmc_accessor_row)) diff --git a/dag/test/claim/supplier/oracle_oci_compartment_ensure_witness_test.dag b/dag/test/claim/supplier/oracle_oci_compartment_ensure_witness_test.dag new file mode 100644 index 00000000000..026b283034c --- /dev/null +++ b/dag/test/claim/supplier/oracle_oci_compartment_ensure_witness_test.dag @@ -0,0 +1,75 @@ +module test.claim.supplier.oracle_oci_compartment_ensure_witness + +import std.types { Bool, Int, String } +import std.upsert_decision { Noop, Apply, Refuse, Converged, Absent, Conflict, Inaccessible, UnknownRefused, Drifted, observation_verdict_label } +import gunbc.oracle_oci.compartment_ensure { + OciExchange, OciAnswered, OciExchangeRefused, + classify_oci_compartment_list, classify_oci_compartment_ensure, oci_compartment_create_body, +} + +// The subject is the compartment decision at the list boundary: what an answered ListCompartments +// decides. Each answer is a supplied OciExchange, with bodies shaped as Oracle's SDK models a +// Compartment. The real signed exchange runs only against the live API, on srv1, and its receipt +// is the inhabitance evidence for this boundary. + +fn decide(status: Int, body: String) -> String { + let c = classify_oci_compartment_ensure(read: classify_oci_compartment_list(exchange: OciAnswered { status: status, body: body })) + let d = match c.decision { + Noop => "noop" + Apply { plan } => "apply" + Refuse { reason } => "refuse" + } + join([observation_verdict_label(verdict: c.verdict), d], "/") +} + +fn row(state: String) -> String { + join(["{\"id\":\"ocid1.compartment.oc1..x\",\"name\":\"gunbc-always-free\",\"lifecycleState\":\"", state, "\"}"], "") +} + +test fn an_empty_list_creates() -> Bool { + decide(status: 200, body: "[]") == join([observation_verdict_label(verdict: Absent), "apply"], "/") +} + +test fn an_active_compartment_is_converged() -> Bool { + decide(status: 200, body: join(["[", row(state: "ACTIVE"), "]"], "")) == join([observation_verdict_label(verdict: Converged), "noop"], "/") +} + +test fn a_creating_compartment_refuses_until_readback_reaches_active() -> Bool { + decide(status: 200, body: join(["[", row(state: "CREATING"), "]"], "")) == join([observation_verdict_label(verdict: Drifted), "refuse"], "/") +} + +// A deleted compartment does not hold the name, so a list holding only a deleted one creates. +test fn a_deleted_namesake_does_not_block_creation() -> Bool { + decide(status: 200, body: join(["[", row(state: "DELETED"), "]"], "")) == join([observation_verdict_label(verdict: Absent), "apply"], "/") +} + +test fn two_live_namesakes_conflict() -> Bool { + decide(status: 200, body: join(["[", row(state: "ACTIVE"), ",", row(state: "ACTIVE"), "]"], "")) == join([observation_verdict_label(verdict: Conflict), "refuse"], "/") +} + +test fn a_rejected_key_is_inaccessible_not_absent() -> Bool { + decide(status: 401, body: "{\"code\":\"NotAuthenticated\"}") == join([observation_verdict_label(verdict: Inaccessible), "refuse"], "/") +} + +// The failure arms refuse. None of them widens into a create. +test fn an_unparseable_or_wrong_shaped_answer_refuses() -> Bool { + decide(status: 200, body: "not json") == join([observation_verdict_label(verdict: UnknownRefused), "refuse"], "/") + && decide(status: 200, body: "{\"items\":[]}") == join([observation_verdict_label(verdict: UnknownRefused), "refuse"], "/") + && decide(status: 500, body: "") == join([observation_verdict_label(verdict: UnknownRefused), "refuse"], "/") +} + +test fn an_exchange_that_never_answered_refuses() -> Bool { + let c = classify_oci_compartment_ensure(read: classify_oci_compartment_list(exchange: OciExchangeRefused { cause: "THE REQUEST WAS NOT SIGNED" })) + match c.decision { + Refuse { reason } => true + Noop => false + Apply { plan } => false + } +} + +// The emitter (extdeps.languages.json.emit serialize_json) writes ": " and ", ". Any JSON is valid for +// OCI. What matters is that this exact string is both hashed and sent, which oci_signed_exchange +// does by construction. +test fn the_create_body_carries_the_three_required_fields() -> Bool { + oci_compartment_create_body() == "{\"compartmentId\": \"ocid1.tenancy.oc1..aaaaaaaaxarkm67vc5fiaqayhea33y2k6j3zdhnt5a4e434ddrrsxipmcf7a\", \"name\": \"gunbc-always-free\", \"description\": \"gunbc: Always Free compute, created and converged by gunbc.oracle_oci.compartment_ensure\"}" +} From c74c82d86bfe3d2470b3729a48d5acf281dc8cea Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Mon, 5 Oct 2026 03:31:07 +0000 Subject: [PATCH 05/15] OCI compartment ensure: enumerate JsonValue arms (no wildcard over a closed coproduct; floor NonFoldResidueRosterDiverged) Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/oracle_oci/compartment_ensure.dag | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/dag/gunbc/oracle_oci/compartment_ensure.dag b/dag/gunbc/oracle_oci/compartment_ensure.dag index 28ec1a75942..045022ab319 100644 --- a/dag/gunbc/oracle_oci/compartment_ensure.dag +++ b/dag/gunbc/oracle_oci/compartment_ensure.dag @@ -15,7 +15,7 @@ import extdeps.filesystem.filesystem_io { Filesystem } import extdeps.auth.jws { JwsSigningKeyRef, HostKeyFile, JwsSigned, JwsSignRefused } import extdeps.cloud.gcp.secret_ref { SecretRef, HashPending } import extdeps.tools.curl { curl_http_signature_get, curl_http_signature_post_json, CurlWroteHttpStatus, CurlWroteNoHttpStatus, classify_curl_http_code_after_newline, curl_body_before_http_code } -import extdeps.languages.json.emit { JsonValue, JsonString, JsonArray, JsonObject, json_object, json_kv, json_string, serialize_json } +import extdeps.languages.json.emit { JsonValue, JsonString, JsonArray, JsonObject, JsonNull, JsonBool, JsonNumber, json_object, json_kv, json_string, serialize_json } import extdeps.languages.json.parse { parse_json_document, JsonDocumentParsed, JsonDocumentUnreadable, json_document_gap_text, json_member_presence, JsonMemberValue, JsonMemberMissing, JsonMemberNull, JsonMemberUnreadable } import extdeps.cloud.oracle_oci_request_signing { OciApiKeyId, OciRequest, OciHttpMethod, OciGet, OciPost, OciRequestBody, OciNoBody, OciJsonBody, @@ -159,7 +159,11 @@ fn oci_string_member(obj: JsonValue, key: String) -> String { match json_member_presence(obj: obj, key: key) { JsonMemberValue { value } => match value { JsonString { value: s } => s - _ => "" + JsonNull => "" + JsonBool { value: b } => "" + JsonNumber { lexeme } => "" + JsonArray { elements } => "" + JsonObject { members } => "" } JsonMemberMissing => "" JsonMemberNull => "" @@ -191,7 +195,11 @@ fn classify_oci_compartment_list(exchange: OciExchange) -> OciCompartmentRead { JsonDocumentUnreadable { gap } => CompartmentReadRefused { cause: concat("ListCompartments body is not JSON: ", json_document_gap_text(gap: gap)) as NonEmptyStr } JsonDocumentParsed { value } => match value { JsonArray { elements } => CompartmentsListed { compartments: map(elements, e => oci_compartment_of_json(v: e)) } - _ => CompartmentReadRefused { cause: "ListCompartments answered 200 with a body that is not an array" } + JsonObject { members } => CompartmentReadRefused { cause: "ListCompartments answered 200 with an object, not an array" } + JsonNull => CompartmentReadRefused { cause: "ListCompartments answered 200 with null, not an array" } + JsonBool { value: b } => CompartmentReadRefused { cause: "ListCompartments answered 200 with a boolean, not an array" } + JsonNumber { lexeme } => CompartmentReadRefused { cause: "ListCompartments answered 200 with a number, not an array" } + JsonString { value: t } => CompartmentReadRefused { cause: "ListCompartments answered 200 with a string, not an array" } } } } From 1b7fd998b4f756e2d9e54e0dfc2509453300a5f5 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Mon, 5 Oct 2026 04:21:48 +0000 Subject: [PATCH 06/15] OCI signing: body digest through the std sha256_hex_of_text seam (pure fold was ~280k eval steps per digest, over the new-witness budget) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cloud/oracle_oci_request_signing.dag | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/dag/extdeps/cloud/oracle_oci_request_signing.dag b/dag/extdeps/cloud/oracle_oci_request_signing.dag index dfefdd0e571..145389bbfce 100644 --- a/dag/extdeps/cloud/oracle_oci_request_signing.dag +++ b/dag/extdeps/cloud/oracle_oci_request_signing.dag @@ -2,9 +2,9 @@ module extdeps.cloud.oracle_oci_request_signing import extdeps.external_authority { ExternalAuthority } import extdeps.uri { Uri, Https } -import extdeps.crypto.sha2 { sha256 } +import extdeps.numeric.base16 { base16_decode_lower } +import v2.std.optional { Absent, Present } import std.encoding { base64_encode, Standard } -import std.integer { QualifiedOctetsResult, QualifiedOctetsReady, QualifiedOctetsRefused, uint8_octets_of_ints } import std.types { Int, List, NonEmptyStr, String } // OCI API REQUEST SIGNING, the upstream specification: what is signed and how the Authorization @@ -67,13 +67,17 @@ fn oci_ascii_octets(text: String) -> OciBodyOctets { oci_ascii_octets_from(text: text, index: 0, acc: []) } -// The digest is 32 octets each in [0, 255], so the octet admission cannot refuse it. The refused arm -// is the admission's own vocabulary and returns the empty string, which no OCI endpoint accepts as a -// digest, so the request is refused by the server rather than signed over an invented hash. -fn oci_sha256_base64(octets: List) -> String { - match uint8_octets_of_ints(values: sha256(message: octets)) { - QualifiedOctetsReady { value } => base64_encode(octets: value, variant: Standard) - QualifiedOctetsRefused { observed } => "" +// The digest comes from the std sha256_hex_of_text host seam rather than the pure extdeps.crypto.sha2 +// fold. The seam exists for this cost: the pure fold is hundreds of thousands of interpreted steps per +// digest, and every signed POST takes one. The pure fold stays the seam's differential oracle +// (test.claim.sha256_host_known_answer_witness). The seam hashes the text's UTF-8 bytes, which for the +// ASCII body this module admits are the code points oci_ascii_octets checked. Lowercase hex that does +// not decode yields the empty string, which no OCI endpoint accepts as a digest, so the server refuses +// the request rather than one being signed over an invented hash. +fn oci_sha256_base64(text: String) -> String { + match base16_decode_lower(hex: sha256_hex_of_text(text: text)) { + Present { value } => base64_encode(octets: value, variant: Standard) + Absent => "" } } @@ -197,7 +201,7 @@ fn oci_signing_plan(req: OciRequest) -> OciSigningPlan { cause: join(["OCI request body has a non-ASCII character at index ", to_string(index), "; only an ASCII body is signed, so its byte length is its character length"], "") as NonEmptyStr, } OciBodyOctetsReady { octets } => { - let digest = oci_sha256_base64(octets: octets) + let digest = oci_sha256_base64(text: text) let length = string_length(text) OciSigningReady { signing_string: join([ From 9c9cefa78682ea5369b0050ed90b9668a209ac8a Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Mon, 5 Oct 2026 07:48:40 +0000 Subject: [PATCH 07/15] OCI: address review on #13335 (all five findings) 1. bind_oci_always_free_offer takes the plan + member name and decides the fit itself; instance and route derive from the admitted member (no transferable FitsAlwaysFree, no separate region) 2. fallible row admission (gunbc.oracle_oci.api oci_admit_rows): a missing/null/mistyped/empty required field refuses the list instead of reading as absent or converged 3. response headers dumped (-D /dev/stderr); a list answered with opc-next-page refuses 4. tenancy-wide fit (OciOtherUsage); zero quote refuses until TenancyAllocationObserved 5. OciRequestShape = OciGetRequest | OciPostJson: one value drives signing and transport cleanup: compartment outcome carries the typed standing; readback reason kept Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cloud/oracle_oci_request_signing.dag | 41 +-- dag/extdeps/tools/curl.dag | 35 ++- .../auth/fleet_secret_accessor_roster.dag | 2 +- .../host_credential_custody_converge.dag | 2 +- dag/gunbc/oracle_oci/api.dag | 256 ++++++++++++++++ dag/gunbc/oracle_oci/compartment_ensure.dag | 282 +++++++----------- dag/gunbc/product/supplier/oracle_oci.dag | 151 +++++++--- .../oracle_oci_always_free_witness_test.dag | 91 +++--- ...le_oci_compartment_ensure_witness_test.dag | 27 +- ...racle_oci_request_signing_witness_test.dag | 8 +- 10 files changed, 596 insertions(+), 299 deletions(-) create mode 100644 dag/gunbc/oracle_oci/api.dag diff --git a/dag/extdeps/cloud/oracle_oci_request_signing.dag b/dag/extdeps/cloud/oracle_oci_request_signing.dag index 145389bbfce..7cdbc330a9f 100644 --- a/dag/extdeps/cloud/oracle_oci_request_signing.dag +++ b/dag/extdeps/cloud/oracle_oci_request_signing.dag @@ -39,12 +39,20 @@ fn oci_key_id_wire(key: OciApiKeyId) -> String { join([key.tenancy as String, "/", key.user as String, "/", key.fingerprint as String], "") } +// THE METHOD AND THE BODY ARE ONE FACT. A request is a GET with no body or a POST with a JSON body. +// Those are the two operations this repository sends, and the signer and the transport both derive +// from this one value, so a request cannot be signed as one method and sent as another (review on +// gunbc#13335). A DELETE, a PUT, a bodied GET or a bodiless POST have no constructor here, rather than +// being admitted and then sent as something else. Each is added as its own arm when a consumer needs +// it, with the transport operation that sends it. +// // A body is signed by its exact bytes. This module only admits an ASCII body, so its length in bytes // is its length in characters and its octets are its code points. A body with any non-ASCII -// character refuses rather than being hashed under a byte encoding this module does not carry. -type OciRequestBody - = OciNoBody - | OciJsonBody { text: String } +// character refuses rather than being hashed under a byte encoding this module does not carry. An +// empty POST body is still a body: Oracle requires its digest and content-length, and it gets them. +type OciRequestShape + = OciGetRequest + | OciPostJson { text: String } type OciBodyOctets = OciBodyOctetsReady { octets: List } @@ -139,16 +147,10 @@ fn oci_http_date(epoch_seconds: Int) -> String { // ── THE SIGNING STRING ──────────────────────────────────────────────────────────────────────── -type OciHttpMethod - = OciGet - | OciPost - | OciDelete - -fn oci_method_lower(method: OciHttpMethod) -> String { - match method { - OciGet => "get" - OciPost => "post" - OciDelete => "delete" +fn oci_method_lower(shape: OciRequestShape) -> String { + match shape { + OciGetRequest => "get" + OciPostJson { text } => "post" } } @@ -156,11 +158,10 @@ fn oci_method_lower(method: OciHttpMethod) -> String { // it will appear on the wire. The page requires the signed target and the sent target to be the same // bytes. type OciRequest { - method: OciHttpMethod + shape: OciRequestShape host: NonEmptyStr path_and_query: NonEmptyStr date: String - body: OciRequestBody } // What a signer must sign, and the headers that must accompany the request. headers_list is the @@ -176,7 +177,7 @@ type OciSigningPlan | OciSigningRefused { cause: NonEmptyStr } fn oci_request_target_line(req: OciRequest) -> String { - join(["(request-target): ", oci_method_lower(method: req.method), " ", req.path_and_query as String], "") + join(["(request-target): ", oci_method_lower(shape: req.shape), " ", req.path_and_query as String], "") } fn oci_bodiless_signing_string(req: OciRequest) -> String { @@ -188,14 +189,14 @@ fn oci_bodiless_signing_string(req: OciRequest) -> String { } fn oci_signing_plan(req: OciRequest) -> OciSigningPlan { - match req.body { - OciNoBody => OciSigningReady { + match req.shape { + OciGetRequest => OciSigningReady { signing_string: oci_bodiless_signing_string(req: req), headers_list: "date (request-target) host", content_sha256: "", content_length: 0, } - OciJsonBody { text } => + OciPostJson { text } => match oci_ascii_octets(text: text) { OciBodyNotAscii { index } => OciSigningRefused { cause: join(["OCI request body has a non-ASCII character at index ", to_string(index), "; only an ASCII body is signed, so its byte length is its character length"], "") as NonEmptyStr, diff --git a/dag/extdeps/tools/curl.dag b/dag/extdeps/tools/curl.dag index fadc7eed945..0d02952000c 100644 --- a/dag/extdeps/tools/curl.dag +++ b/dag/extdeps/tools/curl.dag @@ -400,7 +400,11 @@ fn curl_exit_outcome_name(outcome: CurlExitOutcome) -> String { // The Authorization value appears in argv. It is a signature bound to one request and one date // (Oracle rejects a date more than 5 minutes off), not the key, which never leaves the signer. // The response body comes back followed by a newline and the status, read by -// classify_curl_http_code_after_newline. +// classify_curl_http_code_after_newline. The response HEADERS are dumped to stderr (-D /dev/stderr), +// because a paged API carries its continuation token in a header (Oracle: opc-next-page), and a +// list read without it cannot tell a finished listing from one with more pages. -sS writes only +// errors to stderr, and those arrive only on a nonzero exit, which callers refuse before reading +// headers. service curl.HttpSignature { operation SignedGet { requires Network @@ -412,7 +416,7 @@ service curl.HttpSignature { } readonly transport shell { - argv: [curl_program().invocation, "-sS", "--max-time", "60", "-H", "date: {date}", "-H", "authorization: {authorization}", "-w", "\n%\{http_code\}", "{url}"] + argv: [curl_program().invocation, "-sS", "--max-time", "60", "-H", "date: {date}", "-H", "authorization: {authorization}", "-D", "/dev/stderr", "-w", "\n%\{http_code\}", "{url}"] } exit { 0 => Unit @@ -435,7 +439,7 @@ service curl.HttpSignature { stderr: String from "stderr" } transport shell { - argv: [curl_program().invocation, "-sS", "--max-time", "60", "-X", "POST", "-H", "date: {date}", "-H", "authorization: {authorization}", "-H", "content-type: application/json", "-H", "x-content-sha256: {content_sha256}", "--data-binary", "@-", "-w", "\n%\{http_code\}", "{url}"] + argv: [curl_program().invocation, "-sS", "--max-time", "60", "-X", "POST", "-H", "date: {date}", "-H", "authorization: {authorization}", "-H", "content-type: application/json", "-H", "x-content-sha256: {content_sha256}", "--data-binary", "@-", "-D", "/dev/stderr", "-w", "\n%\{http_code\}", "{url}"] stdin: body } exit { @@ -445,6 +449,31 @@ service curl.HttpSignature { } } +// THE VALUE OF ONE RESPONSE HEADER from a -D dump, by case-insensitive name (RFC 9110 section 5.1). +// Absent is the empty string. With redirects, -D writes one header block per response, and the last +// match wins, which is the final response's. +fn curl_ascii_lower_from(s: String, i: Int, acc: String) -> String { + if i >= string_length(s) { + acc + } else { + let c = char_at(s, i) + let cp = code_point(c) + curl_ascii_lower_from(s: s, i: i + 1, acc: concat(acc, if cp >= 65 && cp <= 90 { from_code_point(cp: cp + 32) } else { c })) + } +} + +fn curl_dumped_header_value(dump: String, name: String) -> String { + let prefix = concat(curl_ascii_lower_from(s: name, i: 0, acc: ""), ":") + let n = string_length(prefix) + fold(split(s: dump, delimiter: "\n"), init: "", f: (acc, line) => + if string_length(line) > n && curl_ascii_lower_from(s: substring(s: line, start: 0, end: n), i: 0, acc: "") == prefix { + trim(s: substring(s: line, start: n, end: string_length(line))) + } else { + acc + } + ) +} + // The body is everything before the final newline that the write-out format adds. fn curl_body_before_http_code(stdout: String) -> String { let lines = split(s: stdout, delimiter: "\n") diff --git a/dag/gunbc/auth/fleet_secret_accessor_roster.dag b/dag/gunbc/auth/fleet_secret_accessor_roster.dag index 7454da8d2ee..2875c6a26d6 100644 --- a/dag/gunbc/auth/fleet_secret_accessor_roster.dag +++ b/dag/gunbc/auth/fleet_secret_accessor_roster.dag @@ -19,7 +19,7 @@ import gunbc.auth.approval_decision_store { approval_mac_key_secret_ref } import gunbc.auth.approval_request_submission { approval_submission_mac_key_secret_ref } import gunbc.auth.approval_store_receipt { approval_store_receipt_mac_key_secret_ref } import gunbc.auth.approval_ntfy_deployment { approval_ntfy_publisher_token_secret_ref } -import gunbc.oracle_oci.compartment_ensure { oci_api_signing_key_secret_ref } +import gunbc.oracle_oci.api { oci_api_signing_key_secret_ref } import gunbc.auth.gcp_secret_access { SecretAccessGrant, secret_accessor_grant, secret_access_ensure_for } import gunbc.auth.access_token_source { AccessTokenSource, OperatorSuppliedToken, read_supplied_access_token, SuppliedTokenReady, SuppliedTokenUnavailable, diff --git a/dag/gunbc/fleet/host_credential_custody_converge.dag b/dag/gunbc/fleet/host_credential_custody_converge.dag index 3decea994d3..29488019482 100644 --- a/dag/gunbc/fleet/host_credential_custody_converge.dag +++ b/dag/gunbc/fleet/host_credential_custody_converge.dag @@ -42,7 +42,7 @@ import gunbc.auth.secret_ref_credential { SecretCredentialResolvedVersionMismatch, } import gunbc.github_effect_perform { lifecycle_controller_app_key } -import gunbc.oracle_oci.compartment_ensure { oci_api_signing_key, oci_api_signing_key_secret_ref } +import gunbc.oracle_oci.api { oci_api_signing_key, oci_api_signing_key_secret_ref } import extdeps.auth.jws { JwsSigningKeyRef, HostKeyFile } import gunbc.fabric_cell_converge { fabric_cell_base_dir } import gunbc.typed_remote_file_write { diff --git a/dag/gunbc/oracle_oci/api.dag b/dag/gunbc/oracle_oci/api.dag new file mode 100644 index 00000000000..599fdd2cf90 --- /dev/null +++ b/dag/gunbc/oracle_oci/api.dag @@ -0,0 +1,256 @@ +module gunbc.oracle_oci.api + +import std.types { Bool, Int, List, NonEmptyStr, String } +import std.algebra { trim } +import std.resources { Network } +import std.encoding { base64_encode, Standard } +import std.integer { uint8_octets_of_ints, QualifiedOctetsReady, QualifiedOctetsRefused } +import extdeps.clock { Clock } +import extdeps.auth.jws { JwsSigningKeyRef, HostKeyFile, JwsSigned, JwsSignRefused } +import extdeps.cloud.gcp.secret_ref { SecretRef, HashPending } +import extdeps.tools.curl { + curl_http_signature_get, curl_http_signature_post_json, curl_dumped_header_value, + CurlWroteHttpStatus, CurlWroteNoHttpStatus, classify_curl_http_code_after_newline, curl_body_before_http_code, +} +import extdeps.languages.json.emit { JsonValue, JsonString, JsonArray, JsonObject, JsonNull, JsonBool, JsonNumber } +import extdeps.languages.json.parse { parse_json_document, JsonDocumentParsed, JsonDocumentUnreadable, json_document_gap_text, json_member_presence, JsonMemberValue, JsonMemberMissing, JsonMemberNull, JsonMemberUnreadable } +import extdeps.cloud.oracle_oci_request_signing { + OciApiKeyId, OciRequest, OciRequestShape, OciGetRequest, OciPostJson, + OciSigningReady, OciSigningRefused, oci_signing_plan, oci_http_date, oci_authorization_header, +} +import gunbc.jws_signer_realize { jws_rs256_sign } +import gunbc.fleet_secrets_config { fleet_secrets_gcp_project } + +// THE OPERATOR'S OCI TENANCY AS AN API: credential, identity, one signed exchange, and the two reads +// every converge over it is built from, a complete list and an admitted row. +// +// The key is a host file, never a value in the evaluator. gunbc.host_credential_custody_converge +// delivers the Secret Manager secret below to that path on srv1, root-owned and owner-read, and the +// openssl handler in gunbc.jws_signer_realize signs from the file. Until custody has delivered it, +// openssl cannot open the path and every exchange refuses at the signer. Nothing falls back. +// +// The OCIDs and the fingerprint are identifiers, given by the operator on 2026-10-05. Possessing them +// grants nothing without the private key. + +data oci_api_signing_key_secret_ref: SecretRef = SecretRef { + project: fleet_secrets_gcp_project, + secret: "oracle-oci-api-signing-key", + version: "1", + hash_state: HashPending, +} + +data oci_api_signing_key: JwsSigningKeyRef = HostKeyFile { + path: "/etc/gunbc/oracle-oci/api-signing-key.pem", +} + +data oci_tenancy_ocid: NonEmptyStr = "ocid1.tenancy.oc1..aaaaaaaaxarkm67vc5fiaqayhea33y2k6j3zdhnt5a4e434ddrrsxipmcf7a" + +data oci_api_key_id: OciApiKeyId = OciApiKeyId { + tenancy: oci_tenancy_ocid, + user: "ocid1.user.oc1..aaaaaaaasu4qfbdfzezarj6e6pylvhxwk7eqvfmvpkqvcrirazgrjw3ga5qa", + fingerprint: "00:46:05:c5:42:80:08:ec:48:3a:e1:7c:34:bc:e1:03", +} + +// Hosts in the home region (us-ashburn-1, product.supplier.oracle_oci), from the endpoint templates +// in Oracle's SDK clients: identity.{region}.oci.{secondLevelDomain} and iaas.{region}.{secondLevelDomain}. +// Identity calls must go to the home region. +data oci_identity_host: NonEmptyStr = "identity.us-ashburn-1.oci.oraclecloud.com" + +data oci_iaas_host: NonEmptyStr = "iaas.us-ashburn-1.oraclecloud.com" + +// ── ONE SIGNED EXCHANGE ─────────────────────────────────────────────────────────────────────── +// +// The request shape decides both what is signed and what is sent: OciGetRequest goes out through +// the signed GET, and OciPostJson through the signed POST with that exact text on stdin. There is no +// separate method to disagree with it. next_page is the opc-next-page response header, or empty. + +type OciExchange + = OciAnswered { status: Int, body: String, next_page: String } + | OciExchangeRefused { cause: NonEmptyStr } + +fn oci_refused(cause: String) -> OciExchange { + OciExchangeRefused { cause: cause as NonEmptyStr } +} + +fn oci_signature_base64(signature: List) -> String { + match uint8_octets_of_ints(values: signature) { + QualifiedOctetsReady { value } => base64_encode(octets: value, variant: Standard) + QualifiedOctetsRefused { observed } => "" + } +} + +fn oci_send(shape: OciRequestShape, url: NonEmptyStr, date: NonEmptyStr, authorization: NonEmptyStr, content_sha256: String) -> OciExchange uses net: Network { + let run = match shape { + OciGetRequest => curl_http_signature_get(url: url, date: date, authorization: authorization) + OciPostJson { text } => curl_http_signature_post_json(url: url, date: date, authorization: authorization, content_sha256: content_sha256 as NonEmptyStr, body: text) + } + if run.exit_code != 0 { + oci_refused(cause: join(["curl exit ", to_string(run.exit_code), ": ", trim(s: run.stderr)], "")) + } else { + match classify_curl_http_code_after_newline(stdout: run.stdout) { + CurlWroteNoHttpStatus { cause } => oci_refused(cause: cause as String) + CurlWroteHttpStatus { status } => OciAnswered { + status: status as Int, + body: curl_body_before_http_code(stdout: run.stdout), + next_page: curl_dumped_header_value(dump: run.stderr, name: "opc-next-page"), + } + } + } +} + +// Read the clock, build the signing plan, sign it with the custodied key, send it. Every step that +// cannot complete refuses with its own cause. The date is read immediately before signing, because +// Oracle rejects a date more than 5 minutes from its own clock. +fn oci_signed_exchange(shape: OciRequestShape, host: NonEmptyStr, path_and_query: NonEmptyStr) -> OciExchange uses net: Network { + match parse_int(s: trim(s: Clock.UnixSecs().unix_secs)) { + Absent => oci_refused(cause: "the host clock did not print epoch seconds") + Present { value: now } => { + let date = oci_http_date(epoch_seconds: now) + match oci_signing_plan(req: OciRequest { shape: shape, host: host, path_and_query: path_and_query, date: date }) { + OciSigningRefused { cause } => OciExchangeRefused { cause: cause } + OciSigningReady { signing_string, headers_list, content_sha256, content_length } => + match jws_rs256_sign(signing_input: signing_string as NonEmptyStr, key: oci_api_signing_key) { + JwsSignRefused { detail } => oci_refused(cause: concat("THE REQUEST WAS NOT SIGNED, NOTHING SENT: ", detail)) + JwsSigned { signature } => + oci_send( + shape: shape, + url: join(["https://", host as String, path_and_query as String], "") as NonEmptyStr, + date: date as NonEmptyStr, + authorization: oci_authorization_header(key: oci_api_key_id, headers_list: headers_list, signature_base64: oci_signature_base64(signature: map(signature, o => o as Int))) as NonEmptyStr, + content_sha256: content_sha256, + ) + } + } + } + } +} + +// ── A COMPLETE LIST ─────────────────────────────────────────────────────────────────────────── +// +// ABSENCE NEEDS A COMPLETE OBSERVATION (review on gunbc#13335). Every OCI List call answers a bare +// JSON array and continues through the opc-next-page header. Oracle documents that a page may be +// EMPTY while more results remain, so neither an empty answer nor a short one says the listing is +// done. Only the absence of the continuation header does. A list answered with a continuation +// REFUSES, because this module does not traverse pages, and a converge that read "absent" from a +// first page would create a duplicate of something on the second. A 401 or 404 is the API refusing +// this identity, and Oracle's error body is kept because it names the reason. + +type OciListRead + = OciListed { items: List } + | OciListInaccessible { status: Int, body: String } + | OciListRefused { cause: NonEmptyStr } + +fn oci_list_refused(operation: String, what: String) -> OciListRead { + OciListRefused { cause: join([operation, " ", what], "") as NonEmptyStr } +} + +fn classify_oci_list(operation: String, exchange: OciExchange) -> OciListRead { + match exchange { + OciExchangeRefused { cause } => OciListRefused { cause: cause } + OciAnswered { status, body, next_page } => + if status == 401 || status == 404 { + OciListInaccessible { status: status, body: body } + } else if status != 200 { + oci_list_refused(operation: operation, what: join(["answered ", to_string(status), ": ", body], "")) + } else if next_page != "" { + oci_list_refused(operation: operation, what: "answered with a continuation (opc-next-page); pages are not traversed, so this answer cannot be read as the complete list") + } else { + match parse_json_document(s: body) { + JsonDocumentUnreadable { gap } => oci_list_refused(operation: operation, what: concat("body is not JSON: ", json_document_gap_text(gap: gap))) + JsonDocumentParsed { value } => match value { + JsonArray { elements } => OciListed { items: elements } + JsonObject { members } => oci_list_refused(operation: operation, what: "answered 200 with an object, not an array") + JsonNull => oci_list_refused(operation: operation, what: "answered 200 with null, not an array") + JsonBool { value: b } => oci_list_refused(operation: operation, what: "answered 200 with a boolean, not an array") + JsonNumber { lexeme } => oci_list_refused(operation: operation, what: "answered 200 with a number, not an array") + JsonString { value: t } => oci_list_refused(operation: operation, what: "answered 200 with a string, not an array") + } + } + } + } +} + +fn oci_list(operation: String, host: NonEmptyStr, path_and_query: NonEmptyStr) -> OciListRead uses net: Network { + classify_oci_list(operation: operation, exchange: oci_signed_exchange(shape: OciGetRequest, host: host, path_and_query: path_and_query)) +} + +// ── AN ADMITTED ROW ─────────────────────────────────────────────────────────────────────────── +// +// AN OBSERVATION THAT CANNOT BE UNDERSTOOD IS NOT AN ABSENCE (review on gunbc#13335). A row whose +// required field is missing, null, the wrong type or empty does not get read as a row with an empty +// name, which a name filter would then drop and a converge would then read as "nothing here, create +// it". It refuses the WHOLE list, naming the row and the field. Each consumer states the fields it +// requires, and reads them only from an admitted row. + +type OciField + = OciFieldText { value: NonEmptyStr } + | OciFieldRefused { cause: String } + +fn oci_required_text(row: JsonValue, key: String) -> OciField { + match row { + JsonObject { members } => + match json_member_presence(obj: row, key: key) { + JsonMemberValue { value } => match value { + JsonString { value: s } => if s == "" { OciFieldRefused { cause: concat(key, " is empty") } } else { OciFieldText { value: s as NonEmptyStr } } + JsonNull => OciFieldRefused { cause: concat(key, " is null") } + JsonBool { value: b } => OciFieldRefused { cause: concat(key, " is a boolean, not a string") } + JsonNumber { lexeme } => OciFieldRefused { cause: concat(key, " is a number, not a string") } + JsonArray { elements } => OciFieldRefused { cause: concat(key, " is an array, not a string") } + JsonObject { members: m } => OciFieldRefused { cause: concat(key, " is an object, not a string") } + } + JsonMemberMissing => OciFieldRefused { cause: concat(key, " is missing") } + JsonMemberNull => OciFieldRefused { cause: concat(key, " is null") } + JsonMemberUnreadable { cause } => OciFieldRefused { cause: join([key, " is unreadable: ", cause], "") } + } + JsonNull => OciFieldRefused { cause: "the row is null, not an object" } + JsonBool { value: b } => OciFieldRefused { cause: "the row is a boolean, not an object" } + JsonNumber { lexeme } => OciFieldRefused { cause: "the row is a number, not an object" } + JsonString { value: s } => OciFieldRefused { cause: "the row is a string, not an object" } + JsonArray { elements } => OciFieldRefused { cause: "the row is an array, not an object" } + } +} + +fn oci_field_cause(f: OciField) -> String { + match f { + OciFieldText { value } => "" + OciFieldRefused { cause } => cause + } +} + +fn oci_field_text(f: OciField) -> String { + match f { + OciFieldText { value } => value as String + OciFieldRefused { cause } => "" + } +} + +type OciRowsAdmission + = OciRowsAdmitted { rows: List } + | OciRowRefused { cause: NonEmptyStr } + +fn oci_row_defect(row: JsonValue, keys: List) -> String { + fold(keys, init: "", f: (acc, k) => if acc != "" { acc } else { oci_field_cause(f: oci_required_text(row: row, key: k)) }) +} + +fn oci_admit_rows_from(operation: String, rows: List, keys: List, index: Int, admitted: List) -> OciRowsAdmission { + match rows.first() { + Absent => OciRowsAdmitted { rows: admitted } + Present { value: row } => { + let defect = oci_row_defect(row: row, keys: keys) + if defect != "" { + OciRowRefused { cause: join([operation, " row ", to_string(index), " is not admitted (", defect, "), so the list cannot be read"], "") as NonEmptyStr } + } else { + oci_admit_rows_from(operation: operation, rows: rows.skip(n: 1), keys: keys, index: index + 1, admitted: concat(admitted, [row])) + } + } + } +} + +fn oci_admit_rows(operation: String, rows: List, keys: List) -> OciRowsAdmission { + oci_admit_rows_from(operation: operation, rows: rows, keys: keys, index: 0, admitted: []) +} + +// A field of an ADMITTED row: only call it with a key the row was admitted on. +fn oci_admitted_text(row: JsonValue, key: String) -> String { + oci_field_text(f: oci_required_text(row: row, key: key)) +} diff --git a/dag/gunbc/oracle_oci/compartment_ensure.dag b/dag/gunbc/oracle_oci/compartment_ensure.dag index 045022ab319..2de2d53cfd8 100644 --- a/dag/gunbc/oracle_oci/compartment_ensure.dag +++ b/dag/gunbc/oracle_oci/compartment_ensure.dag @@ -1,120 +1,20 @@ module gunbc.oracle_oci.compartment_ensure -import std.types { Bool, Int, List, NonEmptyStr, String, HttpStatus } -import std.algebra { trim } +import std.types { Bool, Int, List, NonEmptyStr, String } import std.resources { Network } import std.process { ProcessExit, ExitSuccess, exit_failure } -import std.encoding { base64_encode, Standard } -import std.integer { uint8_octets_of_ints, QualifiedOctetsReady, QualifiedOctetsRefused } import std.upsert_decision { ObservationVerdict, Converged, Absent, Conflict, Inaccessible, UnknownRefused, Drifted, Noop, Apply, Refuse, UpsertClassification, upsert_decision_label, observation_verdict_label, } -import extdeps.clock { Clock } import extdeps.filesystem.filesystem_io { Filesystem } -import extdeps.auth.jws { JwsSigningKeyRef, HostKeyFile, JwsSigned, JwsSignRefused } -import extdeps.cloud.gcp.secret_ref { SecretRef, HashPending } -import extdeps.tools.curl { curl_http_signature_get, curl_http_signature_post_json, CurlWroteHttpStatus, CurlWroteNoHttpStatus, classify_curl_http_code_after_newline, curl_body_before_http_code } -import extdeps.languages.json.emit { JsonValue, JsonString, JsonArray, JsonObject, JsonNull, JsonBool, JsonNumber, json_object, json_kv, json_string, serialize_json } -import extdeps.languages.json.parse { parse_json_document, JsonDocumentParsed, JsonDocumentUnreadable, json_document_gap_text, json_member_presence, JsonMemberValue, JsonMemberMissing, JsonMemberNull, JsonMemberUnreadable } -import extdeps.cloud.oracle_oci_request_signing { - OciApiKeyId, OciRequest, OciHttpMethod, OciGet, OciPost, OciRequestBody, OciNoBody, OciJsonBody, - OciSigningReady, OciSigningRefused, oci_signing_plan, oci_http_date, oci_authorization_header, -} -import gunbc.jws_signer_realize { jws_rs256_sign } -import gunbc.fleet_secrets_config { fleet_secrets_gcp_project } - -// THE OPERATOR'S OCI TENANCY: CREDENTIAL, IDENTITY AND THE COMPARTMENT THE FREE INSTANCES LIVE IN. -// -// The key is a host file, never a value in the evaluator. gunbc.host_credential_custody_converge -// delivers the Secret Manager secret below to that path on srv1, root-owned and owner-read, and the -// openssl handler in gunbc.jws_signer_realize signs from the file. Until custody has delivered it, -// openssl cannot open the path and every exchange refuses at the signer. Nothing falls back. -// -// The OCIDs and the fingerprint are identifiers, given by the operator on 2026-10-05. They are -// public in the sense that matters here: possessing them grants nothing without the private key. - -data oci_api_signing_key_secret_ref: SecretRef = SecretRef { - project: fleet_secrets_gcp_project, - secret: "oracle-oci-api-signing-key", - version: "1", - hash_state: HashPending, -} - -data oci_api_signing_key: JwsSigningKeyRef = HostKeyFile { - path: "/etc/gunbc/oracle-oci/api-signing-key.pem", -} - -data oci_tenancy_ocid: NonEmptyStr = "ocid1.tenancy.oc1..aaaaaaaaxarkm67vc5fiaqayhea33y2k6j3zdhnt5a4e434ddrrsxipmcf7a" - -data oci_api_key_id: OciApiKeyId = OciApiKeyId { - tenancy: oci_tenancy_ocid, - user: "ocid1.user.oc1..aaaaaaaasu4qfbdfzezarj6e6pylvhxwk7eqvfmvpkqvcrirazgrjw3ga5qa", - fingerprint: "00:46:05:c5:42:80:08:ec:48:3a:e1:7c:34:bc:e1:03", -} - -// Identity calls must go to the home region (us-ashburn-1, product.supplier.oracle_oci). The host -// template is Oracle's own, from its SDK's identity client: identity.{region}.oci.{secondLevelDomain}. -data oci_identity_host: NonEmptyStr = "identity.us-ashburn-1.oci.oraclecloud.com" - -// ── ONE SIGNED EXCHANGE ─────────────────────────────────────────────────────────────────────── - -type OciExchange - = OciAnswered { status: Int, body: String } - | OciExchangeRefused { cause: NonEmptyStr } - -fn oci_refused(cause: String) -> OciExchange { - OciExchangeRefused { cause: cause as NonEmptyStr } -} - -fn oci_signature_base64(signature: List) -> String { - match uint8_octets_of_ints(values: signature) { - QualifiedOctetsReady { value } => base64_encode(octets: value, variant: Standard) - QualifiedOctetsRefused { observed } => "" - } -} - -fn oci_send(method: OciHttpMethod, url: NonEmptyStr, date: NonEmptyStr, authorization: NonEmptyStr, content_sha256: String, body: OciRequestBody) -> OciExchange uses net: Network { - let run = match body { - OciNoBody => curl_http_signature_get(url: url, date: date, authorization: authorization) - OciJsonBody { text } => curl_http_signature_post_json(url: url, date: date, authorization: authorization, content_sha256: content_sha256 as NonEmptyStr, body: text) - } - if run.exit_code != 0 { - oci_refused(cause: join(["curl exit ", to_string(run.exit_code), ": ", trim(s: run.stderr)], "")) - } else { - match classify_curl_http_code_after_newline(stdout: run.stdout) { - CurlWroteNoHttpStatus { cause } => oci_refused(cause: cause as String) - CurlWroteHttpStatus { status } => OciAnswered { status: status as Int, body: curl_body_before_http_code(stdout: run.stdout) } - } - } -} - -// Read the clock, build the signing plan, sign it with the custodied key, send it. Every step that -// cannot complete refuses with its own cause. The date is read immediately before signing, because -// Oracle rejects a date more than 5 minutes from its own clock. -fn oci_signed_exchange(method: OciHttpMethod, host: NonEmptyStr, path_and_query: NonEmptyStr, body: OciRequestBody) -> OciExchange uses net: Network { - match parse_int(s: trim(s: Clock.UnixSecs().unix_secs)) { - Absent => oci_refused(cause: "the host clock did not print epoch seconds") - Present { value: now } => { - let date = oci_http_date(epoch_seconds: now) - match oci_signing_plan(req: OciRequest { method: method, host: host, path_and_query: path_and_query, date: date, body: body }) { - OciSigningRefused { cause } => OciExchangeRefused { cause: cause } - OciSigningReady { signing_string, headers_list, content_sha256, content_length } => - match jws_rs256_sign(signing_input: signing_string as NonEmptyStr, key: oci_api_signing_key) { - JwsSignRefused { detail } => oci_refused(cause: concat("THE REQUEST WAS NOT SIGNED, NOTHING SENT: ", detail)) - JwsSigned { signature } => - oci_send( - method: method, - url: join(["https://", host as String, path_and_query as String], "") as NonEmptyStr, - date: date as NonEmptyStr, - authorization: oci_authorization_header(key: oci_api_key_id, headers_list: headers_list, signature_base64: oci_signature_base64(signature: map(signature, o => o as Int))) as NonEmptyStr, - content_sha256: content_sha256, - body: body, - ) - } - } - } - } +import extdeps.languages.json.emit { JsonValue, json_object, json_kv, json_string, serialize_json } +import extdeps.cloud.oracle_oci_request_signing { OciGetRequest, OciPostJson } +import gunbc.oracle_oci.api { + OciExchange, OciAnswered, OciExchangeRefused, + OciListRead, OciListed, OciListInaccessible, OciListRefused, classify_oci_list, + OciRowsAdmission, OciRowsAdmitted, OciRowRefused, oci_admit_rows, oci_admitted_text, + oci_signed_exchange, oci_tenancy_ocid, oci_api_key_id, oci_identity_host, } // ── THE COMPARTMENT ─────────────────────────────────────────────────────────────────────────── @@ -144,65 +44,39 @@ fn oci_compartment_state(raw: String) -> OciCompartmentState { else { CompartmentStateUnknown { raw: raw } } } +// An ADMITTED compartment row: its id, name and lifecycle state were each present as a non-empty +// string, so none of them is a stand-in. type OciCompartment { - id: String - name: String + id: NonEmptyStr + name: NonEmptyStr state: OciCompartmentState } -type OciCompartmentRead - = CompartmentsListed { compartments: List } - | CompartmentReadInaccessible { status: Int, body: String } - | CompartmentReadRefused { cause: NonEmptyStr } - -fn oci_string_member(obj: JsonValue, key: String) -> String { - match json_member_presence(obj: obj, key: key) { - JsonMemberValue { value } => match value { - JsonString { value: s } => s - JsonNull => "" - JsonBool { value: b } => "" - JsonNumber { lexeme } => "" - JsonArray { elements } => "" - JsonObject { members } => "" - } - JsonMemberMissing => "" - JsonMemberNull => "" - JsonMemberUnreadable { cause } => "" - } -} +data oci_compartment_required_fields: List = ["id", "name", "lifecycleState"] -fn oci_compartment_of_json(v: JsonValue) -> OciCompartment { +fn oci_compartment_of_admitted(row: JsonValue) -> OciCompartment { OciCompartment { - id: oci_string_member(obj: v, key: "id"), - name: oci_string_member(obj: v, key: "name"), - state: oci_compartment_state(raw: oci_string_member(obj: v, key: "lifecycleState")), + id: oci_admitted_text(row: row, key: "id") as NonEmptyStr, + name: oci_admitted_text(row: row, key: "name") as NonEmptyStr, + state: oci_compartment_state(raw: oci_admitted_text(row: row, key: "lifecycleState")), } } -// ListCompartments answers a bare JSON array of Compartment (Oracle's SDK: list[Compartment]). A 401 -// or 404 is the API refusing this identity, and its body is kept, because Oracle's error body names -// the reason. Anything else that is not a 200 array refuses with what it was. +type OciCompartmentRead + = CompartmentsListed { compartments: List } + | CompartmentReadInaccessible { status: Int, body: String } + | CompartmentReadRefused { cause: NonEmptyStr } + +// A complete list whose every row is admitted, or a refusal. A malformed row refuses the read rather +// than being filtered out, because filtering it would turn "could not read" into "absent". fn classify_oci_compartment_list(exchange: OciExchange) -> OciCompartmentRead { - match exchange { - OciExchangeRefused { cause } => CompartmentReadRefused { cause: cause } - OciAnswered { status, body } => - if status == 401 || status == 404 { - CompartmentReadInaccessible { status: status, body: body } - } else if status != 200 { - CompartmentReadRefused { cause: join(["ListCompartments answered ", to_string(status), ": ", body], "") as NonEmptyStr } - } else { - match parse_json_document(s: body) { - JsonDocumentUnreadable { gap } => CompartmentReadRefused { cause: concat("ListCompartments body is not JSON: ", json_document_gap_text(gap: gap)) as NonEmptyStr } - JsonDocumentParsed { value } => match value { - JsonArray { elements } => CompartmentsListed { compartments: map(elements, e => oci_compartment_of_json(v: e)) } - JsonObject { members } => CompartmentReadRefused { cause: "ListCompartments answered 200 with an object, not an array" } - JsonNull => CompartmentReadRefused { cause: "ListCompartments answered 200 with null, not an array" } - JsonBool { value: b } => CompartmentReadRefused { cause: "ListCompartments answered 200 with a boolean, not an array" } - JsonNumber { lexeme } => CompartmentReadRefused { cause: "ListCompartments answered 200 with a number, not an array" } - JsonString { value: t } => CompartmentReadRefused { cause: "ListCompartments answered 200 with a string, not an array" } - } - } - } + match classify_oci_list(operation: "ListCompartments", exchange: exchange) { + OciListRefused { cause } => CompartmentReadRefused { cause: cause } + OciListInaccessible { status, body } => CompartmentReadInaccessible { status: status, body: body } + OciListed { items } => match oci_admit_rows(operation: "ListCompartments", rows: items, keys: oci_compartment_required_fields) { + OciRowRefused { cause } => CompartmentReadRefused { cause: cause } + OciRowsAdmitted { rows } => CompartmentsListed { compartments: map(rows, r => oci_compartment_of_admitted(row: r)) } + } } } @@ -211,7 +85,7 @@ fn oci_compartment_list_path() -> NonEmptyStr { } fn read_oci_free_compartment() -> OciCompartmentRead uses net: Network { - classify_oci_compartment_list(exchange: oci_signed_exchange(method: OciGet, host: oci_identity_host, path_and_query: oci_compartment_list_path(), body: OciNoBody)) + classify_oci_compartment_list(exchange: oci_signed_exchange(shape: OciGetRequest, host: oci_identity_host, path_and_query: oci_compartment_list_path())) } type OciCompartmentCreatePlan { @@ -241,6 +115,10 @@ fn oci_compartment_is_live(c: OciCompartment) -> Bool { } } +fn oci_live_namesakes(compartments: List) -> List { + filter(compartments, c => (c.name as String) == (oci_free_compartment_name as String) && oci_compartment_is_live(c: c)) +} + // PURE, SO EVERY ARM IS WITNESSABLE OVER A SUPPLIED READ. A deleted compartment of the same name is // history, not a holder of the name for this decision. One live compartment that is ACTIVE is the // goal. One that is still CREATING refuses with "re-run", because readback has not reached the goal @@ -250,7 +128,7 @@ fn classify_oci_compartment_ensure(read: OciCompartmentRead) -> UpsertClassifica CompartmentReadRefused { cause } => oci_compartment_refuse(verdict: UnknownRefused, reason: concat("THE COMPARTMENT READ WAS REFUSED, NOTHING CREATED: ", cause as String)) CompartmentReadInaccessible { status, body } => oci_compartment_refuse(verdict: Inaccessible, reason: join(["OCI refused this API key (", to_string(status), "), NOTHING CREATED. Check that the key with fingerprint ", oci_api_key_id.fingerprint as String, " is still on the user, and that the user may inspect compartments: ", body], "")) CompartmentsListed { compartments } => { - let live = filter(compartments, c => c.name == (oci_free_compartment_name as String) && oci_compartment_is_live(c: c)) + let live = oci_live_namesakes(compartments: compartments) if live.length() == 0 { UpsertClassification { verdict: Absent, decision: Apply { plan: OciCompartmentCreatePlan { body: oci_compartment_create_body() } } } } else if live.length() > 1 { @@ -260,11 +138,11 @@ fn classify_oci_compartment_ensure(read: OciCompartmentRead) -> UpsertClassifica Absent => oci_compartment_refuse(verdict: UnknownRefused, reason: "the live compartment list was nonempty and had no first element") Present { value: c } => match c.state { CompartmentActive => UpsertClassification { verdict: Converged, decision: Noop } - CompartmentCreating => oci_compartment_refuse(verdict: Drifted, reason: join(["compartment ", c.id, " is still CREATING; run this entry again"], "")) - CompartmentInactive => oci_compartment_refuse(verdict: Conflict, reason: join(["compartment ", c.id, " is INACTIVE; an operator must reactivate or replace it"], "")) - CompartmentDeleting => oci_compartment_refuse(verdict: Conflict, reason: join(["compartment ", c.id, " is DELETING; wait for the deletion, then run this entry again"], "")) + CompartmentCreating => oci_compartment_refuse(verdict: Drifted, reason: join(["compartment ", c.id as String, " is still CREATING; run this entry again"], "")) + CompartmentInactive => oci_compartment_refuse(verdict: Conflict, reason: join(["compartment ", c.id as String, " is INACTIVE; an operator must reactivate or replace it"], "")) + CompartmentDeleting => oci_compartment_refuse(verdict: Conflict, reason: join(["compartment ", c.id as String, " is DELETING; wait for the deletion, then run this entry again"], "")) CompartmentDeleted => oci_compartment_refuse(verdict: UnknownRefused, reason: "a deleted compartment passed the live filter") - CompartmentStateUnknown { raw } => oci_compartment_refuse(verdict: UnknownRefused, reason: join(["compartment ", c.id, " reports lifecycle state ", raw, ", which is not in Oracle's published set"], "")) + CompartmentStateUnknown { raw } => oci_compartment_refuse(verdict: UnknownRefused, reason: join(["compartment ", c.id as String, " reports lifecycle state ", raw, ", which is not in Oracle's published set"], "")) } } } @@ -272,6 +150,30 @@ fn classify_oci_compartment_ensure(read: OciCompartmentRead) -> UpsertClassifica } } +// THE COMPARTMENT AS THE NEXT CONVERGE'S INPUT. Network and instances are created inside it, so they +// need its id, and they get it as a typed value from the read that classified converged, never by +// re-reading or by parsing a receipt line. +type OciCompartmentStanding + = CompartmentReady { id: NonEmptyStr } + | CompartmentNotReady { cause: NonEmptyStr } + +fn oci_compartment_standing_of(read: OciCompartmentRead) -> OciCompartmentStanding { + let classified = classify_oci_compartment_ensure(read: read) + match classified.decision { + Refuse { reason } => CompartmentNotReady { cause: reason } + Apply { plan } => CompartmentNotReady { cause: "the compartment does not exist yet" } + Noop => match read { + CompartmentsListed { compartments } => + match oci_live_namesakes(compartments: compartments).first() { + Present { value: c } => CompartmentReady { id: c.id } + Absent => CompartmentNotReady { cause: "a converged compartment read had no live compartment" } + } + CompartmentReadInaccessible { status, body } => CompartmentNotReady { cause: "a converged classification came from an inaccessible read" } + CompartmentReadRefused { cause } => CompartmentNotReady { cause: cause } + } + } +} + // ── THE EFFECTFUL HALF ──────────────────────────────────────────────────────────────────────── fn oci_compartment_line(classified: UpsertClassification, detail: String) -> String { @@ -280,34 +182,54 @@ fn oci_compartment_line(classified: UpsertClassification OciCompartmentEnsureOutcome { + let classified = classify_oci_compartment_ensure(read: read) + let reason = match classified.decision { + Refuse { reason } => reason as String + Noop => "" + Apply { plan } => "" + } + OciCompartmentEnsureOutcome { + line: oci_compartment_line(classified: classified, detail: join([detail, reason], " ")), + standing: oci_compartment_standing_of(read: read), + } +} + +fn oci_compartment_stopped(line: String) -> OciCompartmentEnsureOutcome { + OciCompartmentEnsureOutcome { line: line, standing: CompartmentNotReady { cause: line as NonEmptyStr } } } // Create is not trusted as the answer. After a 200, the compartment is read back through the same -// list, and only that read decides the outcome. A just-created compartment is normally CREATING, so -// the first run ends "re-run" and the second ends converged. +// complete list, and only that read decides the outcome, reason included. A just-created compartment +// is normally CREATING, so the first run ends "re-run" and the second ends converged. fn apply_oci_compartment_create(plan: OciCompartmentCreatePlan) -> OciCompartmentEnsureOutcome uses net: Network { - match oci_signed_exchange(method: OciPost, host: oci_identity_host, path_and_query: "/20160918/compartments", body: OciJsonBody { text: plan.body }) { - OciExchangeRefused { cause } => OciCompartmentEnsureOutcome { line: concat("oci compartment create refused before an answer: ", cause as String), held: false } - OciAnswered { status, body } => + match oci_signed_exchange(shape: OciPostJson { text: plan.body }, host: oci_identity_host, path_and_query: "/20160918/compartments") { + OciExchangeRefused { cause } => oci_compartment_stopped(line: concat("oci compartment create refused before an answer: ", cause as String)) + OciAnswered { status, body, next_page } => if status != 200 { - OciCompartmentEnsureOutcome { line: join(["oci compartment create answered ", to_string(status), ": ", body], ""), held: false } + oci_compartment_stopped(line: join(["oci compartment create answered ", to_string(status), ": ", body], "")) } else { - let after = classify_oci_compartment_ensure(read: read_oci_free_compartment()) - OciCompartmentEnsureOutcome { - line: oci_compartment_line(classified: after, detail: "(readback after create)"), - held: match after.decision { Noop => true Apply { plan } => false Refuse { reason } => false }, - } + oci_compartment_outcome(read: read_oci_free_compartment(), detail: "(readback after create)") } } } fn ensure_oci_free_compartment() -> OciCompartmentEnsureOutcome uses net: Network { - let classified = classify_oci_compartment_ensure(read: read_oci_free_compartment()) - match classified.decision { - Noop => OciCompartmentEnsureOutcome { line: oci_compartment_line(classified: classified, detail: ""), held: true } - Refuse { reason } => OciCompartmentEnsureOutcome { line: oci_compartment_line(classified: classified, detail: reason as String), held: false } + let read = read_oci_free_compartment() + match classify_oci_compartment_ensure(read: read).decision { Apply { plan } => apply_oci_compartment_create(plan: plan) + Noop => oci_compartment_outcome(read: read, detail: "") + Refuse { reason } => oci_compartment_outcome(read: read, detail: "") + } +} + +fn oci_compartment_held(outcome: OciCompartmentEnsureOutcome) -> Bool { + match outcome.standing { + CompartmentReady { id } => true + CompartmentNotReady { cause } => false } } @@ -320,7 +242,7 @@ fn ensure() -> ProcessExit uses net: Network { let written = Filesystem.Write(path: oci_compartment_ensure_receipt_path, content: outcome.line) if !written.success { exit_failure(reason: concat("THE RECEIPT COULD NOT BE WRITTEN: ", written.error)) - } else if outcome.held { + } else if oci_compartment_held(outcome: outcome) { ExitSuccess } else { exit_failure(reason: outcome.line) diff --git a/dag/gunbc/product/supplier/oracle_oci.dag b/dag/gunbc/product/supplier/oracle_oci.dag index 12bf5887d74..f2af23d9d9e 100644 --- a/dag/gunbc/product/supplier/oracle_oci.dag +++ b/dag/gunbc/product/supplier/oracle_oci.dag @@ -1,6 +1,7 @@ module product.supplier.oracle_oci import std.types { Bool, List, NonEmptyStr } +import v2.std.optional { Absent, Present } import std.nat { Nat } import std.decl_ref { DeclarationRef, decl_ref } import std.measure { @@ -9,6 +10,7 @@ import std.measure { } import extdeps.currency.currency { Usd } import product.fabric.identity { FabricIdentity } +import product.fabric.demand { ObservationReceiptRef } import product.fabric.work { Shape, HardRequirements, CapabilityManifestRef, TrustDomainRef } import product.fabric.envelope { cpu_memory_envelope } import product.fabric.isolation { IsolationProfile } @@ -158,11 +160,26 @@ fn oci_over(planned: Nat, allowed: Nat) -> Bool { planned > allowed } -fn oci_aggregate_breaches(plan: List) -> List { - let ocpus = fold(plan, init: 0, f: (acc, i) => acc + oci_a1_ocpus(i: i)) - let memory = fold(plan, init: 0, f: (acc, i) => acc + oci_a1_memory_gb(i: i)) - let micros = fold(plan, init: 0, f: (acc, i) => acc + oci_micro_count(i: i)) - let block = fold(plan, init: 0, f: (acc, i) => acc + gigabyte_count(g: i.boot_volume)) +// USAGE OUTSIDE THE PLAN. The allowance is TENANCY-wide: block storage counts boot and block volumes +// combined, across every instance and unattached volume, and A1 and the micros are shared by everything +// in the tenancy. A plan evaluated alone is only a NOMINAL fit, so the fit adds whatever else the +// tenancy already holds. oci_no_other_usage is the nominal case: the instance converge uses it to +// decide whether the operator's own plan is even possible, which is a statement about the plan rather +// than about the tenancy. +type OciOtherUsage { + a1_ocpus: Nat + a1_memory_gb: Nat + micros: Nat + block_gb: Nat +} + +data oci_no_other_usage: OciOtherUsage = OciOtherUsage { a1_ocpus: 0, a1_memory_gb: 0, micros: 0, block_gb: 0 } + +fn oci_aggregate_breaches(plan: List, other: OciOtherUsage) -> List { + let ocpus = fold(plan, init: other.a1_ocpus, f: (acc, i) => acc + oci_a1_ocpus(i: i)) + let memory = fold(plan, init: other.a1_memory_gb, f: (acc, i) => acc + oci_a1_memory_gb(i: i)) + let micros = fold(plan, init: other.micros, f: (acc, i) => acc + oci_micro_count(i: i)) + let block = fold(plan, init: other.block_gb, f: (acc, i) => acc + gigabyte_count(g: i.boot_volume)) let ocpu_cap = oci_a1_flex_allowance.always_free_ocpus let memory_cap = gigabyte_count(g: oci_a1_flex_allowance.always_free_memory) let micro_cap = oci_e2_micro_allowance.max_instances @@ -178,9 +195,9 @@ fn oci_instance_breaches(i: OciPlannedInstance) -> List { concat(concat(oci_capacity_breach(i: i), oci_region_breach(i: i)), oci_micro_domain_breach(i: i)) } -fn oci_always_free_fit(plan: List) -> OciAllowanceFit { +fn oci_always_free_fit_beside(plan: List, other: OciOtherUsage) -> OciAllowanceFit { let per_instance = fold(plan, init: [], f: (acc, i) => concat(acc, oci_instance_breaches(i: i))) - let breaches = concat(oci_aggregate_breaches(plan: plan), per_instance) + let breaches = concat(oci_aggregate_breaches(plan: plan, other: other), per_instance) if breaches.length() == 0 { FitsAlwaysFree } else { ExceedsAlwaysFree { breaches: breaches } } } @@ -193,6 +210,11 @@ type OciBreachConsequence data oci_overage_rate_obligation: NonEmptyStr = "The Pay As You Go price of OCI compute and block storage above the Always Free allowance is UNREAD; extdeps.cloud.oracle_oci carries no price row. RESOLVED BY citing Oracle's price list for VM.Standard.A1.Flex OCPU and memory hours and for block volume storage into an extdeps.pricing module." +// The NOMINAL fit: the plan alone, as if the tenancy held nothing else. +fn oci_always_free_fit(plan: List) -> OciAllowanceFit { + oci_always_free_fit_beside(plan: plan, other: oci_no_other_usage) +} + fn oci_breach_consequence(tier: OciTenancyTier) -> OciBreachConsequence { match tier { AlwaysFreeTenancy => ExcessRefusedByVendor @@ -277,53 +299,96 @@ fn oci_shape(size: OciInstanceSize) -> Shape { } } -data oci_always_free_fit_refusal: NonEmptyStr = "This instance belongs to a plan that does not fit the Always Free allowance (see oci_always_free_fit), so a zero quote for it would be false. Bind it only after the plan fits, or bind it through a priced supplier once the overage rate is read." +// THE TENANCY'S OTHER USAGE MUST BE OBSERVED BEFORE A ZERO QUOTE IS TRUE. Nothing reads it today: +// no converge lists the tenancy's volumes and instances outside the plan, and A1 consumption is a +// monthly meter that no observation here carries. So the production standing is the unread arm, and +// the binding refuses on it. +type OciTenancyAllocation + = TenancyAllocationUnread { obligation: DeclarationRef } + | TenancyAllocationObserved { other: OciOtherUsage, receipt: ObservationReceiptRef } + +data oci_tenancy_allocation_unread_obligation: NonEmptyStr = "What the operator's OCI tenancy already holds outside an offered plan (block and boot volumes in every compartment, A1 OCPUs and memory, E2 micro instances) has not been observed. The Always Free allowance is tenancy-wide, so without it a plan's fit is nominal and a zero quote would not be established. RESOLVED BY a receipt from a converge that lists the tenancy's instances, boot volumes and block volumes across compartments, sufficient to fill OciOtherUsage. A1 monthly consumption above steady-state allocation is not carried by OciOtherUsage, and is a further obligation of that receipt." + +data oci_tenancy_allocation: OciTenancyAllocation = TenancyAllocationUnread { + obligation: decl_ref(module_path: "product.supplier.oracle_oci", decl_name: "oci_tenancy_allocation_unread_obligation"), +} +data oci_always_free_fit_refusal: NonEmptyStr = "The plan does not fit the Always Free allowance beside the tenancy's observed other usage (oci_always_free_fit_beside), so a zero quote for any of its members would be false." + +data oci_member_not_in_plan_refusal: NonEmptyStr = "The requested member is not an instance of the admitted plan. A zero quote is established only for a member of the plan whose fit was decided, so nothing else can be bound under it." + +data oci_member_outside_home_region_refusal: NonEmptyStr = "The member is not placed in the tenancy's home region, so it is not Always Free compute and has no zero-quoted route." + +fn oci_plan_member(plan: List, member: NonEmptyStr) -> OciPlannedInstance? { + match filter(plan, i => (i.name as String) == (member as String)).first() { + Present { value } => Present { value: value } + Absent => none + } +} + +fn oci_refused_binding

(member: NonEmptyStr, cause_decl: String) -> OfferBinding

{ + OfferBindingRefused { runs_on_label: member, cause: decl_ref(module_path: "product.supplier.oracle_oci", decl_name: cause_decl) } +} + +// THE FIT IS DECIDED HERE, NOT HANDED IN (review on gunbc#13335). The binding takes the plan and the +// NAME of the member to offer, decides the fit of that plan beside the tenancy's observed other usage, +// and derives both the offered instance and its route from the admitted member. A success computed for +// one plan cannot be carried onto another instance, because no success value crosses this boundary, +// and the route cannot disagree with the member's placement, because nothing supplies it separately. fn bind_oci_always_free_offer

( principal: P, executor: P, - instance: OciPlannedInstance, - plan_fit: OciAllowanceFit, + plan: List, + member: NonEmptyStr, + allocation: OciTenancyAllocation, capabilities: CapabilityManifestRef, trust_domain: TrustDomainRef, readiness: ReadinessObservation, - region: ProviderRegionObservation, ) -> OfferBinding

{ - match plan_fit { - ExceedsAlwaysFree { breaches } => OfferBindingRefused { - runs_on_label: instance.name, - cause: decl_ref(module_path: "product.supplier.oracle_oci", decl_name: "oci_always_free_fit_refusal"), - }, - FitsAlwaysFree => - match provider_route_from_region(provider: oci_provider_ref(), service: oci_service_ref(), region: region) { - ProviderRouteRegionUnobserved { obligation } => - OfferBindingRefused { runs_on_label: instance.name, cause: obligation }, - ProviderRouteObserved { route: bound_route } => - match readiness { - ReadinessUnmeasured { obligation } => - OfferBindingRefused { runs_on_label: instance.name, cause: obligation }, - ReadinessMeasured { ready_at, receipt } => - OfferBound { - bound: SupplierOfferProjection { - offer: SupplierOffer { - id: FabricIdentity { principal: principal, key: instance.name }, - executor: executor, - route: bound_route, - shape: oci_shape(size: instance.size), - capabilities: capabilities, - trust_domain: trust_domain, - isolation: IsolationProfile { guarantees: [] }, - quantity_bound: 1, - ready_at: ready_at, - quote: oci_zero_quote(), - billing_quantum: oci_billing_quantum, - evidence: UnobservedSupply { - obligation: decl_ref(module_path: "product.supplier.oracle_oci", decl_name: "oci_instance_unlaunched_obligation"), + match allocation { + TenancyAllocationUnread { obligation } => OfferBindingRefused { runs_on_label: member, cause: obligation }, + TenancyAllocationObserved { other, receipt } => + match oci_always_free_fit_beside(plan: plan, other: other) { + ExceedsAlwaysFree { breaches } => oci_refused_binding(member: member, cause_decl: "oci_always_free_fit_refusal"), + FitsAlwaysFree => + match oci_plan_member(plan: plan, member: member) { + Absent => oci_refused_binding(member: member, cause_decl: "oci_member_not_in_plan_refusal"), + Present { value: instance } => + match instance.region { + OutsideHomeRegion => oci_refused_binding(member: member, cause_decl: "oci_member_outside_home_region_refusal"), + InHomeRegion => + match provider_route_from_region(provider: oci_provider_ref(), service: oci_service_ref(), region: oci_home_region()) { + ProviderRouteRegionUnobserved { obligation } => + OfferBindingRefused { runs_on_label: member, cause: obligation }, + ProviderRouteObserved { route: bound_route } => + match readiness { + ReadinessUnmeasured { obligation } => + OfferBindingRefused { runs_on_label: member, cause: obligation }, + ReadinessMeasured { ready_at, receipt: readiness_receipt } => + OfferBound { + bound: SupplierOfferProjection { + offer: SupplierOffer { + id: FabricIdentity { principal: principal, key: instance.name }, + executor: executor, + route: bound_route, + shape: oci_shape(size: instance.size), + capabilities: capabilities, + trust_domain: trust_domain, + isolation: IsolationProfile { guarantees: [] }, + quantity_bound: 1, + ready_at: ready_at, + quote: oci_zero_quote(), + billing_quantum: oci_billing_quantum, + evidence: UnobservedSupply { + obligation: decl_ref(module_path: "product.supplier.oracle_oci", decl_name: "oci_instance_unlaunched_obligation"), + }, + }, + unexpressed: [IsolationNotObserved], + }, }, - }, - unexpressed: [IsolationNotObserved], }, }, + }, }, }, } diff --git a/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag b/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag index 86544cfc359..9dd60dde234 100644 --- a/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag +++ b/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag @@ -4,9 +4,8 @@ import std.types { Bool, List, NonEmptyStr } import std.nat { Nat } import std.decl_ref { DeclarationRef, decl_ref, declaration_ref_eq } import std.measure { gigabyte, money_amount_micro_count } -import product.fabric.provider_route { ProviderRegionObservation, ProviderRegionRead } import product.fabric.supply { offer_quote_amount, UnobservedSupply, ObservedSupply, QuotedSupply } -import product.supplier.ubicloud { ReadinessObservation, ReadinessMeasured, OfferBound, OfferBindingRefused } +import product.supplier.ubicloud { ReadinessObservation, ReadinessMeasured, OfferBinding, OfferBound, OfferBindingRefused } import extdeps.cloud.oracle_oci { OnDemandCapacity, PreemptibleCapacity, AlwaysFreeTenancy, PayAsYouGoTenancy, } @@ -19,7 +18,7 @@ import product.supplier.oracle_oci { CapacityTypeFreeEligibilityUnread, ExcessRefusedByVendor, ExcessBilledAtUnreadRate, oci_always_free_fit, oci_breach_consequence, bind_oci_always_free_offer, - oci_home_region, oci_readiness, + oci_readiness, oci_tenancy_allocation, OciTenancyAllocation, TenancyAllocationObserved, OciOtherUsage, } // The subject is the allowance fit and the offer seam, at the vendor figures in @@ -125,67 +124,71 @@ test fn a_breach_is_refused_on_free_and_billed_at_an_unread_rate_on_paid() -> Bo } // ── THE OFFER SEAM ──────────────────────────────────────────────────────────────────────────── -data oci_witness_home_region: NonEmptyStr = "oci-witness-home-region" +// +// The binding decides the fit itself and derives the instance and route from the admitted member. +// These rows supply the plan, the member name and an observed tenancy allocation, and require the +// exact refusal each substitution earns. -fn read_region() -> ProviderRegionObservation { - ProviderRegionRead { - region: decl_ref(module_path: "test.claim.supplier.oracle_oci_always_free_witness", decl_name: "oci_witness_home_region"), +fn measured() -> ReadinessObservation { + ReadinessMeasured { ready_at: "2026-10-05T00:00:00Z", receipt: "gunbc.observation.oci-witness-readiness" } +} + +fn observed_other(block_gb: Nat) -> OciTenancyAllocation { + TenancyAllocationObserved { + other: OciOtherUsage { a1_ocpus: 0, a1_memory_gb: 0, micros: 0, block_gb: block_gb }, + receipt: "gunbc.observation.oci-witness-allocation", } } -fn measured() -> ReadinessObservation { - ReadinessMeasured { ready_at: "2026-10-05T00:00:00Z", receipt: "gunbc.observation.oci-witness-readiness" } +fn bind(plan: List, member: NonEmptyStr, allocation: OciTenancyAllocation, readiness: ReadinessObservation) -> OfferBinding { + bind_oci_always_free_offer( + principal: "gunbc", executor: "gunbc", plan: plan, member: member, allocation: allocation, + capabilities: "cap-manifest-oci", trust_domain: "trust-domain-oci", readiness: readiness, + ) } -fn refused_with(instance: OciPlannedInstance, fit: OciAllowanceFit, region: ProviderRegionObservation, readiness: ReadinessObservation, expected: NonEmptyStr) -> Bool { - match bind_oci_always_free_offer( - principal: "gunbc", executor: "gunbc", instance: instance, plan_fit: fit, - capabilities: "cap-manifest-oci", trust_domain: "trust-domain-oci", - readiness: readiness, region: region, - ) { +fn refused_with(b: OfferBinding, expected: NonEmptyStr) -> Bool { + match b { OfferBound { bound } => false OfferBindingRefused { runs_on_label, cause } => declaration_ref_eq(a: cause, b: decl_ref(module_path: "product.supplier.oracle_oci", decl_name: expected)) } } -// RED: the state production is in. The home region is read (Ashburn) and readiness is not, so the -// offer refuses on the readiness obligation, even with a plan that fits. -test fn production_state_refuses_on_unmeasured_readiness() -> Bool { - let i = a1(name: "a", ocpus: 2, gb: 12, boot: 47) - refused_with(instance: i, fit: oci_always_free_fit(plan: [i]), region: oci_home_region(), readiness: oci_readiness, expected: "oci_readiness_unread_obligation") +fn small_plan() -> List { + [a1(name: "a", ocpus: 2, gb: 12, boot: 47)] } -// The production home region yields a route: with readiness measured, the same call binds. -test fn the_production_home_region_binds_once_readiness_is_measured() -> Bool { - let i = a1(name: "a", ocpus: 2, gb: 12, boot: 47) - match bind_oci_always_free_offer( - principal: "gunbc", executor: "gunbc", instance: i, plan_fit: oci_always_free_fit(plan: [i]), - capabilities: "cap-manifest-oci", trust_domain: "trust-domain-oci", - readiness: measured(), region: oci_home_region(), - ) { - OfferBound { bound } => declaration_ref_eq(a: bound.offer.route.region, b: decl_ref(module_path: "extdeps.cloud.oracle_oci", decl_name: "oci_region_us_ashburn_1")) - OfferBindingRefused { runs_on_label, cause } => false - } +// RED: the state production is in. The tenancy's other usage is unread, so no zero quote is bound, +// even for a plan that fits with readiness measured. +test fn production_state_refuses_on_the_unread_tenancy_allocation() -> Bool { + refused_with(b: bind(plan: small_plan(), member: "a", allocation: oci_tenancy_allocation, readiness: measured()), expected: "oci_tenancy_allocation_unread_obligation") +} + +test fn production_readiness_refuses_once_the_allocation_is_observed() -> Bool { + refused_with(b: bind(plan: small_plan(), member: "a", allocation: observed_other(block_gb: 0), readiness: oci_readiness), expected: "oci_readiness_unread_obligation") +} + +// SUBSTITUTION: an oversized instance cannot be bound under a small plan's fit. Named but absent +// from the plan, it refuses as not a member. Put into the plan, the plan no longer fits. +test fn an_oversized_instance_cannot_ride_a_small_plans_fit() -> Bool { + refused_with(b: bind(plan: small_plan(), member: "big", allocation: observed_other(block_gb: 0), readiness: measured()), expected: "oci_member_not_in_plan_refusal") + && refused_with(b: bind(plan: concat(small_plan(), [a1(name: "big", ocpus: 4, gb: 24, boot: 47)]), member: "big", allocation: observed_other(block_gb: 0), readiness: measured()), expected: "oci_always_free_fit_refusal") } -// A plan over the allowance cannot carry a zero quote, even when region and readiness are both read. -test fn an_over_allowance_plan_refuses_before_any_route() -> Bool { - let i = a1(name: "a", ocpus: 4, gb: 24, boot: 47) - refused_with(instance: i, fit: oci_always_free_fit(plan: [i]), region: read_region(), readiness: measured(), expected: "oci_always_free_fit_refusal") +// TENANCY-WIDE: a plan that fits alone does not fit beside 200 GB the tenancy already holds. +test fn other_tenancy_usage_counts_against_the_allowance() -> Bool { + refused_with(b: bind(plan: small_plan(), member: "a", allocation: observed_other(block_gb: 200), readiness: measured()), expected: "oci_always_free_fit_refusal") } -// POSITIVE CONTROL: with everything read, the same call binds a zero-quoted offer. Its evidence is -// still the unobserved arm, because nothing has been launched yet. -test fn a_fitting_plan_with_region_and_readiness_binds_at_zero_unobserved() -> Bool { - let i = a1(name: "a", ocpus: 2, gb: 12, boot: 47) - match bind_oci_always_free_offer( - principal: "gunbc", executor: "gunbc", instance: i, plan_fit: oci_always_free_fit(plan: [i]), - capabilities: "cap-manifest-oci", trust_domain: "trust-domain-oci", - readiness: measured(), region: read_region(), - ) { +// POSITIVE CONTROL: the same member of the same plan, with allocation and readiness observed, binds a +// zero-quoted offer on the Ashburn route. Its evidence is still the unobserved arm, because nothing has +// been launched. +test fn a_member_of_a_fitting_plan_binds_at_zero_on_the_home_region_route() -> Bool { + match bind(plan: small_plan(), member: "a", allocation: observed_other(block_gb: 0), readiness: measured()) { OfferBound { bound } => money_amount_micro_count(m: offer_quote_amount(q: bound.offer.quote)) == 0 + && declaration_ref_eq(a: bound.offer.route.region, b: decl_ref(module_path: "extdeps.cloud.oracle_oci", decl_name: "oci_region_us_ashburn_1")) && match bound.offer.evidence { UnobservedSupply { obligation } => true ObservedSupply { observed_at, receipt } => false diff --git a/dag/test/claim/supplier/oracle_oci_compartment_ensure_witness_test.dag b/dag/test/claim/supplier/oracle_oci_compartment_ensure_witness_test.dag index 026b283034c..971f83258a4 100644 --- a/dag/test/claim/supplier/oracle_oci_compartment_ensure_witness_test.dag +++ b/dag/test/claim/supplier/oracle_oci_compartment_ensure_witness_test.dag @@ -2,8 +2,8 @@ module test.claim.supplier.oracle_oci_compartment_ensure_witness import std.types { Bool, Int, String } import std.upsert_decision { Noop, Apply, Refuse, Converged, Absent, Conflict, Inaccessible, UnknownRefused, Drifted, observation_verdict_label } +import gunbc.oracle_oci.api { OciExchange, OciAnswered, OciExchangeRefused } import gunbc.oracle_oci.compartment_ensure { - OciExchange, OciAnswered, OciExchangeRefused, classify_oci_compartment_list, classify_oci_compartment_ensure, oci_compartment_create_body, } @@ -13,7 +13,7 @@ import gunbc.oracle_oci.compartment_ensure { // is the inhabitance evidence for this boundary. fn decide(status: Int, body: String) -> String { - let c = classify_oci_compartment_ensure(read: classify_oci_compartment_list(exchange: OciAnswered { status: status, body: body })) + let c = classify_oci_compartment_ensure(read: classify_oci_compartment_list(exchange: OciAnswered { status: status, body: body, next_page: "" })) let d = match c.decision { Noop => "noop" Apply { plan } => "apply" @@ -58,6 +58,29 @@ test fn an_unparseable_or_wrong_shaped_answer_refuses() -> Bool { && decide(status: 500, body: "") == join([observation_verdict_label(verdict: UnknownRefused), "refuse"], "/") } +// AN OBSERVATION THAT CANNOT BE UNDERSTOOD IS NOT AN ABSENCE. Each of these 200 answers is a +// well-formed JSON array whose row is not an admitted compartment, and each refuses, where filtering +// the bad row out would have read "absent" and created, or read the id-less ACTIVE row as converged. +test fn a_malformed_row_refuses_rather_than_reading_as_absent_or_converged() -> Bool { + let refused = join([observation_verdict_label(verdict: UnknownRefused), "refuse"], "/") + decide(status: 200, body: "[{}]") == refused + && decide(status: 200, body: "[null]") == refused + && decide(status: 200, body: "[{\"name\":\"gunbc-always-free\",\"lifecycleState\":\"ACTIVE\"}]") == refused + && decide(status: 200, body: "[{\"id\":null,\"name\":\"gunbc-always-free\",\"lifecycleState\":\"ACTIVE\"}]") == refused + && decide(status: 200, body: "[{\"id\":7,\"name\":\"gunbc-always-free\",\"lifecycleState\":\"ACTIVE\"}]") == refused +} + +// ABSENCE NEEDS A COMPLETE LIST. Oracle documents that an empty page may still have more results +// behind it. An empty first page with a continuation refuses, so it cannot authorize a create that +// would duplicate a compartment on the second page. The same body without a continuation creates. +test fn an_empty_page_with_a_continuation_does_not_authorize_creation() -> Bool { + let continued = classify_oci_compartment_ensure(read: classify_oci_compartment_list(exchange: OciAnswered { status: 200, body: "[]", next_page: "page-2-token" })) + let finished = classify_oci_compartment_ensure(read: classify_oci_compartment_list(exchange: OciAnswered { status: 200, body: "[]", next_page: "" })) + let continued_refuses = match continued.decision { Refuse { reason } => true Noop => false Apply { plan } => false } + let finished_creates = match finished.decision { Apply { plan } => true Noop => false Refuse { reason } => false } + continued_refuses && finished_creates +} + test fn an_exchange_that_never_answered_refuses() -> Bool { let c = classify_oci_compartment_ensure(read: classify_oci_compartment_list(exchange: OciExchangeRefused { cause: "THE REQUEST WAS NOT SIGNED" })) match c.decision { diff --git a/dag/test/claim/supplier/oracle_oci_request_signing_witness_test.dag b/dag/test/claim/supplier/oracle_oci_request_signing_witness_test.dag index 03eb9540ae7..80a34218044 100644 --- a/dag/test/claim/supplier/oracle_oci_request_signing_witness_test.dag +++ b/dag/test/claim/supplier/oracle_oci_request_signing_witness_test.dag @@ -2,7 +2,7 @@ module test.claim.supplier.oracle_oci_request_signing_witness import std.types { Bool, Int, String } import extdeps.cloud.oracle_oci_request_signing { - OciRequest, OciGet, OciPost, OciNoBody, OciJsonBody, + OciRequest, OciGetRequest, OciPostJson, OciSigningPlan, OciSigningReady, OciSigningRefused, OciApiKeyId, oci_signing_plan, oci_http_date, oci_authorization_header, @@ -42,11 +42,10 @@ test fn the_http_date_handles_a_leap_day() -> Bool { test fn the_get_signing_string_is_oracles_vector() -> Bool { let req = OciRequest { - method: OciGet, + shape: OciGetRequest, host: "iaas.us-phoenix-1.oraclecloud.com", path_and_query: oracle_vector_path, date: "Thu, 05 Jan 2014 21:31:40 GMT", - body: OciNoBody, } signing_string_of(p: plan_of(req: req)) == join([ "date: Thu, 05 Jan 2014 21:31:40 GMT", @@ -57,11 +56,10 @@ test fn the_get_signing_string_is_oracles_vector() -> Bool { fn post_plan(body: String) -> OciSigningPlan { plan_of(req: OciRequest { - method: OciPost, + shape: OciPostJson { text: body }, host: "iaas.us-phoenix-1.oraclecloud.com", path_and_query: "/20160918/volumeAttachments", date: "Thu, 05 Jan 2014 21:31:40 GMT", - body: OciJsonBody { text: body }, }) } From 6ba202dda64eac91286550a125100ff382798416 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Mon, 5 Oct 2026 08:01:32 +0000 Subject: [PATCH 08/15] =?UTF-8?q?OCI:=20unit=20carriers=20per=20review=207?= =?UTF-8?q?6355=20=E2=80=94=20OciOtherUsage=20memory/block=20as=20Gigabyte?= =?UTF-8?q?;=20drop=20the=20unconsumed=20monthly-meter=20fields=20(cited?= =?UTF-8?q?=20in=20the=20note,=20enter=20with=20their=20consumer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/extdeps/cloud/oracle_oci.dag | 15 ++++++++------- dag/gunbc/product/supplier/oracle_oci.dag | 10 +++++----- .../oracle_oci_always_free_witness_test.dag | 2 +- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/dag/extdeps/cloud/oracle_oci.dag b/dag/extdeps/cloud/oracle_oci.dag index c785a049f94..71fa49ba6fe 100644 --- a/dag/extdeps/cloud/oracle_oci.dag +++ b/dag/extdeps/cloud/oracle_oci.dag @@ -89,19 +89,20 @@ data oci_a1_flex_processor: DeclarationRef = decl_ref(module_path: "extdeps.cpu. // kept as the page writes them, as Gigabyte counts, and are not converted to bytes. A byte figure // would assert a basis the vendor never stated. // -// The hour figures: "All tenancies get the first 1,500 OCPU hours and 9,000 GB hours per month for -// free for VM instances using the VM.Standard.A1.Flex shape". The always_free figures: "For Always -// Free tenancies, this is equivalent to 2 OCPUs and 12 GB of memory." +// The page states the allowance as a monthly meter, "the first 1,500 OCPU hours and 9,000 GB hours per +// month for free for VM instances using the VM.Standard.A1.Flex shape", and then gives its steady-state +// equivalent: "For Always Free tenancies, this is equivalent to 2 OCPUs and 12 GB of memory." Only the +// equivalent is carried as a field, because it is the only one anything here consumes (the allowance +// fit in product.supplier.oracle_oci). The monthly meter enters as typed fields together with its +// consumer, an observation of the tenancy's month-to-date A1 consumption, which +// product.supplier.oracle_oci oci_tenancy_allocation_unread_obligation names as unread. Carrying it +// before then would be a declaration nothing reads. type OciA1FlexAllowance { - ocpu_hours_per_month: Nat - gb_hours_per_month: Nat always_free_ocpus: Nat always_free_memory: Gigabyte } data oci_a1_flex_allowance: OciA1FlexAllowance = OciA1FlexAllowance { - ocpu_hours_per_month: 1500, - gb_hours_per_month: 9000, always_free_ocpus: 2, always_free_memory: gigabyte(count: 12), } diff --git a/dag/gunbc/product/supplier/oracle_oci.dag b/dag/gunbc/product/supplier/oracle_oci.dag index f2af23d9d9e..4400f8b7fbd 100644 --- a/dag/gunbc/product/supplier/oracle_oci.dag +++ b/dag/gunbc/product/supplier/oracle_oci.dag @@ -168,18 +168,18 @@ fn oci_over(planned: Nat, allowed: Nat) -> Bool { // than about the tenancy. type OciOtherUsage { a1_ocpus: Nat - a1_memory_gb: Nat + a1_memory: Gigabyte micros: Nat - block_gb: Nat + block: Gigabyte } -data oci_no_other_usage: OciOtherUsage = OciOtherUsage { a1_ocpus: 0, a1_memory_gb: 0, micros: 0, block_gb: 0 } +data oci_no_other_usage: OciOtherUsage = OciOtherUsage { a1_ocpus: 0, a1_memory: gigabyte(count: 0), micros: 0, block: gigabyte(count: 0) } fn oci_aggregate_breaches(plan: List, other: OciOtherUsage) -> List { let ocpus = fold(plan, init: other.a1_ocpus, f: (acc, i) => acc + oci_a1_ocpus(i: i)) - let memory = fold(plan, init: other.a1_memory_gb, f: (acc, i) => acc + oci_a1_memory_gb(i: i)) + let memory = fold(plan, init: gigabyte_count(g: other.a1_memory), f: (acc, i) => acc + oci_a1_memory_gb(i: i)) let micros = fold(plan, init: other.micros, f: (acc, i) => acc + oci_micro_count(i: i)) - let block = fold(plan, init: other.block_gb, f: (acc, i) => acc + gigabyte_count(g: i.boot_volume)) + let block = fold(plan, init: gigabyte_count(g: other.block), f: (acc, i) => acc + gigabyte_count(g: i.boot_volume)) let ocpu_cap = oci_a1_flex_allowance.always_free_ocpus let memory_cap = gigabyte_count(g: oci_a1_flex_allowance.always_free_memory) let micro_cap = oci_e2_micro_allowance.max_instances diff --git a/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag b/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag index 9dd60dde234..bb601badac5 100644 --- a/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag +++ b/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag @@ -135,7 +135,7 @@ fn measured() -> ReadinessObservation { fn observed_other(block_gb: Nat) -> OciTenancyAllocation { TenancyAllocationObserved { - other: OciOtherUsage { a1_ocpus: 0, a1_memory_gb: 0, micros: 0, block_gb: block_gb }, + other: OciOtherUsage { a1_ocpus: 0, a1_memory: gigabyte(count: 0), micros: 0, block: gigabyte(count: block_gb) }, receipt: "gunbc.observation.oci-witness-allocation", } } From b019c6cd1e03ad264c985840fea58dddf8c77e96 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Mon, 5 Oct 2026 08:14:54 +0000 Subject: [PATCH 09/15] OCI: rename OciOtherUsage.micros -> e2_micro_instances (review 76364: the name read as money micros) Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/product/supplier/oracle_oci.dag | 6 +++--- .../claim/supplier/oracle_oci_always_free_witness_test.dag | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dag/gunbc/product/supplier/oracle_oci.dag b/dag/gunbc/product/supplier/oracle_oci.dag index 4400f8b7fbd..33d267d219d 100644 --- a/dag/gunbc/product/supplier/oracle_oci.dag +++ b/dag/gunbc/product/supplier/oracle_oci.dag @@ -169,16 +169,16 @@ fn oci_over(planned: Nat, allowed: Nat) -> Bool { type OciOtherUsage { a1_ocpus: Nat a1_memory: Gigabyte - micros: Nat + e2_micro_instances: Nat block: Gigabyte } -data oci_no_other_usage: OciOtherUsage = OciOtherUsage { a1_ocpus: 0, a1_memory: gigabyte(count: 0), micros: 0, block: gigabyte(count: 0) } +data oci_no_other_usage: OciOtherUsage = OciOtherUsage { a1_ocpus: 0, a1_memory: gigabyte(count: 0), e2_micro_instances: 0, block: gigabyte(count: 0) } fn oci_aggregate_breaches(plan: List, other: OciOtherUsage) -> List { let ocpus = fold(plan, init: other.a1_ocpus, f: (acc, i) => acc + oci_a1_ocpus(i: i)) let memory = fold(plan, init: gigabyte_count(g: other.a1_memory), f: (acc, i) => acc + oci_a1_memory_gb(i: i)) - let micros = fold(plan, init: other.micros, f: (acc, i) => acc + oci_micro_count(i: i)) + let micros = fold(plan, init: other.e2_micro_instances, f: (acc, i) => acc + oci_micro_count(i: i)) let block = fold(plan, init: gigabyte_count(g: other.block), f: (acc, i) => acc + gigabyte_count(g: i.boot_volume)) let ocpu_cap = oci_a1_flex_allowance.always_free_ocpus let memory_cap = gigabyte_count(g: oci_a1_flex_allowance.always_free_memory) diff --git a/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag b/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag index bb601badac5..e9261518069 100644 --- a/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag +++ b/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag @@ -135,7 +135,7 @@ fn measured() -> ReadinessObservation { fn observed_other(block_gb: Nat) -> OciTenancyAllocation { TenancyAllocationObserved { - other: OciOtherUsage { a1_ocpus: 0, a1_memory: gigabyte(count: 0), micros: 0, block: gigabyte(count: block_gb) }, + other: OciOtherUsage { a1_ocpus: 0, a1_memory: gigabyte(count: 0), e2_micro_instances: 0, block: gigabyte(count: block_gb) }, receipt: "gunbc.observation.oci-witness-allocation", } } From 4962de1a933a93f0ef31e379920d9596815fd6c8 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Mon, 5 Oct 2026 08:34:00 +0000 Subject: [PATCH 10/15] extdeps.cloud.oracle_oci: drop every unconsumed declaration (bandwidth, backup count, AD rule, idle rule, processor ref, extra authority rows); figures and sources stay in the notes with their named consumers (review 76377) Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/extdeps/cloud/oracle_oci.dag | 88 ++++++++------------------------ 1 file changed, 21 insertions(+), 67 deletions(-) diff --git a/dag/extdeps/cloud/oracle_oci.dag b/dag/extdeps/cloud/oracle_oci.dag index 71fa49ba6fe..0b799a3ea42 100644 --- a/dag/extdeps/cloud/oracle_oci.dag +++ b/dag/extdeps/cloud/oracle_oci.dag @@ -3,9 +3,8 @@ module extdeps.cloud.oracle_oci import extdeps.external_authority { ExternalAuthority } import extdeps.uri { Uri, Https } import extdeps.toolchain.types { Architecture, Aarch64, X86_64 } -import std.decl_ref { DeclarationRef, decl_ref } import std.measure { Gigabyte, gigabyte } -import std.types { Bool, NonEmptyStr } +import std.types { NonEmptyStr } import std.nat { Nat } // ORACLE CLOUD INFRASTRUCTURE COMPUTE, AS ITS ALWAYS FREE ALLOWANCE AND ITS TWO FREE SHAPES. @@ -32,19 +31,9 @@ data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { // (the same reason extdeps.cloud.ubicloud gives for ubicloud_runner_types_authority). data oci_always_free_resources_authority: ExternalAuthority = extdeps_external_authority_anchor -data oci_compute_shapes_authority: ExternalAuthority = ExternalAuthority { - uri: Uri { - scheme: Https - locator: "docs.oracle.com/en-us/iaas/Content/Compute/References/computeshapes.htm" - } -} - -data oci_preemptible_authority: ExternalAuthority = ExternalAuthority { - uri: Uri { - scheme: Https - locator: "docs.oracle.com/en-us/iaas/Content/Compute/Concepts/preemptible.htm" - } -} +// Two further Oracle pages are read by this module and cited in the notes that use them: the compute +// shapes page (docs.oracle.com/en-us/iaas/Content/Compute/References/computeshapes.htm) and the +// preemptible page (docs.oracle.com/en-us/iaas/Content/Compute/Concepts/preemptible.htm). // ── THE TWO ALWAYS FREE SHAPES ──────────────────────────────────────────────────────────────── // @@ -79,9 +68,9 @@ fn oci_vcpus_per_ocpu(shape: OciAlwaysFreeShape) -> Nat { } } -// The compute shapes page names the A1.Flex processor as Ampere Altra Q80-30. That part is already a -// catalog row, which is cited here rather than restated. -data oci_a1_flex_processor: DeclarationRef = decl_ref(module_path: "extdeps.cpu.ampere", decl_name: "altra_q8030_catalog") +// The compute shapes page names the A1.Flex processor as Ampere Altra Q80-30, which is the catalog row +// extdeps.cpu.ampere altra_q8030_catalog. No consumer here needs the processor, so the row is named in +// this note rather than bound to a declaration nothing reads. // ── THE ALLOWANCE ───────────────────────────────────────────────────────────────────────────── // @@ -109,29 +98,28 @@ data oci_a1_flex_allowance: OciA1FlexAllowance = OciA1FlexAllowance { // "All tenancies get up to two Always Free VM instances using the VM.Standard.E2.1.Micro shape". // Each one: "1/8th of an OCPU with the ability to use additional CPU resources", 1 GB of memory, and -// "up to 50 Mbps network bandwidth via the internet". +// "up to 50 Mbps network bandwidth via the internet". The count and the memory are consumed (the fit +// and the offer shape). The bandwidth is not, so it stays in this note until a consumer, such as a +// network-capacity term in placement, needs it as a std.measure Bandwidth. type OciE2MicroAllowance { max_instances: Nat memory_per_instance: Gigabyte - internet_bandwidth_megabits_per_second: Nat } data oci_e2_micro_allowance: OciE2MicroAllowance = OciE2MicroAllowance { max_instances: 2, memory_per_instance: gigabyte(count: 1), - internet_bandwidth_megabits_per_second: 50, } // "All tenancies receive a total of 200 GB of Block Volume storage, and five volume backups". Every -// instance's boot volume is a block volume, so this one pool is shared by every free instance. +// instance's boot volume is a block volume, so this one pool is shared by every free instance. The +// backup count is not consumed until something takes volume backups, so it stays in this note. type OciBlockVolumeAllowance { total: Gigabyte - volume_backups: Nat } data oci_block_volume_allowance: OciBlockVolumeAllowance = OciBlockVolumeAllowance { total: gigabyte(count: 200), - volume_backups: 5, } // ── PLACEMENT RULES ─────────────────────────────────────────────────────────────────────────── @@ -140,18 +128,9 @@ data oci_block_volume_allowance: OciBlockVolumeAllowance = OciBlockVolumeAllowan // about one tenancy, not about Oracle, so it is never written here. The consumer has to supply it. // // "Instances using the VM.Standard.E2.1.Micro shape can only be created in one availability domain", -// while A1 instances may use any availability domain, except in South Korea North (Chuncheon). -type OciAvailabilityDomainRule - = AnyAvailabilityDomain - | SingleEligibleAvailabilityDomain - -fn oci_availability_domain_rule(shape: OciAlwaysFreeShape) -> OciAvailabilityDomainRule { - match shape { - VmStandardA1Flex => AnyAvailabilityDomain - VmStandardE21Micro => SingleEligibleAvailabilityDomain - } -} - +// while A1 instances may use any availability domain, except in South Korea North (Chuncheon). Which +// domain admits the micro is a per-tenancy fact, so the consumer reads it from the API's shape list +// (gunbc.oracle_oci.instance_ensure) and plans carry it as their micro placement. // The five capacity types the instance-creation console offers. The Always Free page describes only // ordinary instances. It says nothing about the other four, so the free-eligibility of those four is // UNREAD, which is different from refused. The preemptible page states that preemptible capacity @@ -169,29 +148,10 @@ type OciCapacityType // "Oracle will deem virtual machine and bare metal compute instances as idle if, during a 7-day // period, the following are true: CPU utilization for the 95th percentile is less than 20%, Network // utilization is less than 20%, Memory utilization is less than 20% (applies to A1 shapes only)". -// All the conditions must hold together, so an instance that is busy on any one axis is not idle. -type OciIdleReclamationRule { - window_days: Nat - cpu_p95_percent_below: Nat - network_percent_below: Nat - memory_percent_below: Nat -} - -data oci_idle_reclamation_rule: OciIdleReclamationRule = OciIdleReclamationRule { - window_days: 7, - cpu_p95_percent_below: 20, - network_percent_below: 20, - memory_percent_below: 20, -} - -// The memory condition "applies to A1 shapes only", so on E2.1.Micro the instance can be judged idle -// on CPU and network alone. -fn oci_idle_rule_reads_memory(shape: OciAlwaysFreeShape) -> Bool { - match shape { - VmStandardA1Flex => true - VmStandardE21Micro => false - } -} +// All the conditions must hold together, so an instance busy on any one axis is not idle, and on +// E2.1.Micro the memory condition does not apply. Nothing here observes utilization yet. The rule +// enters as typed rows together with its consumer, a utilization observation of a running instance +// judged against them. // ── ACCOUNT TIERS ───────────────────────────────────────────────────────────────────────────── // @@ -207,17 +167,11 @@ type OciTenancyTier // ── REGIONS ─────────────────────────────────────────────────────────────────────────────────── // -// One row per region a consumer has needed. Read 2026-10-05 from the regions page cited below: +// One row per region a consumer has needed. Read 2026-10-05 from the regions page, +// docs.oracle.com/en-us/iaas/Content/General/Concepts/regions.htm: // US East (Ashburn), identifier us-ashburn-1, region key IAD, realm OC1, three availability domains. // The domain count matters for the micro rule above, because exactly one of the three can host an // E2.1.Micro. -data oci_regions_authority: ExternalAuthority = ExternalAuthority { - uri: Uri { - scheme: Https - locator: "docs.oracle.com/en-us/iaas/Content/General/Concepts/regions.htm" - } -} - type OciRegion { identifier: NonEmptyStr region_key: NonEmptyStr From 5b429f6a89febf8ad01434a201c9e7aa704d8d04 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Mon, 5 Oct 2026 08:47:16 +0000 Subject: [PATCH 11/15] OCI signing: HTTP date over extdeps.units.iso8601_calendar (civil date, floor division, seconds constants); only the RFC 7231 names and layout stay local (review 76382) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cloud/oracle_oci_request_signing.dag | 51 +++++++------------ 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/dag/extdeps/cloud/oracle_oci_request_signing.dag b/dag/extdeps/cloud/oracle_oci_request_signing.dag index 7cdbc330a9f..e142f0fbd1d 100644 --- a/dag/extdeps/cloud/oracle_oci_request_signing.dag +++ b/dag/extdeps/cloud/oracle_oci_request_signing.dag @@ -6,6 +6,8 @@ import extdeps.numeric.base16 { base16_decode_lower } import v2.std.optional { Absent, Present } import std.encoding { base64_encode, Standard } import std.types { Int, List, NonEmptyStr, String } +import extdeps.units.iso8601 { iso8601_seconds_per_minute } +import extdeps.units.iso8601_calendar { iso8601_civil_from_days, iso8601_floor_div, iso8601_seconds_per_day, iso8601_seconds_per_hour, iso8601_two_digits } // OCI API REQUEST SIGNING, the upstream specification: what is signed and how the Authorization // header is spelled. It is read from the signing-requests page cited below on 2026-10-05. @@ -91,8 +93,10 @@ fn oci_sha256_base64(text: String) -> String { // ── THE HTTP DATE ───────────────────────────────────────────────────────────────────────────── // -// The date is derived from epoch seconds, the magnitude extdeps.clock produces, using the -// days-to-civil-date conversion of the proleptic Gregorian calendar. 1970-01-01 was a Thursday. +// The date is derived from epoch seconds, the magnitude extdeps.clock produces. The calendar is +// extdeps.units.iso8601_calendar's (civil date, floor division and the seconds constants), and only +// what RFC 7231's IMF-fixdate adds is here: the English weekday and month names and the layout. +// 1970-01-01 was a Thursday, so day 0 is index 0. // index 0 is the weekday of day 0, 1970-01-01, which was a Thursday. fn oci_weekday_name(index: Int) -> String { @@ -107,41 +111,22 @@ fn oci_month_name(month: Int) -> String { else if month == 10 { "Oct" } else if month == 11 { "Nov" } else { "Dec" } } -fn oci_two_digits(n: Int) -> String { - if n < 10 { join(["0", to_string(n)], "") } else { to_string(n) } -} - -type OciCivilDate { - year: Int - month: Int - day: Int -} - -fn oci_civil_from_days(days: Int) -> OciCivilDate { - let z = days + 719468 - let era = z / 146097 - let doe = z - era * 146097 - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365 - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100) - let mp = (5 * doy + 2) / 153 - let day = doy - (153 * mp + 2) / 5 + 1 - let month = if mp < 10 { mp + 3 } else { mp - 9 } - let year = yoe + era * 400 + (if month <= 2 { 1 } else { 0 }) - OciCivilDate { year: year, month: month, day: day } -} - fn oci_http_date(epoch_seconds: Int) -> String { - let days = epoch_seconds / 86400 - let secs = epoch_seconds - days * 86400 - let civil = oci_civil_from_days(days: days) + let days = iso8601_floor_div(a: epoch_seconds, b: iso8601_seconds_per_day()) + let secs = epoch_seconds - days * iso8601_seconds_per_day() + let civil = iso8601_civil_from_days(days: days) + let hour = secs / iso8601_seconds_per_hour() + let within_hour = secs - hour * iso8601_seconds_per_hour() + let per_minute = iso8601_seconds_per_minute() as Int + let minute = within_hour / per_minute join([ - oci_weekday_name(index: days % 7), ", ", - oci_two_digits(n: civil.day), " ", + oci_weekday_name(index: days - iso8601_floor_div(a: days, b: 7) * 7), ", ", + iso8601_two_digits(n: civil.day), " ", oci_month_name(month: civil.month), " ", to_string(civil.year), " ", - oci_two_digits(n: secs / 3600), ":", - oci_two_digits(n: (secs % 3600) / 60), ":", - oci_two_digits(n: secs % 60), " GMT", + iso8601_two_digits(n: hour), ":", + iso8601_two_digits(n: minute), ":", + iso8601_two_digits(n: within_hour - minute * per_minute), " GMT", ], "") } From 81136e1c17641d70f13a3fae2ef79ccae15ed4e3 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Mon, 5 Oct 2026 08:49:51 +0000 Subject: [PATCH 12/15] OCI signing: content-length is the UTF-8 byte count via std.bytes (drops the hand-written ASCII walk and refusal) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cloud/oracle_oci_request_signing.dag | 69 +++++++------------ ...racle_oci_request_signing_witness_test.dag | 11 +-- 2 files changed, 31 insertions(+), 49 deletions(-) diff --git a/dag/extdeps/cloud/oracle_oci_request_signing.dag b/dag/extdeps/cloud/oracle_oci_request_signing.dag index e142f0fbd1d..023bfb52f77 100644 --- a/dag/extdeps/cloud/oracle_oci_request_signing.dag +++ b/dag/extdeps/cloud/oracle_oci_request_signing.dag @@ -5,6 +5,7 @@ import extdeps.uri { Uri, Https } import extdeps.numeric.base16 { base16_decode_lower } import v2.std.optional { Absent, Present } import std.encoding { base64_encode, Standard } +import std.bytes { bytes_octets, utf8_encode_bytes } import std.types { Int, List, NonEmptyStr, String } import extdeps.units.iso8601 { iso8601_seconds_per_minute } import extdeps.units.iso8601_calendar { iso8601_civil_from_days, iso8601_floor_div, iso8601_seconds_per_day, iso8601_seconds_per_hour, iso8601_two_digits } @@ -48,40 +49,24 @@ fn oci_key_id_wire(key: OciApiKeyId) -> String { // being admitted and then sent as something else. Each is added as its own arm when a consumer needs // it, with the transport operation that sends it. // -// A body is signed by its exact bytes. This module only admits an ASCII body, so its length in bytes -// is its length in characters and its octets are its code points. A body with any non-ASCII -// character refuses rather than being hashed under a byte encoding this module does not carry. An -// empty POST body is still a body: Oracle requires its digest and content-length, and it gets them. +// A body is signed by its exact bytes: its UTF-8 encoding. An empty POST body is still a body: Oracle +// requires its digest and content-length, and it gets them. type OciRequestShape = OciGetRequest | OciPostJson { text: String } -type OciBodyOctets - = OciBodyOctetsReady { octets: List } - | OciBodyNotAscii { index: Int } - -fn oci_ascii_octets_from(text: String, index: Int, acc: List) -> OciBodyOctets { - if index >= string_length(text) { - OciBodyOctetsReady { octets: acc } - } else { - let cp = code_point(char_at(text, index)) - if cp > 127 { - OciBodyNotAscii { index: index } - } else { - oci_ascii_octets_from(text: text, index: index + 1, acc: concat(acc, [cp])) - } - } -} - -fn oci_ascii_octets(text: String) -> OciBodyOctets { - oci_ascii_octets_from(text: text, index: 0, acc: []) +// A body's length is its UTF-8 BYTE count, from std.bytes utf8_encode_bytes, which is the encoding +// the transport writes to curl's stdin and the one the digest seam hashes. So the content-length that +// is signed, the bytes that are hashed and the bytes that are sent are one encoding of one string. +fn oci_body_byte_length(text: String) -> Int { + bytes_octets(b: utf8_encode_bytes(s: text)).length() } // The digest comes from the std sha256_hex_of_text host seam rather than the pure extdeps.crypto.sha2 // fold. The seam exists for this cost: the pure fold is hundreds of thousands of interpreted steps per // digest, and every signed POST takes one. The pure fold stays the seam's differential oracle -// (test.claim.sha256_host_known_answer_witness). The seam hashes the text's UTF-8 bytes, which for the -// ASCII body this module admits are the code points oci_ascii_octets checked. Lowercase hex that does +// (test.claim.sha256_host_known_answer_witness). The seam hashes the text's UTF-8 bytes, the same encoding +// oci_body_byte_length counts and the transport sends. Lowercase hex that does // not decode yields the empty string, which no OCI endpoint accepts as a digest, so the server refuses // the request rather than one being signed over an invented hash. fn oci_sha256_base64(text: String) -> String { @@ -181,27 +166,21 @@ fn oci_signing_plan(req: OciRequest) -> OciSigningPlan { content_sha256: "", content_length: 0, } - OciPostJson { text } => - match oci_ascii_octets(text: text) { - OciBodyNotAscii { index } => OciSigningRefused { - cause: join(["OCI request body has a non-ASCII character at index ", to_string(index), "; only an ASCII body is signed, so its byte length is its character length"], "") as NonEmptyStr, - } - OciBodyOctetsReady { octets } => { - let digest = oci_sha256_base64(text: text) - let length = string_length(text) - OciSigningReady { - signing_string: join([ - oci_bodiless_signing_string(req: req), - join(["content-length: ", to_string(length)], ""), - "content-type: application/json", - join(["x-content-sha256: ", digest], ""), - ], "\n"), - headers_list: "date (request-target) host content-length content-type x-content-sha256", - content_sha256: digest, - content_length: length, - } - } + OciPostJson { text } => { + let digest = oci_sha256_base64(text: text) + let length = oci_body_byte_length(text: text) + OciSigningReady { + signing_string: join([ + oci_bodiless_signing_string(req: req), + join(["content-length: ", to_string(length)], ""), + "content-type: application/json", + join(["x-content-sha256: ", digest], ""), + ], "\n"), + headers_list: "date (request-target) host content-length content-type x-content-sha256", + content_sha256: digest, + content_length: length, } + } } } diff --git a/dag/test/claim/supplier/oracle_oci_request_signing_witness_test.dag b/dag/test/claim/supplier/oracle_oci_request_signing_witness_test.dag index 80a34218044..14263efb261 100644 --- a/dag/test/claim/supplier/oracle_oci_request_signing_witness_test.dag +++ b/dag/test/claim/supplier/oracle_oci_request_signing_witness_test.dag @@ -89,10 +89,13 @@ test fn an_empty_body_digests_to_the_sha256_of_zero_bytes() -> Bool { } } -test fn a_non_ascii_body_refuses_rather_than_signing() -> Bool { - match post_plan(body: "{\"name\":\"caf\u{e9}\"}") { - OciSigningReady { signing_string, headers_list, content_sha256, content_length } => false - OciSigningRefused { cause } => true +// A body's content-length is its UTF-8 byte count, not its character count: "é" is one character and +// two bytes, so {"n":"é"} is 9 characters and 10 bytes. +test fn a_non_ascii_body_signs_its_utf8_byte_length() -> Bool { + match post_plan(body: "{\"n\":\"\u{e9}\"}") { + OciSigningReady { signing_string, headers_list, content_sha256, content_length } => + content_length == 10 && string_contains(s: signing_string, pattern: "content-length: 10") + OciSigningRefused { cause } => false } } From 2dc8a7f2fff1fcc7b2cf460c78d9611e771d924f Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Mon, 5 Oct 2026 08:51:40 +0000 Subject: [PATCH 13/15] OCI: every create carries opc-retry-token derived from the exact request, so a lost reply and a re-run cannot create twice (review 76384) Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/extdeps/tools/curl.dag | 11 ++++++++--- dag/gunbc/oracle_oci/api.dag | 17 +++++++++++++++-- ...acle_oci_compartment_ensure_witness_test.dag | 12 +++++++++++- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/dag/extdeps/tools/curl.dag b/dag/extdeps/tools/curl.dag index 0d02952000c..a847848c057 100644 --- a/dag/extdeps/tools/curl.dag +++ b/dag/extdeps/tools/curl.dag @@ -397,6 +397,10 @@ fn curl_exit_outcome_name(outcome: CurlExitOutcome) -> String { // bytes, which is the value the caller signed. The headers are fixed slots, because the set of // signed headers is the protocol and not a caller's choice. // +// A POST also carries a caller-derived idempotency token in a fixed slot (Oracle: opc-retry-token), +// so a request whose reply was lost can be re-sent without the server performing it twice. It is not +// a signed header. +// // The Authorization value appears in argv. It is a signature bound to one request and one date // (Oracle rejects a date more than 5 minutes off), not the key, which never leaves the signer. // The response body comes back followed by a newline and the status, read by @@ -431,6 +435,7 @@ service curl.HttpSignature { date: NonEmptyStr, authorization: NonEmptyStr, content_sha256: NonEmptyStr, + retry_token: NonEmptyStr, body: String, } output { @@ -439,7 +444,7 @@ service curl.HttpSignature { stderr: String from "stderr" } transport shell { - argv: [curl_program().invocation, "-sS", "--max-time", "60", "-X", "POST", "-H", "date: {date}", "-H", "authorization: {authorization}", "-H", "content-type: application/json", "-H", "x-content-sha256: {content_sha256}", "--data-binary", "@-", "-D", "/dev/stderr", "-w", "\n%\{http_code\}", "{url}"] + argv: [curl_program().invocation, "-sS", "--max-time", "60", "-X", "POST", "-H", "date: {date}", "-H", "authorization: {authorization}", "-H", "content-type: application/json", "-H", "x-content-sha256: {content_sha256}", "-H", "opc-retry-token: {retry_token}", "--data-binary", "@-", "-D", "/dev/stderr", "-w", "\n%\{http_code\}", "{url}"] stdin: body } exit { @@ -492,7 +497,7 @@ fn curl_http_signature_get(url: NonEmptyStr, date: NonEmptyStr, authorization: N CurlSignedRun { exit_code: run.exit_code, stdout: run.stdout, stderr: run.stderr } } -fn curl_http_signature_post_json(url: NonEmptyStr, date: NonEmptyStr, authorization: NonEmptyStr, content_sha256: NonEmptyStr, body: String) -> CurlSignedRun uses net: Network { - let run = curl.HttpSignature.SignedPostJson(url: url, date: date, authorization: authorization, content_sha256: content_sha256, body: body) +fn curl_http_signature_post_json(url: NonEmptyStr, date: NonEmptyStr, authorization: NonEmptyStr, content_sha256: NonEmptyStr, retry_token: NonEmptyStr, body: String) -> CurlSignedRun uses net: Network { + let run = curl.HttpSignature.SignedPostJson(url: url, date: date, authorization: authorization, content_sha256: content_sha256, retry_token: retry_token, body: body) CurlSignedRun { exit_code: run.exit_code, stdout: run.stdout, stderr: run.stderr } } diff --git a/dag/gunbc/oracle_oci/api.dag b/dag/gunbc/oracle_oci/api.dag index 599fdd2cf90..238aee7109d 100644 --- a/dag/gunbc/oracle_oci/api.dag +++ b/dag/gunbc/oracle_oci/api.dag @@ -79,10 +79,22 @@ fn oci_signature_base64(signature: List) -> String { } } -fn oci_send(shape: OciRequestShape, url: NonEmptyStr, date: NonEmptyStr, authorization: NonEmptyStr, content_sha256: String) -> OciExchange uses net: Network { +// A CREATE IS IDEMPOTENT ON THE WIRE (review 76384 on gunbc#13351). Every POST carries Oracle's +// opc-retry-token, which its SDK documents as "a token that uniquely identifies a request so it can be +// retried in case of a timeout or server error without risk of executing that same action again". The +// token is derived from the exact request, its path and its body, so a re-run that re-sends the same +// create after a lost reply gets Oracle's original answer rather than a second resource, and a create +// whose body changed (a newer image, say) is a different request with a different token. Tokens expire +// after 24 hours. A re-run that late reads the earlier resource back through the list before deciding +// to create, so the token is the guard for the window in which the list may not yet show it. +fn oci_retry_token(path_and_query: String, body: String) -> NonEmptyStr { + concat("gunbc-", substring(s: sha256_hex_of_text(text: join([path_and_query, body], "\n")), start: 0, end: 48)) as NonEmptyStr +} + +fn oci_send(shape: OciRequestShape, url: NonEmptyStr, path_and_query: NonEmptyStr, date: NonEmptyStr, authorization: NonEmptyStr, content_sha256: String) -> OciExchange uses net: Network { let run = match shape { OciGetRequest => curl_http_signature_get(url: url, date: date, authorization: authorization) - OciPostJson { text } => curl_http_signature_post_json(url: url, date: date, authorization: authorization, content_sha256: content_sha256 as NonEmptyStr, body: text) + OciPostJson { text } => curl_http_signature_post_json(url: url, date: date, authorization: authorization, content_sha256: content_sha256 as NonEmptyStr, retry_token: oci_retry_token(path_and_query: path_and_query as String, body: text), body: text) } if run.exit_code != 0 { oci_refused(cause: join(["curl exit ", to_string(run.exit_code), ": ", trim(s: run.stderr)], "")) @@ -115,6 +127,7 @@ fn oci_signed_exchange(shape: OciRequestShape, host: NonEmptyStr, path_and_query oci_send( shape: shape, url: join(["https://", host as String, path_and_query as String], "") as NonEmptyStr, + path_and_query: path_and_query, date: date as NonEmptyStr, authorization: oci_authorization_header(key: oci_api_key_id, headers_list: headers_list, signature_base64: oci_signature_base64(signature: map(signature, o => o as Int))) as NonEmptyStr, content_sha256: content_sha256, diff --git a/dag/test/claim/supplier/oracle_oci_compartment_ensure_witness_test.dag b/dag/test/claim/supplier/oracle_oci_compartment_ensure_witness_test.dag index 971f83258a4..978c91e73ae 100644 --- a/dag/test/claim/supplier/oracle_oci_compartment_ensure_witness_test.dag +++ b/dag/test/claim/supplier/oracle_oci_compartment_ensure_witness_test.dag @@ -2,7 +2,7 @@ module test.claim.supplier.oracle_oci_compartment_ensure_witness import std.types { Bool, Int, String } import std.upsert_decision { Noop, Apply, Refuse, Converged, Absent, Conflict, Inaccessible, UnknownRefused, Drifted, observation_verdict_label } -import gunbc.oracle_oci.api { OciExchange, OciAnswered, OciExchangeRefused } +import gunbc.oracle_oci.api { OciExchange, OciAnswered, OciExchangeRefused, oci_retry_token } import gunbc.oracle_oci.compartment_ensure { classify_oci_compartment_list, classify_oci_compartment_ensure, oci_compartment_create_body, } @@ -96,3 +96,13 @@ test fn an_exchange_that_never_answered_refuses() -> Bool { test fn the_create_body_carries_the_three_required_fields() -> Bool { oci_compartment_create_body() == "{\"compartmentId\": \"ocid1.tenancy.oc1..aaaaaaaaxarkm67vc5fiaqayhea33y2k6j3zdhnt5a4e434ddrrsxipmcf7a\", \"name\": \"gunbc-always-free\", \"description\": \"gunbc: Always Free compute, created and converged by gunbc.oracle_oci.compartment_ensure\"}" } + +// The retry token is a function of the exact request: the same create re-sent after a lost reply +// carries the same token, so Oracle answers it once. A changed body or path is a different request. +test fn the_retry_token_is_stable_per_request_and_distinct_across_requests() -> Bool { + let a = oci_retry_token(path_and_query: "/20160918/compartments", body: "{\"name\": \"x\"}") as String + let again = oci_retry_token(path_and_query: "/20160918/compartments", body: "{\"name\": \"x\"}") as String + let other_body = oci_retry_token(path_and_query: "/20160918/compartments", body: "{\"name\": \"y\"}") as String + let other_path = oci_retry_token(path_and_query: "/20160918/vcns", body: "{\"name\": \"x\"}") as String + a == again && a != other_body && a != other_path && string_length(a) == 54 +} From 090cb40d85a74a2d09b73c433aa9ea9a13c6b9d4 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Mon, 5 Oct 2026 09:06:24 +0000 Subject: [PATCH 14/15] OCI: curl timeout is a caller-supplied Second (oci_request_timeout), not an argv literal; memory/block overage arms keep Gigabyte (review 76392) Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/extdeps/tools/curl.dag | 16 +++++++++------- dag/gunbc/oracle_oci/api.dag | 9 +++++++-- dag/gunbc/product/supplier/oracle_oci.dag | 8 ++++---- .../oracle_oci_always_free_witness_test.dag | 4 ++-- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/dag/extdeps/tools/curl.dag b/dag/extdeps/tools/curl.dag index a847848c057..812fbe326ec 100644 --- a/dag/extdeps/tools/curl.dag +++ b/dag/extdeps/tools/curl.dag @@ -412,7 +412,7 @@ fn curl_exit_outcome_name(outcome: CurlExitOutcome) -> String { service curl.HttpSignature { operation SignedGet { requires Network - input { url: NonEmptyStr, date: NonEmptyStr, authorization: NonEmptyStr } + input { url: NonEmptyStr, date: NonEmptyStr, authorization: NonEmptyStr, max_seconds: NonEmptyStr } output { exit_code: Int from "exit_code" stdout: String from "stdout" @@ -420,7 +420,7 @@ service curl.HttpSignature { } readonly transport shell { - argv: [curl_program().invocation, "-sS", "--max-time", "60", "-H", "date: {date}", "-H", "authorization: {authorization}", "-D", "/dev/stderr", "-w", "\n%\{http_code\}", "{url}"] + argv: [curl_program().invocation, "-sS", "--max-time", "{max_seconds}", "-H", "date: {date}", "-H", "authorization: {authorization}", "-D", "/dev/stderr", "-w", "\n%\{http_code\}", "{url}"] } exit { 0 => Unit @@ -434,6 +434,7 @@ service curl.HttpSignature { url: NonEmptyStr, date: NonEmptyStr, authorization: NonEmptyStr, + max_seconds: NonEmptyStr, content_sha256: NonEmptyStr, retry_token: NonEmptyStr, body: String, @@ -444,7 +445,7 @@ service curl.HttpSignature { stderr: String from "stderr" } transport shell { - argv: [curl_program().invocation, "-sS", "--max-time", "60", "-X", "POST", "-H", "date: {date}", "-H", "authorization: {authorization}", "-H", "content-type: application/json", "-H", "x-content-sha256: {content_sha256}", "-H", "opc-retry-token: {retry_token}", "--data-binary", "@-", "-D", "/dev/stderr", "-w", "\n%\{http_code\}", "{url}"] + argv: [curl_program().invocation, "-sS", "--max-time", "{max_seconds}", "-X", "POST", "-H", "date: {date}", "-H", "authorization: {authorization}", "-H", "content-type: application/json", "-H", "x-content-sha256: {content_sha256}", "-H", "opc-retry-token: {retry_token}", "--data-binary", "@-", "-D", "/dev/stderr", "-w", "\n%\{http_code\}", "{url}"] stdin: body } exit { @@ -492,12 +493,13 @@ type CurlSignedRun { stderr: String } -fn curl_http_signature_get(url: NonEmptyStr, date: NonEmptyStr, authorization: NonEmptyStr) -> CurlSignedRun uses net: Network { - let run = curl.HttpSignature.SignedGet(url: url, date: date, authorization: authorization) +// How long a caller waits is the caller's policy, so it is a parameter here, never a literal in the argv. +fn curl_http_signature_get(url: NonEmptyStr, date: NonEmptyStr, authorization: NonEmptyStr, max_time: Second) -> CurlSignedRun uses net: Network { + let run = curl.HttpSignature.SignedGet(url: url, date: date, authorization: authorization, max_seconds: to_string(second_count(s: max_time)) as NonEmptyStr) CurlSignedRun { exit_code: run.exit_code, stdout: run.stdout, stderr: run.stderr } } -fn curl_http_signature_post_json(url: NonEmptyStr, date: NonEmptyStr, authorization: NonEmptyStr, content_sha256: NonEmptyStr, retry_token: NonEmptyStr, body: String) -> CurlSignedRun uses net: Network { - let run = curl.HttpSignature.SignedPostJson(url: url, date: date, authorization: authorization, content_sha256: content_sha256, retry_token: retry_token, body: body) +fn curl_http_signature_post_json(url: NonEmptyStr, date: NonEmptyStr, authorization: NonEmptyStr, max_time: Second, content_sha256: NonEmptyStr, retry_token: NonEmptyStr, body: String) -> CurlSignedRun uses net: Network { + let run = curl.HttpSignature.SignedPostJson(url: url, date: date, authorization: authorization, max_seconds: to_string(second_count(s: max_time)) as NonEmptyStr, content_sha256: content_sha256, retry_token: retry_token, body: body) CurlSignedRun { exit_code: run.exit_code, stdout: run.stdout, stderr: run.stderr } } diff --git a/dag/gunbc/oracle_oci/api.dag b/dag/gunbc/oracle_oci/api.dag index 238aee7109d..29ed1c7b84a 100644 --- a/dag/gunbc/oracle_oci/api.dag +++ b/dag/gunbc/oracle_oci/api.dag @@ -3,6 +3,7 @@ module gunbc.oracle_oci.api import std.types { Bool, Int, List, NonEmptyStr, String } import std.algebra { trim } import std.resources { Network } +import std.measure { Second, second } import std.encoding { base64_encode, Standard } import std.integer { uint8_octets_of_ints, QualifiedOctetsReady, QualifiedOctetsRefused } import extdeps.clock { Clock } @@ -58,6 +59,10 @@ data oci_identity_host: NonEmptyStr = "identity.us-ashburn-1.oci.oraclecloud.com data oci_iaas_host: NonEmptyStr = "iaas.us-ashburn-1.oraclecloud.com" +// How long one OCI request may take before curl gives up. It is this tenancy's policy, not a fact +// about curl or Oracle. A request that runs out of time ends as a curl exit and refuses. +data oci_request_timeout: Second = second(count: 60) + // ── ONE SIGNED EXCHANGE ─────────────────────────────────────────────────────────────────────── // // The request shape decides both what is signed and what is sent: OciGetRequest goes out through @@ -93,8 +98,8 @@ fn oci_retry_token(path_and_query: String, body: String) -> NonEmptyStr { fn oci_send(shape: OciRequestShape, url: NonEmptyStr, path_and_query: NonEmptyStr, date: NonEmptyStr, authorization: NonEmptyStr, content_sha256: String) -> OciExchange uses net: Network { let run = match shape { - OciGetRequest => curl_http_signature_get(url: url, date: date, authorization: authorization) - OciPostJson { text } => curl_http_signature_post_json(url: url, date: date, authorization: authorization, content_sha256: content_sha256 as NonEmptyStr, retry_token: oci_retry_token(path_and_query: path_and_query as String, body: text), body: text) + OciGetRequest => curl_http_signature_get(url: url, date: date, authorization: authorization, max_time: oci_request_timeout) + OciPostJson { text } => curl_http_signature_post_json(url: url, date: date, authorization: authorization, max_time: oci_request_timeout, content_sha256: content_sha256 as NonEmptyStr, retry_token: oci_retry_token(path_and_query: path_and_query as String, body: text), body: text) } if run.exit_code != 0 { oci_refused(cause: join(["curl exit ", to_string(run.exit_code), ": ", trim(s: run.stderr)], "")) diff --git a/dag/gunbc/product/supplier/oracle_oci.dag b/dag/gunbc/product/supplier/oracle_oci.dag index 33d267d219d..0456ec96a6c 100644 --- a/dag/gunbc/product/supplier/oracle_oci.dag +++ b/dag/gunbc/product/supplier/oracle_oci.dag @@ -88,9 +88,9 @@ type OciPlannedInstance { // the obligation. It must never be read as "Oracle charges for this". type OciAllowanceBreach = A1OcpusOverAllowance { planned: Nat, allowed: Nat } - | A1MemoryOverAllowance { planned: Nat, allowed: Nat } + | A1MemoryOverAllowance { planned: Gigabyte, allowed: Gigabyte } | E2MicroCountOverAllowance { planned: Nat, allowed: Nat } - | BlockVolumeOverAllowance { planned: Nat, allowed: Nat } + | BlockVolumeOverAllowance { planned: Gigabyte, allowed: Gigabyte } | NotInHomeRegion { instance: NonEmptyStr } | MicroOutsideEligibleDomain { instance: NonEmptyStr } | CapacityTypeFreeEligibilityUnread { instance: NonEmptyStr, obligation: DeclarationRef } @@ -185,9 +185,9 @@ fn oci_aggregate_breaches(plan: List, other: OciOtherUsage) let micro_cap = oci_e2_micro_allowance.max_instances let block_cap = gigabyte_count(g: oci_block_volume_allowance.total) let b1 = if oci_over(planned: ocpus, allowed: ocpu_cap) { [A1OcpusOverAllowance { planned: ocpus, allowed: ocpu_cap }] } else { [] } - let b2 = if oci_over(planned: memory, allowed: memory_cap) { [A1MemoryOverAllowance { planned: memory, allowed: memory_cap }] } else { [] } + let b2 = if oci_over(planned: memory, allowed: memory_cap) { [A1MemoryOverAllowance { planned: gigabyte(count: memory), allowed: oci_a1_flex_allowance.always_free_memory }] } else { [] } let b3 = if oci_over(planned: micros, allowed: micro_cap) { [E2MicroCountOverAllowance { planned: micros, allowed: micro_cap }] } else { [] } - let b4 = if oci_over(planned: block, allowed: block_cap) { [BlockVolumeOverAllowance { planned: block, allowed: block_cap }] } else { [] } + let b4 = if oci_over(planned: block, allowed: block_cap) { [BlockVolumeOverAllowance { planned: gigabyte(count: block), allowed: oci_block_volume_allowance.total }] } else { [] } concat(concat(b1, b2), concat(b3, b4)) } diff --git a/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag b/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag index e9261518069..c5a363c1719 100644 --- a/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag +++ b/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag @@ -67,7 +67,7 @@ test fn two_micros_and_one_full_a1_fit_together() -> Bool { // The pre-2026 allowance, 4 OCPUs and 24 GB, no longer fits. It refuses on exactly those two axes. test fn the_old_four_ocpu_allowance_refuses_on_ocpus_and_memory() -> Bool { - breaches_of(plan: [a1(name: "a", ocpus: 4, gb: 24, boot: 47)]) == [A1OcpusOverAllowance { planned: 4, allowed: 2 }, A1MemoryOverAllowance { planned: 24, allowed: 12 }] + breaches_of(plan: [a1(name: "a", ocpus: 4, gb: 24, boot: 47)]) == [A1OcpusOverAllowance { planned: 4, allowed: 2 }, A1MemoryOverAllowance { planned: gigabyte(count: 24), allowed: gigabyte(count: 12) }] } // The maximum instance count is limited by storage. Four 50 GB boot volumes use exactly 200 GB and @@ -77,7 +77,7 @@ test fn four_instances_fit_at_exactly_two_hundred_gigabytes() -> Bool { } test fn one_more_boot_gigabyte_refuses_on_block_volume_alone() -> Bool { - breaches_of(plan: [micro(name: "m1", boot: 51), micro(name: "m2", boot: 50), a1(name: "a1", ocpus: 1, gb: 6, boot: 50), a1(name: "a2", ocpus: 1, gb: 6, boot: 50)]) == [BlockVolumeOverAllowance { planned: 201, allowed: 200 }] + breaches_of(plan: [micro(name: "m1", boot: 51), micro(name: "m2", boot: 50), a1(name: "a1", ocpus: 1, gb: 6, boot: 50), a1(name: "a2", ocpus: 1, gb: 6, boot: 50)]) == [BlockVolumeOverAllowance { planned: gigabyte(count: 201), allowed: gigabyte(count: 200) }] } test fn a_third_micro_refuses_on_micro_count() -> Bool { From 6000600f581420fb675eb6990f2ec5f0075527f5 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Mon, 5 Oct 2026 09:25:37 +0000 Subject: [PATCH 15/15] OCI: zero-priced binding unavailable until plan-relative allocation + A1 monthly meter are admittable (side-chat re-review, finding 4); API hosts derived from the tenancy home-region row; drop now-unconsumed offer-shape helpers Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/extdeps/cloud/oracle_oci.dag | 19 +- dag/gunbc/oracle_oci/api.dag | 7 +- dag/gunbc/product/supplier/oracle_oci.dag | 228 ++++-------------- .../oracle_oci_always_free_witness_test.dag | 86 +++---- 4 files changed, 87 insertions(+), 253 deletions(-) diff --git a/dag/extdeps/cloud/oracle_oci.dag b/dag/extdeps/cloud/oracle_oci.dag index 0b799a3ea42..f4396fcf549 100644 --- a/dag/extdeps/cloud/oracle_oci.dag +++ b/dag/extdeps/cloud/oracle_oci.dag @@ -2,7 +2,6 @@ module extdeps.cloud.oracle_oci import extdeps.external_authority { ExternalAuthority } import extdeps.uri { Uri, Https } -import extdeps.toolchain.types { Architecture, Aarch64, X86_64 } import std.measure { Gigabyte, gigabyte } import std.types { NonEmptyStr } import std.nat { Nat } @@ -51,22 +50,10 @@ fn oci_shape_wire_name(shape: OciAlwaysFreeShape) -> NonEmptyStr { } } -fn oci_shape_architecture(shape: OciAlwaysFreeShape) -> Architecture { - match shape { - VmStandardA1Flex => Aarch64 - VmStandardE21Micro => X86_64 - } -} - // From the compute shapes page: "1 OCPU on Arm A1 (Compute) = 1 core on Arm A1 (Compute) or 1 vCPU", -// and "1 OCPU on x86 (AMD and Intel) = 2 vCPUs". This is the denominator the allowance is written in. -// Without it, "2 OCPUs" on A1 and on E2 would look like the same amount of compute, and they are not. -fn oci_vcpus_per_ocpu(shape: OciAlwaysFreeShape) -> Nat { - match shape { - VmStandardA1Flex => 1 - VmStandardE21Micro => 2 - } -} +// and "1 OCPU on x86 (AMD and Intel) = 2 vCPUs". A1.Flex is Aarch64 and E2.1.Micro is X86_64. These +// enter as typed rows with their consumer, the fabric offer shape, when a zero-priced offer can be +// minted (product.supplier.oracle_oci oci_tenancy_allocation_unread_obligation). // The compute shapes page names the A1.Flex processor as Ampere Altra Q80-30, which is the catalog row // extdeps.cpu.ampere altra_q8030_catalog. No consumer here needs the processor, so the row is named in diff --git a/dag/gunbc/oracle_oci/api.dag b/dag/gunbc/oracle_oci/api.dag index 29ed1c7b84a..4da3361d186 100644 --- a/dag/gunbc/oracle_oci/api.dag +++ b/dag/gunbc/oracle_oci/api.dag @@ -21,6 +21,7 @@ import extdeps.cloud.oracle_oci_request_signing { } import gunbc.jws_signer_realize { jws_rs256_sign } import gunbc.fleet_secrets_config { fleet_secrets_gcp_project } +import product.supplier.oracle_oci { oci_tenancy_home_region } // THE OPERATOR'S OCI TENANCY AS AN API: credential, identity, one signed exchange, and the two reads // every converge over it is built from, a complete list and an admitted row. @@ -52,12 +53,12 @@ data oci_api_key_id: OciApiKeyId = OciApiKeyId { fingerprint: "00:46:05:c5:42:80:08:ec:48:3a:e1:7c:34:bc:e1:03", } -// Hosts in the home region (us-ashburn-1, product.supplier.oracle_oci), from the endpoint templates +// Hosts in the tenancy's home region (product.supplier.oracle_oci oci_tenancy_home_region), from the endpoint templates // in Oracle's SDK clients: identity.{region}.oci.{secondLevelDomain} and iaas.{region}.{secondLevelDomain}. // Identity calls must go to the home region. -data oci_identity_host: NonEmptyStr = "identity.us-ashburn-1.oci.oraclecloud.com" +data oci_identity_host: NonEmptyStr = join(["identity.", oci_tenancy_home_region.identifier as String, ".oci.oraclecloud.com"], "") as NonEmptyStr -data oci_iaas_host: NonEmptyStr = "iaas.us-ashburn-1.oraclecloud.com" +data oci_iaas_host: NonEmptyStr = join(["iaas.", oci_tenancy_home_region.identifier as String, ".oraclecloud.com"], "") as NonEmptyStr // How long one OCI request may take before curl gives up. It is this tenancy's policy, not a fact // about curl or Oracle. A request that runs out of time ends as a curl exit and refuses. diff --git a/dag/gunbc/product/supplier/oracle_oci.dag b/dag/gunbc/product/supplier/oracle_oci.dag index 0456ec96a6c..54811cb658d 100644 --- a/dag/gunbc/product/supplier/oracle_oci.dag +++ b/dag/gunbc/product/supplier/oracle_oci.dag @@ -4,45 +4,25 @@ import std.types { Bool, List, NonEmptyStr } import v2.std.optional { Absent, Present } import std.nat { Nat } import std.decl_ref { DeclarationRef, decl_ref } -import std.measure { - Gigabyte, gigabyte, gigabyte_count, Second, second, - byte_size, hardware_thread_count, money_amount_micro, MoneyPerHour, -} -import extdeps.currency.currency { Usd } -import product.fabric.identity { FabricIdentity } -import product.fabric.demand { ObservationReceiptRef } -import product.fabric.work { Shape, HardRequirements, CapabilityManifestRef, TrustDomainRef } -import product.fabric.envelope { cpu_memory_envelope } -import product.fabric.isolation { IsolationProfile } -import product.fabric.supply { SupplierOffer, OfferQuote, QuotedPerHour, UnobservedSupply } -import product.fabric.provider_route { - ProviderRegionObservation, ProviderRegionRead, ProviderRouteObserved, ProviderRouteRegionUnobserved, - provider_route_from_region, -} -import product.supplier.ubicloud { - SupplierOfferProjection, UnexpressedSupplyFact, IsolationNotObserved, - ReadinessObservation, ReadinessMeasured, ReadinessUnmeasured, - OfferBinding, OfferBound, OfferBindingRefused, -} +import std.measure { Gigabyte, gigabyte, gigabyte_count } +import product.supplier.ubicloud { OfferBinding, OfferBindingRefused } import extdeps.cloud.oracle_oci { OciAlwaysFreeShape, VmStandardA1Flex, VmStandardE21Micro, OciCapacityType, OnDemandCapacity, PreemptibleCapacity, CapacityReservation, DedicatedVirtualMachineHost, ComputeCluster, OciTenancyTier, AlwaysFreeTenancy, PayAsYouGoTenancy, + OciRegion, oci_region_us_ashburn_1, oci_a1_flex_allowance, oci_e2_micro_allowance, oci_block_volume_allowance, - oci_shape_wire_name, oci_shape_architecture, oci_vcpus_per_ocpu, } // ORACLE'S ALWAYS FREE ALLOWANCE AS A FABRIC SUPPLIER: TWO QUESTIONS, ASKED IN ORDER. // -// First: does a planned set of instances fit the allowance? oci_always_free_fit answers that, and it -// is what makes a $0 price true. A zero quote is honest only inside the allowance, so no offer from -// this module carries one unless the plan it belongs to has fit. Second: how does one planned -// instance become a SupplierOffer? bind_oci_always_free_offer answers that, using the same refusals -// as product.supplier.ubicloud. A route needs the tenancy's home region to have been read, and a -// ranked offer needs a measured readiness. Those types are imported from that module rather than -// re-minted, because a second OfferBinding beside the first would be the fork DESIGN section 3 -// forbids. +// First: does a planned set of instances fit the allowance? oci_always_free_fit answers that for a +// plan alone, and oci_always_free_fit_beside answers it beside other usage. Second: can one planned +// instance become a zero-priced SupplierOffer? bind_oci_always_free_offer answers that, and today the +// answer is a typed refusal naming the evidence a zero quote needs. Its OfferBinding is the one +// product.supplier.ubicloud declares, not a second, because a second would be the fork DESIGN +// section 3 forbids. // // Every vendor figure comes from extdeps.cloud.oracle_oci. Nothing here restates it. @@ -225,99 +205,31 @@ fn oci_breach_consequence(tier: OciTenancyTier) -> OciBreachConsequence { } // ── THE OFFER ───────────────────────────────────────────────────────────────────────────────── - -fn oci_provider_ref() -> DeclarationRef { - decl_ref(module_path: "extdeps.cloud.oracle_oci", decl_name: "extdeps_external_authority_anchor") -} - -fn oci_service_ref() -> DeclarationRef { - decl_ref(module_path: "extdeps.cloud.oracle_oci", decl_name: "oci_always_free_resources_authority") -} - -// THE OPERATOR'S TENANCY HOME REGION IS US EAST (ASHBURN), as the operator stated on 2026-10-05. -// It is a fact about our tenancy and not about Oracle, so it lives here, as an attributable operator -// statement, and cites the vendor's region row for what us-ashburn-1 is. Always Free compute can be -// created only in the home region, so this is the region of every offer this supplier makes. -data oci_tenancy_home_region_statement: NonEmptyStr = "Operator statement, 2026-10-05: the gunbc OCI tenancy's home region is US East (Ashburn), us-ashburn-1. Re-read it from the OCI console's Tenancy details if the tenancy is ever recreated." - -fn oci_home_region() -> ProviderRegionObservation { - ProviderRegionRead { - region: decl_ref(module_path: "extdeps.cloud.oracle_oci", decl_name: "oci_region_us_ashburn_1"), - } -} - -data oci_readiness_unread_obligation: NonEmptyStr = "Provisioning delay from an OCI launch request to an Always Free instance accepting work has not been measured. Out-of-capacity refusals are common on A1 in many home regions, so the delay may be unbounded. RESOLVED BY a launch receipt recording request and first-heartbeat timestamps per shape." - -data oci_readiness: ReadinessObservation = ReadinessUnmeasured { - obligation: decl_ref(module_path: "product.supplier.oracle_oci", decl_name: "oci_readiness_unread_obligation"), -} - -// An instance that fits the allowance is not yet supply. Until one is launched and observed, its -// evidence is the unobserved arm, which product.fabric.failure_scenario_admission refuses by name. -data oci_instance_unlaunched_obligation: NonEmptyStr = "No Always Free instance has been launched and observed for this offer. RESOLVED BY a launch receipt naming the instance OCID, shape and home region." - -// The quote is zero, and that is only true inside a plan that fit. The binding takes the fit as an -// argument and refuses when the fit is not FitsAlwaysFree, so a zero cannot outlive the plan that -// earned it. -fn oci_zero_quote() -> OfferQuote { - QuotedPerHour(MoneyPerHour { amount: money_amount_micro(count: 0), currency: Usd }) -} - -// Oracle meters the allowance in OCPU-hours and GB-hours. Above the allowance a free tenancy never -// bills, so the quantum is never consulted on a zero quote. It is stated as an hour because that is -// the unit the allowance is denominated in. -data oci_billing_quantum: Second = second(count: 3600) - -// E2MicroSize is 1/8 OCPU with burst, carried as one hardware thread. The fractional baseline -// cannot be written in a thread count. -fn oci_vcpus(size: OciInstanceSize) -> Nat { - match size { - A1FlexSize { ocpus, memory } => ocpus * oci_vcpus_per_ocpu(shape: VmStandardA1Flex) - E2MicroSize => 1 - } -} - -fn oci_memory(size: OciInstanceSize) -> Gigabyte { - match size { - A1FlexSize { ocpus, memory } => memory - E2MicroSize => oci_e2_micro_allowance.memory_per_instance - } -} - -// Gigabytes are held as the vendor writes them, with no stated basis. The envelope needs bytes, so -// the DECIMAL reading is taken, which is the smaller of the two possible readings. An offer that -// promises less memory than the instance has can only refuse work it could have run. It can never -// admit work the instance cannot hold. -fn oci_shape(size: OciInstanceSize) -> Shape { - Shape { - hard: HardRequirements { threads: hardware_thread_count(count: oci_vcpus(size: size)) }, - envelope: cpu_memory_envelope( - min_threads: hardware_thread_count(count: oci_vcpus(size: size)), - architecture: oci_shape_architecture(shape: oci_instance_shape(size: size)), - min_bytes: byte_size(count: gigabyte_count(g: oci_memory(size: size)) * 1000000000), - ), - } -} - -// THE TENANCY'S OTHER USAGE MUST BE OBSERVED BEFORE A ZERO QUOTE IS TRUE. Nothing reads it today: -// no converge lists the tenancy's volumes and instances outside the plan, and A1 consumption is a -// monthly meter that no observation here carries. So the production standing is the unread arm, and -// the binding refuses on it. -type OciTenancyAllocation - = TenancyAllocationUnread { obligation: DeclarationRef } - | TenancyAllocationObserved { other: OciOtherUsage, receipt: ObservationReceiptRef } - -data oci_tenancy_allocation_unread_obligation: NonEmptyStr = "What the operator's OCI tenancy already holds outside an offered plan (block and boot volumes in every compartment, A1 OCPUs and memory, E2 micro instances) has not been observed. The Always Free allowance is tenancy-wide, so without it a plan's fit is nominal and a zero quote would not be established. RESOLVED BY a receipt from a converge that lists the tenancy's instances, boot volumes and block volumes across compartments, sufficient to fill OciOtherUsage. A1 monthly consumption above steady-state allocation is not carried by OciOtherUsage, and is a further obligation of that receipt." - -data oci_tenancy_allocation: OciTenancyAllocation = TenancyAllocationUnread { - obligation: decl_ref(module_path: "product.supplier.oracle_oci", decl_name: "oci_tenancy_allocation_unread_obligation"), -} - -data oci_always_free_fit_refusal: NonEmptyStr = "The plan does not fit the Always Free allowance beside the tenancy's observed other usage (oci_always_free_fit_beside), so a zero quote for any of its members would be false." - -data oci_member_not_in_plan_refusal: NonEmptyStr = "The requested member is not an instance of the admitted plan. A zero quote is established only for a member of the plan whose fit was decided, so nothing else can be bound under it." - -data oci_member_outside_home_region_refusal: NonEmptyStr = "The member is not placed in the tenancy's home region, so it is not Always Free compute and has no zero-quoted route." +// +// THE OPERATOR'S TENANCY HOME REGION IS US EAST (ASHBURN), as the operator stated on 2026-10-05. It +// is a fact about our tenancy and not about Oracle, so it lives here as an attributable operator +// statement, bound to the vendor's region row. Always Free compute can be created only in the home +// region, and identity calls must go there, so gunbc.oracle_oci.api derives its endpoint hosts from +// this row. If the tenancy is ever recreated, re-read it from the OCI console's Tenancy details. +data oci_tenancy_home_region: OciRegion = oci_region_us_ashburn_1 + +// A ZERO-PRICED OFFER IS NOT AVAILABLE YET, AND THE BINDING SAYS SO RATHER THAN MINTING ONE (review on +// gunbc#13335). A zero quote is true only if the offered plan fits the allowance beside everything else +// the tenancy holds, and that needs two pieces of evidence nothing here can admit yet: +// - usage outside the plan, observed RELATIVE TO THAT PLAN. "Outside the plan" is a relation between +// an inventory and one plan, so an observation that does not carry the plan it excluded can be +// truthful for one plan and reused for another; +// - A1's monthly meter. The allowance is 1,500 OCPU-hours and 9,000 GB-hours a month, and a current +// inventory does not say how much of this month's credit is already spent. +// So the binding decides everything it can decide (the member is in the plan, the plan fits nominally, +// which includes the home-region rule) and then refuses with the obligation that names both. The nominal +// calculator stays, because the instance converge uses it, beside tenancy usage it observes itself, to +// decide whether a launch is possible at all. That is a different claim from a price. +data oci_tenancy_allocation_unread_obligation: NonEmptyStr = "A zero-priced Always Free offer needs two pieces of evidence this repository cannot yet admit. (1) The tenancy's usage outside the offered plan, derived against THAT plan's member identities, so that an observation made for one plan cannot be reused for another. (2) The A1 month-to-date meter, OCPU-hours and GB-hours consumed in the current billing month against the 1,500 / 9,000 allowance, because a current inventory does not establish remaining credit. RESOLVED BY an allocation observation that carries the excluded plan's identity and the billing month's consumption, and a binding that checks both before minting a quote." + +data oci_member_not_in_plan_refusal: NonEmptyStr = "The requested member is not an instance of the offered plan, so nothing can be bound for it under that plan." + +data oci_plan_exceeds_refusal: NonEmptyStr = "The offered plan does not fit the Always Free allowance even on its own (oci_always_free_fit), so no member of it can be free." fn oci_plan_member(plan: List, member: NonEmptyStr) -> OciPlannedInstance? { match filter(plan, i => (i.name as String) == (member as String)).first() { @@ -330,66 +242,18 @@ fn oci_refused_binding

(member: NonEmptyStr, cause_decl: String) -> OfferBindi OfferBindingRefused { runs_on_label: member, cause: decl_ref(module_path: "product.supplier.oracle_oci", decl_name: cause_decl) } } -// THE FIT IS DECIDED HERE, NOT HANDED IN (review on gunbc#13335). The binding takes the plan and the -// NAME of the member to offer, decides the fit of that plan beside the tenancy's observed other usage, -// and derives both the offered instance and its route from the admitted member. A success computed for -// one plan cannot be carried onto another instance, because no success value crosses this boundary, -// and the route cannot disagree with the member's placement, because nothing supplies it separately. -fn bind_oci_always_free_offer

( - principal: P, - executor: P, - plan: List, - member: NonEmptyStr, - allocation: OciTenancyAllocation, - capabilities: CapabilityManifestRef, - trust_domain: TrustDomainRef, - readiness: ReadinessObservation, -) -> OfferBinding

{ - match allocation { - TenancyAllocationUnread { obligation } => OfferBindingRefused { runs_on_label: member, cause: obligation }, - TenancyAllocationObserved { other, receipt } => - match oci_always_free_fit_beside(plan: plan, other: other) { - ExceedsAlwaysFree { breaches } => oci_refused_binding(member: member, cause_decl: "oci_always_free_fit_refusal"), - FitsAlwaysFree => - match oci_plan_member(plan: plan, member: member) { - Absent => oci_refused_binding(member: member, cause_decl: "oci_member_not_in_plan_refusal"), - Present { value: instance } => - match instance.region { - OutsideHomeRegion => oci_refused_binding(member: member, cause_decl: "oci_member_outside_home_region_refusal"), - InHomeRegion => - match provider_route_from_region(provider: oci_provider_ref(), service: oci_service_ref(), region: oci_home_region()) { - ProviderRouteRegionUnobserved { obligation } => - OfferBindingRefused { runs_on_label: member, cause: obligation }, - ProviderRouteObserved { route: bound_route } => - match readiness { - ReadinessUnmeasured { obligation } => - OfferBindingRefused { runs_on_label: member, cause: obligation }, - ReadinessMeasured { ready_at, receipt: readiness_receipt } => - OfferBound { - bound: SupplierOfferProjection { - offer: SupplierOffer { - id: FabricIdentity { principal: principal, key: instance.name }, - executor: executor, - route: bound_route, - shape: oci_shape(size: instance.size), - capabilities: capabilities, - trust_domain: trust_domain, - isolation: IsolationProfile { guarantees: [] }, - quantity_bound: 1, - ready_at: ready_at, - quote: oci_zero_quote(), - billing_quantum: oci_billing_quantum, - evidence: UnobservedSupply { - obligation: decl_ref(module_path: "product.supplier.oracle_oci", decl_name: "oci_instance_unlaunched_obligation"), - }, - }, - unexpressed: [IsolationNotObserved], - }, - }, - }, - }, - }, - }, +// The member is selected from the plan and the plan's nominal fit is decided here, so neither can be +// supplied separately and disagree. A member outside the home region never reaches the final arm, +// because the fit refuses it as NotInHomeRegion. Past +// them, the binding refuses on the allocation obligation above. It is the offer's single entry point, +// so the evidence lands here when it exists, rather than in a caller. +fn bind_oci_always_free_offer

(plan: List, member: NonEmptyStr) -> OfferBinding

{ + match oci_plan_member(plan: plan, member: member) { + Absent => oci_refused_binding(member: member, cause_decl: "oci_member_not_in_plan_refusal"), + Present { value: chosen } => + match oci_always_free_fit(plan: plan) { + ExceedsAlwaysFree { breaches } => oci_refused_binding(member: member, cause_decl: "oci_plan_exceeds_refusal"), + FitsAlwaysFree => oci_refused_binding(member: member, cause_decl: "oci_tenancy_allocation_unread_obligation"), }, } } diff --git a/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag b/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag index c5a363c1719..cd84b6774e8 100644 --- a/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag +++ b/dag/test/claim/supplier/oracle_oci_always_free_witness_test.dag @@ -5,7 +5,7 @@ import std.nat { Nat } import std.decl_ref { DeclarationRef, decl_ref, declaration_ref_eq } import std.measure { gigabyte, money_amount_micro_count } import product.fabric.supply { offer_quote_amount, UnobservedSupply, ObservedSupply, QuotedSupply } -import product.supplier.ubicloud { ReadinessObservation, ReadinessMeasured, OfferBinding, OfferBound, OfferBindingRefused } +import product.supplier.ubicloud { OfferBinding, OfferBound, OfferBindingRefused } import extdeps.cloud.oracle_oci { OnDemandCapacity, PreemptibleCapacity, AlwaysFreeTenancy, PayAsYouGoTenancy, } @@ -17,8 +17,7 @@ import product.supplier.oracle_oci { BlockVolumeOverAllowance, NotInHomeRegion, MicroOutsideEligibleDomain, CapacityTypeFreeEligibilityUnread, ExcessRefusedByVendor, ExcessBilledAtUnreadRate, - oci_always_free_fit, oci_breach_consequence, bind_oci_always_free_offer, - oci_readiness, oci_tenancy_allocation, OciTenancyAllocation, TenancyAllocationObserved, OciOtherUsage, + oci_always_free_fit, oci_always_free_fit_beside, oci_breach_consequence, bind_oci_always_free_offer, OciOtherUsage, } // The subject is the allowance fit and the offer seam, at the vendor figures in @@ -125,26 +124,12 @@ test fn a_breach_is_refused_on_free_and_billed_at_an_unread_rate_on_paid() -> Bo // ── THE OFFER SEAM ──────────────────────────────────────────────────────────────────────────── // -// The binding decides the fit itself and derives the instance and route from the admitted member. -// These rows supply the plan, the member name and an observed tenancy allocation, and require the -// exact refusal each substitution earns. +// The binding selects the member from the plan and decides the plan's fit itself, then refuses on the +// evidence a zero quote still lacks. Each row requires the exact refusal its input earns, so the order +// of the gates is pinned as well as their existence. -fn measured() -> ReadinessObservation { - ReadinessMeasured { ready_at: "2026-10-05T00:00:00Z", receipt: "gunbc.observation.oci-witness-readiness" } -} - -fn observed_other(block_gb: Nat) -> OciTenancyAllocation { - TenancyAllocationObserved { - other: OciOtherUsage { a1_ocpus: 0, a1_memory: gigabyte(count: 0), e2_micro_instances: 0, block: gigabyte(count: block_gb) }, - receipt: "gunbc.observation.oci-witness-allocation", - } -} - -fn bind(plan: List, member: NonEmptyStr, allocation: OciTenancyAllocation, readiness: ReadinessObservation) -> OfferBinding { - bind_oci_always_free_offer( - principal: "gunbc", executor: "gunbc", plan: plan, member: member, allocation: allocation, - capabilities: "cap-manifest-oci", trust_domain: "trust-domain-oci", readiness: readiness, - ) +fn bind(plan: List, member: NonEmptyStr) -> OfferBinding { + bind_oci_always_free_offer(plan: plan, member: member) } fn refused_with(b: OfferBinding, expected: NonEmptyStr) -> Bool { @@ -159,41 +144,38 @@ fn small_plan() -> List { [a1(name: "a", ocpus: 2, gb: 12, boot: 47)] } -// RED: the state production is in. The tenancy's other usage is unread, so no zero quote is bound, -// even for a plan that fits with readiness measured. -test fn production_state_refuses_on_the_unread_tenancy_allocation() -> Bool { - refused_with(b: bind(plan: small_plan(), member: "a", allocation: oci_tenancy_allocation, readiness: measured()), expected: "oci_tenancy_allocation_unread_obligation") +// The production state: a member of a fitting plan in the home region still refuses, because the +// allocation evidence a zero quote needs (plan-relative other usage and the A1 monthly meter) is not +// admitted. +test fn a_fitting_home_region_member_refuses_on_the_unadmitted_allocation() -> Bool { + refused_with(b: bind(plan: small_plan(), member: "a"), expected: "oci_tenancy_allocation_unread_obligation") } -test fn production_readiness_refuses_once_the_allocation_is_observed() -> Bool { - refused_with(b: bind(plan: small_plan(), member: "a", allocation: observed_other(block_gb: 0), readiness: oci_readiness), expected: "oci_readiness_unread_obligation") +// An oversized instance cannot ride a small plan: named but absent from the plan, it is not a member; +// added to the plan, the plan no longer fits. +test fn an_oversized_instance_cannot_ride_a_small_plan() -> Bool { + refused_with(b: bind(plan: small_plan(), member: "big"), expected: "oci_member_not_in_plan_refusal") + && refused_with(b: bind(plan: concat(small_plan(), [a1(name: "big", ocpus: 4, gb: 24, boot: 47)]), member: "big"), expected: "oci_plan_exceeds_refusal") } -// SUBSTITUTION: an oversized instance cannot be bound under a small plan's fit. Named but absent -// from the plan, it refuses as not a member. Put into the plan, the plan no longer fits. -test fn an_oversized_instance_cannot_ride_a_small_plans_fit() -> Bool { - refused_with(b: bind(plan: small_plan(), member: "big", allocation: observed_other(block_gb: 0), readiness: measured()), expected: "oci_member_not_in_plan_refusal") - && refused_with(b: bind(plan: concat(small_plan(), [a1(name: "big", ocpus: 4, gb: 24, boot: 47)]), member: "big", allocation: observed_other(block_gb: 0), readiness: measured()), expected: "oci_always_free_fit_refusal") +// Placement is part of the fit: a member outside the home region makes its plan breach, so it refuses +// there, before the allocation gate. +test fn a_member_outside_the_home_region_refuses_through_the_fit() -> Bool { + let away = OciPlannedInstance { + name: "away", size: E2MicroSize, boot_volume: gigabyte(count: 47), + capacity: OnDemandCapacity, region: OutsideHomeRegion, micro_domain: InMicroEligibleDomain, + } + match bind(plan: [away], member: "away") { + OfferBound { bound } => false + OfferBindingRefused { runs_on_label, cause } => + declaration_ref_eq(a: cause, b: decl_ref(module_path: "product.supplier.oracle_oci", decl_name: "oci_plan_exceeds_refusal")) + } } -// TENANCY-WIDE: a plan that fits alone does not fit beside 200 GB the tenancy already holds. +// The fit beside other usage: a plan that fits alone does not fit beside 200 GB the tenancy holds. test fn other_tenancy_usage_counts_against_the_allowance() -> Bool { - refused_with(b: bind(plan: small_plan(), member: "a", allocation: observed_other(block_gb: 200), readiness: measured()), expected: "oci_always_free_fit_refusal") -} - -// POSITIVE CONTROL: the same member of the same plan, with allocation and readiness observed, binds a -// zero-quoted offer on the Ashburn route. Its evidence is still the unobserved arm, because nothing has -// been launched. -test fn a_member_of_a_fitting_plan_binds_at_zero_on_the_home_region_route() -> Bool { - match bind(plan: small_plan(), member: "a", allocation: observed_other(block_gb: 0), readiness: measured()) { - OfferBound { bound } => - money_amount_micro_count(m: offer_quote_amount(q: bound.offer.quote)) == 0 - && declaration_ref_eq(a: bound.offer.route.region, b: decl_ref(module_path: "extdeps.cloud.oracle_oci", decl_name: "oci_region_us_ashburn_1")) - && match bound.offer.evidence { - UnobservedSupply { obligation } => true - ObservedSupply { observed_at, receipt } => false - QuotedSupply { quoted_at, valid_until, receipt } => false - } - OfferBindingRefused { runs_on_label, cause } => false - } + let other = OciOtherUsage { a1_ocpus: 0, a1_memory: gigabyte(count: 0), e2_micro_instances: 0, block: gigabyte(count: 200) } + let alone = match oci_always_free_fit(plan: small_plan()) { FitsAlwaysFree => true ExceedsAlwaysFree { breaches } => false } + let beside = match oci_always_free_fit_beside(plan: small_plan(), other: other) { ExceedsAlwaysFree { breaches } => true FitsAlwaysFree => false } + alone && beside }