diff --git a/.github/workflows/security-release-gate.yml b/.github/workflows/security-release-gate.yml
index 0820b5d2c0..051e4c9b7f 100644
--- a/.github/workflows/security-release-gate.yml
+++ b/.github/workflows/security-release-gate.yml
@@ -65,6 +65,10 @@ jobs:
'strictWireRejectsMalformedNoncanonicalNestedRawJsonAndUnknownEnvelope',
'decodedJsonLimitAllowsLargeNativeWireAndRejectsOneByteOverflow',
},
+ 'io.cattle.platform.iaas.api.auth.ai.catalog.AiCatalogLaunchMaterializerTest': {
+ 'realTransformerDynamicFieldsReachNativeRoleSchemaProjection',
+ 'dynamicProjectionKeepsRestrictedSchemaAndNonCreateResponseRules',
+ },
'io.github.ibuildthecloud.gdapi.model.impl.CreateOnlyWrappedResourceTest': {
'trueCreationRetainsTheFirstSecretInBothApiVersions',
'postActionsCannotRevealCreateOnlyValues',
diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md
index dbfc20779b..d7e93103a6 100644
--- a/COMPATIBILITY.md
+++ b/COMPATIBILITY.md
@@ -1,5 +1,20 @@
# Compatibility Contract
+## Engine 0.183.366 candidate
+
+This candidate adds bounded `ports` and `healthCheck` parameters only to typed
+Service creation. Compatible clients must keep read-only
+`executionResult.assignedPorts` separate from the approved change summary.
+Services are still created inactive, and private human entry of environment
+values remains pending. Current role/Key checks, Full/legacy behavior, runtime
+dependency coordinates and database schemas are unchanged.
+
+Native IPv6 port formatting now retains brackets for roundtrip parsing;
+existing database values are not rewritten. Formatter coverage does not prove
+host IPv6 binding or routing. Required CI/CodeQL, artifact qualification and
+matching Server/native acceptance remain pending; see the
+[candidate release note](docs/releases/orchestration-engine-0.183.366.md).
+
## Engine 0.183.364 release
The [published release](https://github.com/PastureStack/orchestration-engine/releases/tag/v0.183.364)
diff --git a/README.md b/README.md
index 5ec22f7360..50180d8413 100644
--- a/README.md
+++ b/README.md
@@ -8,35 +8,35 @@ Rancher 1.6 ecosystem. It is not affiliated with Rancher Labs or SUSE.
This fork of [`rancher/cattle`](https://github.com/rancher/cattle) preserves
upstream history, authorship, licenses, and dependency notices.
-## Current release: Engine 0.183.364
+## Engine 0.183.366 candidate
-The [0.183.364 release](https://github.com/PastureStack/orchestration-engine/releases/tag/v0.183.364)
-publishes the original qualified CI bytes. Use the
+This source tree prepares Engine 0.183.366; the candidate is not published.
+The published baseline is
+[Engine 0.183.365](https://github.com/PastureStack/orchestration-engine/releases/tag/v0.183.365).
+Use the
[Server installation guide](https://github.com/PastureStack/server#quick-start)
for deployment; an Engine JAR alone is not a Server installation.
-A CUSTOM Key authorized to delete its standalone container can now finish the
-exact native private OS-image/cache cleanup. Current owner, role, Key revision,
-expiry, root grant and every explicit dependency DENY still apply. Shared or
-foreign resources and public Image/Volume/Pool operations receive no new grant.
-External target replacement also retires an obsolete hostname map when the
-new hostname is absent or different, allowing normal reconciliation to finish.
-
-The External before-value preview, ServiceIndex cleanup authority and official
-MariaDB JDBC3.5.11 fixes are retained. Full/legacy behavior and runtime dependency
-coordinates remain unchanged; the four reported JDBC CVEs remain absent from
-the qualified original product and raw/effective Dapper scans.
+The candidate extends human-reviewed Service creation with bounded native
+`ports` and `healthCheck` fields. Preview, confirmation and apply recheck the
+existing native port preflight. Created Services remain inactive; starting
+one requires a separate approved plan. A successful receipt exposes the actual
+assigned ports through read-only `executionResult.assignedPorts`, while the
+approved change summary remains unchanged. Native IPv6 port formatting now
+preserves the brackets needed to read a mapping back through `PortSpec`.
+Private human input for environment-variable values is not implemented in this
+candidate; `environment` remains outside resource-change parameters.
| Qualification | Current evidence |
|---|---|
-| Component | Required CI/CodeQL passed: 3,872 tests in 416 suites, zero failures/errors, one existing skip. All 14 new cleanup/lifecycle methods passed; seven preview and eight Index methods are inherited. |
-| Publication | Twelve original assets passed anonymous SHA256 readback; producer and merge/tag share the tested tree. No publication rebuild or native SLSA attestation is claimed. |
-| Assembled Server and live workflows | Matching Server publication, native cleanup/External replacement and designated role/Key/cross-ID business QA remain pending. Component publication does not establish workflow acceptance. |
+| Focused source checks | 60 unique unit cases passed on JDK 25 and Maven 3.9.16. Three test-fixture setup corrections were rerun alone; unaffected PASS evidence was retained. |
+| Candidate qualification and publication | Required original component CI/CodeQL, complete package qualification and publication remain pending. No candidate artifact digest or release asset is claimed. |
+| Assembled Server and live workflows | Matching Web Console/Server integration and native role/Key workflows remain pending. IPv6 formatter tests do not establish host IPv6 binding or routing acceptance. |
Use the compatible Web Console typed contract and matching Server broker.
Four independent AI opt-ins remain default-off. The
-[release note](docs/releases/orchestration-engine-0.183.364.md) records artifact
-identity and remaining boundaries. This does not complete all 243 frontend
+[candidate release note](docs/releases/orchestration-engine-0.183.366.md) records
+the exact supported fields and remaining boundaries. This does not complete all 243 frontend
business groups.
## Optional AI resource workflows
@@ -51,9 +51,12 @@ private keys, raw process logs or full launch configurations. Queries and opaque
pagination remain subject to the underlying domain manager's authorization.
`aiResourceChangePlan` supports Stack, Service and Container creation, metadata
-editing and the explicitly registered lifecycle operations. Creation currently
-covers the named basic fields and creates stopped workloads; it does not claim
-all advanced UI form fields. A short-lived plan captures the target state and
+editing and the explicitly registered lifecycle operations. In this candidate,
+Service creation additionally accepts native port-specification strings and a
+closed health-check object. Fixed host ports must match the actual result;
+omitted host ports use native allocation and are reported only after verified
+readback. It does not cover every advanced UI field or private environment-value
+entry. A short-lived plan captures the target state and
Key revision. A human cookie session reviews and approves it; a Key cannot
approve or cancel its own plan. Apply claims one durable operation before
dispatch; an uncertain dispatch is never automatically replayed.
@@ -175,7 +178,7 @@ Before publishing an Engine artifact, run the complete package gate:
```sh
bash scripts/check-cattle-jdk25-full-package
-ENGINE_VERSION=0.183.361 bash scripts/build --release
+ENGINE_VERSION=0.183.366 bash scripts/build --release
bash scripts/check-release-artifact dist/artifacts/cattle.jar
```
diff --git a/code/framework/api-pub-sub-jetty/pom.xml b/code/framework/api-pub-sub-jetty/pom.xml
index 6a1d631c85..3191ed3745 100644
--- a/code/framework/api-pub-sub-jetty/pom.xml
+++ b/code/framework/api-pub-sub-jetty/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/api-pub-sub/pom.xml b/code/framework/api-pub-sub/pom.xml
index d7a8901e1d..e5bfcd0085 100644
--- a/code/framework/api-pub-sub/pom.xml
+++ b/code/framework/api-pub-sub/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/api/pom.xml b/code/framework/api/pom.xml
index bbb1e8ca6a..c1521b1a8b 100644
--- a/code/framework/api/pom.xml
+++ b/code/framework/api/pom.xml
@@ -4,7 +4,7 @@
cattle-parent
io.cattle
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/archaius/pom.xml b/code/framework/archaius/pom.xml
index 8119d83216..f46934b45f 100644
--- a/code/framework/archaius/pom.xml
+++ b/code/framework/archaius/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-meta-parent
- 0.183.365
+ 0.183.366
../../meta-parent/pom.xml
diff --git a/code/framework/async/pom.xml b/code/framework/async/pom.xml
index 7c3de47545..8eafe80a66 100644
--- a/code/framework/async/pom.xml
+++ b/code/framework/async/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/auditing/pom.xml b/code/framework/auditing/pom.xml
index 3e2f46bf53..3fdcb2c9a6 100644
--- a/code/framework/auditing/pom.xml
+++ b/code/framework/auditing/pom.xml
@@ -4,7 +4,7 @@
cattle-parent
io.cattle
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/db-loader/pom.xml b/code/framework/db-loader/pom.xml
index 3b0b741b9b..936117cee1 100644
--- a/code/framework/db-loader/pom.xml
+++ b/code/framework/db-loader/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/deferred/pom.xml b/code/framework/deferred/pom.xml
index e5f5c91603..45d50246d1 100644
--- a/code/framework/deferred/pom.xml
+++ b/code/framework/deferred/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/encryption/pom.xml b/code/framework/encryption/pom.xml
index ec2883f666..ac21aae936 100644
--- a/code/framework/encryption/pom.xml
+++ b/code/framework/encryption/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/engine/pom.xml b/code/framework/engine/pom.xml
index 87b061fe2c..0aba006241 100644
--- a/code/framework/engine/pom.xml
+++ b/code/framework/engine/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/eventing/pom.xml b/code/framework/eventing/pom.xml
index 869605339f..0a2bb1c035 100644
--- a/code/framework/eventing/pom.xml
+++ b/code/framework/eventing/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/events/pom.xml b/code/framework/events/pom.xml
index 5c9d4e9892..c28c45845a 100644
--- a/code/framework/events/pom.xml
+++ b/code/framework/events/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/extension-spring/pom.xml b/code/framework/extension-spring/pom.xml
index 83f94aae92..9e59e72f3a 100644
--- a/code/framework/extension-spring/pom.xml
+++ b/code/framework/extension-spring/pom.xml
@@ -4,7 +4,7 @@
cattle-parent
io.cattle
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/extension/pom.xml b/code/framework/extension/pom.xml
index 927fcf2fad..19a6b394f7 100644
--- a/code/framework/extension/pom.xml
+++ b/code/framework/extension/pom.xml
@@ -4,7 +4,7 @@
cattle-parent
io.cattle
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/java-server/pom.xml b/code/framework/java-server/pom.xml
index 15208c2774..cb7e0c7a4f 100644
--- a/code/framework/java-server/pom.xml
+++ b/code/framework/java-server/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/jmx/pom.xml b/code/framework/jmx/pom.xml
index 929e344d7f..bcba2408e4 100644
--- a/code/framework/jmx/pom.xml
+++ b/code/framework/jmx/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/jooq/pom.xml b/code/framework/jooq/pom.xml
index 02f15fe7de..55619d0f1a 100644
--- a/code/framework/jooq/pom.xml
+++ b/code/framework/jooq/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/json/pom.xml b/code/framework/json/pom.xml
index ea9467a14c..c7eb19e829 100644
--- a/code/framework/json/pom.xml
+++ b/code/framework/json/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/launcher/pom.xml b/code/framework/launcher/pom.xml
index 0e0f5d3b57..3d321de0fa 100644
--- a/code/framework/launcher/pom.xml
+++ b/code/framework/launcher/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/lock/pom.xml b/code/framework/lock/pom.xml
index fd7277d5ac..fd9822fc88 100644
--- a/code/framework/lock/pom.xml
+++ b/code/framework/lock/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/logback/pom.xml b/code/framework/logback/pom.xml
index 57c4cd9282..ac27e218ab 100644
--- a/code/framework/logback/pom.xml
+++ b/code/framework/logback/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-meta-parent
- 0.183.365
+ 0.183.366
../../meta-parent/pom.xml
diff --git a/code/framework/managed-context/pom.xml b/code/framework/managed-context/pom.xml
index a775d9ae6a..e35b4d5036 100644
--- a/code/framework/managed-context/pom.xml
+++ b/code/framework/managed-context/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/metrics/pom.xml b/code/framework/metrics/pom.xml
index 9207cbb9be..8af58d28f3 100644
--- a/code/framework/metrics/pom.xml
+++ b/code/framework/metrics/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/module/pom.xml b/code/framework/module/pom.xml
index ba5c6887e6..9242eb2bad 100644
--- a/code/framework/module/pom.xml
+++ b/code/framework/module/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/object/pom.xml b/code/framework/object/pom.xml
index ec9d9f9426..bce652d977 100644
--- a/code/framework/object/pom.xml
+++ b/code/framework/object/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/pool/pom.xml b/code/framework/pool/pom.xml
index fe99a758ad..caf6095d42 100644
--- a/code/framework/pool/pom.xml
+++ b/code/framework/pool/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/resource-monitor/pom.xml b/code/framework/resource-monitor/pom.xml
index c209cf95dd..0fbe28ead9 100644
--- a/code/framework/resource-monitor/pom.xml
+++ b/code/framework/resource-monitor/pom.xml
@@ -4,7 +4,7 @@
cattle-parent
io.cattle
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/schema/pom.xml b/code/framework/schema/pom.xml
index 338577bd46..b2cfe6c0b5 100644
--- a/code/framework/schema/pom.xml
+++ b/code/framework/schema/pom.xml
@@ -4,7 +4,7 @@
cattle-parent
io.cattle
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/server/pom.xml b/code/framework/server/pom.xml
index a83000ad07..8114bedd91 100644
--- a/code/framework/server/pom.xml
+++ b/code/framework/server/pom.xml
@@ -4,7 +4,7 @@
cattle-meta-parent
io.cattle
- 0.183.365
+ 0.183.366
../../meta-parent/pom.xml
diff --git a/code/framework/spring/pom.xml b/code/framework/spring/pom.xml
index c846df049f..9b193bf375 100644
--- a/code/framework/spring/pom.xml
+++ b/code/framework/spring/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/system-task/pom.xml b/code/framework/system-task/pom.xml
index bc7a7b2dd4..b57bdf467d 100644
--- a/code/framework/system-task/pom.xml
+++ b/code/framework/system-task/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/token/pom.xml b/code/framework/token/pom.xml
index 4d2c48c394..64d42d2b7f 100644
--- a/code/framework/token/pom.xml
+++ b/code/framework/token/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/framework/utils/pom.xml b/code/framework/utils/pom.xml
index eb20d16704..e5159c176d 100644
--- a/code/framework/utils/pom.xml
+++ b/code/framework/utils/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/iaas/agent-instance/pom.xml b/code/iaas/agent-instance/pom.xml
index 901522f09c..d52dc7605b 100644
--- a/code/iaas/agent-instance/pom.xml
+++ b/code/iaas/agent-instance/pom.xml
@@ -4,7 +4,7 @@
cattle-parent
io.cattle
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/iaas/agent-server/pom.xml b/code/iaas/agent-server/pom.xml
index 39b2979eed..28dbd1d760 100644
--- a/code/iaas/agent-server/pom.xml
+++ b/code/iaas/agent-server/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/iaas/agent/pom.xml b/code/iaas/agent/pom.xml
index 5b98349f90..00edfbbcdd 100644
--- a/code/iaas/agent/pom.xml
+++ b/code/iaas/agent/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/iaas/allocator/pom.xml b/code/iaas/allocator/pom.xml
index 711962733b..ff4066e06b 100644
--- a/code/iaas/allocator/pom.xml
+++ b/code/iaas/allocator/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/iaas/api-logic/pom.xml b/code/iaas/api-logic/pom.xml
index a205464292..2352f21955 100644
--- a/code/iaas/api-logic/pom.xml
+++ b/code/iaas/api-logic/pom.xml
@@ -4,7 +4,7 @@
cattle-parent
io.cattle
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/iaas/archaius-management/pom.xml b/code/iaas/archaius-management/pom.xml
index a2ce48f4ba..0de183a1e2 100644
--- a/code/iaas/archaius-management/pom.xml
+++ b/code/iaas/archaius-management/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/iaas/auth-logic/pom.xml b/code/iaas/auth-logic/pom.xml
index 6017819e57..298472a2b1 100644
--- a/code/iaas/auth-logic/pom.xml
+++ b/code/iaas/auth-logic/pom.xml
@@ -4,7 +4,7 @@
cattle-parent
io.cattle
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/ai/catalog/AiCatalogLaunchMaterializer.java b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/ai/catalog/AiCatalogLaunchMaterializer.java
index b1d2678e5c..301d20f890 100644
--- a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/ai/catalog/AiCatalogLaunchMaterializer.java
+++ b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/ai/catalog/AiCatalogLaunchMaterializer.java
@@ -6,6 +6,7 @@
import io.cattle.platform.core.model.tables.records.InstanceRecord;
import io.cattle.platform.docker.transform.DockerTransformer;
import io.cattle.platform.object.util.ObjectUtils;
+import io.cattle.platform.object.util.DataUtils;
import io.github.ibuildthecloud.gdapi.context.ApiContext;
import io.github.ibuildthecloud.gdapi.model.ListOptions;
import io.github.ibuildthecloud.gdapi.model.Pagination;
@@ -55,8 +56,10 @@ PreparedGraph prepare(ApiRequest request, AiCatalogLaunchAccess.Authorization au
InstanceRecord instance = new InstanceRecord();
try { transformer.transform(map(service.get("dockerInspect")), instance); }
catch (RuntimeException invalid) { throw error(422, "CatalogLaunchUnsupported"); }
- // Exactly the normal transform response's schema projection, under the original role.
- WrappedResource resource = new WrappedResource(ApiContext.getContext().getIdFormatter(), schemas, container, instance, "POST");
+ // Match the native projection's data.fields source while retaining the
+ // original role and non-create-response handling of create-only fields.
+ WrappedResource resource = new WrappedResource(ApiContext.getContext().getIdFormatter(), schemas, container, instance,
+ new LinkedHashMap<>(DataUtils.getFields(instance)), null, "POST");
transformed.put(name, new LinkedHashMap<>(resource.getFields()));
}
Map authorizedReferenceIds = new LinkedHashMap<>();
diff --git a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeDescriptor.java b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeDescriptor.java
index 4ebd42ce04..50f5b487f8 100644
--- a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeDescriptor.java
+++ b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeDescriptor.java
@@ -23,7 +23,7 @@ private static Map descriptors() {
add(result, type, "remove", "DELETE", null, "delete", Set.of());
add(result, type, "update_metadata", "PUT", null, "update", Set.of("name", "description"));
add(result, type, "create", "POST", null, "create", "stack".equals(type) ? Set.of("name", "description")
- : "service".equals(type) ? Set.of("name", "description", "image", "scale", "stackId", "command", "entryPoint")
+ : "service".equals(type) ? Set.of("name", "description", "image", "scale", "stackId", "command", "entryPoint", "ports", "healthCheck")
: Set.of("name", "description", "image", "command", "entryPoint"));
}
add(result, "service", "scale", "PUT", null, "update", Set.of("scale"));
@@ -90,6 +90,8 @@ public Map parameters(Object value) {
|| image.contains("@") && !image.matches(".+@sha256:[0-9a-fA-F]{64}")) throw error("ResourceChangeInputInvalid");
}
case "stackId" -> string(content, 1, 128, false);
+ case "ports" -> content = AiServiceCreateChange.ports(content);
+ case "healthCheck" -> content = AiServiceCreateChange.healthCheck(content);
case "command", "entryPoint" -> {
if (!(content instanceof List> values) || values.size() > 64) throw error("ResourceChangeInputInvalid");
List arguments = new ArrayList<>(); int length = 0;
@@ -136,7 +138,8 @@ public Map body(Map parameters) {
if ("service".equals(resourceType)) {
body.putIfAbsent("scale", 1);
Map launch = new LinkedHashMap<>(Map.of("imageUuid", image, "networkMode", "bridge"));
- for (String field : List.of("command", "entryPoint")) if (body.containsKey(field)) launch.put(field, body.remove(field));
+ for (String field : List.of("command", "entryPoint", "ports", "healthCheck"))
+ if (body.containsKey(field)) launch.put(field, body.remove(field));
body.put("launchConfig", launch);
} else {
body.put("imageUuid", image); body.put("networkMode", "bridge");
diff --git a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeDomain.java b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeDomain.java
index 5b00823995..79008657bd 100644
--- a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeDomain.java
+++ b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeDomain.java
@@ -29,6 +29,7 @@
import io.cattle.platform.object.util.DataAccessor;
import io.cattle.platform.object.util.ObjectUtils;
import io.cattle.platform.servicediscovery.api.service.ServiceDeploymentReadiness;
+import io.cattle.platform.servicediscovery.api.service.ServiceCreateValidation;
import io.cattle.platform.servicediscovery.api.service.ResourceArchiveChange;
import io.github.ibuildthecloud.gdapi.context.ApiContext;
import io.github.ibuildthecloud.gdapi.exception.ClientVisibleException;
@@ -64,6 +65,7 @@ private record AliasReference(String type, String publicId, String nativeId) { }
@Inject JsonMapper json;
@Inject ApiKeyTargetResolver targets;
@Inject ServiceDeploymentReadiness deploymentReadiness;
+ @Inject ServiceCreateValidation serviceCreateValidation;
@Inject ResourceArchiveChange archiveChanges;
/** This marker only exists on a fresh, server-created domain request. */
@@ -126,6 +128,14 @@ public Map prepare(ApiRequest original, AiResourceChangeDescript
descriptor.method(), descriptor.action(), body);
return authorized(original, request, () -> {
if (boundPlan != null) keys.authorizeBoundDomain(boundPlan, request);
+ if (descriptor.createsResource() && "service".equals(descriptor.resourceType())) {
+ Map validated = copyMap(request.getRequestObject());
+ if (!(validated.get("launchConfig") instanceof Map, ?> launch)) throw error(403, "PermissionDenied");
+ for (String field : List.of("ports", "healthCheck"))
+ if (parameters.containsKey(field) && !Objects.equals(parameters.get(field), launch.get(field)))
+ throw error(403, "PermissionDenied");
+ serviceCreateValidation.validatePorts(request);
+ }
if ("dnsService".equals(descriptor.resourceType()) && Set.of("update_targets", "remove").contains(descriptor.operation())) {
Map references = aliasReferenceSnapshot(original, descriptor, publicId, parameters);
if ("update_targets".equals(descriptor.operation()) && publicId != null && aliasReferences(original, descriptor, parameters).stream()
@@ -385,6 +395,10 @@ public Object receiptTarget(ApiRequest request, AiResourceChangeDescriptor descr
if (target == null || !Objects.equals(ObjectUtils.getAccountId(target), actor.getAccountId())
|| !Objects.equals(ObjectUtils.getKind(target), descriptor.resourceType()) || current.policy().authorizeObject(target) == null)
throw error(403, "PermissionDenied");
+ if (descriptor.createsResource() && "service".equals(descriptor.resourceType())
+ && (number(ObjectUtils.getId(target)) != number(plan.get("internalResourceId"))
+ || !(plan.get("createdResourceUuid") instanceof String uuid) || uuid.isBlank()
+ || !uuid.equals(ObjectUtils.getPropertyIgnoreErrors(target, "uuid")))) throw error(403, "PermissionDenied");
if (ObjectUtils.getRemoved(target) == null) visible(request, descriptor, (String) plan.get("resourceId"));
return target;
}
@@ -413,8 +427,16 @@ public boolean complete(AiResourceChangeDescriptor descriptor, Object target, Ma
if ("scale".equals(descriptor.operation())) return "active".equals(state)
&& Objects.equals(parameters.get("scale"), DataAccessor.fieldInteger(target, "scale")) && serviceReady((Service) target, true)
&& retiredInstancesSettled((Service) target, before);
- if ("create".equals(descriptor.operation())) return "container".equals(descriptor.resourceType()) ? "stopped".equals(state)
- : "stack".equals(descriptor.resourceType()) ? Set.of("active", "inactive").contains(state) : "inactive".equals(state);
+ if ("create".equals(descriptor.operation())) {
+ if ("service".equals(descriptor.resourceType())) {
+ Map expected = descriptor.body(parameters);
+ String image = (String) ((Map, ?>) expected.get("launchConfig")).get("imageUuid");
+ return target instanceof Service service && AiServiceCreateChange.complete(service, expected,
+ internalId("stack", (String) parameters.get("stackId")), serviceCreateValidation.imageUuid(image));
+ }
+ return "container".equals(descriptor.resourceType()) ? "stopped".equals(state)
+ : "stack".equals(descriptor.resourceType()) && Set.of("active", "inactive").contains(state);
+ }
if ("container".equals(descriptor.resourceType())) return "stop".equals(descriptor.operation()) ? "stopped".equals(state)
: "running".equals(state) && (!"restart".equals(descriptor.operation())
|| number(ObjectUtils.getPropertyIgnoreErrors(target, "startCount")) > number(before.get("startCount")));
@@ -613,7 +635,7 @@ private Object displayed(String field, Object value) {
try { value = AiExternalServiceChange.addresses(value); }
catch (ClientVisibleException unsafe) { return null; }
}
- if (!Set.of("command", "entryPoint", "externalIpAddresses", "targetServices").contains(field)) return value;
+ if (!Set.of("command", "entryPoint", "ports", "healthCheck", "externalIpAddresses", "targetServices").contains(field)) return value;
try { return json.writeValueAsString(value); }
catch (IOException failure) { throw new IllegalStateException("Resource-change summary unavailable", failure); }
}
@@ -622,6 +644,13 @@ public T withArchiveChange(AiResourceChangeDescriptor descriptor, Object loc
return archiveChanges.withChange(lockedTarget, "archive".equals(descriptor.operation()), change);
}
+ /** Called only after complete() verified this created Service and its native port allocation. */
+ public Map serviceCreateResult(Object target) {
+ Object launch = DataAccessor.field(target, "launchConfig", Object.class);
+ Object ports = launch instanceof Map, ?> map ? map.get("ports") : null;
+ return Map.of("assignedPorts", ports == null ? List.of() : AiServiceCreateChange.ports(ports));
+ }
+
private Map change(String field, Object before, Object after) {
Map change = new LinkedHashMap<>(); change.put("field", field);
change.put("before", scalar(before)); change.put("after", scalar(after)); return change;
diff --git a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangePlanService.java b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangePlanService.java
index 82202e1d85..993efa9317 100644
--- a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangePlanService.java
+++ b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangePlanService.java
@@ -157,6 +157,12 @@ public Map action(String id, ApiRequest request) {
long numericId = createdId instanceof Number numeric ? numeric.longValue()
: domain.internalId(descriptor.resourceType(), text(createdId));
if (numericId <= 0) throw new IllegalStateException("Created resource identity unavailable");
+ if ("service".equals(descriptor.resourceType())) {
+ Object uuid = ObjectUtils.getPropertyIgnoreErrors(result, "uuid");
+ if (!(uuid instanceof String identity) || identity.isBlank())
+ throw new IllegalStateException("Created Service identity unavailable");
+ plan.put("createdResourceUuid", uuid);
+ }
plan.put("internalResourceId", numericId); plan.put("resourceId", domain.publicId(descriptor.resourceType(), numericId));
}
plan.put("dispatchAcknowledged", true);
@@ -205,6 +211,9 @@ protected void observe(Map plan, ApiRequest request) {
AiResourceChangeDescriptor descriptor = descriptor(plan);
Object target = domain.receiptTarget(request, descriptor, plan);
if (domain.complete(descriptor, target, map(plan.get("parameters")), map(plan.get("snapshot")))) {
+ if (descriptor.createsResource() && "service".equals(descriptor.resourceType()) && map(plan.get("parameters")).containsKey("ports")) {
+ plan.put("executionResult", domain.serviceCreateResult(target));
+ }
plan.put("status", "succeeded"); plan.put("verifiedResult", true); plan.put("messageCode", "ResourceChangeCompleted"); eraseSnapshot(plan);
} else if (Set.of("error", "erroring").contains(Objects.toString(ObjectUtils.getState(target), ""))) {
plan.put("status", "failed"); plan.put("messageCode", "ResourceChangeProcessFailed"); eraseSnapshot(plan);
@@ -255,6 +264,8 @@ protected Map output(Map plan) {
Map output = new LinkedHashMap<>();
for (String field : List.of("id", "resourceType", "operation", "resourceId", "targetPath", "changes", "confirmationState",
"status", "operationId", "verifiedResult", "messageCode", "expiresAt")) output.put(field, plan.get(field));
+ output.put("executionResult", "succeeded".equals(plan.get("status")) && Boolean.TRUE.equals(plan.get("verifiedResult"))
+ ? plan.get("executionResult") : null);
output.put("resourceTextIsUntrusted", true); return output;
}
diff --git a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiServiceCreateChange.java b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiServiceCreateChange.java
new file mode 100644
index 0000000000..3e49ef5996
--- /dev/null
+++ b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiServiceCreateChange.java
@@ -0,0 +1,147 @@
+package io.cattle.platform.iaas.api.auth.change;
+
+import io.cattle.platform.core.model.Service;
+import io.cattle.platform.core.util.PortBindingAddress;
+import io.cattle.platform.core.util.PortSpec;
+import io.cattle.platform.object.util.DataAccessor;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/** The reviewed Service-create subset; raw launch configuration and environment stay outside it. */
+final class AiServiceCreateChange {
+ static final int MAX_PORTS = 64;
+ private static final Set HEALTH_FIELDS = Set.of("port", "requestLine", "responseTimeout", "interval",
+ "healthyThreshold", "unhealthyThreshold", "initializingTimeout", "reinitializingTimeout", "strategy");
+
+ private AiServiceCreateChange() { }
+
+ static List ports(Object value) {
+ if (!(value instanceof List> values) || values.size() > MAX_PORTS) throw invalid();
+ List result = new ArrayList<>();
+ for (Object item : values) {
+ if (!(item instanceof String text) || text.isEmpty() || text.length() > 128 || text.indexOf('\0') >= 0) throw invalid();
+ port(text); // Preserve the native parser's specific, safe validation code.
+ result.add(text);
+ }
+ return List.copyOf(result);
+ }
+
+ static Map healthCheck(Object value) {
+ if (!(value instanceof Map, ?> input) || input.size() > HEALTH_FIELDS.size()) throw invalid();
+ Map result = new LinkedHashMap<>();
+ for (var entry : input.entrySet()) {
+ if (!(entry.getKey() instanceof String field) || !HEALTH_FIELDS.contains(field)) throw invalid();
+ Object item = entry.getValue();
+ if ("requestLine".equals(field)) {
+ if (!(item instanceof String text) || text.length() > 1024 || text.indexOf('\0') >= 0
+ || text.indexOf('\r') >= 0 || text.indexOf('\n') >= 0) throw invalid();
+ } else if ("strategy".equals(field)) {
+ if (!(item instanceof String text) || !Set.of("none", "recreate").contains(text)) throw invalid();
+ } else {
+ int minimum = Set.of("initializingTimeout", "reinitializingTimeout").contains(field) ? 0 : 1;
+ int maximum = "port".equals(field) ? 65535 : Integer.MAX_VALUE;
+ if (!(item instanceof Number number) || !Double.isFinite(number.doubleValue())
+ || number.doubleValue() != number.longValue() || number.longValue() < minimum
+ || number.longValue() > maximum) throw invalid();
+ item = number.intValue();
+ }
+ result.put(field, item);
+ }
+ if (!result.containsKey("port")) throw invalid();
+ result.putIfAbsent("strategy", "recreate"); // InstanceHealthCheck's native schema default.
+ return java.util.Collections.unmodifiableMap(result);
+ }
+
+ static boolean complete(Service service, Map expected, long stackId, String imageUuid) {
+ if (!"service".equals(service.getKind()) || service.getRemoved() != null || !"inactive".equals(service.getState())
+ || !Objects.equals(service.getName(), expected.get("name"))
+ || !Objects.equals(service.getDescription(), expected.get("description"))
+ || !Objects.equals(service.getStackId(), stackId)
+ || !sameNumber(DataAccessor.field(service, "scale", Object.class), expected.get("scale"))
+ || !Boolean.FALSE.equals(DataAccessor.field(service, "startOnCreate", Object.class))) return false;
+ if (!(expected.get("launchConfig") instanceof Map, ?> wanted)
+ || !(DataAccessor.field(service, "launchConfig", Object.class) instanceof Map, ?> actual)
+ || !Objects.equals(imageUuid, actual.get("imageUuid")) || !"bridge".equals(actual.get("networkMode"))) return false;
+ for (String field : List.of("command", "entryPoint")) {
+ Object value = actual.get(field), target = wanted.get(field);
+ if (!Objects.equals(target, value)) return false;
+ }
+ try {
+ return portsMatch(wanted.get("ports"), actual.get("ports"))
+ && Objects.equals(canonicalHealth(wanted.get("healthCheck")), canonicalHealth(actual.get("healthCheck")));
+ } catch (IllegalArgumentException | io.github.ibuildthecloud.gdapi.exception.ClientVisibleException invalidReadback) {
+ return false;
+ }
+ }
+
+ private static boolean sameNumber(Object actual, Object expected) {
+ return actual instanceof Number first && expected instanceof Number second
+ && first.doubleValue() == first.longValue() && second.doubleValue() == second.longValue()
+ && first.longValue() == second.longValue();
+ }
+
+ private record BoundPort(String bind, Integer publicPort, int privatePort, String protocol) { }
+
+ private static boolean portsMatch(Object expected, Object actual) {
+ List wanted = new ArrayList<>(canonicalPorts(expected));
+ List remaining = new ArrayList<>(canonicalPorts(actual));
+ if (wanted.size() != remaining.size()) return false;
+ for (int i = 0; i < remaining.size(); i++) for (int j = i + 1; j < remaining.size(); j++) {
+ BoundPort left = remaining.get(i), right = remaining.get(j);
+ if (left.publicPort() != null && left.publicPort().equals(right.publicPort())
+ && left.protocol().equals(right.protocol()) && PortBindingAddress.overlaps(left.bind(), right.bind())) return false;
+ }
+ // ServiceCreate retains fixed mappings first, then allocates every unspecified public port.
+ // Match fixed entries first so a random request cannot consume a specifically approved mapping.
+ wanted.sort(java.util.Comparator.comparing(port -> port.publicPort() == null));
+ for (BoundPort port : wanted) {
+ int match = -1;
+ for (int i = 0; i < remaining.size(); i++) {
+ BoundPort candidate = remaining.get(i);
+ if (candidate.publicPort() != null && candidate.publicPort() >= 1 && candidate.publicPort() <= 65535
+ && (port.publicPort() == null || port.publicPort().equals(candidate.publicPort()))
+ && port.privatePort() == candidate.privatePort() && port.protocol().equals(candidate.protocol())
+ && port.bind().equals(candidate.bind())) { match = i; break; }
+ }
+ if (match < 0) return false;
+ remaining.remove(match);
+ }
+ return remaining.isEmpty();
+ }
+
+ private static List canonicalPorts(Object value) {
+ if (value == null) return List.of();
+ List result = new ArrayList<>();
+ for (String text : ports(value)) {
+ PortSpec spec = port(text);
+ result.add(new BoundPort(PortBindingAddress.normalize(spec.getIpAddress()), spec.getPublicPort(),
+ spec.getPrivatePort(), spec.getProtocol()));
+ }
+ return result;
+ }
+
+ private static Map canonicalHealth(Object value) {
+ if (value == null) return null;
+ if (!(value instanceof Map, ?> input)) throw invalid();
+ Map fields = new LinkedHashMap<>();
+ for (var entry : input.entrySet()) {
+ if (!(entry.getKey() instanceof String key)) throw invalid();
+ if ("type".equals(key) && "instanceHealthCheck".equals(entry.getValue())) continue;
+ if (entry.getValue() != null) fields.put(key, entry.getValue());
+ }
+ return healthCheck(fields);
+ }
+
+ private static PortSpec port(String text) {
+ try { return new PortSpec(text); }
+ catch (NumberFormatException invalidNativeNumber) { throw AiResourceChangeDescriptor.error(PortSpec.WRONG_FORMAT); }
+ }
+
+ private static io.github.ibuildthecloud.gdapi.exception.ClientVisibleException invalid() {
+ return AiResourceChangeDescriptor.error("ResourceChangeInputInvalid");
+ }
+}
diff --git a/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/ai/catalog/AiCatalogLaunchMaterializerTest.java b/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/ai/catalog/AiCatalogLaunchMaterializerTest.java
index e406f621d0..f999f2afca 100644
--- a/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/ai/catalog/AiCatalogLaunchMaterializerTest.java
+++ b/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/ai/catalog/AiCatalogLaunchMaterializerTest.java
@@ -5,20 +5,38 @@
import static org.mockito.Mockito.*;
import io.cattle.platform.api.auth.Policy;
+import io.cattle.platform.api.formatter.DefaultIdFormatter;
+import io.cattle.platform.core.addon.LogConfig;
import io.cattle.platform.core.model.Account;
+import io.cattle.platform.core.model.Instance;
import io.cattle.platform.core.model.tables.records.AccountRecord;
+import io.cattle.platform.core.model.tables.records.InstanceRecord;
import io.cattle.platform.core.model.tables.records.SecretRecord;
import io.cattle.platform.docker.transform.DockerTransformer;
+import io.cattle.platform.docker.transform.DockerTransformerImpl;
import io.cattle.platform.iaas.api.auth.impl.ApiAuthenticator;
import io.cattle.platform.json.JacksonJsonMapper;
import io.cattle.platform.object.ObjectManager;
+import io.cattle.platform.object.util.DataAccessor;
+import io.cattle.platform.object.util.DataUtils;
+import io.cattle.platform.schema.processor.AuthOverlayPostProcessor;
+import io.cattle.platform.schema.processor.JpaWritablePostProcessor;
+import io.cattle.platform.schema.processor.JsonFileOverlayPostProcessor;
+import io.cattle.platform.schema.processor.SchemaOverlayImpl;
+import io.cattle.platform.schema.processor.StripSuffixPostProcessor;
import io.github.ibuildthecloud.gdapi.context.ApiContext;
import io.github.ibuildthecloud.gdapi.factory.SchemaFactory;
+import io.github.ibuildthecloud.gdapi.factory.impl.SchemaFactoryImpl;
+import io.github.ibuildthecloud.gdapi.factory.impl.SubSchemaFactory;
import io.github.ibuildthecloud.gdapi.id.IdFormatter;
+import io.github.ibuildthecloud.gdapi.json.JacksonMapper;
+import io.github.ibuildthecloud.gdapi.model.impl.FieldImpl;
import io.github.ibuildthecloud.gdapi.model.impl.SchemaImpl;
import io.github.ibuildthecloud.gdapi.request.ApiRequest;
import io.github.ibuildthecloud.gdapi.request.resource.ResourceManager;
import java.util.ArrayList;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
@@ -89,4 +107,149 @@ public class AiCatalogLaunchMaterializerTest {
assertThrows(UnsupportedOperationException.class, () -> prepared.authorizedReferenceIds().put("other", "1se8"));
assertThrows(UnsupportedOperationException.class, () -> prepared.nativeGraph().put("resolvedSecrets", Map.of()));
}
+
+ @Test public void realTransformerDynamicFieldsReachNativeRoleSchemaProjection() throws Exception {
+ var fixture = new ProjectionFixture(false);
+ Map projected = fixture.prepare();
+ assertEquals("docker:busybox:1.37.0", projected.get("imageUuid"));
+ assertEquals(Map.of("PUBLIC_TEXT", "one=two"), projected.get("environment"));
+ assertEquals(List.of("18080:80/tcp"), projected.get("ports"));
+ assertEquals(Map.of("driver", "json-file", "config", Map.of("max-size", "10m")), projected.get("logConfig"));
+ assertEquals("app", projected.get("name"));
+ assertFalse(projected.containsKey("notInNativeSchema"));
+ assertSame(fixture.schemas, fixture.authority.current().schemas());
+ // Native bean accessors remain real; the four dynamic fields have no
+ // InstanceRecord getter and must come from the data.fields value source.
+ var fields = fixture.schemas.getSchema("container").getResourceFields();
+ assertNotNull(((FieldImpl) fields.get("name")).getReadMethod());
+ for (String field : List.of("imageUuid", "environment", "ports", "logConfig")) {
+ assertNull(field, ((FieldImpl) fields.get(field)).getReadMethod());
+ assertNotNull(field, DataUtils.getFields(fixture.transformer.instance).get(field));
+ }
+ // WrappedResource consumes only the fresh copy, never InstanceRecord's map.
+ assertEquals(Map.of("PUBLIC_TEXT", "one=two"), DataUtils.getFields(fixture.transformer.instance).get("environment"));
+ }
+
+ @Test public void dynamicProjectionKeepsRestrictedSchemaAndNonCreateResponseRules() throws Exception {
+ var fixture = new ProjectionFixture(true);
+ Map projected = fixture.prepare();
+ assertEquals("docker:busybox:1.37.0", projected.get("imageUuid"));
+ assertEquals("app", projected.get("name"));
+ assertFalse("A hidden schema field cannot reappear from additionalFields", projected.containsKey("environment"));
+ assertNull("POST alone must not disclose a read-on-create-only field", projected.get("ports"));
+ assertNull(projected.get("logConfig"));
+ assertFalse(projected.containsKey("notInNativeSchema"));
+ assertEquals(Map.of("PUBLIC_TEXT", "one=two"), DataUtils.getFields(fixture.transformer.instance).get("environment"));
+ assertNotNull(DataUtils.getFields(fixture.transformer.instance).get("ports"));
+ assertNotNull(DataUtils.getFields(fixture.transformer.instance).get("logConfig"));
+ }
+
+ private static final class RecordingTransformer extends DockerTransformerImpl {
+ InstanceRecord instance;
+ @Override public void transform(Map inspect, Instance target) {
+ super.transform(inspect, target);
+ instance = (InstanceRecord) target;
+ DataAccessor.setField(target, "notInNativeSchema", "MUST_NOT_ESCAPE_SCHEMA");
+ }
+ }
+
+ private static final class ProjectionFixture {
+ final JacksonJsonMapper json = new JacksonJsonMapper();
+ final SchemaFactory schemas;
+ final AiCatalogLaunchAccess.Authorization authority;
+ final RecordingTransformer transformer = new RecordingTransformer();
+ final AiCatalogLaunchMaterializer materializer = new AiCatalogLaunchMaterializer();
+ Map projection;
+
+ ProjectionFixture(boolean restrict) throws Exception {
+ schemas = nativeSchemas(json);
+ if (restrict) {
+ // Narrow a copy of the real role schema, retaining its real
+ // accessors. These controls must still win over dynamic data.
+ var fields = schemas.getSchema("container").getResourceFields();
+ ((FieldImpl) fields.get("environment")).setIncludeInList(false);
+ ((FieldImpl) fields.get("ports")).setReadOnCreateOnly(true);
+ ((FieldImpl) fields.get("logConfig")).setReadOnCreateOnly(true);
+ }
+ Policy policy = mock(Policy.class); when(policy.getAccountId()).thenReturn(5L);
+ authority = new AiCatalogLaunchAccess.Authorization(new ApiAuthenticator.CurrentAuthorization(policy, schemas), null);
+ var formatter = new DefaultIdFormatter(); formatter.setSchemaFactory(schemas);
+ formatter.setTypeMappings(Map.of("account", "a", "instance", "i", "stack", "st", "service", "s"));
+ ApiContext.newContext().setIdFormatter(formatter);
+ ObjectManager objects = mock(ObjectManager.class);
+ AccountRecord project = new AccountRecord(); project.setId(5L); project.setName("Default");
+ when(objects.loadResource(Account.class, 5L)).thenReturn(project);
+ materializer.access = new AiCatalogLaunchAccess() {
+ @Override void preflightChild(ApiRequest original, Authorization selected, Map plan,
+ String type, Map body) { assertSame(authority, selected); assertEquals("service", type); }
+ @Override void childActions(Authorization selected, String type, boolean starts) {
+ assertSame(authority, selected); assertEquals("service", type); assertFalse(starts);
+ }
+ };
+ materializer.access.objects = objects;
+ var mapper = DockerTransformerImpl.class.getDeclaredField("jsonMapper");
+ mapper.setAccessible(true); mapper.set(transformer, json);
+ materializer.transformer = transformer;
+ materializer.graphs = new AiCatalogLaunchGraph(); materializer.graphs.json = json;
+ materializer.runner = mock(AiCatalogLaunchRunner.class);
+ Map inspect = Map.of("name", "app", "dockerInspect", Map.of("Name", "app",
+ "Config", Map.of("Image", "busybox:1.37.0", "Env", List.of("PUBLIC_TEXT=one=two"),
+ "ExposedPorts", Map.of("80/tcp", Map.of())),
+ "HostConfig", Map.of("NetworkMode", "bridge",
+ "PortBindings", Map.of("80/tcp", List.of(Map.of("HostIp", "", "HostPort", "18080"))),
+ "LogConfig", Map.of("Type", "json-file", "Config", Map.of("max-size", "10m")))));
+ when(materializer.runner.run(anyMap())).thenAnswer(call -> {
+ Map input = call.getArgument(0);
+ if (!"finalize".equals(input.get("phase"))) return Map.of("services", List.of(inspect));
+ Map configs = AiCatalogLaunchInput.map(input.get("transformedLaunchConfigs"));
+ projection = json.readValue(json.writeValueAsString(configs.get("app")));
+ return Map.of("phase", "finalize", "services", List.of(inspect), "serviceRequests", List.of(Map.of(
+ "name", "app", "resourceType", "service", "createBody", Map.of("name", "app", "stackId", "$createdStack"),
+ "setServiceLinks", Map.of("serviceLinks", List.of()))), "volumeRequests", List.of(),
+ "dockerCompose", "services: {}", "rancherCompose", "", "variables", Map.of());
+ });
+ }
+
+ Map prepare() {
+ ApiRequest request = new ApiRequest(null, schemas); request.setMethod("POST");
+ materializer.prepare(request, authority, Map.of("name", "chosen", "startOnCreate", false),
+ Map.of("files", Map.of("docker-compose.yml", "services: {}")), Map.of());
+ assertNotNull(projection);
+ return projection;
+ }
+ }
+
+ private static SchemaFactory nativeSchemas(JacksonJsonMapper json) throws Exception {
+ Path root = Path.of("").toAbsolutePath();
+ while (root != null && !Files.isDirectory(root.resolve("resources/content/schema"))) root = root.getParent();
+ assertNotNull("Native source schema definitions are required", root);
+ final Path definitions = root.resolve("resources/content/schema");
+ var schemaJson = new JacksonMapper(); schemaJson.init();
+ // Same native field/parent construction and overlay implementation as
+ // CoreSchemaFactory. Only source-tree URL discovery is local to this test.
+ var base = new JsonFileOverlayPostProcessor() {
+ @Override public SchemaImpl postProcessRegister(SchemaImpl schema, SchemaFactory factory) { return schema; }
+ @Override public SchemaImpl postProcess(SchemaImpl schema, SchemaFactory factory) {
+ Path file = definitions.resolve("base/"+schema.getId()+".json");
+ if (!Files.isRegularFile(file)) return schema;
+ try {
+ byte[] content = Files.readAllBytes(file);
+ processSchema(schema, schemaJson.readValue(content, SchemaOverlayImpl.class), json.readValue(content));
+ return schema;
+ } catch (Exception failure) { throw new IllegalStateException(failure); }
+ }
+ };
+ var suffix = new StripSuffixPostProcessor(); suffix.setSuffix("Record");
+ var core = new SchemaFactoryImpl(); core.setTypes(List.of(InstanceRecord.class, LogConfig.class));
+ core.setTypeNames(List.of("container,parent=instance"));
+ core.setPostProcessors(List.of(suffix, new JpaWritablePostProcessor(), base)); core.init();
+ var role = new AuthOverlayPostProcessor(); role.setJsonMapper(json);
+ role.setResources(List.of(definitions.resolve("project/project-auth.json").toUri().toURL())); role.init();
+ var selected = new SubSchemaFactory(); selected.setId("native-project-projection-test"); selected.setSchemaFactory(core);
+ selected.setPostProcessors(List.of(role)); selected.init();
+ assertNotNull(selected.getSchema("container"));
+ for (String field : List.of("imageUuid", "environment", "ports", "logConfig", "name"))
+ assertNotNull(field, selected.getSchema("container").getResourceFields().get(field));
+ return selected;
+ }
}
diff --git a/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeDescriptorTest.java b/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeDescriptorTest.java
index c443841d19..c3713f0e1a 100644
--- a/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeDescriptorTest.java
+++ b/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeDescriptorTest.java
@@ -96,6 +96,64 @@ public class AiResourceChangeDescriptorTest {
assertEquals(command, http.readValue(((String) wireCommand.get("after")).getBytes(java.nio.charset.StandardCharsets.UTF_8), ArrayList.class));
}
+ @Test public void serviceCreatePortsAndHealthCheckUseOnlyTheReviewedNativeLaunchFields() throws Exception {
+ var descriptor = AiResourceChangeDescriptor.require("service", "create");
+ var input = new LinkedHashMap(Map.of("name", "probe", "image", "busybox", "stackId", "1st3",
+ "ports", List.of("18080:80/tcp", "15353:53/udp"), "healthCheck", Map.of("port", 80, "requestLine", "GET / HTTP/1.0",
+ "interval", 2000, "healthyThreshold", 2)));
+ var parameters = descriptor.parameters(input); var body = descriptor.body(parameters);
+ Map, ?> launch = (Map, ?>) body.get("launchConfig");
+ assertEquals(input.get("ports"), launch.get("ports"));
+ assertEquals(Map.of("port", 80, "requestLine", "GET / HTTP/1.0", "interval", 2000, "healthyThreshold", 2,
+ "strategy", "recreate"), launch.get("healthCheck"));
+ assertFalse(body.containsKey("ports")); assertFalse(body.containsKey("healthCheck"));
+ assertEquals(false, body.get("startOnCreate")); assertEquals("bridge", launch.get("networkMode"));
+ var domain = new AiResourceChangeDomain(); domain.json = new JacksonJsonMapper();
+ var changes = domain.changes(descriptor, null, parameters);
+ for (String field : List.of("ports", "healthCheck")) {
+ Object summary = changes.stream().filter(change -> field.equals(change.get("field"))).findFirst().orElseThrow().get("after");
+ Object decoded = new JacksonJsonMapper().readValue((String) summary, Object.class);
+ assertEquals(parameters.get(field), decoded);
+ }
+ for (String type : List.of("container", "stack", "externalService", "dnsService"))
+ for (String field : List.of("ports", "healthCheck")) {
+ var unrelated = new LinkedHashMap(Map.of("name", "probe", "image", "busybox", "stackId", "1st3"));
+ unrelated.put(field, input.get(field));
+ assertCode("ResourceChangeInputInvalid", () -> AiResourceChangeDescriptor.require(type, "create").parameters(unrelated));
+ }
+ for (String field : List.of("environment", "launchConfig", "startOnCreate", "ports", "healthCheck"))
+ assertCode("ResourceChangeInputInvalid", () -> AiResourceChangeDescriptor.require("service", "update_metadata")
+ .parameters(Map.of(field, "forbidden")));
+ input.put("environment", Map.of("TOKEN", "must-never-enter-plan"));
+ assertCode("ResourceChangeInputInvalid", () -> descriptor.parameters(input));
+ }
+
+ @Test public void servicePortAndHealthInputsRejectUnknownTypesAndExcessiveValuesAndKeepNativePortCodes() {
+ for (Object invalid : List.of("80:80", List.of(80), List.of(""), java.util.Collections.nCopies(65, "18080:80"),
+ List.of("1".repeat(129)))) assertCode("ResourceChangeInputInvalid", () -> AiServiceCreateChange.ports(invalid));
+ assertCode("PortInvalidPrivatePort", () -> AiServiceCreateChange.ports(List.of("18080:65536")));
+ assertCode("PortInvalidPublicPort", () -> AiServiceCreateChange.ports(List.of("65536:80")));
+ assertCode("PortInvalidProtocol", () -> AiServiceCreateChange.ports(List.of("18080:80/sctp")));
+ assertCode("PortWrongFormat", () -> AiServiceCreateChange.ports(List.of("9999999999999999999999:80")));
+ for (Object invalid : List.of("80", Map.of(), Map.of("port", "80"), Map.of("port", 1.5), Map.of("port", 0),
+ Map.of("port", 65536), Map.of("port", 80, "unknown", true), Map.of("port", 80, "strategy", "recreateOnQuorum"),
+ Map.of("port", 80, "requestLine", "GET /\r\nX: injected"), Map.of("port", 80, "requestLine", "x".repeat(1025))))
+ assertCode("ResourceChangeInputInvalid", () -> AiServiceCreateChange.healthCheck(invalid));
+ for (String field : List.of("interval", "responseTimeout", "healthyThreshold", "unhealthyThreshold")) {
+ assertCode("ResourceChangeInputInvalid", () -> AiServiceCreateChange.healthCheck(Map.of("port", 80, field, 0)));
+ assertEquals(1, AiServiceCreateChange.healthCheck(Map.of("port", 80, field, 1)).get(field));
+ }
+ for (String field : List.of("interval", "responseTimeout", "healthyThreshold", "unhealthyThreshold", "initializingTimeout", "reinitializingTimeout")) {
+ assertCode("ResourceChangeInputInvalid", () -> AiServiceCreateChange.healthCheck(Map.of("port", 80, field, -1)));
+ assertCode("ResourceChangeInputInvalid", () -> AiServiceCreateChange.healthCheck(Map.of("port", 80, field, 2147483648L)));
+ assertEquals(Integer.MAX_VALUE, AiServiceCreateChange.healthCheck(Map.of("port", 65535, field, 2147483647L)).get(field));
+ }
+ for (String field : List.of("initializingTimeout", "reinitializingTimeout"))
+ assertEquals(0, AiServiceCreateChange.healthCheck(Map.of("port", 1, field, 0)).get(field));
+ var nullable = new LinkedHashMap(); nullable.put("port", 80); nullable.put("strategy", null);
+ assertCode("ResourceChangeInputInvalid", () -> AiServiceCreateChange.healthCheck(nullable));
+ }
+
private static void assertCode(String code, Runnable action) {
assertEquals(code, assertThrows(ClientVisibleException.class, action::run).getCode());
}
diff --git a/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangePlanServiceTest.java b/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangePlanServiceTest.java
index c1f5d8801e..587a94497b 100644
--- a/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangePlanServiceTest.java
+++ b/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangePlanServiceTest.java
@@ -185,6 +185,68 @@ public class AiResourceChangePlanServiceTest {
f.complete = true; assertEquals(true, f.service.get(f.id, f.request(null, false)).get("verifiedResult"));
}
+ @Test public void serviceExecutionEvidenceIsPublishedOnlyAfterActualReadbackAndNeverChangesTheApprovedDiff() {
+ Fixture f = new Fixture(); f.previewServiceCreate(false); f.confirm();
+ f.target.setUuid("created-service"); f.target.setStackId(3L); f.target.setState("inactive");
+ Map launch = new LinkedHashMap<>(Map.of("imageUuid", "docker:busybox:1.37.0", "networkMode", "bridge",
+ "ports", List.of("31001:80/tcp"), "healthCheck", Map.of("port", 80, "strategy", "recreate")));
+ f.target.setDescription(null); f.target.setData(Map.of("fields", Map.of("launchConfig", launch, "scale", 1L, "startOnCreate", false)));
+ doAnswer(call -> { f.dispatched++; return f.target; }).when(f.domain).dispatch(any(), any(), isNull(), anyMap(), anyString());
+ when(f.domain.complete(any(), any(), anyMap(), anyMap())).thenAnswer(call -> AiServiceCreateChange.complete(f.target,
+ ((AiResourceChangeDescriptor) call.getArgument(0)).body(call.getArgument(2)), 3L, "docker:busybox:1.37.0"));
+ when(f.domain.serviceCreateResult(any())).thenCallRealMethod();
+ Object approvedChanges = f.documents.get(f.id).get("changes");
+ assertNull(f.apply(false).get("executionResult"));
+ assertEquals("created-service", f.documents.get(f.id).get("createdResourceUuid"));
+ launch.put("ports", List.of("31001:81/tcp"));
+ var running = f.service.get(f.id, f.request(null, false));
+ assertEquals("running", running.get("status")); assertNull(running.get("executionResult"));
+ launch.put("ports", List.of("31001:80/tcp"));
+ var completed = f.service.get(f.id, f.request(null, false));
+ assertEquals("succeeded", completed.get("status")); assertEquals(true, completed.get("verifiedResult"));
+ assertEquals(Map.of("assignedPorts", List.of("31001:80/tcp")), completed.get("executionResult"));
+ assertEquals(approvedChanges, completed.get("changes")); assertEquals(1, f.claims); assertEquals(1, f.dispatched);
+ f.apply(false); assertEquals(1, f.dispatched);
+ f.documents.get(f.id).put(AiResourceChangePlanStore.PRIVATE_FAILURE, AiResourceChangePlanStore.AUTHORIZATION_DENIED);
+ var failed = f.service.get(f.id, f.request(null, false));
+ assertEquals("failed", failed.get("status")); assertEquals(false, failed.get("verifiedResult")); assertNull(failed.get("executionResult"));
+ }
+
+ @Test public void serviceLostCreateResponseOrMissingUuidRetainsOneUnknownClaimAndNoExecutionEvidence() {
+ for (boolean missingUuid : List.of(false, true)) {
+ Fixture f = new Fixture(); f.previewServiceCreate(false); f.confirm();
+ doAnswer(call -> {
+ f.dispatched++;
+ if (!missingUuid) throw new IllegalStateException("native create succeeded but response was lost");
+ return f.target;
+ }).when(f.domain).dispatch(any(), any(), isNull(), anyMap(), anyString());
+ var unknown = f.apply(false); assertEquals("unknown", unknown.get("status"));
+ assertNull(unknown.get("executionResult"));
+ assertNull(f.documents.get(f.id).get("internalResourceId"));
+ assertEquals("unknown", f.apply(false).get("status"));
+ assertEquals("unknown", f.service.get(f.id, f.request(null, false)).get("status"));
+ assertEquals(1, f.claims); assertEquals(1, f.dispatched);
+ verify(f.domain, never()).complete(any(), any(), anyMap(), anyMap());
+ }
+ }
+
+ @Test public void successfulServiceDispatchWithLostReceiptSaveKeepsTheDurableClaimUnknownAndCannotReplay() {
+ Fixture f = new Fixture(); f.previewServiceCreate(false); f.confirm(); f.target.setUuid("created-service");
+ doAnswer(call -> { f.dispatched++; return f.target; }).when(f.domain).dispatch(any(), any(), isNull(), anyMap(), anyString());
+ var writes = spy(f.service.store); f.service.store = writes;
+ doAnswer(call -> {
+ Map plan = call.getArgument(0);
+ if ("accepted".equals(plan.get("status"))) throw new IllegalStateException("receipt write disconnected");
+ return call.callRealMethod();
+ }).when(writes).save(anyMap());
+ assertThrows(IllegalStateException.class, () -> f.apply(false));
+ assertEquals("dispatching", f.documents.get(f.id).get("status"));
+ assertEquals("unknown", f.apply(false).get("status"));
+ assertEquals("unknown", f.service.get(f.id, f.request(null, false)).get("status"));
+ assertNull(f.documents.get(f.id).get("executionResult")); assertEquals(1, f.claims); assertEquals(1, f.dispatched);
+ verify(f.domain, never()).complete(any(), any(), anyMap(), anyMap());
+ }
+
@Test public void featureDisabledIsRejectedBeforeAnyPersistenceOrDomainRead() {
Object previous = io.cattle.platform.archaius.util.ArchaiusUtil.getConfiguration().getProperty("api.ai.resource.change.enabled");
try {
@@ -327,6 +389,16 @@ Map preview(boolean key) {
Map result = service.preview(request); id = (String) result.get("id"); return result;
}
void confirm() { service.action(id, request("confirm", false)); }
+ void previewServiceCreate(boolean key) { previewServiceCreate(key, List.of("80")); }
+ void previewServiceCreate(boolean key, List ports) {
+ ServiceRecord parent = new ServiceRecord(); parent.setId(3L); parent.setKind("stack"); parent.setName("Default"); parent.setState("active");
+ when(domain.parent(any(), any(), anyMap())).thenReturn(parent);
+ when(domain.lock(eq("stack"), anyLong())).thenReturn(parent);
+ ApiRequest preview = request(null, key); preview.setId(null);
+ preview.setRequestObject(Map.of("resourceType", "service", "operation", "create", "parameters",
+ Map.of("name", "probe", "image", "busybox:1.37.0", "stackId", "1st3", "ports", ports, "healthCheck", Map.of("port", 80))));
+ id = (String) service.preview(preview).get("id");
+ }
Map previewArchive(String operation) {
ApiRequest preview = request(null, false); preview.setId(null);
preview.setRequestObject(Map.of("resourceType", "service", "operation", operation, "resourceId", "1s7", "parameters", Map.of()));
diff --git a/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeReceiptAuthorizationTest.java b/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeReceiptAuthorizationTest.java
index dbc4b7f52c..a44d717962 100644
--- a/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeReceiptAuthorizationTest.java
+++ b/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeReceiptAuthorizationTest.java
@@ -109,6 +109,26 @@ public class AiResourceChangeReceiptAuthorizationTest {
verifyNoInteractions(f.manager);
}
+ @Test public void createdServiceReceiptsBindTheActualIdUuidKindAndOwnerBeforeReadback() throws Exception {
+ Fixture f = new Fixture(); var descriptor = AiResourceChangeDescriptor.require("service", "create");
+ ServiceRecord service = (ServiceRecord) f.objects.loadResource("service", 890L);
+ service.setUuid("created-service"); service.setState("inactive");
+ Map plan = new LinkedHashMap<>(Map.of("internalResourceId", 890L, "resourceId", "1s890",
+ "createdResourceUuid", "created-service"));
+ assertSame(service, f.domain.receiptTarget(f.request, descriptor, plan));
+ plan.remove("createdResourceUuid");
+ assertEquals("PermissionDenied", rejected(() -> f.domain.receiptTarget(f.request, descriptor, plan)));
+ plan.put("createdResourceUuid", "created-service"); service.setUuid("replacement-service");
+ assertEquals("PermissionDenied", rejected(() -> f.domain.receiptTarget(f.request, descriptor, plan)));
+ service.setUuid(null); assertEquals("PermissionDenied", rejected(() -> f.domain.receiptTarget(f.request, descriptor, plan)));
+ service.setUuid("created-service"); service.setId(891L);
+ assertEquals("PermissionDenied", rejected(() -> f.domain.receiptTarget(f.request, descriptor, plan)));
+ service.setId(890L); service.setAccountId(99L);
+ assertEquals("PermissionDenied", rejected(() -> f.domain.receiptTarget(f.request, descriptor, plan)));
+ service.setAccountId(5L); service.setKind("externalService");
+ assertEquals("PermissionDenied", rejected(() -> f.domain.receiptTarget(f.request, descriptor, plan)));
+ }
+
private static String rejected(Runnable action) { return assertThrows(ClientVisibleException.class, action::run).getCode(); }
@Test public void terminalFailureProjectionStillRequiresTheActualLiveReceiptGuards() throws Exception {
Fixture f = new Fixture(); String id = "aichange-11111111-1111-1111-1111-111111111111";
diff --git a/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiServiceCreateChangeTest.java b/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiServiceCreateChangeTest.java
new file mode 100644
index 0000000000..20860468e6
--- /dev/null
+++ b/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiServiceCreateChangeTest.java
@@ -0,0 +1,137 @@
+package io.cattle.platform.iaas.api.auth.change;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+import io.cattle.platform.core.model.tables.records.ServiceRecord;
+import io.cattle.platform.core.util.PortSpec;
+import io.cattle.platform.iaas.api.auth.apikey.ApiKeyTargetResolver;
+import io.cattle.platform.json.JacksonJsonMapper;
+import io.cattle.platform.object.impl.JooqObjectManager;
+import io.cattle.platform.object.util.DataAccessor;
+import io.cattle.platform.servicediscovery.api.filter.ServiceValidationFilter;
+import io.cattle.platform.storage.service.StorageService;
+import io.github.ibuildthecloud.gdapi.model.impl.FieldImpl;
+import io.github.ibuildthecloud.gdapi.model.impl.SchemaImpl;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.Test;
+
+public class AiServiceCreateChangeTest {
+ @Test public void nativeObjectMappingKeepsStartOnCreateInThePersistedServiceDataField() {
+ var schema = new SchemaImpl(); schema.setResourceFields(Map.of("startOnCreate", new FieldImpl()));
+ var service = new ServiceRecord();
+ var nativeObjects = new JooqObjectManager() {
+ void mapCreateField() { setField(schema, service, "startOnCreate", false); }
+ };
+ nativeObjects.mapCreateField();
+ assertEquals(false, DataAccessor.field(service, "startOnCreate", Object.class));
+ assertEquals(false, ((Map, ?>) service.getData().get("fields")).get("startOnCreate"));
+ }
+
+ @Test public void actualNativeReadbackAcceptsAllocatedPortsAndNativeNormalizationWithoutUsingTheRequestBodyAsEvidence() throws Exception {
+ Fixture f = new Fixture();
+ assertTrue(f.complete());
+ var assigned = f.domain.serviceCreateResult(f.service);
+ assertEquals(List.of("18080:80/tcp", "127.0.0.1:31001:80/tcp", "31002:80/udp"),
+ assigned.get("assignedPorts"));
+ // The approved input remains the request for native allocation, rather than the observed assigned values.
+ assertEquals(List.of("127.0.0.1::80/tcp", "18080:080", "80/udp"), f.parameters.get("ports"));
+ }
+
+ @Test public void anyDifferentPrivateProtocolIpFixedPublicMissingOrDuplicateMappingCannotComplete() throws Exception {
+ for (List ports : List.of(
+ List.of("18080:81/tcp", "127.0.0.1:31001:80/tcp", "31002:80/udp"),
+ List.of("18080:80/udp", "127.0.0.1:31001:80/tcp", "31002:80/udp"),
+ List.of("18080:80/tcp", "127.0.0.2:31001:80/tcp", "31002:80/udp"),
+ List.of("18081:80/tcp", "127.0.0.1:31001:80/tcp", "31002:80/udp"),
+ List.of("18080:80/tcp", "127.0.0.1:31001:80/tcp"),
+ List.of("18080:80/tcp", "127.0.0.1:31001:80/tcp", "127.0.0.1:31001:80/tcp"),
+ List.of("18080:80/tcp", "127.0.0.1:80/tcp", "31002:80/udp"),
+ List.of("18080:80/tcp", "127.0.0.1:0:80/tcp", "31002:80/udp"))) {
+ Fixture f = new Fixture(); f.launch.put("ports", ports); assertFalse(ports.toString(), f.complete());
+ }
+ Fixture missing = new Fixture(); missing.launch.remove("ports"); assertFalse(missing.complete());
+ Fixture extra = new Fixture();
+ List ports = new ArrayList<>((List) extra.launch.get("ports")); ports.add("32000:9000/tcp");
+ extra.launch.put("ports", ports); assertFalse(extra.complete());
+ }
+
+ @Test public void fixedMappingsAreMatchedBeforeRandomRequestsForTheSamePrivatePort() throws Exception {
+ Fixture f = new Fixture();
+ f.parameters.put("ports", List.of("80", "18080:80"));
+ f.launch.put("ports", List.of("18080:80/tcp", "31001:80/tcp")); assertTrue(f.complete());
+ f.launch.put("ports", List.of("31001:80/tcp", "31002:80/tcp")); assertFalse(f.complete());
+ f.parameters.put("ports", List.of("80", "80"));
+ f.launch.put("ports", List.of("31001:80/tcp")); assertFalse(f.complete());
+ f.launch.put("ports", List.of("31001:80/tcp", "31001:80/tcp")); assertFalse(f.complete());
+ f.launch.put("ports", List.of("31001:80/tcp", "31002:80/tcp")); assertTrue(f.complete());
+ }
+
+ @Test public void ipv6ReadbackUsesTheNativeBracketedFixedAndAllocatedPortRepresentation() throws Exception {
+ Fixture f = new Fixture();
+ f.parameters.put("ports", List.of("[::1]:18080:80", "[2001:db8::1]:53/udp"));
+ PortSpec fixed = new PortSpec("[::1]:18080:80");
+ PortSpec allocated = new PortSpec("[2001:db8::1]:53/udp"); allocated.setPublicPort(31001);
+ f.launch.put("ports", List.of(fixed.toSpec(), allocated.toSpec()));
+ assertEquals(List.of("[::1]:18080:80/tcp", "[2001:db8::1]:31001:53/udp"), f.launch.get("ports"));
+ assertTrue(f.complete());
+ f.launch.put("ports", List.of("[::1]:18080:80/tcp", "[2001:db8::2]:31001:53/udp")); assertFalse(f.complete());
+ }
+
+ @Test public void healthThresholdPortRequestStrategyAndUnexpectedConfigurationMustMatch() throws Exception {
+ for (var entry : Map.of("port", 81, "healthyThreshold", 3, "interval", 3000,
+ "requestLine", "GET /changed HTTP/1.0", "strategy", "none").entrySet()) {
+ Fixture f = new Fixture(); f.health.put(entry.getKey(), entry.getValue()); assertFalse(entry.getKey(), f.complete());
+ }
+ Fixture missing = new Fixture(); missing.launch.remove("healthCheck"); assertFalse(missing.complete());
+ Fixture unexpected = new Fixture(); unexpected.health.put("recreateOnQuorumStrategyConfig", Map.of("quorum", 1));
+ assertFalse(unexpected.complete());
+ Fixture nativeDefault = new Fixture(); nativeDefault.health.remove("strategy"); assertTrue(nativeDefault.complete());
+ }
+
+ @Test public void nativeInactiveStateAloneCannotProveImageParentProgramsOrMetadataWereCreatedCorrectly() throws Exception {
+ Fixture image = new Fixture(); image.launch.put("imageUuid", "docker:registry.test/team/other:1"); assertFalse(image.complete());
+ Fixture stack = new Fixture(); stack.service.setStackId(4L); assertFalse(stack.complete());
+ Fixture command = new Fixture(); command.launch.put("command", List.of("wrong")); assertFalse(command.complete());
+ Fixture entryPoint = new Fixture(); entryPoint.launch.put("entryPoint", List.of("/wrong")); assertFalse(entryPoint.complete());
+ Fixture name = new Fixture(); name.service.setName("other"); assertFalse(name.complete());
+ Fixture description = new Fixture(); description.service.setDescription("other"); assertFalse(description.complete());
+ Fixture network = new Fixture(); network.launch.put("networkMode", "host"); assertFalse(network.complete());
+ Fixture scale = new Fixture(); scale.fields.put("scale", 2L); assertFalse(scale.complete());
+ Fixture started = new Fixture(); started.fields.put("startOnCreate", true); assertFalse(started.complete());
+ Fixture active = new Fixture(); active.service.setState("active"); assertFalse(active.complete());
+ }
+
+ private static class Fixture {
+ final AiResourceChangeDomain domain = new AiResourceChangeDomain();
+ final AiResourceChangeDescriptor descriptor = AiResourceChangeDescriptor.require("service", "create");
+ final ServiceRecord service = new ServiceRecord();
+ final Map fields = new LinkedHashMap<>(), launch = new LinkedHashMap<>(), health = new LinkedHashMap<>();
+ final Map parameters;
+ Fixture() throws Exception {
+ parameters = new LinkedHashMap<>(descriptor.parameters(Map.of("name", "probe", "image", "registry.test/team/app:1", "stackId", "1st3",
+ "command", List.of("serve", "--foreground"), "entryPoint", List.of("/app"),
+ "ports", List.of("127.0.0.1::80/tcp", "18080:080", "80/udp"),
+ "healthCheck", Map.of("port", 80, "interval", 2000, "healthyThreshold", 2, "requestLine", "GET / HTTP/1.0"))));
+ // A native record independently represents the persisted output of ServiceCreate.setPorts:
+ // fixed mapping first (PortSpec.toSpec), then the allocated public ports, preserving private/protocol/IP.
+ service.setId(91L); service.setUuid("created-service-uuid"); service.setAccountId(77L); service.setKind("service");
+ service.setName("probe"); service.setStackId(3L); service.setState("inactive");
+ launch.put("imageUuid", "docker:registry.test/team/app:1"); launch.put("networkMode", "bridge");
+ launch.put("command", List.of("serve", "--foreground")); launch.put("entryPoint", List.of("/app"));
+ launch.put("ports", List.of("18080:80/tcp", "127.0.0.1:31001:80/tcp", "31002:80/udp"));
+ health.put("port", 80L); health.put("interval", 2000L); health.put("healthyThreshold", 2L);
+ health.put("requestLine", "GET / HTTP/1.0"); health.put("strategy", "recreate"); launch.put("healthCheck", health);
+ fields.put("scale", 1L); fields.put("startOnCreate", false); fields.put("launchConfig", launch);
+ service.setData(Map.of("fields", fields));
+ domain.targets = mock(ApiKeyTargetResolver.class); when(domain.targets.parseScopeId("stack", "1st3")).thenReturn(3L);
+ var nativeFilter = new ServiceValidationFilter(); StorageService storage = mock(StorageService.class);
+ when(storage.isValidUUID(anyString())).thenReturn(true);
+ var injection = ServiceValidationFilter.class.getDeclaredField("storageService"); injection.setAccessible(true); injection.set(nativeFilter, storage);
+ domain.serviceCreateValidation = nativeFilter; domain.json = new JacksonJsonMapper();
+ }
+ boolean complete() { return domain.complete(descriptor, service, parameters, Map.of()); }
+ }
+}
diff --git a/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiServiceCreateNativePreflightTest.java b/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiServiceCreateNativePreflightTest.java
new file mode 100644
index 0000000000..d27e28b51c
--- /dev/null
+++ b/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiServiceCreateNativePreflightTest.java
@@ -0,0 +1,146 @@
+package io.cattle.platform.iaas.api.auth.change;
+
+import static org.junit.Assert.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+import io.cattle.platform.api.formatter.DefaultIdFormatter;
+import io.cattle.platform.archaius.util.ArchaiusUtil;
+import io.cattle.platform.core.model.Stack;
+import io.cattle.platform.core.model.tables.records.HostRecord;
+import io.cattle.platform.core.model.tables.records.ServiceRecord;
+import io.cattle.platform.core.model.tables.records.StackRecord;
+import io.cattle.platform.iaas.api.port.PortPreflightDao;
+import io.cattle.platform.iaas.api.port.PortPreflightService;
+import io.cattle.platform.servicediscovery.api.filter.ServiceValidationFilter;
+import io.github.ibuildthecloud.gdapi.context.ApiContext;
+import io.github.ibuildthecloud.gdapi.exception.ClientVisibleException;
+import io.github.ibuildthecloud.gdapi.model.Field;
+import io.github.ibuildthecloud.gdapi.model.Schema;
+import io.github.ibuildthecloud.gdapi.model.impl.FieldImpl;
+import io.github.ibuildthecloud.gdapi.request.resource.ResourceManager;
+import io.github.ibuildthecloud.gdapi.request.resource.ResourceManagerLocator;
+import io.github.ibuildthecloud.gdapi.validation.ValidationHandler;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/** The real schema validator and the same native filter/parser/preflight used by manager.create. */
+public class AiServiceCreateNativePreflightTest {
+ private Object enabled;
+ @Before public void enable() {
+ enabled = ArchaiusUtil.getConfiguration().getProperty("api.ai.bridge.enabled");
+ ArchaiusUtil.getConfiguration().setProperty("api.ai.bridge.enabled", true);
+ }
+ @After public void cleanup() {
+ ApiContext.remove();
+ if (enabled == null) ArchaiusUtil.getConfiguration().clearProperty("api.ai.bridge.enabled");
+ else ArchaiusUtil.getConfiguration().setProperty("api.ai.bridge.enabled", enabled);
+ }
+
+ @Test public void realNativeBodyUsesConvertedParentAndHealthFieldsAndDispatchStillUsesTheFilteredManager() throws Exception {
+ Fixture f = new Fixture();
+ var parameters = f.parameters(List.of("80/tcp"));
+ var body = f.domain.prepare(f.request, f.descriptor, null, parameters);
+ assertEquals(3, ((Number) body.get("stackId")).intValue());
+ assertEquals(false, body.get("startOnCreate")); assertEquals(1, ((Number) body.get("scale")).intValue());
+ Map, ?> launch = (Map, ?>) body.get("launchConfig");
+ assertEquals("bridge", launch.get("networkMode")); assertEquals("docker:busybox:1.37.0", launch.get("imageUuid"));
+ assertEquals(List.of("80/tcp"), launch.get("ports"));
+ assertEquals(Map.of("port", 80, "interval", 2000, "strategy", "recreate"), launch.get("healthCheck"));
+ assertFalse(body.containsKey("ports")); assertFalse(body.containsKey("healthCheck"));
+ verify(f.nativeInventory).getEligibleHosts(77L); verify(f.nativeInventory).getPortOwners(77L);
+ verifyNoInteractions(f.manager);
+ ServiceRecord created = new ServiceRecord(); created.setId(91L);
+ when(f.manager.create(eq("service"), any())).thenReturn(created);
+ assertSame(created, f.domain.dispatch(f.request, f.descriptor, null, parameters, "plan"));
+ verify(f.manager, times(1)).create(eq("service"), any());
+ assertSame(f.request, ApiContext.getContext().getApiRequest());
+ }
+
+ @Test public void realNativeDuplicatePortConflictFailsBeforeAnyManagerCreateAndParserCodesRemainNative() throws Exception {
+ Fixture f = new Fixture();
+ var denied = assertThrows(ClientVisibleException.class, () -> f.domain.prepare(f.request, f.descriptor, null,
+ f.parameters(List.of("18080:80/tcp", "18080:81/tcp"))));
+ assertEquals("PortConflict", denied.getApiError().getCode()); assertEquals(422, denied.getApiError().getStatus());
+ var malformed = new LinkedHashMap<>(f.parameters(List.of("80"))); malformed.put("ports", List.of("18080:0"));
+ var parser = assertThrows(ClientVisibleException.class, () -> f.domain.prepare(f.request, f.descriptor, null, malformed));
+ assertEquals("PortInvalidPrivatePort", parser.getCode()); verifyNoInteractions(f.manager);
+ assertSame(f.request, ApiContext.getContext().getApiRequest());
+ }
+
+ @Test public void nativeNestedRoleFieldRemovalCannotSilentlyTurnApprovedHealthCheckIntoAnotherConfiguration() throws Exception {
+ Fixture f = new Fixture(); f.healthFields.get("interval").setCreate(false);
+ var denied = assertThrows(ClientVisibleException.class, () -> f.domain.prepare(f.request, f.descriptor, null, f.parameters(List.of("80"))));
+ assertEquals("PermissionDenied", denied.getCode()); verifyNoInteractions(f.manager, f.nativeInventory);
+ }
+
+ @Test public void liveNativePortAvailabilityIsRepeatedAtConfirmationAndImmediatelyBeforeTheDurableClaim() throws Exception {
+ Fixture nativeDomain = new Fixture(); var available = nativeDomain.nativeInventory.getEligibleHosts(77L);
+ var formatter = ApiContext.getContext().getIdFormatter();
+ var workflow = new AiResourceChangePlanServiceTest.Fixture();
+ ApiContext.getContext().setIdFormatter(formatter);
+ when(workflow.domain.prepare(any(), any(), isNull(), anyMap())).thenAnswer(call ->
+ nativeDomain.domain.prepare(nativeDomain.request, call.getArgument(1), null, call.getArgument(3)));
+ when(workflow.domain.prepare(any(), any(), isNull(), anyMap(), anyMap())).thenAnswer(call ->
+ nativeDomain.domain.prepare(nativeDomain.request, call.getArgument(1), null, call.getArgument(3), call.getArgument(4)));
+ workflow.previewServiceCreate(false, List.of("18080:80/tcp"));
+ when(nativeDomain.nativeInventory.getEligibleHosts(77L)).thenReturn(List.of());
+ var confirm = assertThrows(ClientVisibleException.class, workflow::confirm);
+ assertEquals("PortConflict", confirm.getCode()); assertEquals(422, confirm.getStatus());
+ assertEquals("pending", workflow.documents.get(workflow.id).get("confirmationState"));
+ when(nativeDomain.nativeInventory.getEligibleHosts(77L)).thenReturn(available); workflow.confirm();
+ when(nativeDomain.nativeInventory.getEligibleHosts(77L)).thenReturn(List.of());
+ var apply = assertThrows(ClientVisibleException.class, () -> workflow.apply(false));
+ assertEquals("PortConflict", apply.getCode()); assertEquals(422, apply.getStatus());
+ assertEquals("preview", workflow.documents.get(workflow.id).get("status"));
+ when(nativeDomain.nativeInventory.getEligibleHosts(77L)).thenReturn(available);
+ doThrow(new ClientVisibleException(403, "ApiKeyRevoked")).when(nativeDomain.domain.keys).authorizeBoundDomain(anyMap(), any());
+ assertEquals("ApiKeyRevoked", assertThrows(ClientVisibleException.class, () -> workflow.apply(false)).getCode());
+ assertEquals(0, workflow.claims); assertEquals(0, workflow.dispatched); verifyNoInteractions(nativeDomain.manager);
+ }
+
+ private static class Fixture extends AiResourceChangeDomainTest.Fixture {
+ final AiResourceChangeDescriptor descriptor = AiResourceChangeDescriptor.require("service", "create");
+ final PortPreflightDao nativeInventory = mock(PortPreflightDao.class);
+ final ResourceManager manager = mock(ResourceManager.class);
+ final Map healthFields = new LinkedHashMap<>();
+ Fixture() throws Exception {
+ when(schema.getCollectionMethods()).thenReturn(List.of("POST"));
+ Map fields = new LinkedHashMap<>();
+ for (String field : List.of("name", "description", "scale", "stackId", "launchConfig", "startOnCreate"))
+ fields.put(field, field(switch (field) { case "scale" -> "int"; case "stackId" -> "reference[stack]";
+ case "launchConfig" -> "container"; case "startOnCreate" -> "boolean"; default -> "string"; }));
+ when(schema.getResourceFields()).thenReturn(fields);
+ Schema launch = mock(Schema.class); Map launchFields = new LinkedHashMap<>();
+ for (String field : List.of("imageUuid", "networkMode", "ports", "healthCheck"))
+ launchFields.put(field, field("ports".equals(field) ? "array[string]" : "healthCheck".equals(field) ? "instanceHealthCheck" : "string"));
+ when(launch.getResourceFields()).thenReturn(launchFields); when(schemas.getSchema("container")).thenReturn(launch);
+ Schema health = mock(Schema.class);
+ for (String field : List.of("port", "interval", "strategy")) healthFields.put(field, field("strategy".equals(field) ? "string" : "int"));
+ when(health.getResourceFields()).thenReturn((Map) healthFields); when(schemas.getSchema("instanceHealthCheck")).thenReturn(health);
+ var formatter = new DefaultIdFormatter(); formatter.setSchemaFactory(schemas); formatter.setTypeMappings(Map.of("stack", "st"));
+ ApiContext.getContext().setIdFormatter(formatter);
+ var validator = new ValidationHandler(); validator.init(); domain.validation = validator;
+ var stack = new StackRecord(); stack.setId(3L); stack.setAccountId(77L);
+ when(domain.objects.loadResource(Stack.class, 3L)).thenReturn(stack);
+ var host = new HostRecord(); host.setId(1L); host.setAccountId(77L);
+ when(nativeInventory.getEligibleHosts(77L)).thenReturn(List.of(host)); when(nativeInventory.getPortOwners(77L)).thenReturn(List.of());
+ var nativePreflight = new PortPreflightService(); inject(nativePreflight, "dao", nativeInventory); inject(nativePreflight, "objectManager", domain.objects);
+ var nativeFilter = new ServiceValidationFilter(); inject(nativeFilter, "objectManager", domain.objects);
+ inject(nativeFilter, "jsonMapper", domain.json); inject(nativeFilter, "portPreflightService", nativePreflight);
+ domain.serviceCreateValidation = nativeFilter;
+ domain.managers = mock(ResourceManagerLocator.class); when(domain.managers.getResourceManagerByType("service")).thenReturn(manager);
+ }
+ Map parameters(List ports) {
+ return descriptor.parameters(Map.of("name", "probe", "image", "busybox:1.37.0", "stackId", "1st3", "ports", ports,
+ "healthCheck", Map.of("port", 80, "interval", 2000)));
+ }
+ private static FieldImpl field(String type) { var result = new FieldImpl(); result.setType(type); result.setCreate(true); return result; }
+ private static void inject(Object target, String field, Object value) throws Exception {
+ var injection = target.getClass().getDeclaredField(field); injection.setAccessible(true); injection.set(target, value);
+ }
+ }
+}
diff --git a/code/iaas/bootstrap/pom.xml b/code/iaas/bootstrap/pom.xml
index dbf1e0c2b3..b71bb9724f 100644
--- a/code/iaas/bootstrap/pom.xml
+++ b/code/iaas/bootstrap/pom.xml
@@ -4,7 +4,7 @@
cattle-parent
io.cattle
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/iaas/config-item/api/pom.xml b/code/iaas/config-item/api/pom.xml
index acbde8ec9b..9ee50b1f0e 100644
--- a/code/iaas/config-item/api/pom.xml
+++ b/code/iaas/config-item/api/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../../parent/pom.xml
diff --git a/code/iaas/config-item/common/pom.xml b/code/iaas/config-item/common/pom.xml
index 316c7a7b10..d56f3f6094 100644
--- a/code/iaas/config-item/common/pom.xml
+++ b/code/iaas/config-item/common/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../../parent/pom.xml
diff --git a/code/iaas/config-item/server/pom.xml b/code/iaas/config-item/server/pom.xml
index 462c951a4f..1cb2b0894c 100644
--- a/code/iaas/config-item/server/pom.xml
+++ b/code/iaas/config-item/server/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../../parent/pom.xml
diff --git a/code/iaas/engine-jooq/pom.xml b/code/iaas/engine-jooq/pom.xml
index 21c44130a4..e34f3c5d55 100644
--- a/code/iaas/engine-jooq/pom.xml
+++ b/code/iaas/engine-jooq/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/iaas/events/pom.xml b/code/iaas/events/pom.xml
index 4b1b024ece..527187149b 100644
--- a/code/iaas/events/pom.xml
+++ b/code/iaas/events/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/iaas/external-handler/pom.xml b/code/iaas/external-handler/pom.xml
index 955185ea5c..f40f7551b2 100644
--- a/code/iaas/external-handler/pom.xml
+++ b/code/iaas/external-handler/pom.xml
@@ -4,7 +4,7 @@
cattle-parent
io.cattle
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/iaas/ha/pom.xml b/code/iaas/ha/pom.xml
index 6717b6641f..9fd6c4fcbc 100644
--- a/code/iaas/ha/pom.xml
+++ b/code/iaas/ha/pom.xml
@@ -4,7 +4,7 @@
cattle-parent
io.cattle
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/iaas/healthcheck/pom.xml b/code/iaas/healthcheck/pom.xml
index b156d0d891..184e59e237 100644
--- a/code/iaas/healthcheck/pom.xml
+++ b/code/iaas/healthcheck/pom.xml
@@ -4,7 +4,7 @@
cattle-parent
io.cattle
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/iaas/labels/pom.xml b/code/iaas/labels/pom.xml
index a37ad42849..6a6d72feda 100644
--- a/code/iaas/labels/pom.xml
+++ b/code/iaas/labels/pom.xml
@@ -4,7 +4,7 @@
cattle-parent
io.cattle
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/iaas/logic-common/pom.xml b/code/iaas/logic-common/pom.xml
index 489deb4ebc..6c987e79bd 100644
--- a/code/iaas/logic-common/pom.xml
+++ b/code/iaas/logic-common/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/iaas/logic/pom.xml b/code/iaas/logic/pom.xml
index f3f51a3575..7274f69796 100644
--- a/code/iaas/logic/pom.xml
+++ b/code/iaas/logic/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/iaas/metadata/pom.xml b/code/iaas/metadata/pom.xml
index ec8a6b5e9c..db3df2f448 100644
--- a/code/iaas/metadata/pom.xml
+++ b/code/iaas/metadata/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/iaas/model/pom.xml b/code/iaas/model/pom.xml
index 3ebac4ed34..4f10cfe668 100644
--- a/code/iaas/model/pom.xml
+++ b/code/iaas/model/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/iaas/model/src/main/java/io/cattle/platform/core/util/PortSpec.java b/code/iaas/model/src/main/java/io/cattle/platform/core/util/PortSpec.java
index 0d7ee9ef2b..773ff1f57c 100644
--- a/code/iaas/model/src/main/java/io/cattle/platform/core/util/PortSpec.java
+++ b/code/iaas/model/src/main/java/io/cattle/platform/core/util/PortSpec.java
@@ -126,7 +126,11 @@ public String toSpec() {
String publicPort = this.publicPort != null ? this.publicPort.toString() + ":" : "";
String bindIP = "";
if (StringUtils.isNotBlank(this.ipAddress)) {
- bindIP = this.ipAddress + ":";
+ String address = this.ipAddress;
+ if (address.contains(":") && !(address.startsWith("[") && address.endsWith("]"))) {
+ address = "[" + address + "]";
+ }
+ bindIP = address + ":";
}
return bindIP + publicPort + privatePortProto;
}
diff --git a/code/iaas/model/src/test/java/io/cattle/platform/core/util/PortSpecTest.java b/code/iaas/model/src/test/java/io/cattle/platform/core/util/PortSpecTest.java
index 23d5f11174..081a54eac5 100644
--- a/code/iaas/model/src/test/java/io/cattle/platform/core/util/PortSpecTest.java
+++ b/code/iaas/model/src/test/java/io/cattle/platform/core/util/PortSpecTest.java
@@ -111,4 +111,29 @@ public void testBadFormats() {
assertEquals(PortSpec.INVALID_PROTOCOL, e.getCode());
}
}
+
+ @Test
+ public void nativeFormattingKeepsExistingIpv4NoBindAndAllocatedPortOutputs() {
+ assertEquals("80/tcp", new PortSpec("80").toSpec());
+ assertEquals("81:80/tcp", new PortSpec("81:80").toSpec());
+ assertEquals("127.0.0.1:81:80/udp", new PortSpec("127.0.0.1:81:80/udp").toSpec());
+ PortSpec allocated = new PortSpec("127.0.0.1::80"); allocated.setPublicPort(31001);
+ assertEquals("127.0.0.1:31001:80/tcp", allocated.toSpec());
+ PortSpec unbound = new PortSpec("80/udp"); unbound.setPublicPort(31002);
+ assertEquals("31002:80/udp", unbound.toSpec());
+ }
+
+ @Test
+ public void ipv6FixedAndNativeAllocatedMappingsRoundTripThroughTheSameParser() {
+ for (String input : new String[] { "[::1]:81:80", "[2001:db8::1]:53/udp", "[::]:65535:65535/tcp" }) {
+ PortSpec expected = new PortSpec(input); PortSpec actual = new PortSpec(expected.toSpec());
+ assertEquals(expected.getIpAddress(), actual.getIpAddress()); assertEquals(expected.getPublicPort(), actual.getPublicPort());
+ assertEquals(expected.getPrivatePort(), actual.getPrivatePort()); assertEquals(expected.getProtocol(), actual.getProtocol());
+ }
+ PortSpec allocated = new PortSpec("[2001:db8::1]:53/udp"); allocated.setPublicPort(31001);
+ assertEquals("[2001:db8::1]:31001:53/udp", allocated.toSpec());
+ PortSpec read = new PortSpec(allocated.toSpec());
+ assertEquals("2001:db8::1", read.getIpAddress()); assertEquals(Integer.valueOf(31001), read.getPublicPort());
+ assertEquals(53, read.getPrivatePort()); assertEquals("udp", read.getProtocol());
+ }
}
diff --git a/code/iaas/resource-pool/pom.xml b/code/iaas/resource-pool/pom.xml
index 88a5363554..1a63af9992 100644
--- a/code/iaas/resource-pool/pom.xml
+++ b/code/iaas/resource-pool/pom.xml
@@ -4,7 +4,7 @@
io.cattle
cattle-parent
- 0.183.365
+ 0.183.366
../../parent/pom.xml
diff --git a/code/iaas/service-discovery/api/pom.xml b/code/iaas/service-discovery/api/pom.xml
index 41f12c98ef..3d75f86672 100644
--- a/code/iaas/service-discovery/api/pom.xml
+++ b/code/iaas/service-discovery/api/pom.xml
@@ -4,7 +4,7 @@
cattle-parent
io.cattle
- 0.183.365
+ 0.183.366
../../../parent/pom.xml
diff --git a/code/iaas/service-discovery/api/src/main/java/io/cattle/platform/servicediscovery/api/filter/ServiceValidationFilter.java b/code/iaas/service-discovery/api/src/main/java/io/cattle/platform/servicediscovery/api/filter/ServiceValidationFilter.java
index dfb59af32d..762bd86ba2 100644
--- a/code/iaas/service-discovery/api/src/main/java/io/cattle/platform/servicediscovery/api/filter/ServiceValidationFilter.java
+++ b/code/iaas/service-discovery/api/src/main/java/io/cattle/platform/servicediscovery/api/filter/ServiceValidationFilter.java
@@ -26,6 +26,7 @@
import io.cattle.platform.object.util.DataUtils;
import io.cattle.platform.object.util.ObjectUtils;
import io.cattle.platform.servicediscovery.api.util.ServiceDiscoveryUtil;
+import io.cattle.platform.servicediscovery.api.service.ServiceCreateValidation;
import io.cattle.platform.servicediscovery.api.util.selector.SelectorUtils;
import io.cattle.platform.storage.api.filter.ExternalTemplateInstanceFilter;
import io.cattle.platform.storage.service.StorageService;
@@ -52,7 +53,7 @@
import org.apache.commons.lang3.StringUtils;
@Named
-public class ServiceValidationFilter extends AbstractDefaultResourceManagerFilter {
+public class ServiceValidationFilter extends AbstractDefaultResourceManagerFilter implements ServiceCreateValidation {
@Inject
ObjectManager objectManager;
@@ -198,10 +199,22 @@ static Map