From b576bcdaf8e8f722f9e2958d76a02cdb58735821 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Fri, 7 Aug 2026 18:01:02 +0800 Subject: [PATCH 1/8] test(sdk/go): cover compose hash against the JS reference vectors The Go compose hash implementation had no tests at all, while the JS SDK has a full suite. A compose hash is the identity of a deployed app: if Go disagrees with the reference implementation by one byte, an app deployed through Go fails attestation. That is not something to leave uncovered. CROSS_LANGUAGE_CONSISTENCY_TESTING.md names the JavaScript SDK as the canonical implementation, so the expected hashes are reference values produced by sdk/js/src/get-compose-hash.ts. Each was additionally cross-checked against the Python SDK to rule out a mistake in generating them. The vectors cover the fields Go declares today: the minimal compose, a full legacy field set, both normalization branches (bash drops docker_compose_file, docker-compose drops bash_script), empty pre_launch_script removal, requirements and the nerdctl snapshotter. All pass against the current implementation, so this commit pins existing behaviour rather than changing it. --- sdk/go/dstack/compose_hash_test.go | 167 +++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 sdk/go/dstack/compose_hash_test.go diff --git a/sdk/go/dstack/compose_hash_test.go b/sdk/go/dstack/compose_hash_test.go new file mode 100644 index 000000000..9893d2413 --- /dev/null +++ b/sdk/go/dstack/compose_hash_test.go @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +package dstack + +import "testing" + +// The expected hashes below are reference values produced by the JavaScript +// SDK (sdk/js/src/get-compose-hash.ts), which CROSS_LANGUAGE_CONSISTENCY_TESTING.md +// designates as the canonical implementation. Each one was additionally +// cross-checked against the Python SDK (sdk/python/src/dstack_sdk/get_compose_hash.py). +// +// A compose hash is the identity of a deployed app: if Go disagrees with the +// other SDKs by a single byte, an app deployed through Go fails attestation. +// These vectors are the guard against that. + +func boolPtr(v bool) *bool { return &v } + +func TestGetComposeHashMatchesJSReference(t *testing.T) { + tests := []struct { + name string + compose AppCompose + normalize bool + want string + }{ + { + name: "minimal", + compose: AppCompose{Runner: "docker-compose"}, + want: "1120e42a7b5f2ca50128696ea414771441b4fa92373427108a7173181cc80b55", + }, + { + name: "docker compose file", + compose: AppCompose{ + Runner: "docker-compose", + DockerComposeFile: "services:\n app:\n image: nginx\n", + }, + want: "1ce6eb200cb550901bf9d1f3b30d3a2a678ae6a7dc7d78ff226dd41d78dbb355", + }, + { + name: "full legacy field set", + compose: AppCompose{ + ManifestVersion: 1, + Name: "my-app", + Runner: "docker-compose", + DockerComposeFile: "docker-compose.yml", + DockerConfig: &DockerConfig{Registry: "docker.io", Username: "myuser", TokenKey: "token123"}, + Features: []string{"legacy-feature"}, + PublicLogs: boolPtr(true), + PublicSysinfo: boolPtr(false), + PublicTcbinfo: boolPtr(true), + KmsEnabled: boolPtr(true), + GatewayEnabled: boolPtr(false), + TproxyEnabled: boolPtr(true), + LocalKeyProviderEnabled: boolPtr(true), + KeyProvider: KeyProviderKMS, + KeyProviderID: "abcd1234", + AllowedEnvs: []string{"NODE_ENV", "PORT"}, + NoInstanceID: boolPtr(false), + SecureTime: boolPtr(true), + PreLaunchScript: "echo 'Starting...'", + }, + want: "9e16e3de034ac9537fd436c3a579785450dde60508c288ffc11bd90adfbbdcdf", + }, + { + name: "bash runner drops docker_compose_file when normalized", + compose: AppCompose{ + Runner: "bash", + BashScript: "start.sh", + DockerComposeFile: "docker-compose.yml", + }, + normalize: true, + want: "75a6f53f70c26c8f7b545f48c2f5ef2f76c27d13f7034be1021cf25f5d9853d2", + }, + { + name: "docker runner drops bash_script when normalized", + compose: AppCompose{ + Runner: "docker-compose", + DockerComposeFile: "docker-compose.yml", + BashScript: "start.sh", + }, + normalize: true, + want: "6148eabae40413a5c15aeab33343e7a219edc0cda2c2556add9a96ec76d61b11", + }, + { + name: "empty pre_launch_script is dropped", + compose: AppCompose{ + Runner: "docker-compose", + DockerComposeFile: "docker-compose.yml", + PreLaunchScript: "", + }, + normalize: true, + want: "6148eabae40413a5c15aeab33343e7a219edc0cda2c2556add9a96ec76d61b11", + }, + { + name: "requirements", + compose: AppCompose{ + Runner: "docker-compose", + DockerComposeFile: "docker-compose.yml", + Requirements: &Requirements{ + OsVersion: "0.5.4", + Platforms: &[]RequirementPlatform{RequirementPlatformTdx, RequirementPlatformGcpTdx}, + TdxMeasureAcpiTables: boolPtr(true), + LaunchTokenHash: "deadbeef", + }, + }, + want: "36348dde5e4c20fc09d7a2399405866301dd6afd5e75978b3876270543470406", + }, + { + name: "nerdctl snapshotter", + compose: AppCompose{ + Runner: "nerdctl-compose", + DockerComposeFile: "docker-compose.yml", + Snapshotter: "stargz", + }, + want: "872e2833c1d8915bb61718f8bebb018ece4244e5e6d1d3bb611375b475cf9d4f", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetComposeHash(tt.compose, tt.normalize) + if err != nil { + t.Fatalf("GetComposeHash: %v", err) + } + if got != tt.want { + t.Fatalf("hash = %s, want %s (JS reference)", got, tt.want) + } + }) + } +} + +func TestGetComposeHashIsDeterministic(t *testing.T) { + compose := AppCompose{ + Runner: "docker-compose", + DockerComposeFile: "docker-compose.yml", + AllowedEnvs: []string{"NODE_ENV", "PORT"}, + } + + first, err := GetComposeHash(compose, false) + if err != nil { + t.Fatalf("GetComposeHash: %v", err) + } + for i := 0; i < 10; i++ { + got, err := GetComposeHash(compose, false) + if err != nil { + t.Fatalf("GetComposeHash: %v", err) + } + if got != first { + t.Fatalf("hash changed between calls: %s then %s", first, got) + } + } +} + +func TestGetComposeHashDistinguishesDifferentComposes(t *testing.T) { + a, err := GetComposeHash(AppCompose{Runner: "docker-compose", DockerComposeFile: "docker-compose.yml"}, false) + if err != nil { + t.Fatalf("GetComposeHash: %v", err) + } + b, err := GetComposeHash(AppCompose{Runner: "bash", BashScript: "start.sh"}, false) + if err != nil { + t.Fatalf("GetComposeHash: %v", err) + } + if a == b { + t.Fatal("different composes produced the same hash") + } +} From 1a5babde8662110b669122d84a4217eae7b0a5a5 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Fri, 7 Aug 2026 18:02:50 +0800 Subject: [PATCH 2/8] fix(sdk/go): keep unknown app_compose fields in the compose hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetComposeHash marshals the AppCompose struct, so any key the struct does not declare is silently dropped before hashing. The other SDKs do not behave this way: the JavaScript AppCompose carries an index signature and the Python one accepts **kwargs, so both hash whatever the caller passed. That makes Go the only SDK that computes a different compose hash for the same app_compose document. Since the compose hash is the app's on-chain identity, the failure is silent and total: the Go caller registers one hash, the guest measures another, attestation fails. Every field added to dstack-types since this struct was last synced reproduces it — port_policy is simply the one that surfaced it. Add an Extra map, merged into the top-level object on marshal and populated from it on unmarshal, so a compose using fields newer than this SDK round-trips and hashes identically to JS and Python. An Extra key that collides with a declared field is rejected instead of silently overriding it, since which one won would otherwise depend on map iteration order. The declared-name set is derived by reflection over the struct tags rather than from a marshalled document, so the collision check still fires for a field that omitempty left out. Tests use the same JS reference vectors as the existing ones, including port_policy and non-ASCII values, plus a round trip that decodes a document with scrambled key order and re-hashes it. --- sdk/go/dstack/compose_hash.go | 98 ++++++++++++++++++++++++++++++ sdk/go/dstack/compose_hash_test.go | 94 +++++++++++++++++++++++++++- 2 files changed, 191 insertions(+), 1 deletion(-) diff --git a/sdk/go/dstack/compose_hash.go b/sdk/go/dstack/compose_hash.go index d513967e3..ecbcf9760 100644 --- a/sdk/go/dstack/compose_hash.go +++ b/sdk/go/dstack/compose_hash.go @@ -8,7 +8,11 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "fmt" + "reflect" "sort" + "strings" + "sync" ) // KeyProviderKind represents the key provider type @@ -69,6 +73,100 @@ type AppCompose struct { Requirements *Requirements `json:"requirements,omitempty"` BashScript string `json:"bash_script,omitempty"` // Legacy PreLaunchScript string `json:"pre_launch_script,omitempty"` // Legacy + + // Extra carries app_compose keys this SDK version does not declare. + // + // The compose hash is taken over the whole app_compose document, so a key + // dropped during marshalling changes the resulting app identity. The + // JavaScript SDK (index signature) and the Python SDK (**kwargs) both keep + // unknown keys; Go marshals a struct, which silently discards them. Extra + // closes that gap, so a compose using a field newer than this SDK still + // hashes to the same value as it does everywhere else. + // + // Keys are merged into the top-level object on marshal and collected from + // it on unmarshal. A key that is already a declared field is rejected + // rather than silently overriding it. + Extra map[string]any `json:"-"` +} + +// declaredComposeFields returns the JSON names of every declared AppCompose +// field, including those omitempty would leave out of a given document. +var declaredComposeFields = sync.OnceValue(func() map[string]struct{} { + names := make(map[string]struct{}) + t := reflect.TypeOf(AppCompose{}) + for i := 0; i < t.NumField(); i++ { + tag := t.Field(i).Tag.Get("json") + name, _, _ := strings.Cut(tag, ",") + if name == "" || name == "-" { + continue + } + names[name] = struct{}{} + } + return names +}) + +// MarshalJSON emits the declared fields and then merges Extra into the same +// object. +func (a AppCompose) MarshalJSON() ([]byte, error) { + type plain AppCompose + encoded, err := json.Marshal(plain(a)) + if err != nil { + return nil, err + } + if len(a.Extra) == 0 { + return encoded, nil + } + + var merged map[string]json.RawMessage + if err := json.Unmarshal(encoded, &merged); err != nil { + return nil, err + } + + declared := declaredComposeFields() + for key, value := range a.Extra { + if _, isDeclared := declared[key]; isDeclared { + return nil, fmt.Errorf("dstack: app_compose Extra key %q collides with a declared field; set the field instead", key) + } + raw, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("dstack: app_compose Extra key %q: %w", key, err) + } + merged[key] = raw + } + return json.Marshal(merged) +} + +// UnmarshalJSON decodes the declared fields and collects everything else into +// Extra, so a document can be re-hashed without losing keys. +func (a *AppCompose) UnmarshalJSON(data []byte) error { + type plain AppCompose + var decoded plain + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *a = AppCompose(decoded) + + var all map[string]json.RawMessage + if err := json.Unmarshal(data, &all); err != nil { + return err + } + + declared := declaredComposeFields() + extra := make(map[string]any) + for key, raw := range all { + if _, isDeclared := declared[key]; isDeclared { + continue + } + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return fmt.Errorf("dstack: app_compose field %q: %w", key, err) + } + extra[key] = value + } + if len(extra) > 0 { + a.Extra = extra + } + return nil } // preprocessAppCompose removes conflicting fields based on runner type diff --git a/sdk/go/dstack/compose_hash_test.go b/sdk/go/dstack/compose_hash_test.go index 9893d2413..43a999c5e 100644 --- a/sdk/go/dstack/compose_hash_test.go +++ b/sdk/go/dstack/compose_hash_test.go @@ -4,7 +4,10 @@ package dstack -import "testing" +import ( + "encoding/json" + "testing" +) // The expected hashes below are reference values produced by the JavaScript // SDK (sdk/js/src/get-compose-hash.ts), which CROSS_LANGUAGE_CONSISTENCY_TESTING.md @@ -165,3 +168,92 @@ func TestGetComposeHashDistinguishesDifferentComposes(t *testing.T) { t.Fatal("different composes produced the same hash") } } + +// TestGetComposeHashPassesUnknownFieldsThrough covers the fields Go does not +// declare. The JS SDK's AppCompose carries an index signature and the Python +// SDK accepts **kwargs, so both hash unknown keys. Go marshals a struct, which +// silently drops them — producing a hash that disagrees with every other SDK +// for the same compose. +func TestGetComposeHashPassesUnknownFieldsThrough(t *testing.T) { + tests := []struct { + name string + extra map[string]any + want string + }{ + { + name: "port_policy", + extra: map[string]any{ + "port_policy": map[string]any{ + "ports": []any{ + map[string]any{"port": 443, "pp": true}, + map[string]any{"port": 8080, "pp": false}, + }, + "restrict_mode": true, + }, + }, + want: "6be823decce06179698ee6fd087d82951c21ba6a24ba6419a6801b0be1ce2bdc", + }, + { + name: "utf8 values", + extra: map[string]any{"text": "你好世界", "description": "🚀 Deploy"}, + want: "735ef5a6a9ac2405dda08948b551c9c7b529f4e4b790d6784ff5e2ee2e394f41", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compose := AppCompose{Runner: "docker-compose", Extra: tt.extra} + if tt.name == "port_policy" { + compose.DockerComposeFile = "docker-compose.yml" + } + got, err := GetComposeHash(compose, false) + if err != nil { + t.Fatalf("GetComposeHash: %v", err) + } + if got != tt.want { + t.Fatalf("hash = %s, want %s (JS reference)", got, tt.want) + } + }) + } +} + +// TestExtraCannotShadowDeclaredFields guards the one way Extra could corrupt a +// hash: a key that also exists as a struct field would otherwise be emitted +// twice, and which one wins would depend on map iteration order. +func TestExtraCannotShadowDeclaredFields(t *testing.T) { + _, err := GetComposeHash(AppCompose{ + Runner: "docker-compose", + Extra: map[string]any{"runner": "bash"}, + }, false) + if err == nil { + t.Fatal("GetComposeHash accepted an Extra key shadowing a declared field") + } +} + +// TestAppComposeRoundTripsUnknownFields covers decoding: an app_compose read +// from the wire must keep the fields this SDK version does not know about, or +// re-hashing it would produce a different app identity. +func TestAppComposeRoundTripsUnknownFields(t *testing.T) { + // Key order is deliberately scrambled: the hash must not depend on it. + raw := `{"runner":"docker-compose","docker_compose_file":"docker-compose.yml","port_policy":{"restrict_mode":true,"ports":[{"pp":true,"port":443},{"pp":false,"port":8080}]}}` + + var compose AppCompose + if err := json.Unmarshal([]byte(raw), &compose); err != nil { + t.Fatalf("unmarshal app compose: %v", err) + } + if compose.Runner != "docker-compose" { + t.Fatalf("Runner = %q, want docker-compose", compose.Runner) + } + if _, ok := compose.Extra["port_policy"]; !ok { + t.Fatal("port_policy dropped during decoding") + } + + got, err := GetComposeHash(compose, false) + if err != nil { + t.Fatalf("GetComposeHash: %v", err) + } + want := "6be823decce06179698ee6fd087d82951c21ba6a24ba6419a6801b0be1ce2bdc" + if got != want { + t.Fatalf("hash after round trip = %s, want %s", got, want) + } +} From b8db7839d17dd8b500882c6a6bd608aa15f103b4 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Fri, 7 Aug 2026 18:05:48 +0800 Subject: [PATCH 3/8] feat(sdk/go): declare the app_compose fields added since the last sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Go AppCompose had drifted behind dstack-types AppCompose (dstack/dstack-types/src/lib.rs). The previous commit stops the drift from corrupting compose hashes, but callers still had no typed way to set these fields. Declare them: - port_policy (PortPolicy / PortAttrs): per-port PROXY protocol opt-in and the restrict-mode port whitelist. - init_script: bash scripts run before the application runner starts. - storage_fs, swap_size: guest filesystem and swap sizing. - event_log_version: the event log digest format, serialized as a number. - verity_volumes (VerityVolume): pre-baked read-only dm-verity volumes. - KeyProviderTPM, which the Rust, JS and Python SDKs already had. Two wire-format details that a straight port would get wrong, both pinned by tests: - swap_size is serialized by dstack-types through its human_size helper, which emits a *string* ("2G") for human-readable formats such as JSON — not a byte count. The Go field is therefore a string. - dstack-types always serializes port_policy, but emitting an empty policy for an app that does not use one would change that app's compose hash. The Go field is a pointer with omitempty, so absent stays absent. Expected hashes are JS reference values, as in the surrounding tests. Two guards accompany them: a minimal compose must still hash to its pre-existing value (otherwise every deployed app changes identity), and a document carrying these keys must now decode into the struct with Extra left empty. The passthrough tests from the previous commit move from port_policy to a made-up future_policy key, since port_policy is a declared field now and the collision guard rejects it in Extra. --- sdk/go/dstack/compose_hash.go | 59 ++++++++- sdk/go/dstack/compose_hash_test.go | 187 ++++++++++++++++++++++++++--- 2 files changed, 226 insertions(+), 20 deletions(-) diff --git a/sdk/go/dstack/compose_hash.go b/sdk/go/dstack/compose_hash.go index ecbcf9760..b3e117615 100644 --- a/sdk/go/dstack/compose_hash.go +++ b/sdk/go/dstack/compose_hash.go @@ -22,8 +22,46 @@ const ( KeyProviderNone KeyProviderKind = "none" KeyProviderKMS KeyProviderKind = "kms" KeyProviderLocal KeyProviderKind = "local" + KeyProviderTPM KeyProviderKind = "tpm" ) +// EventLogVersion selects the event log digest format. It is serialized as a +// number, matching dstack-types EventLogVersion. +type EventLogVersion int + +const ( + // EventLogVersionV1 is the legacy binary digest and the implicit default. + EventLogVersionV1 EventLogVersion = 1 + // EventLogVersionV2 is the JCS canonical JSON digest. + EventLogVersionV2 EventLogVersion = 2 +) + +// PortAttrs holds the gateway policy for a single port. +type PortAttrs struct { + Port uint16 `json:"port"` + // PP asks the gateway to send a PROXY protocol header on outbound + // connections to this port. + PP bool `json:"pp"` +} + +// PortPolicy is the per-port policy consumed by the gateway. +type PortPolicy struct { + Ports []PortAttrs `json:"ports"` + // RestrictMode makes the gateway forward only to ports listed in Ports and + // reject everything else at TCP-accept time. + RestrictMode bool `json:"restrict_mode"` +} + +// VerityVolume is a pre-baked, read-only dm-verity volume attached to the CVM. +type VerityVolume struct { + // Source is a bare image file name resolved by the VMM under its volumes dir. + Source string `json:"source"` + // VerityRoot is the hex dm-verity root hash: the volume's content identity. + VerityRoot string `json:"verity_root"` + // Target is the absolute mount path inside the guest. + Target string `json:"target"` +} + // DockerConfig represents Docker configuration type DockerConfig struct { Registry string `json:"registry,omitempty"` @@ -71,8 +109,25 @@ type AppCompose struct { NoInstanceID *bool `json:"no_instance_id,omitempty"` SecureTime *bool `json:"secure_time,omitempty"` Requirements *Requirements `json:"requirements,omitempty"` - BashScript string `json:"bash_script,omitempty"` // Legacy - PreLaunchScript string `json:"pre_launch_script,omitempty"` // Legacy + // InitScript holds bash scripts run before the application runner starts. + InitScript []string `json:"init_script,omitempty"` + // StorageFS selects the guest data filesystem ("ext4" or "zfs"). + StorageFS string `json:"storage_fs,omitempty"` + // SwapSize is a human-readable size such as "2G". dstack-types serializes + // this field as a string in JSON, not as a byte count. + SwapSize string `json:"swap_size,omitempty"` + // EventLogVersion selects the event log digest format. Leave it unset for + // v1, which dstack-types omits from the document. + EventLogVersion EventLogVersion `json:"event_log_version,omitempty"` + // PortPolicy is optional here even though dstack-types always serializes + // it: emitting an empty policy for an app that does not use one would + // change that app's compose hash. + PortPolicy *PortPolicy `json:"port_policy,omitempty"` + // VerityVolumes are measured as part of these compose bytes, so the guest + // only mounts content matching the attested app. + VerityVolumes []VerityVolume `json:"verity_volumes,omitempty"` + BashScript string `json:"bash_script,omitempty"` // Legacy + PreLaunchScript string `json:"pre_launch_script,omitempty"` // Legacy // Extra carries app_compose keys this SDK version does not declare. // diff --git a/sdk/go/dstack/compose_hash_test.go b/sdk/go/dstack/compose_hash_test.go index 43a999c5e..d6bb5c967 100644 --- a/sdk/go/dstack/compose_hash_test.go +++ b/sdk/go/dstack/compose_hash_test.go @@ -176,22 +176,23 @@ func TestGetComposeHashDistinguishesDifferentComposes(t *testing.T) { // for the same compose. func TestGetComposeHashPassesUnknownFieldsThrough(t *testing.T) { tests := []struct { - name string - extra map[string]any - want string + name string + dockerComposeFile string + extra map[string]any + want string }{ { - name: "port_policy", + // Stands in for whatever dstack-types adds next: a field this SDK + // version has never heard of still has to reach the hash. + name: "field newer than this SDK", + dockerComposeFile: "docker-compose.yml", extra: map[string]any{ - "port_policy": map[string]any{ - "ports": []any{ - map[string]any{"port": 443, "pp": true}, - map[string]any{"port": 8080, "pp": false}, - }, - "restrict_mode": true, + "future_policy": map[string]any{ + "enabled": true, + "limits": []any{1, 2, 3}, }, }, - want: "6be823decce06179698ee6fd087d82951c21ba6a24ba6419a6801b0be1ce2bdc", + want: "c43b5c245b0f5ee1380784e8edc1180846d6f1fdf279a7a80174466647e049b5", }, { name: "utf8 values", @@ -202,9 +203,10 @@ func TestGetComposeHashPassesUnknownFieldsThrough(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - compose := AppCompose{Runner: "docker-compose", Extra: tt.extra} - if tt.name == "port_policy" { - compose.DockerComposeFile = "docker-compose.yml" + compose := AppCompose{ + Runner: "docker-compose", + DockerComposeFile: tt.dockerComposeFile, + Extra: tt.extra, } got, err := GetComposeHash(compose, false) if err != nil { @@ -235,7 +237,8 @@ func TestExtraCannotShadowDeclaredFields(t *testing.T) { // re-hashing it would produce a different app identity. func TestAppComposeRoundTripsUnknownFields(t *testing.T) { // Key order is deliberately scrambled: the hash must not depend on it. - raw := `{"runner":"docker-compose","docker_compose_file":"docker-compose.yml","port_policy":{"restrict_mode":true,"ports":[{"pp":true,"port":443},{"pp":false,"port":8080}]}}` + // port_policy is declared, future_policy is not — both must survive. + raw := `{"runner":"docker-compose","docker_compose_file":"docker-compose.yml","future_policy":{"enabled":true},"port_policy":{"restrict_mode":true,"ports":[{"pp":true,"port":443},{"pp":false,"port":8080}]}}` var compose AppCompose if err := json.Unmarshal([]byte(raw), &compose); err != nil { @@ -244,16 +247,164 @@ func TestAppComposeRoundTripsUnknownFields(t *testing.T) { if compose.Runner != "docker-compose" { t.Fatalf("Runner = %q, want docker-compose", compose.Runner) } - if _, ok := compose.Extra["port_policy"]; !ok { - t.Fatal("port_policy dropped during decoding") + if compose.PortPolicy == nil { + t.Fatal("declared port_policy dropped during decoding") + } + if _, ok := compose.Extra["future_policy"]; !ok { + t.Fatal("undeclared future_policy dropped during decoding") } got, err := GetComposeHash(compose, false) if err != nil { t.Fatalf("GetComposeHash: %v", err) } - want := "6be823decce06179698ee6fd087d82951c21ba6a24ba6419a6801b0be1ce2bdc" + want := "de89dda5b47f62d463757b3ffd2aa1dd4af08401683717bdd1244649f18ba40f" if got != want { t.Fatalf("hash after round trip = %s, want %s", got, want) } } + +// TestGetComposeHashCoversFieldsAddedSinceV010 pins the app_compose fields the +// Go struct gained to match dstack-types. Each expected hash is the JS +// reference value for the same document, so a wrong JSON tag or wire type +// (swap_size is a human-readable string, not a byte count) fails here rather +// than at attestation time. +func TestGetComposeHashCoversFieldsAddedSinceV010(t *testing.T) { + tests := []struct { + name string + compose AppCompose + want string + }{ + { + name: "port_policy", + compose: AppCompose{ + Runner: "docker-compose", + DockerComposeFile: "docker-compose.yml", + PortPolicy: &PortPolicy{ + Ports: []PortAttrs{{Port: 443, PP: true}, {Port: 8080, PP: false}}, + RestrictMode: true, + }, + }, + want: "6be823decce06179698ee6fd087d82951c21ba6a24ba6419a6801b0be1ce2bdc", + }, + { + name: "empty port_policy still serializes its keys", + compose: AppCompose{ + Runner: "docker-compose", + PortPolicy: &PortPolicy{Ports: []PortAttrs{}, RestrictMode: false}, + }, + want: "3b647a53cded50c2d33f29bc07dd2ce7871d08cdff6e41d76f915d262c58bf57", + }, + { + name: "storage_fs and swap_size", + compose: AppCompose{Runner: "docker-compose", StorageFS: "zfs", SwapSize: "2G"}, + want: "b140f74b52ae10dc42efe16e4368784b60f755ada3781a0be8b8aafae6a6ab86", + }, + { + name: "init_script", + compose: AppCompose{Runner: "docker-compose", InitScript: []string{"echo one", "echo two"}}, + want: "8c35247d5b685a8d24b97182f3b0a4e3c1ab6d8eecee6704424a0de0dd8a66a3", + }, + { + name: "event_log_version", + compose: AppCompose{Runner: "docker-compose", EventLogVersion: EventLogVersionV2}, + want: "8d1f7a3b6a6fc64236667a69c7618c5075d85d4c3afd8fb9a4b0c733b60ae8f5", + }, + { + name: "verity_volumes", + compose: AppCompose{ + Runner: "docker-compose", + VerityVolumes: []VerityVolume{{ + Source: "data.img", + VerityRoot: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + Target: "/mnt/data", + }}, + }, + want: "3eb00892f370ab31f854172991b655a0880ccb7786d121dfb75cbd9b038df19f", + }, + { + name: "tpm key provider", + compose: AppCompose{Runner: "docker-compose", KeyProvider: KeyProviderTPM, KeyProviderID: "aabb"}, + want: "e4bfbeaf851f73b873f04e74cc5699668dc282bd96e15dbf0ab1e5e05cc2ca76", + }, + { + name: "all new fields together", + compose: AppCompose{ + ManifestVersion: "3", + Name: "my-app", + Runner: "docker-compose", + DockerComposeFile: "docker-compose.yml", + InitScript: []string{"echo hello"}, + StorageFS: "ext4", + SwapSize: "1G", + EventLogVersion: EventLogVersionV2, + PortPolicy: &PortPolicy{ + Ports: []PortAttrs{{Port: 443, PP: true}}, + RestrictMode: false, + }, + VerityVolumes: []VerityVolume{{ + Source: "a.img", + VerityRoot: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + Target: "/mnt/a", + }}, + KeyProvider: KeyProviderTPM, + }, + want: "24e4d4f046fda84fba03df6199ad41fdcb67482e86f3705e46c28be8769b895a", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetComposeHash(tt.compose, false) + if err != nil { + t.Fatalf("GetComposeHash: %v", err) + } + if got != tt.want { + t.Fatalf("hash = %s, want %s (JS reference)", got, tt.want) + } + }) + } +} + +// TestUnsetNewFieldsDoNotChangeExistingHashes guards the upgrade path: a +// compose that predates these fields must keep hashing to the value it had +// before they were declared, or every already-deployed app changes identity. +func TestUnsetNewFieldsDoNotChangeExistingHashes(t *testing.T) { + got, err := GetComposeHash(AppCompose{Runner: "docker-compose"}, false) + if err != nil { + t.Fatalf("GetComposeHash: %v", err) + } + const want = "1120e42a7b5f2ca50128696ea414771441b4fa92373427108a7173181cc80b55" + if got != want { + t.Fatalf("minimal compose hash = %s, want %s", got, want) + } +} + +// TestNewFieldsDecodeIntoDeclaredFields checks the fields moved out of Extra: +// a document carrying them must now populate the struct. +func TestNewFieldsDecodeIntoDeclaredFields(t *testing.T) { + raw := `{"runner":"docker-compose","port_policy":{"ports":[{"port":443,"pp":true}],"restrict_mode":true},"swap_size":"2G","storage_fs":"zfs","event_log_version":2,"init_script":["echo one"]}` + + var compose AppCompose + if err := json.Unmarshal([]byte(raw), &compose); err != nil { + t.Fatalf("unmarshal app compose: %v", err) + } + if compose.PortPolicy == nil || !compose.PortPolicy.RestrictMode { + t.Fatalf("PortPolicy = %+v, want restrict_mode true", compose.PortPolicy) + } + if len(compose.PortPolicy.Ports) != 1 || compose.PortPolicy.Ports[0].Port != 443 || !compose.PortPolicy.Ports[0].PP { + t.Fatalf("PortPolicy.Ports = %+v, want one entry 443/pp", compose.PortPolicy.Ports) + } + if compose.SwapSize != "2G" || compose.StorageFS != "zfs" { + t.Fatalf("SwapSize/StorageFS = %q/%q, want 2G/zfs", compose.SwapSize, compose.StorageFS) + } + if compose.EventLogVersion != EventLogVersionV2 { + t.Fatalf("EventLogVersion = %d, want 2", compose.EventLogVersion) + } + if len(compose.InitScript) != 1 { + t.Fatalf("InitScript = %v, want one entry", compose.InitScript) + } + if len(compose.Extra) != 0 { + t.Fatalf("Extra = %v, want empty now that these fields are declared", compose.Extra) + } +} From 3e1a640715b381aa19de6323c39bad984e04c404 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Fri, 7 Aug 2026 20:35:36 +0800 Subject: [PATCH 4/8] fix(sdk/go): stop HTML-escaping the hashed compose JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit encoding/json escapes <, > and & as <, > and & unless told otherwise. JSON.stringify and json.dumps emit them literally, so Go hashed different bytes than every other SDK for any compose containing one of those three characters. They are not exotic. ">=" is the canonical form of a semver requirement, so every requirements.os_version range hit this. "&&" chains shell commands, so every docker_compose_file with a compound command hit it too. This affected a large share of real apps, and like the dropped-field bug it failed silently: the Go caller registers one hash, the guest measures another, attestation fails with nothing pointing at the cause. Switch toDeterministicJSON to json.Encoder with SetEscapeHTML(false), trimming the trailing newline Encoder appends — a stray newline would change every hash. The intermediate Marshal/Unmarshal round trip inside GetComposeHash needs no change, since unmarshalling reverses the escaping before the final encode. Verified against the JS SDK and cross-checked with the Python SDK: all three now agree on the same three cases, which the tests pin. --- sdk/go/dstack/compose_hash.go | 20 ++++++++-- sdk/go/dstack/compose_hash_test.go | 60 ++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/sdk/go/dstack/compose_hash.go b/sdk/go/dstack/compose_hash.go index b3e117615..879826f10 100644 --- a/sdk/go/dstack/compose_hash.go +++ b/sdk/go/dstack/compose_hash.go @@ -5,6 +5,7 @@ package dstack import ( + "bytes" "crypto/sha256" "encoding/hex" "encoding/json" @@ -264,14 +265,25 @@ func sortKeys(v interface{}) interface{} { } } -// toDeterministicJSON converts the structure to deterministic JSON +// toDeterministicJSON converts the structure to deterministic JSON. +// +// encoding/json escapes <, > and & as \u003c, \u003e and \u0026 unless told +// otherwise, while JSON.stringify and json.dumps emit them literally. Those +// characters are ordinary in a compose document — ">=0.6.0" in +// requirements.os_version, "sh -c \"migrate && serve\"" in +// docker_compose_file — so the escaping alone made Go disagree with every other +// SDK about an app's compose hash. Encoder.SetEscapeHTML(false) turns it off; +// Encoder also appends a newline, which is not part of the hashed bytes. func toDeterministicJSON(v interface{}) (string, error) { sorted := sortKeys(v) - jsonBytes, err := json.Marshal(sorted) - if err != nil { + + var buf bytes.Buffer + encoder := json.NewEncoder(&buf) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(sorted); err != nil { return "", err } - return string(jsonBytes), nil + return strings.TrimSuffix(buf.String(), "\n"), nil } // GetComposeHash computes the SHA256 hash of the application composition diff --git a/sdk/go/dstack/compose_hash_test.go b/sdk/go/dstack/compose_hash_test.go index d6bb5c967..a85e4ea58 100644 --- a/sdk/go/dstack/compose_hash_test.go +++ b/sdk/go/dstack/compose_hash_test.go @@ -408,3 +408,63 @@ func TestNewFieldsDecodeIntoDeclaredFields(t *testing.T) { t.Fatalf("Extra = %v, want empty now that these fields are declared", compose.Extra) } } + +// TestGetComposeHashDoesNotHTMLEscape covers the characters encoding/json +// escapes by default. They are ordinary in a compose document — ">=" in an OS +// version requirement, "&&" in a shell command inside docker_compose_file — so +// escaping them made Go disagree with JS and Python about an app's identity for +// a large share of real apps, not just exotic ones. +func TestGetComposeHashDoesNotHTMLEscape(t *testing.T) { + tests := []struct { + name string + compose AppCompose + want string + }{ + { + name: "ampersands in a shell command", + compose: AppCompose{ + Runner: "docker-compose", + DockerComposeFile: "services:\n app:\n command: sh -c \"migrate && serve\"\n", + }, + want: "bc86cfbcfe7da69519337f0a9d782d131b8b5174c56687f1c1442fd97bc36a7b", + }, + { + name: "semver range in os_version", + compose: AppCompose{ + Runner: "docker-compose", + Requirements: &Requirements{OsVersion: ">=0.6.0"}, + }, + want: "60a87802e72b8dbb07f1f50a07465e7e1af4994a4f434dc0398c1339cf05357d", + }, + { + name: "angle brackets in a name", + compose: AppCompose{Runner: "docker-compose", Name: "ac"}, + want: "5c3fd99daeb46af71c7e127d53ed041bfc9c1935b60992eee8f8d5a02181f483", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetComposeHash(tt.compose, false) + if err != nil { + t.Fatalf("GetComposeHash: %v", err) + } + if got != tt.want { + t.Fatalf("hash = %s, want %s (JS reference)", got, tt.want) + } + }) + } +} + +// TestDeterministicJSONEmitsNoTrailingNewline guards the other half of the +// switch to json.Encoder: Encoder appends a newline that Marshal does not, and +// a stray newline would change every hash. +func TestDeterministicJSONEmitsNoTrailingNewline(t *testing.T) { + got, err := toDeterministicJSON(map[string]any{"runner": "docker-compose"}) + if err != nil { + t.Fatalf("toDeterministicJSON: %v", err) + } + if got != `{"runner":"docker-compose"}` { + t.Fatalf("deterministic JSON = %q, want the compact form with no trailing newline", got) + } +} From deff3d479d5be5b44ff06385b68292dd1dcab9a4 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Fri, 7 Aug 2026 20:36:44 +0800 Subject: [PATCH 5/8] feat(sdk/go): declare requirements.gpu_policy dstack-types Requirements has a fifth field, gpu_policy, that none of the SDKs declared. It is not inert: an omitted field is parsed and measured as the default empty policy, and its JCS-canonicalized digest is emitted as the gpu-policy-hash launch event right after compose-hash. A caller who needs a GPU policy had no way to express one. Every GpuPolicy field is omitted when unset. That is a safety property, not a style choice: attest_gpu defaults to true guest-side, so emitting a bare false for a caller who only set Rego would silently disable GPU attestation. AttestGPU is therefore a *bool, and a dedicated test asserts an unset one stays out of the document. The three allow_* flags default false in both directions, so plain bools with omitempty are equivalent. Expected hashes are JS reference values, as in the surrounding tests. --- sdk/go/dstack/compose_hash.go | 26 ++++++++++ sdk/go/dstack/compose_hash_test.go | 81 ++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/sdk/go/dstack/compose_hash.go b/sdk/go/dstack/compose_hash.go index 879826f10..14ce16c79 100644 --- a/sdk/go/dstack/compose_hash.go +++ b/sdk/go/dstack/compose_hash.go @@ -80,12 +80,38 @@ const ( RequirementPlatformNitro RequirementPlatform = "dstack-nitro-enclave" ) +// GpuPolicy is the application GPU policy applied before key provisioning. +// +// Every field is omitted when unset so the guest applies its own default. That +// matters most for AttestGPU, whose guest-side default is true: emitting a bare +// false would silently turn off GPU attestation for a caller who only wanted to +// set Rego. +type GpuPolicy struct { + // AttestGPU requires an attached GPU to pass local TEE attestation before + // the guest continues booting. Nil leaves the guest default (true) in place. + AttestGPU *bool `json:"attest_gpu,omitempty"` + // Rego is an optional Rego v0 policy evaluated against nvattest's claims + // array. It must define the boolean rule data.policy.nv_match. + Rego string `json:"rego,omitempty"` + // AllowDevtools permits NVIDIA DevTools mode, which disables the GPU + // memory-confidentiality guarantees expected in production. + AllowDevtools bool `json:"allow_devtools,omitempty"` + // AllowDebug permits claims whose GPU attestation debug status is enabled. + AllowDebug bool `json:"allow_debug,omitempty"` + // AllowInsecureBoot permits claims that do not assert GPU secure boot. + AllowInsecureBoot bool `json:"allow_insecure_boot,omitempty"` +} + // Requirements represents guest-side requirements. type Requirements struct { OsVersion string `json:"os_version,omitempty"` Platforms *[]RequirementPlatform `json:"platforms,omitempty"` TdxMeasureAcpiTables *bool `json:"tdx_measure_acpi_tables,omitempty"` LaunchTokenHash string `json:"launch_token_hash,omitempty"` + // GpuPolicy is measured even when absent: the guest parses an omitted + // field as the default empty policy {} and emits its digest as the + // gpu-policy-hash launch event. + GpuPolicy *GpuPolicy `json:"gpu_policy,omitempty"` } // AppCompose represents the application composition structure diff --git a/sdk/go/dstack/compose_hash_test.go b/sdk/go/dstack/compose_hash_test.go index a85e4ea58..1ac350f59 100644 --- a/sdk/go/dstack/compose_hash_test.go +++ b/sdk/go/dstack/compose_hash_test.go @@ -468,3 +468,84 @@ func TestDeterministicJSONEmitsNoTrailingNewline(t *testing.T) { t.Fatalf("deterministic JSON = %q, want the compact form with no trailing newline", got) } } + +// TestGetComposeHashCoversGpuPolicy pins requirements.gpu_policy, the fifth +// Requirements field in dstack-types that no SDK had declared. +func TestGetComposeHashCoversGpuPolicy(t *testing.T) { + tests := []struct { + name string + compose AppCompose + want string + }{ + { + name: "rego only", + compose: AppCompose{ + Runner: "docker-compose", + Requirements: &Requirements{GpuPolicy: &GpuPolicy{Rego: "package policy\nnv_match := true"}}, + }, + want: "a5032c9af6ccb3403062e80def3b41d4308753da5237735d99c913484fc75865", + }, + { + name: "every field set", + compose: AppCompose{ + Runner: "docker-compose", + Requirements: &Requirements{ + OsVersion: ">=0.6.0", + GpuPolicy: &GpuPolicy{ + AttestGPU: boolPtr(true), + Rego: "package policy\nnv_match := true", + AllowDevtools: true, + AllowDebug: true, + AllowInsecureBoot: true, + }, + }, + }, + want: "46d26e08d9aef4b229f210bdda13443b99d210707c827d631e2f1115cde57370", + }, + { + name: "attestation explicitly disabled", + compose: AppCompose{ + Runner: "docker-compose", + Requirements: &Requirements{GpuPolicy: &GpuPolicy{AttestGPU: boolPtr(false)}}, + }, + want: "4b508db2484d27c3ea89ef3ae42411b7dbe00593dd577c5c8797c30d89f7d847", + }, + { + name: "empty policy", + compose: AppCompose{ + Runner: "docker-compose", + Requirements: &Requirements{GpuPolicy: &GpuPolicy{}}, + }, + want: "4a691b287d3991fa248e7efd982c90b94f34b56d23037cdf724597198407dfda", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetComposeHash(tt.compose, false) + if err != nil { + t.Fatalf("GetComposeHash: %v", err) + } + if got != tt.want { + t.Fatalf("hash = %s, want %s (JS reference)", got, tt.want) + } + }) + } +} + +// TestUnsetAttestGPUIsOmitted guards the safety default: a caller who sets only +// Rego must not silently ship attest_gpu=false, which would turn off GPU +// attestation. +func TestUnsetAttestGPUIsOmitted(t *testing.T) { + encoded, err := json.Marshal(&GpuPolicy{Rego: "package policy"}) + if err != nil { + t.Fatalf("marshal gpu policy: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("unmarshal gpu policy: %v", err) + } + if _, present := decoded["attest_gpu"]; present { + t.Fatalf("attest_gpu emitted when unset: %s", encoded) + } +} From 477a4841c3179bba66e473eef85821c033972ca8 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Fri, 7 Aug 2026 20:38:20 +0800 Subject: [PATCH 6/8] fix(sdk/go): extend unknown-field passthrough to nested compose objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppCompose.Extra only covered the top level, so the bug it was meant to fix survived one level down: Requirements, GpuPolicy, DockerConfig, PortPolicy, PortAttrs and VerityVolume are all plain structs, and each dropped unknown keys on marshal. requirements.gpu_policy was a live example — before the previous commit declared it, a Go caller could not produce the same hash as JS for a compose carrying it. Generalize the marshal/unmarshal pair into marshalWithExtra / unmarshalExtra plus a per-type cache of declared JSON names, then give every nested compose object the same Extra field and the same collision guard. Requirements and GpuPolicy carry deny_unknown_fields in dstack-types, so an unknown key there yields a compose the guest will refuse to parse. Passing it through is still correct for hashing: the SDK's job is to hash the document the caller supplied, and the JS SDK does exactly that. Failing at launch with a clear parse error beats registering a hash nobody can reproduce. Vectors are JS reference values covering each nested object, plus a round trip that decodes nested unknown keys and re-hashes unchanged. --- sdk/go/dstack/compose_hash.go | 227 +++++++++++++++++++++++++---- sdk/go/dstack/compose_hash_test.go | 119 +++++++++++++++ 2 files changed, 315 insertions(+), 31 deletions(-) diff --git a/sdk/go/dstack/compose_hash.go b/sdk/go/dstack/compose_hash.go index 14ce16c79..f136f51de 100644 --- a/sdk/go/dstack/compose_hash.go +++ b/sdk/go/dstack/compose_hash.go @@ -43,6 +43,9 @@ type PortAttrs struct { // PP asks the gateway to send a PROXY protocol header on outbound // connections to this port. PP bool `json:"pp"` + + // Extra carries keys this SDK version does not declare. See AppCompose.Extra. + Extra map[string]any `json:"-"` } // PortPolicy is the per-port policy consumed by the gateway. @@ -51,6 +54,9 @@ type PortPolicy struct { // RestrictMode makes the gateway forward only to ports listed in Ports and // reject everything else at TCP-accept time. RestrictMode bool `json:"restrict_mode"` + + // Extra carries keys this SDK version does not declare. See AppCompose.Extra. + Extra map[string]any `json:"-"` } // VerityVolume is a pre-baked, read-only dm-verity volume attached to the CVM. @@ -61,6 +67,9 @@ type VerityVolume struct { VerityRoot string `json:"verity_root"` // Target is the absolute mount path inside the guest. Target string `json:"target"` + + // Extra carries keys this SDK version does not declare. See AppCompose.Extra. + Extra map[string]any `json:"-"` } // DockerConfig represents Docker configuration @@ -68,6 +77,9 @@ type DockerConfig struct { Registry string `json:"registry,omitempty"` Username string `json:"username,omitempty"` TokenKey string `json:"token_key,omitempty"` + + // Extra carries keys this SDK version does not declare. See AppCompose.Extra. + Extra map[string]any `json:"-"` } // RequirementPlatform represents an allowed guest attestation platform. @@ -100,6 +112,9 @@ type GpuPolicy struct { AllowDebug bool `json:"allow_debug,omitempty"` // AllowInsecureBoot permits claims that do not assert GPU secure boot. AllowInsecureBoot bool `json:"allow_insecure_boot,omitempty"` + + // Extra carries keys this SDK version does not declare. See AppCompose.Extra. + Extra map[string]any `json:"-"` } // Requirements represents guest-side requirements. @@ -112,6 +127,9 @@ type Requirements struct { // field as the default empty policy {} and emits its digest as the // gpu-policy-hash launch event. GpuPolicy *GpuPolicy `json:"gpu_policy,omitempty"` + + // Extra carries keys this SDK version does not declare. See AppCompose.Extra. + Extra map[string]any `json:"-"` } // AppCompose represents the application composition structure @@ -171,11 +189,15 @@ type AppCompose struct { Extra map[string]any `json:"-"` } -// declaredComposeFields returns the JSON names of every declared AppCompose -// field, including those omitempty would leave out of a given document. -var declaredComposeFields = sync.OnceValue(func() map[string]struct{} { +var declaredFieldCache sync.Map // reflect.Type -> map[string]struct{} + +// declaredFieldNames returns the JSON names of every declared field on t, +// including those omitempty would leave out of a given document. +func declaredFieldNames(t reflect.Type) map[string]struct{} { + if cached, ok := declaredFieldCache.Load(t); ok { + return cached.(map[string]struct{}) + } names := make(map[string]struct{}) - t := reflect.TypeOf(AppCompose{}) for i := 0; i < t.NumField(); i++ { tag := t.Field(i).Tag.Get("json") name, _, _ := strings.Cut(tag, ",") @@ -184,18 +206,18 @@ var declaredComposeFields = sync.OnceValue(func() map[string]struct{} { } names[name] = struct{}{} } + declaredFieldCache.Store(t, names) return names -}) +} -// MarshalJSON emits the declared fields and then merges Extra into the same -// object. -func (a AppCompose) MarshalJSON() ([]byte, error) { - type plain AppCompose - encoded, err := json.Marshal(plain(a)) +// marshalWithExtra encodes declared and merges extra into the same JSON object. +// typeName only appears in error messages. +func marshalWithExtra(declared any, extra map[string]any, t reflect.Type, typeName string) ([]byte, error) { + encoded, err := json.Marshal(declared) if err != nil { return nil, err } - if len(a.Extra) == 0 { + if len(extra) == 0 { return encoded, nil } @@ -204,20 +226,52 @@ func (a AppCompose) MarshalJSON() ([]byte, error) { return nil, err } - declared := declaredComposeFields() - for key, value := range a.Extra { - if _, isDeclared := declared[key]; isDeclared { - return nil, fmt.Errorf("dstack: app_compose Extra key %q collides with a declared field; set the field instead", key) + names := declaredFieldNames(t) + for key, value := range extra { + if _, isDeclared := names[key]; isDeclared { + return nil, fmt.Errorf("dstack: %s Extra key %q collides with a declared field; set the field instead", typeName, key) } raw, err := json.Marshal(value) if err != nil { - return nil, fmt.Errorf("dstack: app_compose Extra key %q: %w", key, err) + return nil, fmt.Errorf("dstack: %s Extra key %q: %w", typeName, key, err) } merged[key] = raw } return json.Marshal(merged) } +// unmarshalExtra returns the keys of data that t does not declare. +func unmarshalExtra(data []byte, t reflect.Type, typeName string) (map[string]any, error) { + var all map[string]json.RawMessage + if err := json.Unmarshal(data, &all); err != nil { + return nil, err + } + + names := declaredFieldNames(t) + extra := make(map[string]any) + for key, raw := range all { + if _, isDeclared := names[key]; isDeclared { + continue + } + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return nil, fmt.Errorf("dstack: %s field %q: %w", typeName, key, err) + } + extra[key] = value + } + if len(extra) == 0 { + return nil, nil + } + return extra, nil +} + +// MarshalJSON emits the declared fields and then merges Extra into the same +// object. +func (a AppCompose) MarshalJSON() ([]byte, error) { + type plain AppCompose + return marshalWithExtra(plain(a), a.Extra, reflect.TypeFor[AppCompose](), "app_compose") +} + // UnmarshalJSON decodes the declared fields and collects everything else into // Extra, so a document can be re-hashed without losing keys. func (a *AppCompose) UnmarshalJSON(data []byte) error { @@ -228,26 +282,137 @@ func (a *AppCompose) UnmarshalJSON(data []byte) error { } *a = AppCompose(decoded) - var all map[string]json.RawMessage - if err := json.Unmarshal(data, &all); err != nil { + extra, err := unmarshalExtra(data, reflect.TypeFor[AppCompose](), "app_compose") + if err != nil { return err } + a.Extra = extra + return nil +} - declared := declaredComposeFields() - extra := make(map[string]any) - for key, raw := range all { - if _, isDeclared := declared[key]; isDeclared { - continue - } - var value any - if err := json.Unmarshal(raw, &value); err != nil { - return fmt.Errorf("dstack: app_compose field %q: %w", key, err) - } - extra[key] = value +func (r Requirements) MarshalJSON() ([]byte, error) { + type plain Requirements + return marshalWithExtra(plain(r), r.Extra, reflect.TypeFor[Requirements](), "requirements") +} + +func (r *Requirements) UnmarshalJSON(data []byte) error { + type plain Requirements + var decoded plain + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *r = Requirements(decoded) + + extra, err := unmarshalExtra(data, reflect.TypeFor[Requirements](), "requirements") + if err != nil { + return err } - if len(extra) > 0 { - a.Extra = extra + r.Extra = extra + return nil +} + +func (g GpuPolicy) MarshalJSON() ([]byte, error) { + type plain GpuPolicy + return marshalWithExtra(plain(g), g.Extra, reflect.TypeFor[GpuPolicy](), "gpu_policy") +} + +func (g *GpuPolicy) UnmarshalJSON(data []byte) error { + type plain GpuPolicy + var decoded plain + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *g = GpuPolicy(decoded) + + extra, err := unmarshalExtra(data, reflect.TypeFor[GpuPolicy](), "gpu_policy") + if err != nil { + return err + } + g.Extra = extra + return nil +} + +func (d DockerConfig) MarshalJSON() ([]byte, error) { + type plain DockerConfig + return marshalWithExtra(plain(d), d.Extra, reflect.TypeFor[DockerConfig](), "docker_config") +} + +func (d *DockerConfig) UnmarshalJSON(data []byte) error { + type plain DockerConfig + var decoded plain + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *d = DockerConfig(decoded) + + extra, err := unmarshalExtra(data, reflect.TypeFor[DockerConfig](), "docker_config") + if err != nil { + return err + } + d.Extra = extra + return nil +} + +func (p PortPolicy) MarshalJSON() ([]byte, error) { + type plain PortPolicy + return marshalWithExtra(plain(p), p.Extra, reflect.TypeFor[PortPolicy](), "port_policy") +} + +func (p *PortPolicy) UnmarshalJSON(data []byte) error { + type plain PortPolicy + var decoded plain + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *p = PortPolicy(decoded) + + extra, err := unmarshalExtra(data, reflect.TypeFor[PortPolicy](), "port_policy") + if err != nil { + return err + } + p.Extra = extra + return nil +} + +func (p PortAttrs) MarshalJSON() ([]byte, error) { + type plain PortAttrs + return marshalWithExtra(plain(p), p.Extra, reflect.TypeFor[PortAttrs](), "port_policy.ports entry") +} + +func (p *PortAttrs) UnmarshalJSON(data []byte) error { + type plain PortAttrs + var decoded plain + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *p = PortAttrs(decoded) + + extra, err := unmarshalExtra(data, reflect.TypeFor[PortAttrs](), "port_policy.ports entry") + if err != nil { + return err + } + p.Extra = extra + return nil +} + +func (v VerityVolume) MarshalJSON() ([]byte, error) { + type plain VerityVolume + return marshalWithExtra(plain(v), v.Extra, reflect.TypeFor[VerityVolume](), "verity_volumes entry") +} + +func (v *VerityVolume) UnmarshalJSON(data []byte) error { + type plain VerityVolume + var decoded plain + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *v = VerityVolume(decoded) + + extra, err := unmarshalExtra(data, reflect.TypeFor[VerityVolume](), "verity_volumes entry") + if err != nil { + return err } + v.Extra = extra return nil } diff --git a/sdk/go/dstack/compose_hash_test.go b/sdk/go/dstack/compose_hash_test.go index 1ac350f59..f9fc38915 100644 --- a/sdk/go/dstack/compose_hash_test.go +++ b/sdk/go/dstack/compose_hash_test.go @@ -549,3 +549,122 @@ func TestUnsetAttestGPUIsOmitted(t *testing.T) { t.Fatalf("attest_gpu emitted when unset: %s", encoded) } } + +// TestNestedObjectsPassUnknownFieldsThrough covers the nested counterpart of +// AppCompose.Extra. A struct field anywhere in the document drops unknown keys +// on marshal, so passthrough at the top level alone still left every nested +// object able to change an app's hash. +func TestNestedObjectsPassUnknownFieldsThrough(t *testing.T) { + tests := []struct { + name string + compose AppCompose + want string + }{ + { + name: "requirements", + compose: AppCompose{ + Runner: "docker-compose", + Requirements: &Requirements{ + OsVersion: ">=0.6.0", + Extra: map[string]any{"future_req": true}, + }, + }, + want: "18955f7789a7aa43e3ae2c1d00b96c26caef8a91f1f0c55ad362f0a0a39215c0", + }, + { + name: "docker_config", + compose: AppCompose{ + Runner: "docker-compose", + DockerConfig: &DockerConfig{ + Registry: "docker.io", + Extra: map[string]any{"future_key": 1}, + }, + }, + want: "37cd67d88435bdb727b3e240a9a0ff57b7bebbc17eacd573c3d4746aacd104f6", + }, + { + name: "port_policy", + compose: AppCompose{ + Runner: "docker-compose", + PortPolicy: &PortPolicy{ + Ports: []PortAttrs{{Port: 443, PP: true}}, + Extra: map[string]any{"future_mode": "strict"}, + }, + }, + want: "57ff3fb4784d409a6339feb615f02695f74a713d9c914d700c0b62d20c578a7e", + }, + { + name: "port_policy.ports entry", + compose: AppCompose{ + Runner: "docker-compose", + PortPolicy: &PortPolicy{ + Ports: []PortAttrs{{Port: 443, PP: true, Extra: map[string]any{"future_attr": 7}}}, + }, + }, + want: "04ebb30d7c219a18cadf4ae77bf32e7230060f2b0bf90775296ec9f94aa40dbb", + }, + { + name: "verity_volumes entry", + compose: AppCompose{ + Runner: "docker-compose", + VerityVolumes: []VerityVolume{{ + Source: "a.img", + VerityRoot: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + Target: "/mnt/a", + Extra: map[string]any{"future_opt": true}, + }}, + }, + want: "3a50068f1ffb7626725c73f0a1511843f9e918d9b3a51144a445c3d2dd1ba2a6", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetComposeHash(tt.compose, false) + if err != nil { + t.Fatalf("GetComposeHash: %v", err) + } + if got != tt.want { + t.Fatalf("hash = %s, want %s (JS reference)", got, tt.want) + } + }) + } +} + +// TestNestedExtraCannotShadowDeclaredFields checks the collision guard reaches +// nested objects too. +func TestNestedExtraCannotShadowDeclaredFields(t *testing.T) { + _, err := GetComposeHash(AppCompose{ + Runner: "docker-compose", + Requirements: &Requirements{Extra: map[string]any{"os_version": ">=0.6.0"}}, + }, false) + if err == nil { + t.Fatal("GetComposeHash accepted a nested Extra key shadowing a declared field") + } +} + +// TestNestedUnknownFieldsRoundTrip decodes a document whose unknown keys sit +// inside nested objects and re-hashes it unchanged. +func TestNestedUnknownFieldsRoundTrip(t *testing.T) { + raw := `{"runner":"docker-compose","requirements":{"future_req":true,"os_version":">=0.6.0"}}` + + var compose AppCompose + if err := json.Unmarshal([]byte(raw), &compose); err != nil { + t.Fatalf("unmarshal app compose: %v", err) + } + if compose.Requirements == nil { + t.Fatal("requirements dropped during decoding") + } + if _, ok := compose.Requirements.Extra["future_req"]; !ok { + t.Fatalf("nested unknown key dropped during decoding: %+v", compose.Requirements) + } + + got, err := GetComposeHash(compose, false) + if err != nil { + t.Fatalf("GetComposeHash: %v", err) + } + const want = "18955f7789a7aa43e3ae2c1d00b96c26caef8a91f1f0c55ad362f0a0a39215c0" + if got != want { + t.Fatalf("hash after round trip = %s, want %s", got, want) + } +} From 03967dfd9a8c966c54b323e7b955670dbbb85251 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Fri, 7 Aug 2026 20:39:47 +0800 Subject: [PATCH 7/8] fix(sdk/python): stop raising on unknown fields in nested compose objects AppCompose already accepted **kwargs, so an unknown top-level key hashed fine. Its nested objects did not: from_dict calls DockerConfig(**dc) and Requirements(**req), so any key outside their fixed signatures raised TypeError and the compose could not be hashed at all. requirements.gpu_policy is a live example. It exists in dstack-types but was never added here, so `get_compose_hash({"requirements": {"gpu_policy": ...}})` raised `TypeError: Requirements.__init__() got an unexpected keyword argument`. Give both classes the same **kwargs plus to_dict merge that AppCompose uses, and declare gpu_policy explicitly since it is a real field rather than a hypothetical future one. Failing loudly is better than the Go SDK's silent wrong hash, but it still means a document the JS SDK hashes fine is unusable here. Expected values in the tests are JS reference hashes for the same documents. --- sdk/python/src/dstack_sdk/get_compose_hash.py | 14 +++++++-- sdk/python/tests/test_get_compose_hash.py | 29 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/sdk/python/src/dstack_sdk/get_compose_hash.py b/sdk/python/src/dstack_sdk/get_compose_hash.py index 9f8aeb9e7..8c4c1a777 100644 --- a/sdk/python/src/dstack_sdk/get_compose_hash.py +++ b/sdk/python/src/dstack_sdk/get_compose_hash.py @@ -28,11 +28,13 @@ def __init__( registry: Optional[str] = None, username: Optional[str] = None, token_key: Optional[str] = None, + **kwargs: Any, ) -> None: - """Initialize a new ``DockerConfig`` instance.""" + """Initialize a new ``DockerConfig`` instance with arbitrary extra fields.""" self.registry = registry self.username = username self.token_key = token_key + self._extra = dict(kwargs) def to_dict(self) -> Dict[str, Any]: """Return a dictionary representation excluding ``None`` fields.""" @@ -43,6 +45,7 @@ def to_dict(self) -> Dict[str, Any]: result["username"] = self.username if self.token_key is not None: result["token_key"] = self.token_key + result.update(self._extra) return result @@ -55,12 +58,16 @@ def __init__( platforms: Optional[List[str]] = None, tdx_measure_acpi_tables: Optional[bool] = None, launch_token_hash: Optional[str] = None, + gpu_policy: Optional[Dict[str, Any]] = None, + **kwargs: Any, ) -> None: - """Initialize a new ``Requirements`` instance.""" + """Initialize a new ``Requirements`` instance with arbitrary extra fields.""" self.os_version = os_version self.platforms = platforms self.tdx_measure_acpi_tables = tdx_measure_acpi_tables self.launch_token_hash = launch_token_hash + self.gpu_policy = gpu_policy + self._extra = dict(kwargs) def to_dict(self) -> Dict[str, Any]: """Return a dictionary representation excluding ``None`` fields.""" @@ -73,6 +80,9 @@ def to_dict(self) -> Dict[str, Any]: result["tdx_measure_acpi_tables"] = self.tdx_measure_acpi_tables if self.launch_token_hash is not None: result["launch_token_hash"] = self.launch_token_hash + if self.gpu_policy is not None: + result["gpu_policy"] = self.gpu_policy + result.update(self._extra) return result diff --git a/sdk/python/tests/test_get_compose_hash.py b/sdk/python/tests/test_get_compose_hash.py index 50ea9f0a4..ad293ff89 100644 --- a/sdk/python/tests/test_get_compose_hash.py +++ b/sdk/python/tests/test_get_compose_hash.py @@ -244,3 +244,32 @@ def test_sort_object_function(): # Nested keys should also be sorted nested_keys = list(sorted_obj["nested"].keys()) assert nested_keys == ["a", "z"] + + +def test_nested_unknown_fields_do_not_crash(): + """Nested objects must accept keys this SDK version does not declare. + + requirements.gpu_policy is a live example: it exists in dstack-types but was + never added to Requirements here, so from_dict raised TypeError and the + compose could not be hashed at all. + """ + compose = { + "runner": "docker-compose", + "requirements": {"os_version": ">=0.6.0", "gpu_policy": {"rego": "package x"}}, + } + # JS reference value for the same document. + assert ( + get_compose_hash(dict(compose)) + == "0eb176e27fed3e305de09e5e83fec2dacc87485eaa612b0164a2d4d0b289ccb3" + ) + + +def test_docker_config_accepts_unknown_fields(): + compose = { + "runner": "docker-compose", + "docker_config": {"registry": "docker.io", "future_key": 1}, + } + assert ( + get_compose_hash(dict(compose)) + == "37cd67d88435bdb727b3e240a9a0ff57b7bebbc17eacd573c3d4746aacd104f6" + ) From ab95a041fe3eff5b531b6011837557b6b8a42615 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Fri, 7 Aug 2026 20:41:37 +0800 Subject: [PATCH 8/8] feat(sdk/js,sdk/python): declare the app_compose fields added since the last sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both SDKs hashed these fields correctly already — JS through its index signature, Python through **kwargs — so this is a typing gap, not a correctness one. Declaring them gives callers type checking and, more usefully, pins the wire shapes that are easy to get wrong: - swap_size is a string ("2G"), because dstack-types serializes it through its human_size helper, not as a byte count. - event_log_version is a number, omitted for v1. - attest_gpu inside gpu_policy defaults to true guest-side, so it is optional rather than defaulted to false. Fields declared: init_script, storage_fs, swap_size, event_log_version, port_policy (with PortPolicy / PortAttrs), verity_volumes (with VerityVolume) and requirements.gpu_policy (with GpuPolicy). The JS tests double as the source of the cross-language vectors used by the Go and Python suites: they run against the real @noble/hashes implementation, so they confirm those vectors are the reference implementation's own output. --- sdk/js/src/__tests__/get-compose-hash.test.ts | 80 +++++++++++++++++++ sdk/js/src/get-compose-hash.ts | 42 ++++++++++ sdk/python/src/dstack_sdk/get_compose_hash.py | 16 ++++ sdk/python/tests/test_get_compose_hash.py | 71 ++++++++++++++++ 4 files changed, 209 insertions(+) diff --git a/sdk/js/src/__tests__/get-compose-hash.test.ts b/sdk/js/src/__tests__/get-compose-hash.test.ts index 79e64170b..2d6c0cf3d 100644 --- a/sdk/js/src/__tests__/get-compose-hash.test.ts +++ b/sdk/js/src/__tests__/get-compose-hash.test.ts @@ -559,4 +559,84 @@ describe('Deterministic JSON Serialization', () => { expect(hash).toHaveLength(64) }) }) + + describe('Fields Declared From dstack-types', () => { + // These were reachable through the index signature all along; declaring them + // gives callers type checking and pins the wire shapes that are easy to get + // wrong. The expected hashes are the values this same function produces, so + // they double as cross-language vectors for the Go and Python SDKs. + + it('should hash port_policy', () => { + const compose: AppCompose = { + runner: "docker-compose", + docker_compose_file: "docker-compose.yml", + port_policy: { + ports: [{ port: 443, pp: true }, { port: 8080, pp: false }], + restrict_mode: true, + }, + } + + expect(getComposeHash(compose)).toBe( + "6be823decce06179698ee6fd087d82951c21ba6a24ba6419a6801b0be1ce2bdc" + ) + }) + + it('should hash swap_size as a string, not a byte count', () => { + const compose: AppCompose = { + runner: "docker-compose", + storage_fs: "zfs", + swap_size: "2G", + } + + expect(getComposeHash(compose)).toBe( + "b140f74b52ae10dc42efe16e4368784b60f755ada3781a0be8b8aafae6a6ab86" + ) + }) + + it('should hash init_script, event_log_version and verity_volumes', () => { + expect(getComposeHash({ + runner: "docker-compose", + init_script: ["echo one", "echo two"], + })).toBe("8c35247d5b685a8d24b97182f3b0a4e3c1ab6d8eecee6704424a0de0dd8a66a3") + + expect(getComposeHash({ + runner: "docker-compose", + event_log_version: 2, + })).toBe("8d1f7a3b6a6fc64236667a69c7618c5075d85d4c3afd8fb9a4b0c733b60ae8f5") + + expect(getComposeHash({ + runner: "docker-compose", + verity_volumes: [{ + source: "data.img", + verity_root: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + target: "/mnt/data", + }], + })).toBe("3eb00892f370ab31f854172991b655a0880ccb7786d121dfb75cbd9b038df19f") + }) + + it('should hash requirements.gpu_policy', () => { + const compose: AppCompose = { + runner: "docker-compose", + requirements: { + gpu_policy: { rego: "package policy\nnv_match := true" }, + }, + } + + expect(getComposeHash(compose)).toBe( + "a5032c9af6ccb3403062e80def3b41d4308753da5237735d99c913484fc75865" + ) + }) + + it('should hash the tpm key provider', () => { + const compose: AppCompose = { + runner: "docker-compose", + key_provider: "tpm", + key_provider_id: "aabb", + } + + expect(getComposeHash(compose)).toBe( + "e4bfbeaf851f73b873f04e74cc5699668dc282bd96e15dbf0ab1e5e05cc2ca76" + ) + }) + }) }) diff --git a/sdk/js/src/get-compose-hash.ts b/sdk/js/src/get-compose-hash.ts index f8cf0e8f6..10559c26e 100644 --- a/sdk/js/src/get-compose-hash.ts +++ b/sdk/js/src/get-compose-hash.ts @@ -48,11 +48,44 @@ export interface DockerConfig extends SortableObject { token_key?: string; } +export interface GpuPolicy extends SortableObject { + /** Guest-side default is true; omit to keep it rather than sending false. */ + attest_gpu?: boolean; + /** Rego v0 policy over nvattest claims; must define data.policy.nv_match. */ + rego?: string; + allow_devtools?: boolean; + allow_debug?: boolean; + allow_insecure_boot?: boolean; +} + export interface Requirements extends SortableObject { os_version?: string; platforms?: RequirementPlatform[]; tdx_measure_acpi_tables?: boolean; launch_token_hash?: string; + /** Measured even when absent: an omitted policy is measured as {}. */ + gpu_policy?: GpuPolicy; +} + +export interface PortAttrs extends SortableObject { + port: number; + /** Send a PROXY protocol header on outbound connections to this port. */ + pp?: boolean; +} + +export interface PortPolicy extends SortableObject { + ports?: PortAttrs[]; + /** Forward only to the listed ports; reject the rest at TCP-accept time. */ + restrict_mode?: boolean; +} + +export interface VerityVolume extends SortableObject { + /** Bare image file name resolved by the VMM under its volumes dir. */ + source: string; + /** Hex dm-verity root hash: the volume's content identity. */ + verity_root: string; + /** Absolute mount path inside the guest. */ + target: string; } export interface AppCompose extends SortableObject { @@ -77,6 +110,15 @@ export interface AppCompose extends SortableObject { allowed_envs?: string[]; no_instance_id?: boolean; secure_time?: boolean; + /** Bash scripts run before the application runner starts. */ + init_script?: string[]; + storage_fs?: "ext4" | "zfs"; + /** Human-readable size such as "2G"; dstack-types serializes it as a string. */ + swap_size?: string; + /** Event log digest format. Omit for v1, which dstack-types omits too. */ + event_log_version?: number; + port_policy?: PortPolicy; + verity_volumes?: VerityVolume[]; requirements?: Requirements; // Legacy fields for backward compatibility bash_script?: string; diff --git a/sdk/python/src/dstack_sdk/get_compose_hash.py b/sdk/python/src/dstack_sdk/get_compose_hash.py index 8c4c1a777..0fb7cd52f 100644 --- a/sdk/python/src/dstack_sdk/get_compose_hash.py +++ b/sdk/python/src/dstack_sdk/get_compose_hash.py @@ -113,6 +113,12 @@ def __init__( bash_script: Optional[str] = None, # Legacy pre_launch_script: Optional[str] = None, # Legacy snapshotter: Optional[str] = None, + init_script: Optional[List[str]] = None, + storage_fs: Optional[str] = None, + swap_size: Optional[str] = None, + event_log_version: Optional[int] = None, + port_policy: Optional[Dict[str, Any]] = None, + verity_volumes: Optional[List[Dict[str, Any]]] = None, **kwargs: Any, ) -> None: """Initialize a new ``AppCompose`` instance with arbitrary extra fields.""" @@ -138,6 +144,16 @@ def __init__( self.requirements = requirements self.bash_script = bash_script self.pre_launch_script = pre_launch_script + # Bash scripts run before the application runner starts. + self.init_script = init_script + self.storage_fs = storage_fs + # Human-readable size such as "2G"; dstack-types serializes it as a + # string, not a byte count. + self.swap_size = swap_size + # Event log digest format. Leave unset for v1, which dstack-types omits. + self.event_log_version = event_log_version + self.port_policy = port_policy + self.verity_volumes = verity_volumes # Add any additional fields for key, value in kwargs.items(): diff --git a/sdk/python/tests/test_get_compose_hash.py b/sdk/python/tests/test_get_compose_hash.py index ad293ff89..0b51345b0 100644 --- a/sdk/python/tests/test_get_compose_hash.py +++ b/sdk/python/tests/test_get_compose_hash.py @@ -4,6 +4,7 @@ from dstack_sdk.get_compose_hash import AppCompose from dstack_sdk.get_compose_hash import DockerConfig +from dstack_sdk.get_compose_hash import Requirements from dstack_sdk.get_compose_hash import get_compose_hash from dstack_sdk.get_compose_hash import sort_object @@ -273,3 +274,73 @@ def test_docker_config_accepts_unknown_fields(): get_compose_hash(dict(compose)) == "37cd67d88435bdb727b3e240a9a0ff57b7bebbc17eacd573c3d4746aacd104f6" ) + + +def test_fields_declared_from_dstack_types(): + """Fields that reached the hash through **kwargs are now named parameters. + + Expected values are the JS SDK's, which the consistency doc designates as + the reference implementation. + """ + assert ( + get_compose_hash( + AppCompose( + runner="docker-compose", + docker_compose_file="docker-compose.yml", + port_policy={ + "ports": [{"port": 443, "pp": True}, {"port": 8080, "pp": False}], + "restrict_mode": True, + }, + ) + ) + == "6be823decce06179698ee6fd087d82951c21ba6a24ba6419a6801b0be1ce2bdc" + ) + + assert ( + get_compose_hash( + AppCompose(runner="docker-compose", storage_fs="zfs", swap_size="2G") + ) + == "b140f74b52ae10dc42efe16e4368784b60f755ada3781a0be8b8aafae6a6ab86" + ) + + assert ( + get_compose_hash( + AppCompose(runner="docker-compose", init_script=["echo one", "echo two"]) + ) + == "8c35247d5b685a8d24b97182f3b0a4e3c1ab6d8eecee6704424a0de0dd8a66a3" + ) + + assert ( + get_compose_hash(AppCompose(runner="docker-compose", event_log_version=2)) + == "8d1f7a3b6a6fc64236667a69c7618c5075d85d4c3afd8fb9a4b0c733b60ae8f5" + ) + + assert ( + get_compose_hash( + AppCompose( + runner="docker-compose", + verity_volumes=[ + { + "source": "data.img", + "verity_root": "00112233445566778899aabbccddeeff" + "00112233445566778899aabbccddeeff", + "target": "/mnt/data", + } + ], + ) + ) + == "3eb00892f370ab31f854172991b655a0880ccb7786d121dfb75cbd9b038df19f" + ) + + +def test_gpu_policy_is_a_named_requirements_field(): + compose = AppCompose( + runner="docker-compose", + requirements=Requirements( + gpu_policy={"rego": "package policy\nnv_match := true"} + ), + ) + assert ( + get_compose_hash(compose) + == "a5032c9af6ccb3403062e80def3b41d4308753da5237735d99c913484fc75865" + )