-
Notifications
You must be signed in to change notification settings - Fork 21
Expand file tree
/
Copy pathmanifest.go
More file actions
655 lines (585 loc) · 20.6 KB
/
manifest.go
File metadata and controls
655 lines (585 loc) · 20.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
package rofl
import (
"errors"
"fmt"
"maps"
"net/mail"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/github/go-spdx/v2/spdxexp"
"gopkg.in/yaml.v3"
beacon "github.com/oasisprotocol/oasis-core/go/beacon/api"
"github.com/oasisprotocol/oasis-core/go/common/sgx"
"github.com/oasisprotocol/oasis-core/go/common/sgx/quote"
"github.com/oasisprotocol/oasis-core/go/common/version"
"github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/rofl"
)
// ManifestFileNames are the manifest file names that are tried when loading the manifest.
var ManifestFileNames = []string{
"rofl.yaml",
"rofl.yml",
}
// Supported ROFL app kinds.
const (
AppKindRaw = "raw"
AppKindContainer = "container"
)
// Supported TEE types.
const (
TEETypeSGX = "sgx"
TEETypeTDX = "tdx"
)
// Well-known scripts.
const (
ScriptBuildPre = "build-pre"
ScriptBuildPost = "build-post"
ScriptBundlePost = "bundle-post"
)
// Manifest is the ROFL app manifest that configures various aspects of the app in a single place.
type Manifest struct {
// Name is the human readable ROFL app name.
Name string `yaml:"name" json:"name"`
// Version is the ROFL app version.
Version string `yaml:"version" json:"version"`
// Repository is the ROFL app repository URL.
Repository string `yaml:"repository,omitempty" json:"repository,omitempty"`
// Author is the ROFL app author full name and e-mail.
Author string `yaml:"author,omitempty" json:"author,omitempty"`
// License is the ROFL app SPDX license expression.
License string `yaml:"license,omitempty" json:"license,omitempty"`
// Homepage is the ROFL app homepage.
Homepage string `yaml:"homepage,omitempty" json:"homepage,omitempty"`
// Description is the ROFL app description.
Description string `yaml:"description,omitempty" json:"description,omitempty"`
// TEE is the type of TEE to build for.
TEE string `yaml:"tee" json:"tee"`
// Kind is the kind of ROFL app to build.
Kind string `yaml:"kind" json:"kind"`
// Resources are the requested ROFL app resources.
Resources ResourcesConfig `yaml:"resources" json:"resources"`
// Artifacts are the optional artifact location overrides.
Artifacts *ArtifactsConfig `yaml:"artifacts,omitempty" json:"artifacts,omitempty"`
// Deployments are the ROFL app deployments.
Deployments map[string]*Deployment `yaml:"deployments,omitempty" json:"deployments,omitempty"`
// Scripts are custom scripts that are executed by the build system at specific stages.
Scripts map[string]string `yaml:"scripts,omitempty" json:"scripts,omitempty"`
// Tooling contains information about the tooling used to generate/update the manifest.
Tooling *ToolingConfig `yaml:"tooling,omitempty" json:"tooling,omitempty"`
// sourceFn is the filename from which the manifest has been loaded.
sourceFn string
}
// ToolingConfig contains information about the tooling used to manage the manifest.
type ToolingConfig struct {
// Version is the CLI version that last modified this manifest.
Version string `yaml:"version" json:"version"`
}
// ManifestExists checks whether a manifest file exist. No attempt is made to load, parse or
// validate any of the found manifest files.
func ManifestExists() bool {
for _, fn := range ManifestFileNames {
_, err := os.Stat(fn)
switch {
case errors.Is(err, os.ErrNotExist):
continue
default:
return true
}
}
return false
}
// LoadManifest attempts to find and load the ROFL app manifest from a local file.
func LoadManifest() (*Manifest, error) {
for _, fn := range ManifestFileNames {
f, err := os.Open(fn)
switch {
case err == nil:
case errors.Is(err, os.ErrNotExist):
continue
default:
return nil, fmt.Errorf("failed to load manifest from '%s': %w", fn, err)
}
var m Manifest
dec := yaml.NewDecoder(f)
if err = dec.Decode(&m); err != nil {
f.Close()
return nil, fmt.Errorf("malformed manifest '%s': %w", fn, err)
}
if err = m.Validate(); err != nil {
f.Close()
return nil, fmt.Errorf("invalid manifest '%s': %w", fn, err)
}
m.sourceFn, _ = filepath.Abs(f.Name()) // Record source filename.
f.Close()
return &m, nil
}
return nil, fmt.Errorf("no ROFL app manifest found (tried: %s)", strings.Join(ManifestFileNames, ", "))
}
// Validate validates the manifest for correctness.
func (m *Manifest) Validate() error {
if len(m.Name) == 0 {
return fmt.Errorf("name cannot be empty")
}
if len(m.Version) == 0 {
return fmt.Errorf("version cannot be empty")
}
if _, err := version.FromString(m.Version); err != nil {
return fmt.Errorf("malformed version: %w", err)
}
if _, err := url.Parse(m.Repository); err != nil && m.Repository != "" {
return fmt.Errorf("malformed repository URL: %w", err)
}
if _, err := mail.ParseAddress(m.Author); err != nil && m.Author != "" {
return fmt.Errorf("malformed author: %w", err)
}
if _, err := spdxexp.ExtractLicenses(m.License); err != nil && m.License != "" {
return fmt.Errorf("malformed license: %w", err)
}
if _, err := url.Parse(m.Homepage); err != nil && m.Homepage != "" {
return fmt.Errorf("malformed homepage URL: %w", err)
}
switch m.TEE {
case TEETypeSGX, TEETypeTDX:
default:
return fmt.Errorf("unsupported TEE type: %s", m.TEE)
}
switch m.Kind {
case AppKindRaw:
case AppKindContainer:
if m.TEE != TEETypeTDX {
return fmt.Errorf("containers are only supported under TDX")
}
default:
return fmt.Errorf("unsupported app kind: %s", m.Kind)
}
if err := m.Resources.Validate(m.TEE); err != nil {
return fmt.Errorf("bad resources config: %w", err)
}
var defaultNames []string
for name, d := range m.Deployments {
if d == nil {
return fmt.Errorf("bad deployment: %s", name)
}
if d.Default {
defaultNames = append(defaultNames, name)
}
if err := d.Validate(); err != nil {
return fmt.Errorf("bad deployment '%s': %w", name, err)
}
}
if len(defaultNames) > 1 {
return fmt.Errorf("multiple deployments marked as default: %s", strings.Join(defaultNames, ", "))
}
return nil
}
// globalMetadataPrefix is the prefix used for all global metadata.
const globalMetadataPrefix = "net.oasis.rofl."
// GetMetadata derives metadata from the attributes defined in the manifest and combines it with
// the metadata for the specified deployment.
func (m *Manifest) GetMetadata(deployment string) map[string]string {
meta := make(map[string]string)
for _, md := range []struct {
name string
value string
}{
{"name", m.Name},
{"version", m.Version},
{"repository", m.Repository},
{"author", m.Author},
{"license", m.License},
{"homepage", m.Homepage},
{"description", m.Description},
} {
if md.value == "" {
continue
}
meta[globalMetadataPrefix+md.name] = md.value
}
d, ok := m.Deployments[deployment]
if ok {
maps.Copy(meta, d.Metadata)
}
return meta
}
// SourceFileName returns the filename of the manifest file from which the manifest was loaded or
// an empty string in case the filename is not available.
func (m *Manifest) SourceFileName() string {
return m.sourceFn
}
// Save serializes the manifest and writes it to the file returned by `SourceFileName`, overwriting
// any previous manifest.
//
// If no previous source filename is available, a default one is set.
func (m *Manifest) Save() error {
if m.sourceFn == "" {
m.sourceFn = ManifestFileNames[0]
}
f, err := os.Create(m.sourceFn)
if err != nil {
return err
}
defer f.Close()
enc := yaml.NewEncoder(f)
enc.SetIndent(2)
return enc.Encode(m)
}
// DefaultDeploymentName is the legacy name of the default deployment. It is used as a fallback
// when no deployment is explicitly marked as default.
const DefaultDeploymentName = "default"
// DefaultDeployment returns the name of the default deployment. Resolution order:
// 1. Deployment explicitly marked as default (default: true).
// 2. Legacy fallback: deployment named "default".
// 3. If exactly one deployment exists, use it.
//
// Returns an empty string if no default can be determined.
func (m *Manifest) DefaultDeployment() string {
for name, d := range m.Deployments {
if d != nil && d.Default {
return name
}
}
if _, ok := m.Deployments[DefaultDeploymentName]; ok {
return DefaultDeploymentName
}
if len(m.Deployments) == 1 {
for name := range m.Deployments {
return name
}
}
return ""
}
// SetDefaultDeployment sets the given deployment as the default, clearing the flag from all others.
func (m *Manifest) SetDefaultDeployment(name string) error {
d := m.Deployments[name]
if d == nil {
return fmt.Errorf("deployment '%s' does not exist", name)
}
for _, other := range m.Deployments {
if other != nil {
other.Default = false
}
}
d.Default = true
return nil
}
// DefaultMachineName is the name of the default machine into which the app is deployed when no
// specific machine is passed.
const DefaultMachineName = "default"
// Deployment describes a single ROFL app deployment.
type Deployment struct {
// Default indicates whether this is the default deployment.
Default bool `yaml:"default,omitempty" json:"default,omitempty"`
// AppID is the Bech32-encoded ROFL app ID.
AppID string `yaml:"app_id,omitempty" json:"app_id,omitempty"`
// Network is the identifier of the network to deploy to.
Network string `yaml:"network" json:"network"`
// ParaTime is the identifier of the paratime to deploy to.
ParaTime string `yaml:"paratime" json:"paratime"`
// Admin is the identifier of the admin account.
Admin string `yaml:"admin,omitempty" json:"admin,omitempty"`
// Debug is a flag denoting whether this is a debuggable deployment.
Debug bool `yaml:"debug,omitempty" json:"debug,omitempty"`
// OCIRepository is the optional OCI repository where one can push the ORC to.
OCIRepository string `yaml:"oci_repository,omitempty" json:"oci_repository,omitempty"`
// TrustRoot is the optional trust root configuration.
TrustRoot *TrustRootConfig `yaml:"trust_root,omitempty" json:"trust_root,omitempty"`
// Policy is the ROFL app policy.
Policy *AppAuthPolicy `yaml:"policy,omitempty" json:"policy,omitempty"`
// Metadata contains custom metadata.
Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
// Secrets contains encrypted secrets.
Secrets []*SecretConfig `yaml:"secrets,omitempty" json:"secrets,omitempty"`
// Machines are the machines on which app replicas are deployed.
Machines map[string]*Machine `yaml:"machines,omitempty" json:"machines,omitempty"`
}
// Validate validates the deployment for correctness.
func (d *Deployment) Validate() error {
if len(d.AppID) > 0 {
var appID rofl.AppID
if err := appID.UnmarshalText([]byte(d.AppID)); err != nil {
return fmt.Errorf("malformed app ID: %w", err)
}
}
if d.Network == "" {
return fmt.Errorf("network cannot be empty")
}
if d.ParaTime == "" {
return fmt.Errorf("paratime cannot be empty")
}
if d.Policy != nil {
err := d.Policy.Validate()
if err != nil {
return fmt.Errorf("bad app policy: %w", err)
}
}
for _, s := range d.Secrets {
if err := s.Validate(); err != nil {
return fmt.Errorf("bad secret: %w", err)
}
}
for name, machine := range d.Machines {
if err := machine.Validate(); err != nil {
return fmt.Errorf("bad machine '%s': %w", name, err)
}
}
return nil
}
// HasAppID returns true iff the deployment has an application identifier set.
func (d *Deployment) HasAppID() bool {
return len(d.AppID) > 0
}
// AppAuthPolicy is the per-application ROFL policy.
//
// This is a different type from `rofl.AppAuthPolicy` in order to add extra structure that makes it
// easier to configure without changing the on-chain representation.
type AppAuthPolicy struct {
// Quotes is a quote policy.
Quotes quote.Policy `json:"quotes" yaml:"quotes"`
// Enclaves is the set of allowed enclave identities.
Enclaves []*EnclaveIdentity `json:"enclaves" yaml:"enclaves"`
// Endorsements is the set of allowed endorsements.
Endorsements []rofl.AllowedEndorsement `json:"endorsements" yaml:"endorsements"`
// Fees is the gas fee payment policy.
Fees rofl.FeePolicy `json:"fees" yaml:"fees"`
// MaxExpiration is the maximum number of future epochs for which one can register.
MaxExpiration beacon.EpochTime `json:"max_expiration" yaml:"max_expiration"`
}
// Validate validates the policy for correctness.
func (p *AppAuthPolicy) Validate() error {
for idx, ei := range p.Enclaves {
if err := ei.Validate(); err != nil {
return fmt.Errorf("bad enclave identity %d: %w", idx, err)
}
}
return nil
}
// AsDescriptor converts the structure into an on-chain policy descriptor.
func (p *AppAuthPolicy) AsDescriptor() *rofl.AppAuthPolicy {
enclaves := make([]sgx.EnclaveIdentity, 0, len(p.Enclaves))
for _, ei := range p.Enclaves {
enclaves = append(enclaves, ei.ID)
}
return &rofl.AppAuthPolicy{
Quotes: p.Quotes,
Enclaves: enclaves,
Endorsements: p.Endorsements,
Fees: p.Fees,
MaxExpiration: p.MaxExpiration,
}
}
// EnclaveIdentity is the cryptographic enclave identity.
type EnclaveIdentity struct {
// ID is the enclave identity.
ID sgx.EnclaveIdentity `json:"id" yaml:"id"`
// Version is an optional version this enclave identity is for, with an empty value indicating
// the latest version.
//
// This can be used to keep historic versions in the current policy.
Version string `json:"version,omitempty" yaml:"version,omitempty"`
// Description is an optional description of an enclave identity.
Description string `json:"description,omitempty" yaml:"description,omitempty"`
}
// UnmarshalYAML implements yaml.Unmarshaler.
func (ei *EnclaveIdentity) UnmarshalYAML(value *yaml.Node) error {
switch value.ShortTag() {
case "!!str":
// Simple mode with just the enclave identity and no other information.
ei.Description = ""
ei.Version = ""
return value.Decode(&ei.ID)
default:
type enclaveIdentity EnclaveIdentity
return value.Decode((*enclaveIdentity)(ei))
}
}
// Validate validates the enclave identity for correctness.
func (ei *EnclaveIdentity) Validate() error {
if len(ei.Version) > 0 {
if _, err := version.FromString(ei.Version); err != nil {
return fmt.Errorf("malformed version: %w", err)
}
}
return nil
}
// IsLatest returns true iff the enclave identity is for the latest app version.
func (ei *EnclaveIdentity) IsLatest() bool {
return ei.Version == ""
}
// Machine is a hosted machine where a ROFL app is deployed.
type Machine struct {
// Provider is the address of the ROFL market provider to deploy to.
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
// Offer is the provider's offer identifier to provision.
Offer string `yaml:"offer,omitempty" json:"offer,omitempty"`
// ID is the identifier of the machine to deploy into.
ID string `yaml:"id,omitempty" json:"id,omitempty"`
// Permissions is a map of permissions for the machine.
Permissions map[string][]string `yaml:"permissions,omitempty" json:"permissions,omitempty"`
}
// Validate validates the machine for correctness.
func (m *Machine) Validate() error {
if m.Offer != "" && m.Provider == "" {
return fmt.Errorf("offer identifier cannot be specified without a provider")
}
if m.ID != "" && m.Provider == "" {
return fmt.Errorf("machine identifier cannot be specified without a provider")
}
return nil
}
// TrustRootConfig is the trust root configuration.
type TrustRootConfig struct {
// Height is the consensus layer block height where to take the trust root.
Height uint64 `yaml:"height,omitempty" json:"height,omitempty"`
// Hash is the consensus layer block header hash corresponding to the passed height.
Hash string `yaml:"hash,omitempty" json:"hash,omitempty"`
}
// ResourcesConfig is the resources configuration.
type ResourcesConfig struct {
// Memory is the amount of memory needed by the app in megabytes.
Memory uint64 `yaml:"memory" json:"memory"`
// CPUCount is the number of vCPUs needed by the app.
CPUCount uint8 `yaml:"cpus" json:"cpus"`
// Storage is the storage configuration.
Storage *StorageConfig `yaml:"storage,omitempty" json:"storage,omitempty"`
}
// Validate validates the resources configuration for correctness.
func (r *ResourcesConfig) Validate(tee string) error {
if r.Memory < 16 {
return fmt.Errorf("memory size must be at least 16M")
}
if r.CPUCount < 1 {
return fmt.Errorf("vCPU count must be at least 1")
}
if tee == TEETypeSGX && r.Storage != nil {
return fmt.Errorf("SGX apps do not support disk storage")
}
if r.Storage != nil {
err := r.Storage.Validate()
if err != nil {
return fmt.Errorf("bad storage config: %w", err)
}
}
return nil
}
// Supported storage kinds.
const (
StorageKindNone = "none"
StorageKindDiskEphemeral = "disk-ephemeral"
StorageKindDiskPersistent = "disk-persistent"
StorageKindRAM = "ram"
)
// StorageConfig is the storage configuration.
type StorageConfig struct {
// Kind is the storage kind.
Kind string `yaml:"kind" json:"kind"`
// Size is the amount of storage in megabytes.
Size uint64 `yaml:"size" json:"size"`
}
// Validate validates the storage configuration for correctness.
func (e *StorageConfig) Validate() error {
switch e.Kind {
case StorageKindNone, StorageKindDiskEphemeral, StorageKindDiskPersistent, StorageKindRAM:
default:
return fmt.Errorf("unsupported storage kind: %s", e.Kind)
}
if e.Size < 16 {
return fmt.Errorf("storage size must be at least 16M")
}
return nil
}
// ArtifactsConfig is the artifact location override configuration.
type ArtifactsConfig struct {
// Builder is the OCI reference to the builder container image. Empty to not use a builder.
Builder string `yaml:"builder,omitempty" json:"builder,omitempty"`
// Firmware is the URI/path to the firmware artifact.
Firmware string `yaml:"firmware,omitempty" json:"firmware,omitempty"`
// Kernel is the URI/path to the kernel artifact.
Kernel string `yaml:"kernel,omitempty" json:"kernel,omitempty"`
// Stage2 is the URI/path to the stage 2 disk artifact.
Stage2 string `yaml:"stage2,omitempty" json:"stage2,omitempty"`
// Container is the container artifacts configuration.
Container ContainerArtifactsConfig `yaml:"container,omitempty" json:"container,omitempty"`
}
type artifactUpgrade struct {
existing *string
new string
}
func upgradeArtifacts(upgrade []artifactUpgrade) bool {
var changed bool
for _, artifact := range upgrade {
if artifact.new == "" {
continue
}
if *artifact.existing == artifact.new {
continue
}
*artifact.existing = artifact.new
changed = true
}
return changed
}
// upgradePossible checks if any explicitly configured artifact differs from the latest.
//
// In contrast to upgradeArtifacts() empty existing fields will be assumed as up-to-date.
func upgradePossible(check []artifactUpgrade) bool {
for _, artifact := range check {
if artifact.new == "" {
continue
}
if *artifact.existing != "" && *artifact.existing != artifact.new {
return true
}
}
return false
}
// UpgradePossible returns true iff any explicitly set artifacts differ from latest.
// Empty fields are ignored (they use defaults from code, so already latest).
func (ac *ArtifactsConfig) UpgradePossible(latest *ArtifactsConfig) bool {
if upgradePossible([]artifactUpgrade{
{&ac.Builder, latest.Builder},
{&ac.Firmware, latest.Firmware},
{&ac.Kernel, latest.Kernel},
{&ac.Stage2, latest.Stage2},
}) {
return true
}
return ac.Container.UpgradePossible(&latest.Container)
}
// UpgradePossible returns true iff any explicitly set container artifacts differ from latest.
func (cc *ContainerArtifactsConfig) UpgradePossible(latest *ContainerArtifactsConfig) bool {
return upgradePossible([]artifactUpgrade{
{&cc.Compose, latest.Compose},
{&cc.Runtime, latest.Runtime},
})
}
// UpgradeTo upgrades the artifacts to the latest version by updating any relevant fields.
//
// Returns true iff any artifacts have been updated.
func (ac *ArtifactsConfig) UpgradeTo(latest *ArtifactsConfig) bool {
var changed bool
changed = upgradeArtifacts([]artifactUpgrade{
{&ac.Builder, latest.Builder},
{&ac.Firmware, latest.Firmware},
{&ac.Kernel, latest.Kernel},
{&ac.Stage2, latest.Stage2},
})
changed = ac.Container.UpgradeTo(&latest.Container) || changed
return changed
}
// ContainerArtifactsConfig is the container artifacts configuration.
type ContainerArtifactsConfig struct {
// Runtime is the URI/path to the container runtime artifact (empty to use default).
Runtime string `yaml:"runtime,omitempty" json:"runtime,omitempty"`
// Compose is the URI/path to the docker-compose.yaml artifact (empty to use default).
Compose string `yaml:"compose,omitempty" json:"compose,omitempty"`
}
// UpgradeTo upgrades the artifacts to the latest version by updating any relevant fields.
//
// Returns true iff any artifacts have been updated.
func (cc *ContainerArtifactsConfig) UpgradeTo(latest *ContainerArtifactsConfig) bool {
return upgradeArtifacts([]artifactUpgrade{
{&cc.Compose, latest.Compose},
{&cc.Runtime, latest.Runtime},
})
}