From 441768b8627805bdcf09eed85593e02e2ae2731b Mon Sep 17 00:00:00 2001 From: Martin Tomazic Date: Thu, 9 Jul 2026 22:44:34 +0200 Subject: [PATCH 1/2] cmd/rofl/build: Disable revive for tdx tests Same comments exists for the rest of the package. --- cmd/rofl/build/tdx_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/rofl/build/tdx_test.go b/cmd/rofl/build/tdx_test.go index 9ebe2fc4..d6e9f608 100644 --- a/cmd/rofl/build/tdx_test.go +++ b/cmd/rofl/build/tdx_test.go @@ -1,4 +1,4 @@ -package build +package build //revive:disable import ( "os" From 17537ed879ffbd708ee354a76f3c1199f3973588 Mon Sep 17 00:00:00 2001 From: Martin Tomazic Date: Sun, 5 Jul 2026 22:29:37 +0200 Subject: [PATCH 2/2] Add oasis rofl public-var subcommand --- build/rofl/artifacts.go | 2 +- cmd/rofl/mgmt.go | 3 + cmd/rofl/public_var.go | 308 +++++++++++++++++++ cmd/rofl/public_var_test.go | 148 +++++++++ cmd/rofl/rofl.go | 1 + docs/rofl.md | 50 +++ examples/rofl/public-var-get.in.static | 1 + examples/rofl/public-var-get.out.static | 3 + examples/rofl/public-var-import.in.static | 1 + examples/rofl/public-var-rm.in.static | 1 + examples/rofl/public-var-set-file.in.static | 1 + examples/rofl/public-var-set-stdin.in.static | 1 + 12 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 cmd/rofl/public_var.go create mode 100644 cmd/rofl/public_var_test.go create mode 100644 examples/rofl/public-var-get.in.static create mode 100644 examples/rofl/public-var-get.out.static create mode 100644 examples/rofl/public-var-import.in.static create mode 100644 examples/rofl/public-var-rm.in.static create mode 100644 examples/rofl/public-var-set-file.in.static create mode 100644 examples/rofl/public-var-set-stdin.in.static diff --git a/build/rofl/artifacts.go b/build/rofl/artifacts.go index 70df5892..6aa73374 100644 --- a/build/rofl/artifacts.go +++ b/build/rofl/artifacts.go @@ -21,6 +21,6 @@ var LatestContainerArtifacts = ArtifactsConfig{ Kernel: "https://github.com/oasisprotocol/oasis-boot/releases/download/v0.6.2/stage1.bin#e5d4d654ca1fa2c388bf64b23fc6e67815893fc7cb8b7cfee253d87963f54973", Stage2: "https://github.com/oasisprotocol/oasis-boot/releases/download/v0.6.2/stage2-podman.tar.bz2#b2ea2a0ca769b6b2d64e3f0c577ee9c08f0bb81a6e33ed5b15b2a7e50ef9a09f", Container: ContainerArtifactsConfig{ - Runtime: "https://github.com/oasisprotocol/oasis-sdk/releases/download/rofl-containers%2Fv0.8.6/rofl-containers#5aa26a3c5a7e1d284e217959d89b7a620ea7b7fc5079909e25042124ffb384e8", + Runtime: "https://github.com/oasisprotocol/oasis-sdk/releases/download/rofl-containers%2Fv0.9.0/rofl-containers#e2e074d03ab2fbacaacb01e6d63ce093fde04e4cc620dd8804e8afbb20390525", }, } diff --git a/cmd/rofl/mgmt.go b/cmd/rofl/mgmt.go index 60114f34..2ae619c4 100644 --- a/cmd/rofl/mgmt.go +++ b/cmd/rofl/mgmt.go @@ -652,6 +652,7 @@ var ( if err := appID.UnmarshalText([]byte(deployment.AppID)); err != nil { cobra.CheckErr(fmt.Errorf("malformed ROFL app ID: %w", err)) } + cobra.CheckErr(checkNoPublicVarNamed(deployment.Metadata, secretName)) // Establish connection with the target network. ctx := context.Background() @@ -769,6 +770,8 @@ var ( var imported, updated int for _, name := range names { + cobra.CheckErr(checkNoPublicVarNamed(deployment.Metadata, name)) + value := []byte(entries[name]) encValue, err := buildRofl.EncryptSecret(name, value, appCfg.SEK) diff --git a/cmd/rofl/public_var.go b/cmd/rofl/public_var.go new file mode 100644 index 00000000..b4520d8e --- /dev/null +++ b/cmd/rofl/public_var.go @@ -0,0 +1,308 @@ +package rofl + +import ( + "fmt" + "io" + "os" + "sort" + "strings" + "unicode/utf8" + + "github.com/spf13/cobra" + + buildDotenv "github.com/oasisprotocol/cli/build/dotenv" + buildRofl "github.com/oasisprotocol/cli/build/rofl" + "github.com/oasisprotocol/cli/cmd/common" + roflCommon "github.com/oasisprotocol/cli/cmd/rofl/common" +) + +const publicVarMetadataPrefix = "env." + +var ( + publicVarCmd = &cobra.Command{ + Use: "public-var", + Short: "Public variable management commands", + Long: "Public variable management commands.\n\n" + + "Values are stored unencrypted in ROFL app metadata and exposed to containers as\n" + + "environment variables. Use `oasis rofl secret` for confidential values.", + } + + publicVarSetCmd = &cobra.Command{ + Use: "set |-", + Short: "Set a public variable in the manifest, reading the value from file or stdin", + Args: cobra.ExactArgs(2), + Run: func(_ *cobra.Command, args []string) { + publicVarName := args[0] + publicVarFn := args[1] + + manifest, deployment, _ := roflCommon.LoadManifestAndSetNPA(&roflCommon.ManifestOptions{ + NeedAppID: false, + NeedAdmin: false, + }) + + key, err := publicVarMetadataKey(publicVarName) + if err != nil { + cobra.CheckErr(err) + } + cobra.CheckErr(checkNoSecretNamed(deployment.Secrets, publicVarName)) + + // Read public variable. + var publicVarValueRaw []byte + if publicVarFn == "-" { + publicVarValueRaw, err = io.ReadAll(os.Stdin) + if err != nil { + cobra.CheckErr(fmt.Errorf("failed to read public variable from standard input: %w", err)) + } + } else { + publicVarValueRaw, err = os.ReadFile(publicVarFn) + if err != nil { + cobra.CheckErr(fmt.Errorf("failed to read public variable from file: %w", err)) + } + } + publicVarValue, err := parsePublicVarValue(publicVarValueRaw) + if err != nil { + cobra.CheckErr(err) + } + + if existing, ok := deployment.Metadata[key]; ok { + if existing == publicVarValue { + fmt.Printf("Public variable '%s' did not change; no update needed.\n", publicVarName) + return + } + common.CheckForceErr(fmt.Errorf("the public variable named '%s' for deployment '%s' already exists", publicVarName, roflCommon.DeploymentName)) + } + if deployment.Metadata == nil { + deployment.Metadata = make(map[string]string) + } + deployment.Metadata[key] = publicVarValue + + // Update manifest. + if err = manifest.Save(); err != nil { + cobra.CheckErr(fmt.Errorf("failed to update manifest: %w", err)) + } + + fmt.Printf("Run `oasis rofl update` to update your ROFL app's on-chain configuration.\n") + }, + } + + // publicVarImportCmd bulk-imports public variables from a .env file (key=value with # comments). + // Supports '-' to read from stdin. Existing public variables are replaced only with --force. + publicVarImportCmd = &cobra.Command{ + Use: "import |-", + Short: "Import multiple public variables from a .env file", + Args: cobra.ExactArgs(1), + Run: func(_ *cobra.Command, args []string) { + envFn := args[0] + + manifest, deployment, _ := roflCommon.LoadManifestAndSetNPA(&roflCommon.ManifestOptions{ + NeedAppID: false, + NeedAdmin: false, + }) + + // Read .env data (supports stdin via "-"). + var ( + raw []byte + err error + ) + if envFn == "-" { + raw, err = io.ReadAll(os.Stdin) + if err != nil { + cobra.CheckErr(fmt.Errorf("failed to read .env from standard input: %w", err)) + } + } else { + raw, err = os.ReadFile(envFn) + if err != nil { + cobra.CheckErr(fmt.Errorf("failed to read .env file: %w", err)) + } + } + + entries, err := buildDotenv.Parse(string(raw)) + if err != nil { + cobra.CheckErr(fmt.Errorf("failed to parse .env: %w", err)) + } + if len(entries) == 0 { + fmt.Println("No key=value pairs found in the provided .env input.") + return + } + + // Deterministic iteration for stable updates. + names := make([]string, 0, len(entries)) + for k := range entries { + names = append(names, k) + } + sort.Strings(names) + + if deployment.Metadata == nil { + deployment.Metadata = make(map[string]string) + } + + var imported, updated int + for _, name := range names { + key, err := publicVarMetadataKey(name) + if err != nil { + cobra.CheckErr(err) + } + cobra.CheckErr(checkNoSecretNamed(deployment.Secrets, name)) + + if existing, ok := deployment.Metadata[key]; ok { + if existing == entries[name] { + continue + } + common.CheckForceErr(fmt.Errorf("the public variable named '%s' for deployment '%s' already exists", name, roflCommon.DeploymentName)) + updated++ + } else { + imported++ + } + deployment.Metadata[key] = entries[name] + } + + src := envFn + if envFn == "-" { + src = "stdin" + } + if imported == 0 && updated == 0 { + fmt.Printf("No public variables were imported or updated using '%s'.\n", src) + return + } + + // Update manifest. + if err = manifest.Save(); err != nil { + cobra.CheckErr(fmt.Errorf("failed to update manifest: %w", err)) + } + + fmt.Printf("Imported %d public variables, updated %d existing from '%s'.\n", imported, updated, src) + fmt.Printf("Run `oasis rofl update` to update your ROFL app's on-chain configuration.\n") + }, + } + + publicVarGetCmd = &cobra.Command{ + Use: "get ", + Short: "Show the given public variable", + Args: cobra.ExactArgs(1), + Run: func(_ *cobra.Command, args []string) { + publicVarName := args[0] + + _, deployment, _ := roflCommon.LoadManifestAndSetNPA(&roflCommon.ManifestOptions{ + NeedAppID: false, + NeedAdmin: false, + }) + + key, err := publicVarMetadataKey(publicVarName) + if err != nil { + cobra.CheckErr(err) + } + value, ok := deployment.Metadata[key] + if !ok { + cobra.CheckErr(fmt.Errorf("public variable named '%s' does not exist for deployment '%s'", publicVarName, roflCommon.DeploymentName)) + return // Lint doesn't know that cobra.CheckErr never returns. + } + + fmt.Printf("Name: %s\n", publicVarName) + fmt.Printf("Value: %s\n", value) + fmt.Printf("Size: %d bytes\n", len(value)) + }, + } + + publicVarRmCmd = &cobra.Command{ + Use: "rm ", + Short: "Remove the given public variable from the manifest", + Args: cobra.ExactArgs(1), + Run: func(_ *cobra.Command, args []string) { + publicVarName := args[0] + + manifest, deployment, _ := roflCommon.LoadManifestAndSetNPA(&roflCommon.ManifestOptions{ + NeedAppID: false, + NeedAdmin: false, + }) + + key, err := publicVarMetadataKey(publicVarName) + if err != nil { + cobra.CheckErr(err) + } + if _, ok := deployment.Metadata[key]; !ok { + cobra.CheckErr(fmt.Errorf("public variable named '%s' does not exist for deployment '%s'", publicVarName, roflCommon.DeploymentName)) + } + delete(deployment.Metadata, key) + if len(deployment.Metadata) == 0 { + deployment.Metadata = nil + } + + // Update manifest. + if err := manifest.Save(); err != nil { + cobra.CheckErr(fmt.Errorf("failed to update manifest: %w", err)) + } + + fmt.Printf("Run `oasis rofl update` to update your ROFL app's on-chain configuration.\n") + }, + } +) + +func publicVarMetadataKey(name string) (string, error) { + if err := validatePublicVarName(name); err != nil { + return "", err + } + return publicVarMetadataPrefix + name, nil +} + +// checkNoSecretNamed returns an error if a secret exposed under the given environment variable +// name exists among the secrets. +func checkNoSecretNamed(secrets []*buildRofl.SecretConfig, name string) error { + for _, sc := range secrets { + if secretEnvVarName(sc.Name) == name { + return fmt.Errorf("the secret named '%s' for deployment '%s' already exists", sc.Name, roflCommon.DeploymentName) + } + } + return nil +} + +// checkNoPublicVarNamed returns an error if a public variable exposed under the same environment +// variable name as a secret with the given name exists in the deployment metadata. +func checkNoPublicVarNamed(metadata map[string]string, name string) error { + envName := secretEnvVarName(name) + if _, ok := metadata[publicVarMetadataPrefix+envName]; ok { + return fmt.Errorf("the public variable named '%s' for deployment '%s' already exists", envName, roflCommon.DeploymentName) + } + return nil +} + +// secretEnvVarName returns the environment variable name under which rofl-containers exposes a +// secret with the given name. +func secretEnvVarName(name string) string { + return strings.ToUpper(strings.ReplaceAll(name, " ", "_")) +} + +func validatePublicVarName(name string) error { + if name == "" { + return fmt.Errorf("public variable name cannot be empty") + } + for _, ch := range name { + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' { + continue + } + return fmt.Errorf("public variable name '%s' is invalid, only ASCII letters, digits and '_' are allowed", name) + } + return nil +} + +func parsePublicVarValue(raw []byte) (string, error) { + if !utf8.Valid(raw) { + return "", fmt.Errorf("public variable values must be valid UTF-8") + } + return string(raw), nil +} + +func init() { + publicVarSetCmd.Flags().AddFlagSet(roflCommon.DeploymentFlags) + publicVarSetCmd.Flags().AddFlagSet(common.ForceFlag) + publicVarCmd.AddCommand(publicVarSetCmd) + + publicVarImportCmd.Flags().AddFlagSet(roflCommon.DeploymentFlags) + publicVarImportCmd.Flags().AddFlagSet(common.ForceFlag) + publicVarCmd.AddCommand(publicVarImportCmd) + + publicVarGetCmd.Flags().AddFlagSet(roflCommon.DeploymentFlags) + publicVarCmd.AddCommand(publicVarGetCmd) + + publicVarRmCmd.Flags().AddFlagSet(roflCommon.DeploymentFlags) + publicVarCmd.AddCommand(publicVarRmCmd) +} diff --git a/cmd/rofl/public_var_test.go b/cmd/rofl/public_var_test.go new file mode 100644 index 00000000..4ff5d09c --- /dev/null +++ b/cmd/rofl/public_var_test.go @@ -0,0 +1,148 @@ +package rofl + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + buildRofl "github.com/oasisprotocol/cli/build/rofl" + cliCommon "github.com/oasisprotocol/cli/cmd/common" + cliConfig "github.com/oasisprotocol/cli/config" +) + +func TestPublicVarRejectsInvalidNames(t *testing.T) { + for _, name := range []string{ + "", + "API-URL", + "API URL", + "service.API_URL", + "env.API_URL", + "å", + } { + key, err := publicVarMetadataKey(name) + require.Error(t, err) + require.Empty(t, key) + } +} + +func TestParsePublicVarRejectsInvalidValues(t *testing.T) { + for _, value := range [][]byte{ + {0xff}, + } { + parsed, err := parsePublicVarValue(value) + require.Error(t, err) + require.Empty(t, parsed) + } +} + +func TestPublicVarCommandsUpdateManifest(t *testing.T) { + require := require.New(t) + + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + const ( + testGreetingVar = "GREETING" + testGreetingValue = "hello" + testAPIURLVar = "API_URL" + testAPIURLValue = "https://example.com?a=b#c" + testConfigVar = "CONFIG" + testConfigValue = `{"mode":"prod"}` + ) + + prevConfig := *cliConfig.Global() + cliConfig.ResetDefaults() + t.Cleanup(func() { + *cliConfig.Global() = prevConfig + }) + + const manifestYAML = ` +name: public-var-test +version: 0.1.0 +tee: tdx +kind: container +resources: + memory: 512 + cpus: 1 + storage: + kind: disk-persistent + size: 512 +deployments: + testnet: + default: true + network: testnet + paratime: sapphire +` + err := os.WriteFile(filepath.Join(tmpDir, "rofl.yaml"), []byte(manifestYAML), 0o600) + require.NoError(err) + + // Test public-var set and import + greetingFile := filepath.Join(tmpDir, "greeting.txt") + err = os.WriteFile(greetingFile, []byte(testGreetingValue), 0o600) + require.NoError(err) + publicVarSetCmd.Run(publicVarSetCmd, []string{testGreetingVar, greetingFile}) + + envFile := filepath.Join(tmpDir, ".env") + env := fmt.Sprintf("# public vars\n%s=%s # trailing comment\n%s=%s\n", testAPIURLVar, testAPIURLValue, testConfigVar, testConfigValue) + err = os.WriteFile(envFile, []byte(env), 0o600) + require.NoError(err) + publicVarImportCmd.Run(publicVarImportCmd, []string{envFile}) + + manifest, err := buildRofl.LoadManifest() + require.NoError(err) + metadata := manifest.Deployments["testnet"].Metadata + require.Equal(testGreetingValue, metadata[publicVarMetadataPrefix+testGreetingVar]) + require.Equal(testAPIURLValue, metadata[publicVarMetadataPrefix+testAPIURLVar]) + require.Equal(testConfigValue, metadata[publicVarMetadataPrefix+testConfigVar]) + + // Test public-var get + output := captureStdout(t, func() { + publicVarGetCmd.Run(publicVarGetCmd, []string{testGreetingVar}) + }) + require.Contains(output, fmt.Sprintf("Name: %s", testGreetingVar)) + require.Contains(output, fmt.Sprintf("Value: %s", testGreetingValue)) + + // Test public-var rm + publicVarRmCmd.Run(publicVarRmCmd, []string{testGreetingVar}) + manifest, err = buildRofl.LoadManifest() + require.NoError(err) + metadata = manifest.Deployments["testnet"].Metadata + require.NotContains(metadata, publicVarMetadataPrefix+testGreetingVar) + require.Equal(testAPIURLValue, metadata[publicVarMetadataPrefix+testAPIURLVar]) + require.Equal(testConfigValue, metadata[publicVarMetadataPrefix+testConfigVar]) + + // Override public variable to an empty string. + emptyFile := filepath.Join(tmpDir, "empty.txt") + err = os.WriteFile(emptyFile, nil, 0o600) + require.NoError(err) + withForce(t, true, func() { + publicVarSetCmd.Run(publicVarSetCmd, []string{testConfigVar, emptyFile}) + }) + manifest, err = buildRofl.LoadManifest() + require.NoError(err) + metadata = manifest.Deployments["testnet"].Metadata + require.Equal(testAPIURLValue, metadata[publicVarMetadataPrefix+testAPIURLVar]) + require.Equal("", metadata[publicVarMetadataPrefix+testConfigVar]) +} + +func withForce(t *testing.T, enabled bool, fn func()) { + t.Helper() + + boolFlagValue := func(v bool) string { + if v { + return "true" + } + return "false" + } + + prev := cliCommon.IsForce() + require.NoError(t, cliCommon.ForceFlag.Set("force", boolFlagValue(enabled))) + defer func() { + require.NoError(t, cliCommon.ForceFlag.Set("force", boolFlagValue(prev))) + }() + + fn() +} diff --git a/cmd/rofl/rofl.go b/cmd/rofl/rofl.go index 3c988235..121cbcf3 100644 --- a/cmd/rofl/rofl.go +++ b/cmd/rofl/rofl.go @@ -29,6 +29,7 @@ func init() { Cmd.AddCommand(build.Cmd) Cmd.AddCommand(identityCmd) Cmd.AddCommand(secretCmd) + Cmd.AddCommand(publicVarCmd) Cmd.AddCommand(upgradeCmd) Cmd.AddCommand(setAdminCmd) Cmd.AddCommand(provider.Cmd) diff --git a/docs/rofl.md b/docs/rofl.md index a4a35a6c..ef12bc1a 100644 --- a/docs/rofl.md +++ b/docs/rofl.md @@ -186,6 +186,56 @@ Run `rofl secret rm ` to remove the secret from your manifest file. ![code shell](../examples/rofl/secret-rm.in.static) +## Public variables management {#public-var} + +Public variables are stored unencrypted in ROFL app metadata and on-chain. +They are exposed to containers as environment variables. Use [`rofl secret`] +to store confidential values. + +[`rofl secret`]: #secret + +### Set public variable {#public-var-set} + +Run `rofl public-var set |-` to store a public variable in the +manifest file. + +If you have the value in a file, run: + +![code shell](../examples/rofl/public-var-set-file.in.static) + +You can also feed the value from standard input: + +![code shell](../examples/rofl/public-var-set-stdin.in.static) + +Use `--force` to replace an existing public variable. + +### Import public variables from `.env` files {#public-var-import} + +Run `rofl public-var import |-` to bulk-import public variables +from a [dotenv](https://github.com/motdotla/dotenv) compatible file (key=value +with `#` comments). + +![code shell](../examples/rofl/public-var-import.in.static) + +Each `KEY=VALUE` pair becomes a separate public variable in your manifest. Use +`--force` to replace existing public variables. + +### Get public variable info {#public-var-get} + +Run `rofl public-var get ` to show the value currently stored in your +manifest file. + +![code shell](../examples/rofl/public-var-get.in.static) + +![code](../examples/rofl/public-var-get.out.static) + +### Remove public variable {#public-var-rm} + +Run `rofl public-var rm ` to remove the public variable from your manifest +file. + +![code shell](../examples/rofl/public-var-rm.in.static) + ## Update ROFL app config on-chain {#update} Use `rofl update` command to push the ROFL app's configuration to the chain: diff --git a/examples/rofl/public-var-get.in.static b/examples/rofl/public-var-get.in.static new file mode 100644 index 00000000..e015eced --- /dev/null +++ b/examples/rofl/public-var-get.in.static @@ -0,0 +1 @@ +oasis rofl public-var get API_URL diff --git a/examples/rofl/public-var-get.out.static b/examples/rofl/public-var-get.out.static new file mode 100644 index 00000000..b0f4cc07 --- /dev/null +++ b/examples/rofl/public-var-get.out.static @@ -0,0 +1,3 @@ +Name: API_URL +Value: https://api.example.com +Size: 23 bytes diff --git a/examples/rofl/public-var-import.in.static b/examples/rofl/public-var-import.in.static new file mode 100644 index 00000000..f5cab6ba --- /dev/null +++ b/examples/rofl/public-var-import.in.static @@ -0,0 +1 @@ +oasis rofl public-var import .env.production diff --git a/examples/rofl/public-var-rm.in.static b/examples/rofl/public-var-rm.in.static new file mode 100644 index 00000000..f12ee354 --- /dev/null +++ b/examples/rofl/public-var-rm.in.static @@ -0,0 +1 @@ +oasis rofl public-var rm API_URL diff --git a/examples/rofl/public-var-set-file.in.static b/examples/rofl/public-var-set-file.in.static new file mode 100644 index 00000000..e9182d8c --- /dev/null +++ b/examples/rofl/public-var-set-file.in.static @@ -0,0 +1 @@ +oasis rofl public-var set API_URL api-url.txt diff --git a/examples/rofl/public-var-set-stdin.in.static b/examples/rofl/public-var-set-stdin.in.static new file mode 100644 index 00000000..1eaf9cc0 --- /dev/null +++ b/examples/rofl/public-var-set-stdin.in.static @@ -0,0 +1 @@ +echo -n "https://api.example.com" | oasis rofl public-var set API_URL -