diff --git a/sdk/go/dstack/compose_hash.go b/sdk/go/dstack/compose_hash.go index d513967e3..f136f51de 100644 --- a/sdk/go/dstack/compose_hash.go +++ b/sdk/go/dstack/compose_hash.go @@ -5,10 +5,15 @@ package dstack import ( + "bytes" "crypto/sha256" "encoding/hex" "encoding/json" + "fmt" + "reflect" "sort" + "strings" + "sync" ) // KeyProviderKind represents the key provider type @@ -18,13 +23,63 @@ 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"` + + // 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. +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"` + + // 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. +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"` + + // Extra carries keys this SDK version does not declare. See AppCompose.Extra. + Extra map[string]any `json:"-"` +} + // DockerConfig represents Docker configuration 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. @@ -37,12 +92,44 @@ 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"` + + // Extra carries keys this SDK version does not declare. See AppCompose.Extra. + Extra map[string]any `json:"-"` +} + // 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"` + + // Extra carries keys this SDK version does not declare. See AppCompose.Extra. + Extra map[string]any `json:"-"` } // AppCompose represents the application composition structure @@ -67,8 +154,266 @@ 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. + // + // 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:"-"` +} + +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{}) + 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{}{} + } + declaredFieldCache.Store(t, names) + return names +} + +// 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(extra) == 0 { + return encoded, nil + } + + var merged map[string]json.RawMessage + if err := json.Unmarshal(encoded, &merged); err != nil { + return nil, err + } + + 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: %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 { + type plain AppCompose + var decoded plain + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *a = AppCompose(decoded) + + extra, err := unmarshalExtra(data, reflect.TypeFor[AppCompose](), "app_compose") + if err != nil { + return err + } + a.Extra = extra + return nil +} + +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 + } + 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 } // preprocessAppCompose removes conflicting fields based on runner type @@ -111,14 +456,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 new file mode 100644 index 000000000..f9fc38915 --- /dev/null +++ b/sdk/go/dstack/compose_hash_test.go @@ -0,0 +1,670 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +package dstack + +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 +// 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") + } +} + +// 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 + dockerComposeFile string + extra map[string]any + want string + }{ + { + // 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{ + "future_policy": map[string]any{ + "enabled": true, + "limits": []any{1, 2, 3}, + }, + }, + want: "c43b5c245b0f5ee1380784e8edc1180846d6f1fdf279a7a80174466647e049b5", + }, + { + 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", + DockerComposeFile: tt.dockerComposeFile, + Extra: tt.extra, + } + 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. + // 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 { + t.Fatalf("unmarshal app compose: %v", err) + } + if compose.Runner != "docker-compose" { + t.Fatalf("Runner = %q, want docker-compose", compose.Runner) + } + 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 := "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) + } +} + +// 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) + } +} + +// 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) + } +} + +// 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) + } +} 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 9f8aeb9e7..0fb7cd52f 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 @@ -103,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.""" @@ -128,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 50ea9f0a4..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 @@ -244,3 +245,102 @@ 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" + ) + + +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" + )