From ea4e8621f86a54f4b40934d528118f0e027d3f27 Mon Sep 17 00:00:00 2001
From: Jernej Kos <jernej@kos.mx>
Date: Thu, 12 Dec 2024 15:04:20 +0100
Subject: [PATCH] feat(cmd/rofl): Add TDX container build support

---
 build/rofl/manifest.go      | 218 +++++++++++++++++++
 build/rofl/manifest_test.go | 161 ++++++++++++++
 cmd/rofl/build/artifacts.go | 116 ++++++++---
 cmd/rofl/build/build.go     |  80 +++++++
 cmd/rofl/build/container.go | 132 ++++++++++++
 cmd/rofl/build/sgx.go       |  25 ++-
 cmd/rofl/build/tdx.go       | 405 +++++++++++++++++++++++++-----------
 cmd/rofl/common/identity.go |  57 +++++
 cmd/rofl/common/manifest.go |  68 ++++++
 cmd/rofl/identity.go        |  55 +----
 cmd/rofl/mgmt.go            |  75 +++++--
 11 files changed, 1172 insertions(+), 220 deletions(-)
 create mode 100644 build/rofl/manifest.go
 create mode 100644 build/rofl/manifest_test.go
 create mode 100644 cmd/rofl/build/container.go
 create mode 100644 cmd/rofl/common/identity.go
 create mode 100644 cmd/rofl/common/manifest.go

diff --git a/build/rofl/manifest.go b/build/rofl/manifest.go
new file mode 100644
index 00000000..5b890f83
--- /dev/null
+++ b/build/rofl/manifest.go
@@ -0,0 +1,218 @@
+package rofl
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"strings"
+
+	"gopkg.in/yaml.v3"
+
+	"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.yml",
+	"rofl.yaml",
+}
+
+// Supported ROFL app kinds.
+const (
+	AppKindRaw       = "raw"
+	AppKindContainer = "container"
+)
+
+// Supported TEE types.
+const (
+	TEETypeSGX = "sgx"
+	TEETypeTDX = "tdx"
+)
+
+// Manifest is the ROFL app manifest that configures various aspects of the app in a single place.
+type Manifest struct {
+	// AppID is the Bech32-encoded ROFL app ID.
+	AppID string `yaml:"app_id" json:"app_id"`
+	// 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"`
+	// Network is the identifier of the network to deploy to by default.
+	Network string `yaml:"network,omitempty" json:"network,omitempty"`
+	// ParaTime is the identifier of the paratime to deploy to by default.
+	ParaTime string `yaml:"paratime,omitempty" json:"paratime,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"`
+	// TrustRoot is the optional trust root configuration.
+	TrustRoot *TrustRootConfig `yaml:"trust_root,omitempty" json:"trust_root,omitempty"`
+	// 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"`
+
+	// Policy is the ROFL app policy to deploy by default.
+	Policy *rofl.AppAuthPolicy `yaml:"policy,omitempty" json:"policy,omitempty"`
+	// Admin is the identifier of the admin account.
+	Admin string `yaml:"admin,omitempty" json:"admin,omitempty"`
+}
+
+// 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)
+		}
+
+		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.AppID) == 0 {
+		return fmt.Errorf("app ID cannot be empty")
+	}
+	var appID rofl.AppID
+	if err := appID.UnmarshalText([]byte(m.AppID)); err != nil {
+		return fmt.Errorf("malformed app ID: %w", err)
+	}
+
+	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)
+	}
+
+	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(); err != nil {
+		return fmt.Errorf("bad resources config: %w", err)
+	}
+
+	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"`
+	// EphemeralStorage is the ephemeral storage configuration.
+	EphemeralStorage *EphemeralStorageConfig `yaml:"ephemeral_storage,omitempty" json:"ephemeral_storage,omitempty"`
+}
+
+// Validate validates the resources configuration for correctness.
+func (r *ResourcesConfig) Validate() 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 r.EphemeralStorage != nil {
+		err := r.EphemeralStorage.Validate()
+		if err != nil {
+			return fmt.Errorf("bad ephemeral storage config: %w", err)
+		}
+	}
+	return nil
+}
+
+// Supported ephemeral storage kinds.
+const (
+	EphemeralStorageKindNone = "none"
+	EphemeralStorageKindDisk = "disk"
+	EphemeralStorageKindRAM  = "ram"
+)
+
+// EphemeralStorageConfig is the ephemeral storage configuration.
+type EphemeralStorageConfig struct {
+	// Kind is the storage kind.
+	Kind string `yaml:"kind" json:"kind"`
+	// Size is the amount of ephemeral storage in megabytes.
+	Size uint64 `yaml:"size" json:"size"`
+}
+
+// Validate validates the ephemeral storage configuration for correctness.
+func (e *EphemeralStorageConfig) Validate() error {
+	switch e.Kind {
+	case EphemeralStorageKindNone, EphemeralStorageKindDisk, EphemeralStorageKindRAM:
+	default:
+		return fmt.Errorf("unsupported ephemeral storage kind: %s", e.Kind)
+	}
+
+	if e.Size < 16 {
+		return fmt.Errorf("ephemeral storage size must be at least 16M")
+	}
+	return nil
+}
+
+// ArtifactsConfig is the artifact location override configuration.
+type ArtifactsConfig struct {
+	// Firmware is the URI/path to the firmware artifact (empty to use default).
+	Firmware string `yaml:"firmware,omitempty" json:"firmware,omitempty"`
+	// Kernel is the URI/path to the kernel artifact (empty to use default).
+	Kernel string `yaml:"kernel,omitempty" json:"kernel,omitempty"`
+	// Stage2 is the URI/path to the stage 2 disk artifact (empty to use default).
+	Stage2 string `yaml:"stage2,omitempty" json:"stage2,omitempty"`
+	// Container is the container artifacts configuration.
+	Container ContainerArtifactsConfig `yaml:"container,omitempty" json:"container,omitempty"`
+}
+
+// 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"`
+}
diff --git a/build/rofl/manifest_test.go b/build/rofl/manifest_test.go
new file mode 100644
index 00000000..3413a2ce
--- /dev/null
+++ b/build/rofl/manifest_test.go
@@ -0,0 +1,161 @@
+package rofl
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"gopkg.in/yaml.v3"
+)
+
+func TestManifestValidation(t *testing.T) {
+	require := require.New(t)
+
+	// Empty manifest is not valid.
+	m := Manifest{}
+	err := m.Validate()
+	require.ErrorContains(err, "app ID cannot be empty")
+
+	// Invalid app ID.
+	m.AppID = "foo"
+	err = m.Validate()
+	require.ErrorContains(err, "malformed app ID")
+
+	// Empty name.
+	m.AppID = "rofl1qpa9ydy3qmka3yrqzx0pxuvyfexf9mlh75hker5j"
+	err = m.Validate()
+	require.ErrorContains(err, "name cannot be empty")
+
+	// Empty version.
+	m.Name = "my-simple-app"
+	err = m.Validate()
+	require.ErrorContains(err, "version cannot be empty")
+
+	// Invalid version.
+	m.Version = "foo"
+	err = m.Validate()
+	require.ErrorContains(err, "malformed version")
+
+	// Unsupported TEE type.
+	m.Version = "0.1.0"
+	err = m.Validate()
+	require.ErrorContains(err, "unsupported TEE type")
+
+	// Unsupported app kind.
+	m.TEE = "sgx"
+	err = m.Validate()
+	require.ErrorContains(err, "unsupported app kind")
+
+	// Containers are only supported under TDX.
+	m.Kind = "container"
+	err = m.Validate()
+	require.ErrorContains(err, "containers are only supported under TDX")
+
+	// Bad resources configuration.
+	m.TEE = "tdx"
+	err = m.Validate()
+	require.ErrorContains(err, "bad resources config: memory size must be at least 16M")
+
+	m.Resources.Memory = 16
+	err = m.Validate()
+	require.ErrorContains(err, "bad resources config: vCPU count must be at least 1")
+
+	// Finally, everything is valid.
+	m.Resources.CpuCount = 1
+	err = m.Validate()
+	require.NoError(err)
+
+	// Add ephemeral storage configuration.
+	m.Resources.EphemeralStorage = &EphemeralStorageConfig{}
+	err = m.Validate()
+	require.ErrorContains(err, "bad resources config: bad ephemeral storage config: unsupported ephemeral storage kind")
+
+	m.Resources.EphemeralStorage.Kind = "ram"
+	err = m.Validate()
+	require.ErrorContains(err, "bad resources config: bad ephemeral storage config: ephemeral storage size must be at least 16M")
+
+	m.Resources.EphemeralStorage.Size = 16
+	err = m.Validate()
+	require.NoError(err)
+}
+
+const serializedYamlManifest = `
+app_id: rofl1qpa9ydy3qmka3yrqzx0pxuvyfexf9mlh75hker5j
+name: my-simple-app
+version: 0.1.0
+tee: tdx
+kind: container
+resources:
+    memory: 16
+    cpus: 1
+    ephemeral_storage:
+        kind: ram
+        size: 16
+`
+
+func TestManifestSerialization(t *testing.T) {
+	require := require.New(t)
+
+	var m Manifest
+	err := yaml.Unmarshal([]byte(serializedYamlManifest), &m)
+	require.NoError(err, "yaml.Unmarshal")
+	err = m.Validate()
+	require.NoError(err, "m.Validate")
+	require.Equal("rofl1qpa9ydy3qmka3yrqzx0pxuvyfexf9mlh75hker5j", m.AppID)
+	require.Equal("my-simple-app", m.Name)
+	require.Equal("0.1.0", m.Version)
+	require.Equal("tdx", m.TEE)
+	require.Equal("container", m.Kind)
+	require.EqualValues(16, m.Resources.Memory)
+	require.EqualValues(1, m.Resources.CpuCount)
+	require.NotNil(m.Resources.EphemeralStorage)
+	require.Equal("ram", m.Resources.EphemeralStorage.Kind)
+	require.EqualValues(16, m.Resources.EphemeralStorage.Size)
+
+	enc, err := yaml.Marshal(m)
+	require.NoError(err, "yaml.Marshal")
+
+	var dec Manifest
+	err = yaml.Unmarshal(enc, &dec)
+	require.NoError(err, "yaml.Unmarshal(round-trip)")
+	require.EqualValues(m, dec, "serialization should round-trip")
+	err = dec.Validate()
+	require.NoError(err, "dec.Validate")
+}
+
+func TestLoadManifest(t *testing.T) {
+	require := require.New(t)
+
+	tmpDir, err := os.MkdirTemp("", "oasis-test-load-manifest")
+	require.NoError(err)
+	defer os.RemoveAll(tmpDir)
+
+	err = os.Chdir(tmpDir)
+	require.NoError(err)
+
+	_, err = LoadManifest()
+	require.ErrorIs(err, "no ROFL app manifest found")
+
+	manifestFn := filepath.Join(tmpDir, "rofl.yml")
+	err = os.WriteFile(manifestFn, []byte("foo"), 0o644)
+	require.NoError(err)
+	_, err = LoadManifest()
+	require.ErrorContains(err, "malformed manifest 'rofl.yml'")
+
+	err = os.WriteFile(manifestFn, []byte(serializedYamlManifest), 0o644)
+	require.NoError(err)
+	m, err := LoadManifest()
+	require.NoError(err)
+	require.Equal("rofl1qpa9ydy3qmka3yrqzx0pxuvyfexf9mlh75hker5j", m.AppID)
+
+	err = os.Remove(manifestFn)
+	require.NoError(err)
+
+	manifestFn = "rofl.yaml"
+	err = os.WriteFile(manifestFn, []byte(serializedYamlManifest), 0o644)
+	require.NoError(err)
+	m, err = LoadManifest()
+	require.NoError(err)
+	require.Equal("rofl1qpa9ydy3qmka3yrqzx0pxuvyfexf9mlh75hker5j", m.AppID)
+}
diff --git a/cmd/rofl/build/artifacts.go b/cmd/rofl/build/artifacts.go
index 54228f2d..bb341c13 100644
--- a/cmd/rofl/build/artifacts.go
+++ b/cmd/rofl/build/artifacts.go
@@ -4,6 +4,7 @@ import (
 	"archive/tar"
 	"compress/bzip2"
 	"crypto/sha256"
+	"encoding/hex"
 	"errors"
 	"fmt"
 	"io"
@@ -25,24 +26,32 @@ import (
 const artifactCacheDir = "build_cache"
 
 // maybeDownloadArtifact downloads the given artifact and optionally verifies its integrity against
-// the provided hash.
-func maybeDownloadArtifact(kind, uri, knownHash string) string {
+// the hash provided in the URI fragment.
+func maybeDownloadArtifact(kind, uri string) string {
 	fmt.Printf("Downloading %s artifact...\n", kind)
 	fmt.Printf("  URI: %s\n", uri)
-	if knownHash != "" {
-		fmt.Printf("  Hash: %s\n", knownHash)
-	}
 
 	url, err := url.Parse(uri)
 	if err != nil {
 		cobra.CheckErr(fmt.Errorf("failed to parse %s artifact URL: %w", kind, err))
 	}
 
-	// In case the URI represents a local file, just return it.
+	// In case the URI represents a local file, check that it exists and return it.
 	if url.Host == "" {
+		_, err = os.Stat(url.Path)
+		cobra.CheckErr(err)
 		return url.Path
 	}
 
+	// If the URI contains a fragment and the known hash is empty, treat it as a known hash.
+	var knownHash string
+	if url.Fragment != "" {
+		knownHash = url.Fragment
+	}
+	if knownHash != "" {
+		fmt.Printf("  Hash: %s\n", knownHash)
+	}
+
 	// TODO: Prune cache.
 	cacheHash := hash.NewFromBytes([]byte(uri)).Hex()
 	cacheFn, err := xdg.CacheFile(filepath.Join("oasis", artifactCacheDir, cacheHash))
@@ -192,6 +201,11 @@ FILES:
 
 // copyFile copies the file at path src to a file at path dst using the given mode.
 func copyFile(src, dst string, mode os.FileMode) error {
+	err := os.MkdirAll(filepath.Dir(dst), 0o755)
+	if err != nil {
+		return fmt.Errorf("failed to create destination directory for '%s': %w", dst, err)
+	}
+
 	sf, err := os.Open(src)
 	if err != nil {
 		return fmt.Errorf("failed to open '%s': %w", src, err)
@@ -204,8 +218,16 @@ func copyFile(src, dst string, mode os.FileMode) error {
 	}
 	defer df.Close()
 
-	_, err = io.Copy(df, sf)
-	return err
+	if _, err = io.Copy(df, sf); err != nil {
+		return fmt.Errorf("failed to copy '%s': %w", src, err)
+	}
+
+	// Ensure times are constant for deterministic builds.
+	if err = extractChtimes(dst, time.Time{}, time.Time{}); err != nil {
+		return fmt.Errorf("failed to change atime/mtime for '%s': %w", dst, err)
+	}
+
+	return nil
 }
 
 // computeDirSize computes the size of the given directory.
@@ -237,35 +259,33 @@ func ensureBinaryExists(name, pkg string) error {
 	return nil
 }
 
-// createExt4Fs creates an ext4 filesystem in the given file using directory dir to populate it.
+// createSquashFs creates a squashfs filesystem in the given file using directory dir to populate
+// it.
 //
 // Returns the size of the created filesystem image in bytes.
-func createExt4Fs(fn, dir string) (int64, error) {
-	const mkfsExt4Bin = "mkfs.ext4"
-	if err := ensureBinaryExists(mkfsExt4Bin, "e2fsprogs"); err != nil {
+func createSquashFs(fn, dir string) (int64, error) {
+	const mkSquashFsBin = "mksquashfs"
+	if err := ensureBinaryExists(mkSquashFsBin, "squashfs-tools"); err != nil {
 		return 0, err
 	}
 
-	// Compute filesystem size in bytes.
-	fsSize, err := computeDirSize(dir)
-	if err != nil {
-		return 0, err
-	}
-	fsSize /= 1024                // Convert to kilobytes.
-	fsSize = (fsSize * 150) / 100 // Scale by overhead factor of 1.5.
-
 	// Execute mkfs.ext4.
 	cmd := exec.Command( //nolint:gosec
-		mkfsExt4Bin,
-		"-E", "root_owner=0:0",
-		"-d", dir,
+		mkSquashFsBin,
+		dir,
 		fn,
-		fmt.Sprintf("%dK", fsSize),
+		"-comp", "gzip",
+		"-noappend",
+		"-mkfs-time", "1234",
+		"-all-time", "1234",
+		"-root-time", "1234",
+		"-all-root",
+		"-reproducible",
 	)
 	var out strings.Builder
 	cmd.Stderr = &out
 	cmd.Stdout = &out
-	if err = cmd.Run(); err != nil {
+	if err := cmd.Run(); err != nil {
 		return 0, fmt.Errorf("%w\n%s", err, out.String())
 	}
 
@@ -285,12 +305,26 @@ func createVerityHashTree(fsFn, hashFn string) (string, error) {
 		return "", err
 	}
 
+	// Generate a deterministic salt by hashing the filesystem.
+	f, err := os.Open(fsFn)
+	if err != nil {
+		return "", fmt.Errorf("failed to open filesystem file: %w", err)
+	}
+	defer f.Close()
+	h := sha256.New()
+	if _, err = io.Copy(h, f); err != nil {
+		return "", fmt.Errorf("failed to read filesystem file: %w", err)
+	}
+	salt := h.Sum([]byte{})
+
 	rootHashFn := hashFn + ".roothash"
 
 	cmd := exec.Command( //nolint:gosec
 		veritysetupBin, "format",
 		"--data-block-size=4096",
 		"--hash-block-size=4096",
+		"--uuid=00000000-0000-0000-0000-000000000000",
+		"--salt="+hex.EncodeToString(salt),
 		"--root-hash-file="+rootHashFn,
 		fsFn,
 		hashFn,
@@ -326,3 +360,35 @@ func concatFiles(a, b string) error {
 	_, err = io.Copy(df, sf)
 	return err
 }
+
+// appendEmptySpace appends empty space to the given file. If the filesystem supports sparse files,
+// this should not actually take any extra space.
+//
+// The function ensures that the given space respects alignment by adding padding as needed.
+//
+// Returns the offset where the empty space starts.
+func appendEmptySpace(fn string, size uint64, align uint64) (uint64, error) {
+	f, err := os.OpenFile(fn, os.O_RDWR, 0644)
+	if err != nil {
+		return 0, err
+	}
+	defer f.Close()
+
+	fi, err := f.Stat()
+	if err != nil {
+		return 0, err
+	}
+	offset := uint64(fi.Size())
+
+	// Ensure proper alignment.
+	if size % align != 0 {
+		return 0, fmt.Errorf("size is not properly aligned")
+	}
+	offset += (align - (offset % align)) % align
+
+	if err = f.Truncate(int64(offset + size)); err != nil {
+		return 0, err
+	}
+
+	return offset, nil
+}
diff --git a/cmd/rofl/build/build.go b/cmd/rofl/build/build.go
index 466497e8..0ecfb4b4 100644
--- a/cmd/rofl/build/build.go
+++ b/cmd/rofl/build/build.go
@@ -2,14 +2,19 @@ package build
 
 import (
 	"context"
+	"encoding/base64"
 	"fmt"
+	"os"
 
 	"github.com/spf13/cobra"
 	flag "github.com/spf13/pflag"
 
+	coreCommon "github.com/oasisprotocol/oasis-core/go/common"
+	"github.com/oasisprotocol/oasis-core/go/common/cbor"
 	consensus "github.com/oasisprotocol/oasis-core/go/consensus/api"
 	"github.com/oasisprotocol/oasis-sdk/client-sdk/go/connection"
 
+	buildRofl "github.com/oasisprotocol/cli/build/rofl"
 	"github.com/oasisprotocol/cli/cmd/common"
 )
 
@@ -54,6 +59,81 @@ func detectBuildMode(npa *common.NPASelection) {
 	}
 }
 
+func setupBuildEnv(manifest *buildRofl.Manifest, npa *common.NPASelection) {
+	if manifest == nil {
+		return
+	}
+
+	// Configure app ID.
+	os.Setenv("ROFL_APP_ID", manifest.AppID)
+
+	// Obtain and configure trust root.
+	trustRoot, err := fetchTrustRoot(npa, manifest.TrustRoot)
+	cobra.CheckErr(err)
+	os.Setenv("ROFL_CONSENSUS_TRUST_ROOT", trustRoot)
+}
+
+// fetchTrustRoot fetches the trust root based on configuration and returns a serialized version
+// suitable for inclusion as an environment variable.
+func fetchTrustRoot(npa *common.NPASelection, cfg *buildRofl.TrustRootConfig) (string, error) {
+	var (
+		height int64
+		hash   string
+	)
+	switch {
+	case cfg == nil || cfg.Hash == "":
+		// Hash is not known, we need to fetch it if not in offline mode.
+		if offline {
+			return "", fmt.Errorf("trust root hash not available in manifest while in offline mode")
+		}
+
+		// Establish connection with the target network.
+		ctx := context.Background()
+		conn, err := connection.Connect(ctx, npa.Network)
+		if err != nil {
+			return "", err
+		}
+
+		switch cfg {
+		case nil:
+			// Use latest height.
+			height, err = common.GetActualHeight(ctx, conn.Consensus())
+			if err != nil {
+				return "", err
+			}
+		default:
+			// Use configured height.
+			height = int64(cfg.Height)
+		}
+
+		blk, err := conn.Consensus().GetBlock(ctx, height)
+		if err != nil {
+			return "", err
+		}
+		hash = blk.Hash.Hex()
+	default:
+		// Hash is known, just use it.
+		height = int64(cfg.Height)
+		hash = cfg.Hash
+	}
+
+	// TODO: Move this structure to Core.
+	type trustRoot struct {
+		Height       uint64               `json:"height"`
+		Hash         string               `json:"hash"`
+		RuntimeID    coreCommon.Namespace `json:"runtime_id"`
+		ChainContext string               `json:"chain_context"`
+	}
+	root := trustRoot{
+		Height:       uint64(height),
+		Hash:         hash,
+		RuntimeID:    npa.ParaTime.Namespace(),
+		ChainContext: npa.Network.ChainContext,
+	}
+	encRoot := cbor.Marshal(root)
+	return base64.StdEncoding.EncodeToString(encRoot), nil
+}
+
 func init() {
 	globalFlags := flag.NewFlagSet("", flag.ContinueOnError)
 	globalFlags.StringVar(&buildMode, "mode", "auto", "build mode [production, unsafe, auto]")
diff --git a/cmd/rofl/build/container.go b/cmd/rofl/build/container.go
new file mode 100644
index 00000000..33d5bde5
--- /dev/null
+++ b/cmd/rofl/build/container.go
@@ -0,0 +1,132 @@
+package build
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/spf13/cobra"
+	flag "github.com/spf13/pflag"
+
+	"github.com/oasisprotocol/oasis-core/go/common/version"
+	"github.com/oasisprotocol/oasis-core/go/runtime/bundle"
+
+	"github.com/oasisprotocol/cli/cmd/common"
+	roflCommon "github.com/oasisprotocol/cli/cmd/rofl/common"
+	cliConfig "github.com/oasisprotocol/cli/config"
+)
+
+const (
+	artifactContainerRuntime = "rofl-container runtime"
+	artifactContainerCompose = "compose.yaml"
+
+	defaultContainerStage2TemplateURI = "https://github.com/oasisprotocol/oasis-boot/releases/download/v0.3.0/stage2-podman.tar.bz2"
+
+	defaultContainerRuntimeURI = "https://github.com/oasisprotocol/oasis-sdk/releases/download/rofl-containers/v0.1.0/runtime"
+)
+
+var (
+	tdxContainerRuntimeURI string
+	tdxContainerComposeURI string
+
+	tdxContainerCmd = &cobra.Command{
+		Use:   "container",
+		Short: "Build a container-based TDX ROFL application",
+		Args:  cobra.NoArgs,
+		Run: func(_ *cobra.Command, _ []string) {
+			cfg := cliConfig.Global()
+			npa := common.GetNPASelection(cfg)
+			manifest := roflCommon.LoadManifestAndSetNPA(cfg, npa)
+
+			wantedArtifacts := tdxGetDefaultArtifacts()
+			wantedArtifacts = append(wantedArtifacts,
+				&artifact{
+					kind: artifactContainerRuntime,
+					uri:  tdxContainerRuntimeURI,
+				},
+				&artifact{
+					kind: artifactContainerCompose,
+					uri:  tdxContainerComposeURI,
+				},
+			)
+			tdxOverrideArtifacts(manifest, wantedArtifacts)
+			artifacts := tdxFetchArtifacts(wantedArtifacts)
+
+			fmt.Println("Building a container-based TDX ROFL application...")
+
+			detectBuildMode(npa)
+
+			// Start creating the bundle early so we can fail before building anything.
+			bnd := &bundle.Bundle{
+				Manifest: &bundle.Manifest{
+					Name: manifest.Name,
+					ID:   npa.ParaTime.Namespace(),
+				},
+			}
+			var err error
+			bnd.Manifest.Version, err = version.FromString(manifest.Version)
+			if err != nil {
+				cobra.CheckErr(fmt.Errorf("unsupported package version format: %w", err))
+			}
+
+			fmt.Printf("App ID:  %s\n", manifest.AppID)
+			fmt.Printf("Name:    %s\n", bnd.Manifest.Name)
+			fmt.Printf("Version: %s\n", bnd.Manifest.Version)
+
+			// Use the pre-built container runtime.
+			initPath := artifacts[artifactContainerRuntime]
+
+			stage2, err := tdxPrepareStage2(artifacts, initPath, map[string]string{
+				artifacts[artifactContainerCompose]: "etc/oasis/containers/compose.yaml",
+			})
+			cobra.CheckErr(err)
+			defer os.RemoveAll(stage2.tmpDir)
+
+			// Configure app ID.
+			var extraKernelOpts []string
+			extraKernelOpts = append(extraKernelOpts,
+				fmt.Sprintf("ROFL_APP_ID=%s", manifest.AppID),
+			)
+
+			// Obtain and configure trust root.
+			trustRoot, err := fetchTrustRoot(npa, manifest.TrustRoot)
+			if err != nil {
+				_ = os.RemoveAll(stage2.tmpDir)
+				cobra.CheckErr(err)
+			}
+			extraKernelOpts = append(extraKernelOpts,
+				fmt.Sprintf("ROFL_CONSENSUS_TRUST_ROOT=%s", trustRoot),
+			)
+
+			fmt.Println("Creating ORC bundle...")
+
+			outFn, err := tdxBundleComponent(manifest, artifacts, bnd, stage2, extraKernelOpts)
+			if err != nil {
+				_ = os.RemoveAll(stage2.tmpDir)
+				cobra.CheckErr(err)
+			}
+
+			fmt.Println("Computing enclave identity...")
+
+			eids, err := roflCommon.ComputeEnclaveIdentity(bnd, "")
+			cobra.CheckErr(err)
+
+			fmt.Println("Update the manifest with the following identities to use the new app:")
+			fmt.Println()
+			for _, enclaveID := range eids {
+				data, _ := enclaveID.MarshalText()
+				fmt.Printf("- \"%s\"\n", string(data))
+			}
+			fmt.Println()
+
+			fmt.Printf("ROFL app built and bundle written to '%s'.\n", outFn)
+		},
+	}
+)
+
+func init() {
+	tdxContainerFlags := flag.NewFlagSet("", flag.ContinueOnError)
+	tdxContainerFlags.StringVar(&tdxContainerRuntimeURI, "runtime", defaultContainerRuntimeURI, "URL or path to runtime binary")
+	tdxContainerFlags.StringVar(&tdxContainerComposeURI, "compose", "compose.yaml", "URL or path to compose.yaml")
+
+	tdxContainerCmd.Flags().AddFlagSet(tdxContainerFlags)
+}
diff --git a/cmd/rofl/build/sgx.go b/cmd/rofl/build/sgx.go
index 21251ab2..bf27045f 100644
--- a/cmd/rofl/build/sgx.go
+++ b/cmd/rofl/build/sgx.go
@@ -20,8 +20,10 @@ import (
 	"github.com/oasisprotocol/oasis-core/go/runtime/bundle/component"
 
 	"github.com/oasisprotocol/cli/build/cargo"
+	buildRofl "github.com/oasisprotocol/cli/build/rofl"
 	"github.com/oasisprotocol/cli/build/sgxs"
 	"github.com/oasisprotocol/cli/cmd/common"
+	roflCommon "github.com/oasisprotocol/cli/cmd/rofl/common"
 	cliConfig "github.com/oasisprotocol/cli/config"
 )
 
@@ -37,6 +39,7 @@ var (
 		Run: func(_ *cobra.Command, _ []string) {
 			cfg := cliConfig.Global()
 			npa := common.GetNPASelection(cfg)
+			manifest, _ := roflCommon.MaybeLoadManifestAndSetNPA(cfg, npa)
 
 			if npa.ParaTime == nil {
 				cobra.CheckErr("no ParaTime selected")
@@ -47,7 +50,7 @@ var (
 			fmt.Println("Building an SGX-based Rust ROFL application...")
 
 			detectBuildMode(npa)
-			features := sgxSetupBuildEnv()
+			features := sgxSetupBuildEnv(manifest, npa)
 
 			// Obtain package metadata.
 			pkgMeta, err := cargo.GetMetadata()
@@ -58,11 +61,21 @@ var (
 			// Start creating the bundle early so we can fail before building anything.
 			bnd := &bundle.Bundle{
 				Manifest: &bundle.Manifest{
-					Name: pkgMeta.Name,
-					ID:   npa.ParaTime.Namespace(),
+					ID: npa.ParaTime.Namespace(),
 				},
 			}
-			bnd.Manifest.Version, err = version.FromString(pkgMeta.Version)
+			var rawVersion string
+			switch manifest {
+			case nil:
+				// No ROFL app manifest, use Cargo manifest.
+				bnd.Manifest.Name = pkgMeta.Name
+				rawVersion = pkgMeta.Version
+			default:
+				// Use ROFL app manifest.
+				bnd.Manifest.Name = manifest.Name
+				rawVersion = manifest.Version
+			}
+			bnd.Manifest.Version, err = version.FromString(rawVersion)
 			if err != nil {
 				cobra.CheckErr(fmt.Errorf("unsupported package version format: %w", err))
 			}
@@ -259,7 +272,9 @@ NextSetOfPrimes:
 }
 
 // sgxSetupBuildEnv sets up the SGX build environment and returns the list of features to enable.
-func sgxSetupBuildEnv() []string {
+func sgxSetupBuildEnv(manifest *buildRofl.Manifest, npa *common.NPASelection) []string {
+	setupBuildEnv(manifest, npa)
+
 	switch buildMode {
 	case buildModeProduction, buildModeAuto:
 		// Production builds.
diff --git a/cmd/rofl/build/tdx.go b/cmd/rofl/build/tdx.go
index 1c2aaf43..50330288 100644
--- a/cmd/rofl/build/tdx.go
+++ b/cmd/rofl/build/tdx.go
@@ -14,7 +14,9 @@ import (
 	"github.com/oasisprotocol/oasis-core/go/runtime/bundle/component"
 
 	"github.com/oasisprotocol/cli/build/cargo"
+	buildRofl "github.com/oasisprotocol/cli/build/rofl"
 	"github.com/oasisprotocol/cli/cmd/common"
+	roflCommon "github.com/oasisprotocol/cli/cmd/rofl/common"
 	cliConfig "github.com/oasisprotocol/cli/config"
 )
 
@@ -24,28 +26,22 @@ const (
 	artifactKernel   = "kernel"
 	artifactStage2   = "stage 2 template"
 
-	defaultFirmwareURI       = "https://github.com/oasisprotocol/oasis-boot/releases/download/v0.2.0/ovmf.tdx.fd"
-	defaultKernelURI         = "https://github.com/oasisprotocol/oasis-boot/releases/download/v0.2.0/stage1.bin"
-	defaultStage2TemplateURI = "https://github.com/oasisprotocol/oasis-boot/releases/download/v0.2.0/stage2-basic.tar.bz2"
+	defaultFirmwareURI       = "https://github.com/oasisprotocol/oasis-boot/releases/download/v0.2.0/ovmf.tdx.fd#db47100a7d6a0c1f6983be224137c3f8d7cb09b63bb1c7a5ee7829d8e994a42f"
+	defaultKernelURI         = "https://github.com/oasisprotocol/oasis-boot/releases/download/v0.2.0/stage1.bin#0c4a74af5e3860e1b9c79b38aff9de8c59aa92f14da715fbfd04a9362ee4cd59"
+	defaultStage2TemplateURI = "https://github.com/oasisprotocol/oasis-boot/releases/download/v0.2.0/stage2-basic.tar.bz2#8cbc67e4a05b01e6fc257a3ef378db50ec230bc4c7aacbfb9abf0f5b17dcb8fd"
 )
 
-var knownHashes = map[string]string{
-	defaultFirmwareURI:       "db47100a7d6a0c1f6983be224137c3f8d7cb09b63bb1c7a5ee7829d8e994a42f",
-	defaultKernelURI:         "0c4a74af5e3860e1b9c79b38aff9de8c59aa92f14da715fbfd04a9362ee4cd59",
-	defaultStage2TemplateURI: "8cbc67e4a05b01e6fc257a3ef378db50ec230bc4c7aacbfb9abf0f5b17dcb8fd",
-}
-
 var (
-	tdxFirmwareURI        string
-	tdxFirmwareHash       string
-	tdxKernelURI          string
-	tdxKernelHash         string
-	tdxStage2TemplateURI  string
-	tdxStage2TemplateHash string
+	tdxFirmwareURI       string
+	tdxKernelURI         string
+	tdxStage2TemplateURI string
 
 	tdxResourcesMemory   uint64
 	tdxResourcesCPUCount uint8
 
+	tdxTmpStorageMode string
+	tdxTmpStorageSize uint64
+
 	tdxCmd = &cobra.Command{
 		Use:   "tdx",
 		Short: "Build a TDX-based ROFL application",
@@ -53,34 +49,20 @@ var (
 		Run: func(_ *cobra.Command, _ []string) {
 			cfg := cliConfig.Global()
 			npa := common.GetNPASelection(cfg)
+			manifest, _ := roflCommon.MaybeLoadManifestAndSetNPA(cfg, npa)
 
 			if npa.ParaTime == nil {
 				cobra.CheckErr("no ParaTime selected")
 			}
 
-			// Obtain required artifacts.
-			artifacts := make(map[string]string)
-			for _, ar := range []struct {
-				kind      string
-				uri       string
-				knownHash string
-			}{
-				{artifactFirmware, tdxFirmwareURI, tdxFirmwareHash},
-				{artifactKernel, tdxKernelURI, tdxKernelHash},
-				{artifactStage2, tdxStage2TemplateURI, tdxStage2TemplateHash},
-			} {
-				// Automatically populate known hashes for known URIs.
-				if ar.knownHash == "" {
-					ar.knownHash = knownHashes[ar.uri]
-				}
-
-				artifacts[ar.kind] = maybeDownloadArtifact(ar.kind, ar.uri, ar.knownHash)
-			}
+			wantedArtifacts := tdxGetDefaultArtifacts()
+			tdxOverrideArtifacts(manifest, wantedArtifacts)
+			artifacts := tdxFetchArtifacts(wantedArtifacts)
 
 			fmt.Println("Building a TDX-based Rust ROFL application...")
 
 			detectBuildMode(npa)
-			tdxSetupBuildEnv()
+			tdxSetupBuildEnv(manifest, npa)
 
 			// Obtain package metadata.
 			pkgMeta, err := cargo.GetMetadata()
@@ -102,11 +84,21 @@ var (
 			// Start creating the bundle early so we can fail before building anything.
 			bnd := &bundle.Bundle{
 				Manifest: &bundle.Manifest{
-					Name: pkgMeta.Name,
-					ID:   npa.ParaTime.Namespace(),
+					ID: npa.ParaTime.Namespace(),
 				},
 			}
-			bnd.Manifest.Version, err = version.FromString(pkgMeta.Version)
+			var rawVersion string
+			switch manifest {
+			case nil:
+				// No ROFL app manifest, use Cargo manifest.
+				bnd.Manifest.Name = pkgMeta.Name
+				rawVersion = pkgMeta.Version
+			default:
+				// Use ROFL app manifest.
+				bnd.Manifest.Name = manifest.Name
+				rawVersion = manifest.Version
+			}
+			bnd.Manifest.Version, err = version.FromString(rawVersion)
 			if err != nil {
 				cobra.CheckErr(fmt.Errorf("unsupported package version format: %w", err))
 			}
@@ -120,108 +112,262 @@ var (
 				cobra.CheckErr(fmt.Errorf("failed to build runtime binary: %w", err))
 			}
 
-			// Create temporary directory and unpack stage 2 template into it.
-			fmt.Println("Preparing stage 2 root filesystem...")
-			tmpDir, err := os.MkdirTemp("", "oasis-build-stage2")
+			stage2, err := tdxPrepareStage2(artifacts, initPath, nil)
 			if err != nil {
-				cobra.CheckErr(fmt.Errorf("failed to create temporary stage 2 build directory: %w", err))
+				cobra.CheckErr(err)
 			}
-			defer os.RemoveAll(tmpDir) // TODO: This doesn't work because of cobra.CheckErr
+			defer os.RemoveAll(stage2.tmpDir)
 
-			rootfsDir := filepath.Join(tmpDir, "rootfs")
-			if err = os.Mkdir(rootfsDir, 0o755); err != nil {
-				cobra.CheckErr(fmt.Errorf("failed to create temporary rootfs directory: %w", err))
-			}
+			fmt.Println("Creating ORC bundle...")
 
-			// Unpack template into temporary directory.
-			fmt.Println("Unpacking template...")
-			if err = extractArchive(artifacts[artifactStage2], rootfsDir); err != nil {
-				cobra.CheckErr(fmt.Errorf("failed to extract stage 2 template: %w", err))
+			outFn, err := tdxBundleComponent(manifest, artifacts, bnd, stage2, nil)
+			if err != nil {
+				_ = os.RemoveAll(stage2.tmpDir)
+				cobra.CheckErr(err)
 			}
 
-			// Add runtime as init.
-			fmt.Println("Adding runtime as init...")
-			err = copyFile(initPath, filepath.Join(rootfsDir, "init"), 0o755)
-			cobra.CheckErr(err)
+			fmt.Printf("ROFL app built and bundle written to '%s'.\n", outFn)
+		},
+	}
+)
 
-			// Create an ext4 filesystem.
-			fmt.Println("Creating ext4 filesystem...")
-			rootfsImage := filepath.Join(tmpDir, "rootfs.ext4")
-			rootfsSize, err := createExt4Fs(rootfsImage, rootfsDir)
-			if err != nil {
-				cobra.CheckErr(fmt.Errorf("failed to create rootfs image: %w", err))
-			}
+type artifact struct {
+	kind string
+	uri  string
+}
 
-			// Create dm-verity hash tree.
-			fmt.Println("Creating dm-verity hash tree...")
-			hashFile := filepath.Join(tmpDir, "rootfs.hash")
-			rootHash, err := createVerityHashTree(rootfsImage, hashFile)
-			if err != nil {
-				cobra.CheckErr(fmt.Errorf("failed to create verity hash tree: %w", err))
-			}
+// tdxGetDefaultArtifacts returns the list of default TDX artifacts.
+func tdxGetDefaultArtifacts() []*artifact {
+	return []*artifact{
+		{artifactFirmware, tdxFirmwareURI},
+		{artifactKernel, tdxKernelURI},
+		{artifactStage2, tdxStage2TemplateURI},
+	}
+}
 
-			// Concatenate filesystem and hash tree into one image.
-			if err = concatFiles(rootfsImage, hashFile); err != nil {
-				cobra.CheckErr(fmt.Errorf("failed to concatenate rootfs and hash tree files: %w", err))
-			}
+// tdxFetchArtifacts obtains all of the required artifacts for a TDX image.
+func tdxFetchArtifacts(artifacts []*artifact) map[string]string {
+	result := make(map[string]string)
+	for _, ar := range artifacts {
+		result[ar.kind] = maybeDownloadArtifact(ar.kind, ar.uri)
+	}
+	return result
+}
 
-			fmt.Println("Creating ORC bundle...")
+// tdxOverrideArtifacts overrides artifacts based on the manifest.
+func tdxOverrideArtifacts(manifest *buildRofl.Manifest, artifacts []*artifact) {
+	if manifest == nil || manifest.Artifacts == nil {
+		return
+	}
+	overrides := manifest.Artifacts
+
+	for _, artifact := range artifacts {
+		var overrideURI string
+		switch artifact.kind {
+		case artifactFirmware:
+			overrideURI = overrides.Firmware
+		case artifactKernel:
+			overrideURI = overrides.Kernel
+		case artifactStage2:
+			overrideURI = overrides.Stage2
+		case artifactContainerRuntime:
+			overrideURI = overrides.Container.Runtime
+		case artifactContainerCompose:
+			overrideURI = overrides.Container.Compose
+		default:
+		}
 
-			// Add the ROFL component.
-			firmwareName := "firmware.fd"
-			kernelName := "kernel.bin"
-			stage2Name := "stage2.img"
-
-			comp := bundle.Component{
-				Kind: component.ROFL,
-				Name: pkgMeta.Name,
-				TDX: &bundle.TDXMetadata{
-					Firmware:    firmwareName,
-					Kernel:      kernelName,
-					Stage2Image: stage2Name,
-					ExtraKernelOptions: []string{
-						"console=ttyS0",
-						fmt.Sprintf("oasis.stage2.roothash=%s", rootHash),
-						fmt.Sprintf("oasis.stage2.hash_offset=%d", rootfsSize),
-					},
-					Resources: bundle.TDXResources{
-						Memory:   tdxResourcesMemory,
-						CPUCount: tdxResourcesCPUCount,
-					},
-				},
-			}
-			bnd.Manifest.Components = append(bnd.Manifest.Components, &comp)
+		if overrideURI == "" {
+			continue
+		}
+		artifact.uri = overrideURI
+	}
+}
 
-			if err = bnd.Manifest.Validate(); err != nil {
-				cobra.CheckErr(fmt.Errorf("failed to validate manifest: %w", err))
-			}
+type tdxStage2 struct {
+	tmpDir   string
+	fn       string
+	rootHash string
+	fsSize   int64
+}
 
-			// Add all files.
-			fileMap := map[string]string{
-				firmwareName: artifacts[artifactFirmware],
-				kernelName:   artifacts[artifactKernel],
-				stage2Name:   rootfsImage,
-			}
-			for dst, src := range fileMap {
-				_ = bnd.Add(dst, bundle.NewFileData(src))
-			}
+// tdxPrepareStage2 prepares the stage 2 rootfs.
+func tdxPrepareStage2(artifacts map[string]string, initPath string, extraFiles map[string]string) (*tdxStage2, error) {
+	var ok bool
 
-			// Write the bundle out.
-			outFn := fmt.Sprintf("%s.orc", bnd.Manifest.Name)
-			if outputFn != "" {
-				outFn = outputFn
-			}
-			if err = bnd.Write(outFn); err != nil {
-				cobra.CheckErr(fmt.Errorf("failed to write output bundle: %w", err))
-			}
+	// Create temporary directory and unpack stage 2 template into it.
+	fmt.Println("Preparing stage 2 root filesystem...")
+	tmpDir, err := os.MkdirTemp("", "oasis-build-stage2")
+	if err != nil {
+		return nil, fmt.Errorf("failed to create temporary stage 2 build directory: %w", err)
+	}
+	defer func() {
+		// Ensure temporary directory is removed on errors.
+		if !ok {
+			_ = os.RemoveAll(tmpDir)
+		}
+	}()
 
-			fmt.Printf("ROFL app built and bundle written to '%s'.\n", outFn)
+	rootfsDir := filepath.Join(tmpDir, "rootfs")
+	if err = os.Mkdir(rootfsDir, 0o755); err != nil {
+		return nil, fmt.Errorf("failed to create temporary rootfs directory: %w", err)
+	}
+
+	// Unpack template into temporary directory.
+	fmt.Println("Unpacking template...")
+	if err = extractArchive(artifacts[artifactStage2], rootfsDir); err != nil {
+		return nil, fmt.Errorf("failed to extract stage 2 template: %w", err)
+	}
+
+	// Add runtime as init.
+	fmt.Println("Adding runtime as init...")
+	if err = copyFile(initPath, filepath.Join(rootfsDir, "init"), 0o755); err != nil {
+		return nil, err
+	}
+
+	// Copy any extra files.
+	fmt.Println("Adding extra files...")
+	for src, dst := range extraFiles {
+		if err = copyFile(src, filepath.Join(rootfsDir, dst), 0o644); err != nil {
+			return nil, err
+		}
+	}
+
+	// Create the root filesystem.
+	fmt.Println("Creating squashfs filesystem...")
+	rootfsImage := filepath.Join(tmpDir, "rootfs.squashfs")
+	rootfsSize, err := createSquashFs(rootfsImage, rootfsDir)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create rootfs image: %w", err)
+	}
+
+	// Create dm-verity hash tree.
+	fmt.Println("Creating dm-verity hash tree...")
+	hashFile := filepath.Join(tmpDir, "rootfs.hash")
+	rootHash, err := createVerityHashTree(rootfsImage, hashFile)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create verity hash tree: %w", err)
+	}
+
+	// Concatenate filesystem and hash tree into one image.
+	if err = concatFiles(rootfsImage, hashFile); err != nil {
+		return nil, fmt.Errorf("failed to concatenate rootfs and hash tree files: %w", err)
+	}
+
+	ok = true
+
+	return &tdxStage2{
+		tmpDir:   tmpDir,
+		fn:       rootfsImage,
+		rootHash: rootHash,
+		fsSize:   rootfsSize,
+	}, nil
+}
+
+// tdxBundleComponent adds the ROFL component to the given bundle.
+func tdxBundleComponent(
+	manifest *buildRofl.Manifest,
+	artifacts map[string]string,
+	bnd *bundle.Bundle,
+	stage2 *tdxStage2,
+	extraKernelOpts []string,
+) (string, error) {
+	// Add the ROFL component.
+	firmwareName := "firmware.fd"
+	kernelName := "kernel.bin"
+	stage2Name := "stage2.img"
+
+	comp := bundle.Component{
+		Kind: component.ROFL,
+		Name: bnd.Manifest.Name,
+		TDX: &bundle.TDXMetadata{
+			Firmware:    firmwareName,
+			Kernel:      kernelName,
+			Stage2Image: stage2Name,
+			ExtraKernelOptions: []string{
+				"console=ttyS0",
+				fmt.Sprintf("oasis.stage2.roothash=%s", stage2.rootHash),
+				fmt.Sprintf("oasis.stage2.hash_offset=%d", stage2.fsSize),
+			},
+			Resources: bundle.TDXResources{
+				Memory:   tdxResourcesMemory,
+				CPUCount: tdxResourcesCPUCount,
+			},
 		},
 	}
-)
+
+	// When manifest is available, override values from manifest.
+	if manifest != nil {
+		switch {
+		case manifest.Resources.EphemeralStorage == nil:
+			tdxTmpStorageMode = buildRofl.EphemeralStorageKindNone
+		default:
+			tdxTmpStorageMode = manifest.Resources.EphemeralStorage.Kind
+			tdxTmpStorageSize = manifest.Resources.EphemeralStorage.Size
+		}
+	}
+
+	switch tdxTmpStorageMode {
+	case buildRofl.EphemeralStorageKindNone:
+	case buildRofl.EphemeralStorageKindRAM:
+		comp.TDX.ExtraKernelOptions = append(comp.TDX.ExtraKernelOptions,
+			"oasis.stage2.storage_mode=ram",
+			fmt.Sprintf("oasis.stage2.storage_size=%d", tdxTmpStorageSize*1024*1024),
+		)
+	case buildRofl.EphemeralStorageKindDisk:
+		// Allocate some space after regular stage2.
+		const sectorSize = 512
+		storageSize := tdxTmpStorageSize * 1024 * 1024
+		storageOffset, err := appendEmptySpace(stage2.fn, storageSize, sectorSize)
+		if err != nil {
+			return "", err
+		}
+
+		comp.TDX.ExtraKernelOptions = append(comp.TDX.ExtraKernelOptions,
+			"oasis.stage2.storage_mode=disk",
+			fmt.Sprintf("oasis.stage2.storage_size=%d", storageSize / sectorSize),
+			fmt.Sprintf("oasis.stage2.storage_offset=%d", storageOffset / sectorSize),
+		)
+	default:
+		return "", fmt.Errorf("unsupported ephemeral storage mode: %s", tdxTmpStorageMode)
+	}
+
+	// TODO: (Oasis Core 25.0+) Use qcow2 image format to support sparse files.
+
+	// Add extra kernel options.
+	comp.TDX.ExtraKernelOptions = append(comp.TDX.ExtraKernelOptions, extraKernelOpts...)
+
+	bnd.Manifest.Components = append(bnd.Manifest.Components, &comp)
+
+	if err := bnd.Manifest.Validate(); err != nil {
+		return "", fmt.Errorf("failed to validate manifest: %w", err)
+	}
+
+	// Add all files.
+	fileMap := map[string]string{
+		firmwareName: artifacts[artifactFirmware],
+		kernelName:   artifacts[artifactKernel],
+		stage2Name:   stage2.fn,
+	}
+	for dst, src := range fileMap {
+		_ = bnd.Add(dst, bundle.NewFileData(src))
+	}
+
+	// Write the bundle out.
+	outFn := fmt.Sprintf("%s.orc", bnd.Manifest.Name)
+	if outputFn != "" {
+		outFn = outputFn
+	}
+	if err := bnd.Write(outFn); err != nil {
+		return "", fmt.Errorf("failed to write output bundle: %w", err)
+	}
+	return outFn, nil
+}
 
 // tdxSetupBuildEnv sets up the TDX build environment.
-func tdxSetupBuildEnv() {
+func tdxSetupBuildEnv(manifest *buildRofl.Manifest, npa *common.NPASelection) {
+	setupBuildEnv(manifest, npa)
+
 	switch buildMode {
 	case buildModeProduction, buildModeAuto:
 		// Production builds.
@@ -250,15 +396,28 @@ func tdxSetupBuildEnv() {
 func init() {
 	tdxFlags := flag.NewFlagSet("", flag.ContinueOnError)
 	tdxFlags.StringVar(&tdxFirmwareURI, "firmware", defaultFirmwareURI, "URL or path to firmware image")
-	tdxFlags.StringVar(&tdxFirmwareHash, "firmware-hash", "", "optional SHA256 hash of firmware image")
 	tdxFlags.StringVar(&tdxKernelURI, "kernel", defaultKernelURI, "URL or path to kernel image")
-	tdxFlags.StringVar(&tdxKernelHash, "kernel-hash", "", "optional SHA256 hash of kernel image")
 	tdxFlags.StringVar(&tdxStage2TemplateURI, "template", defaultStage2TemplateURI, "URL or path to stage 2 template")
-	tdxFlags.StringVar(&tdxStage2TemplateHash, "template-hash", "", "optional SHA256 hash of stage 2 template")
 
 	tdxFlags.Uint64Var(&tdxResourcesMemory, "memory", 512, "required amount of VM memory in megabytes")
 	tdxFlags.Uint8Var(&tdxResourcesCPUCount, "cpus", 1, "required number of vCPUs")
 
+	tdxFlags.StringVar(&tdxTmpStorageMode, "ephemeral-storage-mode", "none", "ephemeral storage mode")
+	tdxFlags.Uint64Var(&tdxTmpStorageSize, "ephemeral-storage-size", 64, "ephemeral storage size in megabytes")
+
 	tdxCmd.Flags().AddFlagSet(common.SelectorNPFlags)
 	tdxCmd.Flags().AddFlagSet(tdxFlags)
+
+	// XXX: We need to define the flags here due to init order (container gets called before tdx).
+	tdxContainerCmd.Flags().AddFlagSet(common.SelectorNPFlags)
+	tdxContainerCmd.Flags().AddFlagSet(tdxFlags)
+
+	// Override some flag defaults.
+	flags := tdxContainerCmd.Flags()
+	flags.Lookup("template").DefValue = defaultContainerStage2TemplateURI
+	_ = flags.Lookup("template").Value.Set(defaultContainerStage2TemplateURI)
+	flags.Lookup("ephemeral-storage-mode").DefValue = "ram"
+	_ = flags.Lookup("ephemeral-storage-mode").Value.Set("ram")
+
+	tdxCmd.AddCommand(tdxContainerCmd)
 }
diff --git a/cmd/rofl/common/identity.go b/cmd/rofl/common/identity.go
new file mode 100644
index 00000000..792e43ba
--- /dev/null
+++ b/cmd/rofl/common/identity.go
@@ -0,0 +1,57 @@
+package common
+
+import (
+	"fmt"
+
+	"github.com/oasisprotocol/oasis-core/go/common/sgx"
+	"github.com/oasisprotocol/oasis-core/go/runtime/bundle"
+	"github.com/oasisprotocol/oasis-core/go/runtime/bundle/component"
+
+	"github.com/oasisprotocol/cli/build/measurement"
+)
+
+// ComputeEnclaveIdentity computes the enclave identity of the given ROFL components. If no specific
+// component ID is passed, it uses the first ROFL component.
+func ComputeEnclaveIdentity(bnd *bundle.Bundle, compID string) ([]*sgx.EnclaveIdentity, error) {
+	var cid component.ID
+	if compID != "" {
+		if err := cid.UnmarshalText([]byte(compID)); err != nil {
+			return nil, fmt.Errorf("malformed component ID: %w", err)
+		}
+	}
+
+	for _, comp := range bnd.Manifest.GetAvailableComponents() {
+		if comp.Kind != component.ROFL {
+			continue // Skip non-ROFL components.
+		}
+		switch compID {
+		case "":
+			// When not specified we use the first ROFL app.
+		default:
+			if !comp.Matches(cid) {
+				continue
+			}
+		}
+
+		switch teeKind := comp.TEEKind(); teeKind {
+		case component.TEEKindSGX:
+			var enclaveID *sgx.EnclaveIdentity
+			enclaveID, err := bnd.EnclaveIdentity(comp.ID())
+			if err != nil {
+				return nil, err
+			}
+			return []*sgx.EnclaveIdentity{enclaveID}, nil
+		case component.TEEKindTDX:
+			return measurement.MeasureTdxQemu(bnd, comp)
+		default:
+			return nil, fmt.Errorf("identity computation for TEE kind '%s' not supported", teeKind)
+		}
+	}
+
+	switch compID {
+	case "":
+		return nil, fmt.Errorf("no ROFL apps found in bundle")
+	default:
+		return nil, fmt.Errorf("ROFL app '%s' not found in bundle", compID)
+	}
+}
diff --git a/cmd/rofl/common/manifest.go b/cmd/rofl/common/manifest.go
new file mode 100644
index 00000000..9ae2323a
--- /dev/null
+++ b/cmd/rofl/common/manifest.go
@@ -0,0 +1,68 @@
+package common
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+
+	"github.com/oasisprotocol/cli/build/rofl"
+	"github.com/oasisprotocol/cli/cmd/common"
+	"github.com/oasisprotocol/cli/config"
+)
+
+// LoadManifestAndSetNPA loads the ROFL app manifest and reconfigures the network/paratime/account
+// selection.
+//
+// In case there is an error in loading the manifest, it aborts the application.
+func LoadManifestAndSetNPA(cfg *config.Config, npa *common.NPASelection) *rofl.Manifest {
+	manifest, err := MaybeLoadManifestAndSetNPA(cfg, npa)
+	cobra.CheckErr(err)
+	return manifest
+}
+
+// MaybeLoadManifestAndSetNPA loads the ROFL app manifest and reconfigures the
+// network/paratime/account selection.
+//
+// In case there is an error in loading the manifest, it is returned.
+func MaybeLoadManifestAndSetNPA(cfg *config.Config, npa *common.NPASelection) (*rofl.Manifest, error) {
+	manifest, err := rofl.LoadManifest()
+	if err != nil {
+		return nil, err
+	}
+
+	switch manifest.Network {
+	case "":
+		if npa.Network == nil {
+			return nil, fmt.Errorf("no network selected")
+		}
+	default:
+		npa.Network = cfg.Networks.All[manifest.Network]
+		if npa.Network == nil {
+			return nil, fmt.Errorf("network '%s' does not exist", manifest.Network)
+		}
+		npa.NetworkName = manifest.Network
+	}
+	switch manifest.ParaTime {
+	case "":
+		if npa.ParaTime == nil {
+			return nil, fmt.Errorf("no ParaTime selected")
+		}
+	default:
+		npa.ParaTime = npa.Network.ParaTimes.All[manifest.ParaTime]
+		if npa.ParaTime == nil {
+			return nil, fmt.Errorf("paratime '%s' does not exist", manifest.ParaTime)
+		}
+		npa.ParaTimeName = manifest.ParaTime
+	}
+	switch manifest.Admin {
+	case "":
+	default:
+		accCfg, err := common.LoadAccountConfig(cfg, manifest.Admin)
+		if err != nil {
+			return nil, err
+		}
+		npa.Account = accCfg
+		npa.AccountName = manifest.Admin
+	}
+	return manifest, nil
+}
diff --git a/cmd/rofl/identity.go b/cmd/rofl/identity.go
index ad9fbbd1..a45835c0 100644
--- a/cmd/rofl/identity.go
+++ b/cmd/rofl/identity.go
@@ -6,11 +6,9 @@ import (
 	"github.com/spf13/cobra"
 	flag "github.com/spf13/pflag"
 
-	"github.com/oasisprotocol/oasis-core/go/common/sgx"
 	"github.com/oasisprotocol/oasis-core/go/runtime/bundle"
-	"github.com/oasisprotocol/oasis-core/go/runtime/bundle/component"
 
-	"github.com/oasisprotocol/cli/build/measurement"
+	roflCommon "github.com/oasisprotocol/cli/cmd/rofl/common"
 )
 
 var (
@@ -29,53 +27,12 @@ var (
 				cobra.CheckErr(fmt.Errorf("failed to open bundle: %w", err))
 			}
 
-			var cid component.ID
-			if compID != "" {
-				if err = cid.UnmarshalText([]byte(compID)); err != nil {
-					cobra.CheckErr(fmt.Errorf("malformed component ID: %w", err))
-				}
-			}
-
-			for _, comp := range bnd.Manifest.GetAvailableComponents() {
-				if comp.Kind != component.ROFL {
-					continue // Skip non-ROFL components.
-				}
-				switch compID {
-				case "":
-					// When not specified we use the first ROFL app.
-				default:
-					if !comp.Matches(cid) {
-						continue
-					}
-				}
-
-				var eids []*sgx.EnclaveIdentity
-				switch teeKind := comp.TEEKind(); teeKind {
-				case component.TEEKindSGX:
-					var enclaveID *sgx.EnclaveIdentity
-					enclaveID, err = bnd.EnclaveIdentity(comp.ID())
-					eids = append(eids, enclaveID)
-				case component.TEEKindTDX:
-					eids, err = measurement.MeasureTdxQemu(bnd, comp)
-				default:
-					cobra.CheckErr(fmt.Errorf("identity computation for TEE kind '%s' not supported", teeKind))
-				}
-				if err != nil {
-					cobra.CheckErr(fmt.Errorf("failed to generate enclave identity of '%s': %w", comp.ID(), err))
-				}
-
-				for _, enclaveID := range eids {
-					data, _ := enclaveID.MarshalText()
-					fmt.Println(string(data))
-				}
-				return
-			}
+			eids, err := roflCommon.ComputeEnclaveIdentity(bnd, compID)
+			cobra.CheckErr(err)
 
-			switch compID {
-			case "":
-				cobra.CheckErr("no ROFL apps found in bundle")
-			default:
-				cobra.CheckErr(fmt.Errorf("ROFL app '%s' not found in bundle", compID))
+			for _, enclaveID := range eids {
+				data, _ := enclaveID.MarshalText()
+				fmt.Println(string(data))
 			}
 		},
 	}
diff --git a/cmd/rofl/mgmt.go b/cmd/rofl/mgmt.go
index 79bbc8b5..d2d41ac8 100644
--- a/cmd/rofl/mgmt.go
+++ b/cmd/rofl/mgmt.go
@@ -16,6 +16,7 @@ import (
 	"github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/rofl"
 
 	"github.com/oasisprotocol/cli/cmd/common"
+	roflCommon "github.com/oasisprotocol/cli/cmd/rofl/common"
 	cliConfig "github.com/oasisprotocol/cli/config"
 )
 
@@ -30,14 +31,21 @@ var (
 	adminAddress string
 
 	createCmd = &cobra.Command{
-		Use:   "create <policy.yml>",
+		Use:   "create [<policy.yml>]",
 		Short: "Create a new ROFL application",
-		Args:  cobra.ExactArgs(1),
+		Args:  cobra.MaximumNArgs(1),
 		Run: func(_ *cobra.Command, args []string) {
 			cfg := cliConfig.Global()
 			npa := common.GetNPASelection(cfg)
 			txCfg := common.GetTransactionConfig()
-			policyFn = args[0]
+
+			var policy *rofl.AppAuthPolicy
+			if len(args) > 0 {
+				policy = loadPolicy(args[0])
+			} else {
+				manifest := roflCommon.LoadManifestAndSetNPA(cfg, npa)
+				policy = manifest.Policy
+			}
 
 			if npa.Account == nil {
 				cobra.CheckErr("no accounts configured in your wallet")
@@ -46,8 +54,6 @@ var (
 				cobra.CheckErr("no ParaTime selected")
 			}
 
-			policy := loadPolicy(policyFn)
-
 			// When not in offline mode, connect to the given network endpoint.
 			ctx := context.Background()
 			var conn connection.Connection
@@ -82,14 +88,30 @@ var (
 	}
 
 	updateCmd = &cobra.Command{
-		Use:   "update <app-id> --policy <policy.yml> --admin <address>",
+		Use:   "update [<app-id> --policy <policy.yml> --admin <address>]",
 		Short: "Update an existing ROFL application",
-		Args:  cobra.ExactArgs(1),
+		Args:  cobra.MaximumNArgs(1),
 		Run: func(_ *cobra.Command, args []string) {
 			cfg := cliConfig.Global()
 			npa := common.GetNPASelection(cfg)
 			txCfg := common.GetTransactionConfig()
-			rawAppID := args[0]
+
+			var (
+				rawAppID string
+				policy   *rofl.AppAuthPolicy
+			)
+			if len(args) > 0 {
+				rawAppID = args[0]
+				policy = loadPolicy(policyFn)
+			} else {
+				manifest := roflCommon.LoadManifestAndSetNPA(cfg, npa)
+				rawAppID = manifest.AppID
+
+				if adminAddress == "" && manifest.Admin != "" {
+					adminAddress = "self"
+				}
+				policy = manifest.Policy
+			}
 			var appID rofl.AppID
 			if err := appID.UnmarshalText([]byte(rawAppID)); err != nil {
 				cobra.CheckErr(fmt.Errorf("malformed ROFL app ID: %w", err))
@@ -102,8 +124,12 @@ var (
 				cobra.CheckErr("no ParaTime selected")
 			}
 
-			if policyFn == "" || adminAddress == "" {
-				fmt.Println("You must specify both --policy and --admin.")
+			if adminAddress == "" {
+				fmt.Println("You must specify --admin or configure an admin in the manifest.")
+				return
+			}
+			if policy == nil {
+				fmt.Println("You must specify --policy or configure policy in the manifest.")
 				return
 			}
 
@@ -118,7 +144,7 @@ var (
 
 			updateBody := rofl.Update{
 				ID:     appID,
-				Policy: *loadPolicy(policyFn),
+				Policy: *policy,
 			}
 
 			// Update administrator address.
@@ -143,14 +169,21 @@ var (
 	}
 
 	removeCmd = &cobra.Command{
-		Use:   "remove <app-id>",
+		Use:   "remove [<app-id>]",
 		Short: "Remove an existing ROFL application",
-		Args:  cobra.ExactArgs(1),
+		Args:  cobra.MaximumNArgs(1),
 		Run: func(_ *cobra.Command, args []string) {
 			cfg := cliConfig.Global()
 			npa := common.GetNPASelection(cfg)
 			txCfg := common.GetTransactionConfig()
-			rawAppID := args[0]
+
+			var rawAppID string
+			if len(args) > 0 {
+				rawAppID = args[0]
+			} else {
+				manifest := roflCommon.LoadManifestAndSetNPA(cfg, npa)
+				rawAppID = manifest.AppID
+			}
 			var appID rofl.AppID
 			if err := appID.UnmarshalText([]byte(rawAppID)); err != nil {
 				cobra.CheckErr(fmt.Errorf("malformed ROFL app ID: %w", err))
@@ -186,13 +219,20 @@ var (
 	}
 
 	showCmd = &cobra.Command{
-		Use:   "show <app-id>",
+		Use:   "show [<app-id>]",
 		Short: "Show information about a ROFL application",
-		Args:  cobra.ExactArgs(1),
+		Args:  cobra.MaximumNArgs(1),
 		Run: func(_ *cobra.Command, args []string) {
 			cfg := cliConfig.Global()
 			npa := common.GetNPASelection(cfg)
-			rawAppID := args[0]
+
+			var rawAppID string
+			if len(args) > 0 {
+				rawAppID = args[0]
+			} else {
+				manifest := roflCommon.LoadManifestAndSetNPA(cfg, npa)
+				rawAppID = manifest.AppID
+			}
 			var appID rofl.AppID
 			if err := appID.UnmarshalText([]byte(rawAppID)); err != nil {
 				cobra.CheckErr(fmt.Errorf("malformed ROFL app ID: %w", err))
@@ -240,7 +280,6 @@ var (
 )
 
 func loadPolicy(fn string) *rofl.AppAuthPolicy {
-	// Load app policy.
 	rawPolicy, err := os.ReadFile(fn)
 	cobra.CheckErr(err)