diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a048d63079..367bef1a5b 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,7 +36,7 @@ jobs: strategy: matrix: go-version: - - 1.26.4 + - 1.26.5 sys: - { os: ubuntu-latest } - { os: macos-15-intel, shell: zsh } diff --git a/.gitignore b/.gitignore index eff1608360..01a63bde4f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,10 @@ cmd/state/versioninfo.json cmd/state/resource.syso cmd/state-svc/versioninfo.json cmd/state-svc/resource.syso +cmd/state-installer/versioninfo.json +cmd/state-installer/resource.syso +cmd/state-exec/versioninfo.json +cmd/state-exec/resource.syso +cmd/state-mcp/versioninfo.json +cmd/state-mcp/resource.syso +test/ssl/ diff --git a/activestate.yaml b/activestate.yaml index 19bff2be58..d4e08cc354 100644 --- a/activestate.yaml +++ b/activestate.yaml @@ -1,4 +1,4 @@ -project: https://platform.activestate.com/ActiveState/cli?branch=main&commitID=9eee7512-b2ab-4600-b78b-ab0cf2e817d8 +project: https://platform.activestate.com/ActiveState/cli?branch=main&commitID=ce30761b-3c98-4d7a-b737-bcb1beeef87c constants: - name: CLI_BUILDFLAGS value: -ldflags="-s -w" @@ -94,6 +94,9 @@ scripts: if [[ "$GOOS" == "windows" || "$OS" == "Windows_NT" ]]; then go run scripts/versioninfo-generator/main.go version.txt cmd/state/versioninfo.json "State Tool" go run scripts/versioninfo-generator/main.go version.txt cmd/state-svc/versioninfo.json "State Service" + go run scripts/versioninfo-generator/main.go version.txt cmd/state-installer/versioninfo.json "State Installer" + go run scripts/versioninfo-generator/main.go version.txt cmd/state-exec/versioninfo.json "State Executor" + go run scripts/versioninfo-generator/main.go version.txt cmd/state-mcp/versioninfo.json "State MCP" fi - name: build language: bash @@ -136,6 +139,13 @@ scripts: value: | set -e $constants.SET_ENV + + # Generate resource.syso for Windows + if [[ "$GOOS" == "windows" || "$OS" == "Windows_NT" ]]; then + pushd cmd/state-exec > /dev/null + go generate + popd > /dev/null + fi TARGET=$BUILD_TARGET_DIR/$constants.BUILD_EXEC_TARGET echo "Building $TARGET" go build -tags "$GO_BUILD_TAGS" -o $TARGET $constants.CLI_BUILDFLAGS $constants.EXECUTOR_PKGS @@ -146,6 +156,13 @@ scripts: value: | set -e $constants.SET_ENV + + # Generate resource.syso for Windows + if [[ "$GOOS" == "windows" || "$OS" == "Windows_NT" ]]; then + pushd cmd/state-mcp > /dev/null + go generate + popd > /dev/null + fi TARGET=$BUILD_TARGET_DIR/$constants.BUILD_MCP_TARGET echo "Building $TARGET" go build -tags "$GO_BUILD_TAGS" -o $TARGET $constants.CLI_BUILDFLAGS $constants.MCP_PKGS @@ -175,6 +192,12 @@ scripts: set -e $constants.SET_ENV + # Generate resource.syso for Windows + if [[ "$GOOS" == "windows" || "$OS" == "Windows_NT" ]]; then + pushd cmd/state-installer > /dev/null + go generate + popd > /dev/null + fi go build -tags "$GO_BUILD_TAGS" -o $BUILD_TARGET_DIR/$constants.BUILD_INSTALLER_TARGET $constants.INSTALLER_PKGS - name: build-remote-installer language: bash @@ -459,6 +482,55 @@ scripts: language: bash standalone: true value: go run $project.path()/scripts/to-buildexpression/main.go $@ + - name: serve-org-keys + language: bash + if: ne .Shell "cmd" + description: Runs a server that serves org keys for testing encrypted private ingredients + value: | + org="$1" + if [ -z "$org" ]; then + echo "Usage: state run serve-org-keys []" + echo "Error: missing organization name" + exit 1 + fi + key="$2" + + echo "Starting org key server." + if [ -z "$key" ]; then + echo "Using random encryption key (see below)" + python3 -- scripts/orgkeyserver.py --org "$org" + else + python3 -- scripts/orgkeyserver.py --org "$org" --key "$key" + fi + - name: serve-org-keys + language: batch + if: eq .Shell "cmd" + description: Runs a server that serves org keys for testing encrypted private ingredients + value: | + set "org=%~1" + if "%org%"=="" ( + echo Usage: state run serve-org-keys ^ [^] + echo Error: missing organization name + exit /b 1 + ) + set "key=%~2" + + set "PY=" + where python >nul 2>nul && set "PY=python" + if not defined PY (where py >nul 2>nul && set "PY=py -3") + if not defined PY (where python3 >nul 2>nul && set "PY=python3") + if not defined PY ( + echo Error: no Python interpreter found on PATH ^(tried python, py, python3^). + exit /b 1 + ) + + echo Starting org key server. + if "%key%"=="" ( + echo Using random encryption key ^(see below^) + %PY% scripts\orgkeyserver.py --org "%org%" + ) else ( + %PY% scripts\orgkeyserver.py --org "%org%" --key "%key%" + ) events: - name: activate diff --git a/changelog.md b/changelog.md index a699c53aaa..dca5b57c95 100644 --- a/changelog.md +++ b/changelog.md @@ -24,11 +24,16 @@ and this project adheres to - `privateingredient.bearer_token_env`: name of an environment variable to read a bearer token from. - `privateingredient.bearer_token_file`: path to a file to read a bearer token from. - `privateingredient.cache_key_on_disk`: whether to cache the fetched key on disk for headless or offline reuse. +- `state config` now shows where a configured value comes from (set locally, set by environment variable, or the default). + - All configuration keys can be overridden by a corresponding `ACTIVESTATE_CONFIG_*` environment variable, where all + characters are in upper-case, and '.' is replaced with '_'. For example, the "security.prompt.level" key is + controlled using `ACTIVESTATE_CONFIG_SECURITY_PROMPT_LEVEL`. ### Fixed - Fixed occasional panic due to a concurrent map read/write during runtime setup. - Fixed `state clean cache` from accidentally deleting State Tool binaries on Windows. +- Fixed unnecessary Windows installer UAC elevation prompt. ### Security diff --git a/cmd/state-exec/main_windows.go b/cmd/state-exec/main_windows.go new file mode 100644 index 0000000000..1765eedbf5 --- /dev/null +++ b/cmd/state-exec/main_windows.go @@ -0,0 +1,3 @@ +//go:generate goversioninfo + +package main diff --git a/cmd/state-installer/main_windows.go b/cmd/state-installer/main_windows.go new file mode 100644 index 0000000000..1765eedbf5 --- /dev/null +++ b/cmd/state-installer/main_windows.go @@ -0,0 +1,3 @@ +//go:generate goversioninfo + +package main diff --git a/cmd/state-mcp/main_windows.go b/cmd/state-mcp/main_windows.go new file mode 100644 index 0000000000..1765eedbf5 --- /dev/null +++ b/cmd/state-mcp/main_windows.go @@ -0,0 +1,3 @@ +//go:generate goversioninfo + +package main diff --git a/cmd/state-remote-installer/versioninfo.json b/cmd/state-remote-installer/versioninfo.json index 56871e7d53..4048639c5e 100644 --- a/cmd/state-remote-installer/versioninfo.json +++ b/cmd/state-remote-installer/versioninfo.json @@ -39,5 +39,5 @@ } }, "IconPath": "../../internal/assets/contents/icon.ico", - "ManifestPath": "" + "ManifestPath": "../../internal/assets/contents/asInvoker.manifest" } \ No newline at end of file diff --git a/cmd/state-svc/internal/notifications/notifications.go b/cmd/state-svc/internal/notifications/notifications.go index 19136e29a9..b2555ea723 100644 --- a/cmd/state-svc/internal/notifications/notifications.go +++ b/cmd/state-svc/internal/notifications/notifications.go @@ -25,7 +25,7 @@ import ( ) func init() { - configMediator.RegisterOption(constants.NotificationsURLConfig, configMediator.String, "") + configMediator.RegisterOption(constants.NotificationsURLConfig, configMediator.String, "", constants.NotificationsOverrideEnvVarName) } const ConfigKeyLastReport = "notifications.last_reported" diff --git a/cmd/state/main.go b/cmd/state/main.go index 8ddde10de9..64b17ad955 100644 --- a/cmd/state/main.go +++ b/cmd/state/main.go @@ -97,7 +97,7 @@ func main() { // Configuration options // This should only be used if the config option is not exclusive to one package. configMediator.RegisterOption(constants.OptinBuildscriptsConfig, configMediator.Bool, false) - configMediator.RegisterOption(constants.NotificationsURLConfig, configMediator.String, "") + configMediator.RegisterOption(constants.NotificationsURLConfig, configMediator.String, "", constants.NotificationsOverrideEnvVarName) // Set up our output formatter/writer outFlags := parseOutputFlags(os.Args) diff --git a/go.mod b/go.mod index a8f48a6f6c..423130e401 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/ActiveState/cli -go 1.26.4 +go 1.26.5 require ( github.com/99designs/gqlgen v0.17.54 diff --git a/internal/analytics/analytics.go b/internal/analytics/analytics.go index 05641be0d1..a1bdfdabf5 100644 --- a/internal/analytics/analytics.go +++ b/internal/analytics/analytics.go @@ -7,7 +7,7 @@ import ( ) func init() { - configMediator.RegisterOption(constants.AnalyticsPixelOverrideConfig, configMediator.String, "") + configMediator.RegisterOption(constants.AnalyticsPixelOverrideConfig, configMediator.String, "", constants.AnalyticsPixelOverrideEnv) } // Dispatcher describes a struct that can send analytics event in the background diff --git a/internal/assets/contents/asInvoker.manifest b/internal/assets/contents/asInvoker.manifest new file mode 100644 index 0000000000..b9bbfd462e --- /dev/null +++ b/internal/assets/contents/asInvoker.manifest @@ -0,0 +1,25 @@ + + + + + + + + + + + diff --git a/internal/assets/contents/versioninfo.json b/internal/assets/contents/versioninfo.json index fc06be7342..e38c63334a 100644 --- a/internal/assets/contents/versioninfo.json +++ b/internal/assets/contents/versioninfo.json @@ -37,5 +37,5 @@ } }, "IconPath": "../../internal/assets/contents/icon.ico", - "ManifestPath": "" + "ManifestPath": "../../internal/assets/contents/asInvoker.manifest" } \ No newline at end of file diff --git a/internal/config/instance.go b/internal/config/instance.go index bfc3979bc4..4d666fa850 100644 --- a/internal/config/instance.go +++ b/internal/config/instance.go @@ -178,11 +178,20 @@ func (i *Instance) rawGet(key string) interface{} { } func (i *Instance) Get(key string) interface{} { + opt := mediator.GetOption(key) + + // An environment variable override takes precedence over any stored or default value. + if mediator.KnownOption(opt) { + if value, _, ok := mediator.EnvOverride(opt); ok { + return value + } + } + result := i.rawGet(key) if result != nil { return result } - if opt := mediator.GetOption(key); mediator.KnownOption(opt) { + if mediator.KnownOption(opt) { return mediator.GetDefault(opt) } return nil diff --git a/internal/locale/locales/en-us.yaml b/internal/locale/locales/en-us.yaml index d7a6d8e8c3..3c1f136aa0 100644 --- a/internal/locale/locales/en-us.yaml +++ b/internal/locale/locales/en-us.yaml @@ -1545,6 +1545,12 @@ value: other: Value default: other: Default +source: + other: Source +config_source_local: + other: local +config_source_default: + other: default vulnerabilities: other: Vulnerabilities (CVEs) dependency_row: diff --git a/internal/mediators/config/registry.go b/internal/mediators/config/registry.go index f6f327f53d..80f7695afc 100644 --- a/internal/mediators/config/registry.go +++ b/internal/mediators/config/registry.go @@ -1,6 +1,24 @@ package config -import "sort" +import ( + "os" + "sort" + "strings" + + "github.com/spf13/cast" +) + +// EnvVarPrefix is prepended to a config key to derive its canonical environment-variable override. +const EnvVarPrefix = "ACTIVESTATE_CONFIG_" + +var envVarReplacer = strings.NewReplacer(".", "_", "-", "_") + +// CanonicalEnvVarName returns the environment variable that overrides the given config key. Every +// registered config option can be overridden this way, e.g. "api.host" maps to +// "ACTIVESTATE_CONFIG_API_HOST". +func CanonicalEnvVarName(key string) string { + return EnvVarPrefix + strings.ToUpper(envVarReplacer.Replace(key)) +} type Type int @@ -25,9 +43,13 @@ var EmptyEvent = func(value interface{}) (interface{}, error) { // Option defines what a config value's name and type should be, along with any get/set events type Option struct { - Name string - Type Type - Default interface{} + Name string + Type Type + Default interface{} + // EnvAliases are additional (legacy/bespoke) environment variables that override this option, in + // addition to its canonical CanonicalEnvVarName. They are kept for backwards compatibility with + // env vars that predate the canonical ACTIVESTATE_CONFIG_* scheme (e.g. ACTIVESTATE_API_HOST). + EnvAliases []string GetEvent Event SetEvent Event isRegistered bool @@ -52,28 +74,92 @@ func NewEnum(options []string, default_ string) *Enums { func GetOption(key string) Option { rule, ok := registry[key] if !ok { - return Option{key, String, "", EmptyEvent, EmptyEvent, false, false} + return Option{key, String, "", nil, EmptyEvent, EmptyEvent, false, false} } return rule } -// Registers a config option without get/set events. -func RegisterOption(key string, t Type, defaultValue interface{}) { - registerOption(key, t, defaultValue, EmptyEvent, EmptyEvent, false) +// RegisterOption registers a config option without get/set events. Any envAliases are additional +// legacy/bespoke environment variables that override the option, in addition to its canonical +// ACTIVESTATE_CONFIG_* variable. They exist only for env vars that predate and do not use the +// canonical prefix (e.g. ACTIVESTATE_API_HOST). +func RegisterOption(key string, t Type, defaultValue interface{}, envAliases ...string) { + registerOption(key, t, defaultValue, envAliases, EmptyEvent, EmptyEvent, false) } // Registers a hidden config option without get/set events. func RegisterHiddenOption(key string, t Type, defaultValue interface{}) { - registerOption(key, t, defaultValue, EmptyEvent, EmptyEvent, true) + registerOption(key, t, defaultValue, nil, EmptyEvent, EmptyEvent, true) } // Registers a config option with get/set events. func RegisterOptionWithEvents(key string, t Type, defaultValue interface{}, get, set Event) { - registerOption(key, t, defaultValue, get, set, false) + registerOption(key, t, defaultValue, nil, get, set, false) +} + +func registerOption(key string, t Type, defaultValue interface{}, envAliases []string, get, set Event, hidden bool) { + registry[key] = Option{key, t, defaultValue, envAliases, get, set, true, hidden} +} + +// EnvVarNames returns every environment variable that can override this option: its canonical +// ACTIVESTATE_CONFIG_* variable first, followed by any legacy aliases. +func EnvVarNames(opt Option) []string { + names := make([]string, 0, len(opt.EnvAliases)+1) + names = append(names, CanonicalEnvVarName(opt.Name)) + names = append(names, opt.EnvAliases...) + return names } -func registerOption(key string, t Type, defaultValue interface{}, get, set Event, hidden bool) { - registry[key] = Option{key, t, defaultValue, get, set, true, hidden} +// EnvOverride returns the effective override value for the option when one of its environment +// variables is currently set to a non-empty, valid value, coerced to the option's type. The second +// return value is the name of the variable in effect; the bool reports whether an override applies. +// +// A variable whose value cannot be strictly coerced to the option's type (an unparseable bool/int +// or an enum value outside the allowed set) is ignored, so a mis-typed variable falls back to the +// stored/default value rather than silently changing behavior to a zero value. +func EnvOverride(opt Option) (interface{}, string, bool) { + for _, name := range EnvVarNames(opt) { + raw, ok := os.LookupEnv(name) + if !ok || raw == "" { + continue + } + if value, valid := coerceToType(opt, raw); valid { + return value, name, true + } + } + return nil, "", false +} + +// coerceToType converts a raw environment-variable string to the option's configured type so that +// callers receive the same Go type they would get from a stored value. The bool result reports +// whether the raw value is valid for the option's type; invalid values must not be applied. +func coerceToType(opt Option, raw string) (interface{}, bool) { + switch opt.Type { + case Bool: + v, err := cast.ToBoolE(raw) + if err != nil { + return nil, false + } + return v, true + case Int: + v, err := cast.ToIntE(raw) + if err != nil { + return nil, false + } + return v, true + case Enum: + if enums, ok := opt.Default.(*Enums); ok { + for _, option := range enums.Options { + if option == raw { + return raw, true + } + } + return nil, false + } + return raw, true + default: // String + return raw, true + } } func KnownOption(rule Option) bool { diff --git a/internal/mediators/config/registry_test.go b/internal/mediators/config/registry_test.go new file mode 100644 index 0000000000..b66829e1c4 --- /dev/null +++ b/internal/mediators/config/registry_test.go @@ -0,0 +1,98 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCanonicalEnvVarName(t *testing.T) { + assert.Equal(t, "ACTIVESTATE_CONFIG_API_HOST", CanonicalEnvVarName("api.host")) + assert.Equal(t, "ACTIVESTATE_CONFIG_AUTOUPDATE", CanonicalEnvVarName("autoupdate")) + assert.Equal(t, "ACTIVESTATE_CONFIG_SECURITY_PROMPT_LEVEL", CanonicalEnvVarName("security.prompt.level")) + assert.Equal(t, "ACTIVESTATE_CONFIG_PRIVATEINGREDIENT_MTLS_CERT", CanonicalEnvVarName("privateingredient.mtls_cert")) +} + +func TestEnvOverrideCanonical(t *testing.T) { + RegisterOption("test.canonical.key", String, "default") + opt := GetOption("test.canonical.key") + + // Not set -> no override. + _, _, ok := EnvOverride(opt) + assert.False(t, ok) + + // Canonical variable applies. + t.Setenv("ACTIVESTATE_CONFIG_TEST_CANONICAL_KEY", "from-canonical") + value, envVar, ok := EnvOverride(opt) + assert.True(t, ok) + assert.Equal(t, "from-canonical", value) + assert.Equal(t, "ACTIVESTATE_CONFIG_TEST_CANONICAL_KEY", envVar) +} + +func TestEnvOverrideAliasAndPrecedence(t *testing.T) { + RegisterOption("test.alias.key", String, "default", "LEGACY_ALIAS_VAR") + opt := GetOption("test.alias.key") + + // A legacy alias applies when the canonical var is unset. + t.Setenv("LEGACY_ALIAS_VAR", "from-alias") + value, envVar, ok := EnvOverride(opt) + assert.True(t, ok) + assert.Equal(t, "from-alias", value) + assert.Equal(t, "LEGACY_ALIAS_VAR", envVar) + + // The canonical variable takes precedence over the alias. + t.Setenv("ACTIVESTATE_CONFIG_TEST_ALIAS_KEY", "from-canonical") + value, envVar, ok = EnvOverride(opt) + assert.True(t, ok) + assert.Equal(t, "from-canonical", value) + assert.Equal(t, "ACTIVESTATE_CONFIG_TEST_ALIAS_KEY", envVar) +} + +func TestEnvOverrideTypeCoercion(t *testing.T) { + RegisterOption("test.coerce.bool", Bool, false) + RegisterOption("test.coerce.int", Int, 0) + + t.Setenv("ACTIVESTATE_CONFIG_TEST_COERCE_BOOL", "true") + v, _, ok := EnvOverride(GetOption("test.coerce.bool")) + assert.True(t, ok) + assert.Equal(t, true, v, "bool env value should be coerced to a real bool") + + t.Setenv("ACTIVESTATE_CONFIG_TEST_COERCE_INT", "42") + v, _, ok = EnvOverride(GetOption("test.coerce.int")) + assert.True(t, ok) + assert.Equal(t, 42, v, "int env value should be coerced to a real int") +} + +func TestEnvOverrideEmptyIgnored(t *testing.T) { + RegisterOption("test.empty.key", String, "default") + t.Setenv("ACTIVESTATE_CONFIG_TEST_EMPTY_KEY", "") + _, _, ok := EnvOverride(GetOption("test.empty.key")) + assert.False(t, ok, "an empty env var should be treated as unset") +} + +func TestEnvOverrideInvalidIgnored(t *testing.T) { + RegisterOption("test.invalid.bool", Bool, false) + RegisterOption("test.invalid.int", Int, 0) + RegisterOption("test.invalid.enum", Enum, NewEnum([]string{"low", "high"}, "low")) + + // An unparseable bool must be ignored rather than coerced to false. + t.Setenv("ACTIVESTATE_CONFIG_TEST_INVALID_BOOL", "notabool") + _, _, ok := EnvOverride(GetOption("test.invalid.bool")) + assert.False(t, ok, "an unparseable bool env var must not apply") + + // An unparseable int must be ignored rather than coerced to 0. + t.Setenv("ACTIVESTATE_CONFIG_TEST_INVALID_INT", "notanint") + _, _, ok = EnvOverride(GetOption("test.invalid.int")) + assert.False(t, ok, "an unparseable int env var must not apply") + + // An enum value outside the allowed set must be ignored. + t.Setenv("ACTIVESTATE_CONFIG_TEST_INVALID_ENUM", "banana") + _, _, ok = EnvOverride(GetOption("test.invalid.enum")) + assert.False(t, ok, "an out-of-set enum env var must not apply") + + // A valid enum value still applies. + t.Setenv("ACTIVESTATE_CONFIG_TEST_INVALID_ENUM", "high") + value, _, ok := EnvOverride(GetOption("test.invalid.enum")) + assert.True(t, ok) + assert.Equal(t, "high", value) +} diff --git a/internal/runbits/runtime/runtime.go b/internal/runbits/runtime/runtime.go index dcdf29b446..ef4789697e 100644 --- a/internal/runbits/runtime/runtime.go +++ b/internal/runbits/runtime/runtime.go @@ -283,24 +283,33 @@ func Update( q.Set("commitID", commitID.String()) u.RawQuery = q.Encode() rtOpts = append(rtOpts, runtime.WithBuildProgressUrl(u.String())) + // Fallback for when the build-log stream is unavailable. + rtOpts = append(rtOpts, runtime.WithBuildPlanPoller(func() (*buildplan.BuildPlan, error) { + bpm := bpModel.NewBuildPlannerModel(prime.Auth(), prime.SvcModel()) + if err := bpm.WaitForBuild(commitID, proj.Owner(), proj.Name(), nil); err != nil { + return nil, errs.Wrap(err, "Could not wait for the in-progress build to complete") + } + c, err := bpm.FetchCommit(commitID, proj.Owner(), proj.Name(), nil) + if err != nil { + return nil, errs.Wrap(err, "Could not fetch the completed build plan") + } + return c.BuildPlan(), nil + })) } if proj.IsPortable() { rtOpts = append(rtOpts, runtime.WithPortable()) } rtOpts = append(rtOpts, runtime.WithCacheSize(prime.Config().GetInt(constants.RuntimeCacheSizeConfigKey))) - // Fetch the organization key for private ingredients, if a key service is configured. + // If a key service is configured, provide a lazy fetch of the organization key upon encountering + // an encrypted private artifact. orgKeyProvider := orgkey.New(prime.Config(), proj.Owner()) if orgKeyProvider.Configured() { defer orgKeyProvider.Close() - key, keyID, err := orgKeyProvider.Key(context.Background()) - if err != nil { - prime.Output().Notice(locale.Tl("warn_orgkey_unavailable", - "[WARNING]Warning:[/RESET] Could not fetch the organization key: {{.V0}}. Encrypted private artifacts will be skipped. Ensure the key is available and run '[ACTIONABLE]state refresh[/RESET]' to try installing them again.", - errs.JoinMessage(err))) - } else { - rtOpts = append(rtOpts, runtime.WithDecryptionKey(key, keyID)) - } + rtOpts = append(rtOpts, runtime.WithDecryptionKey(func() ([]byte, error) { + key, _, err := orgKeyProvider.Key(context.Background()) + return key, err + })) } if isArmPlatform(buildPlan) { @@ -312,9 +321,15 @@ func Update( } if len(skipped.names) > 0 { + // The fetch is memoized, so this returns the error that caused the skip + // without contacting the key service again. + reason := "" + if _, _, err := orgKeyProvider.Key(context.Background()); err != nil { + reason = ": " + errs.JoinMessage(err) + } prime.Output().Notice(locale.Tl("warn_private_artifacts_skipped", - "[WARNING]Warning:[/RESET] These private packages were skipped because the organization key was unavailable: {{.V0}}. Ensure the key is available and run '[ACTIONABLE]state refresh[/RESET]' to try installing them again.", - strings.Join(skipped.names, ", "))) + "[WARNING]Warning:[/RESET] These private packages were skipped because the organization key was unavailable{{.V1}}. Private packages: {{.V0}}. Ensure the key is available and run '[ACTIONABLE]state refresh[/RESET]' to try installing them again.", + strings.Join(skipped.names, ", "), reason)) } return rt, nil diff --git a/internal/runners/config/config.go b/internal/runners/config/config.go index 689b40e7ef..b890b56e7f 100644 --- a/internal/runners/config/config.go +++ b/internal/runners/config/config.go @@ -29,11 +29,46 @@ func NewList(prime primeable) (*List, error) { }, nil } +// Where an option's effective value comes from. +const ( + sourceEnvironment = "environment" // overridden by an environment variable + sourceLocal = "local" // set locally by the user (stored in config.db) + sourceDefault = "default" // neither set nor overridden; using the built-in default +) + type structuredConfigData struct { Key string `json:"key"` Value interface{} `json:"value"` Default interface{} `json:"default"` - opt mediator.Option + // Source is where the effective value comes from: "environment", "local", or "default". + Source string `json:"source"` + // EnvVar is the canonical environment variable that can override this key (always present). + EnvVar string `json:"envVar"` + // Env is the name of the environment variable currently overriding this value, if any. + Env string `json:"env,omitempty"` + opt mediator.Option +} + +// effectiveValue returns the value the State Tool will actually use for the option, along with the +// name of the environment variable overriding it (empty when the value comes from stored config or +// the built-in default). +func effectiveValue(cfg *config.Instance, opt mediator.Option) (interface{}, string) { + if envValue, envVar, ok := mediator.EnvOverride(opt); ok { + return envValue, envVar + } + return cfg.Get(opt.Name), "" +} + +// configSource reports where an option's effective value comes from, and the name of the +// environment variable in effect when the source is the environment. +func configSource(cfg *config.Instance, opt mediator.Option) (source string, envVar string) { + if _, name, ok := mediator.EnvOverride(opt); ok { + return sourceEnvironment, name + } + if cfg.IsSet(opt.Name) { + return sourceLocal, "" + } + return sourceDefault, "" } func (c *List) Run() error { @@ -44,11 +79,15 @@ func (c *List) Run() error { var data []structuredConfigData for _, opt := range registered { - configuredValue := cfg.Get(opt.Name) + configuredValue, envVar := effectiveValue(cfg, opt) + source, _ := configSource(cfg, opt) data = append(data, structuredConfigData{ Key: opt.Name, Value: configuredValue, Default: mediator.GetDefault(opt), + Source: source, + EnvVar: mediator.CanonicalEnvVarName(opt.Name), + Env: envVar, opt: opt, }) } @@ -68,12 +107,13 @@ func (c *List) renderUserFacing(configData []structuredConfigData) error { cfg := c.prime.Config() out := c.prime.Output() - tbl := table.New(locale.Ts("key", "value", "default")) + tbl := table.New(locale.Ts("key", "value", "source", "default")) tbl.HideDash = true for _, config := range configData { tbl.AddRow([]string{ fmt.Sprintf("[CYAN]%s[/RESET]", config.Key), renderConfigValue(cfg, config.opt), + renderConfigSource(cfg, config.opt), fmt.Sprintf("[DISABLED]%s[/RESET]", formatValue(config.opt, config.Default)), }) } @@ -87,7 +127,7 @@ func (c *List) renderUserFacing(configData []structuredConfigData) error { } func renderConfigValue(cfg *config.Instance, opt mediator.Option) string { - configured := cfg.Get(opt.Name) + configured, _ := effectiveValue(cfg, opt) var tags []string if opt.Type == mediator.Bool { if configured == true { @@ -98,11 +138,6 @@ func renderConfigValue(cfg *config.Instance, opt mediator.Option) string { } value := formatValue(opt, configured) - if cfg.IsSet(opt.Name) { - tags = append(tags, "[BOLD]") - value = value + "*" - } - if len(tags) > 0 { return fmt.Sprintf("%s%s[/RESET]", strings.Join(tags, ""), value) } @@ -110,6 +145,20 @@ func renderConfigValue(cfg *config.Instance, opt mediator.Option) string { return value } +// renderConfigSource renders the Source column: the environment variable in effect (when overridden +// by the environment), "local" (set by the user), or a de-emphasized "default". +func renderConfigSource(cfg *config.Instance, opt mediator.Option) string { + source, envVar := configSource(cfg, opt) + switch source { + case sourceEnvironment: + return fmt.Sprintf("[BOLD]%s[/RESET]", envVar) + case sourceLocal: + return fmt.Sprintf("[BOLD]%s[/RESET]", locale.Tl("config_source_local", "local")) + default: + return fmt.Sprintf("[DISABLED]%s[/RESET]", locale.Tl("config_source_default", "default")) + } +} + func formatValue(opt mediator.Option, value interface{}) string { switch opt.Type { case mediator.Enum, mediator.String: diff --git a/internal/runners/config/env_test.go b/internal/runners/config/env_test.go new file mode 100644 index 0000000000..6f7dd10722 --- /dev/null +++ b/internal/runners/config/env_test.go @@ -0,0 +1,69 @@ +package config + +import ( + "testing" + + "github.com/ActiveState/cli/internal/config" + configMediator "github.com/ActiveState/cli/internal/mediators/config" + "github.com/ActiveState/cli/internal/testhelpers/outputhelper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestEffectiveValueEnvOverride verifies precedence used by `state config` (list): an environment +// variable override wins over a stored value, which wins over the default. +func TestEffectiveValueEnvOverride(t *testing.T) { + cfg, err := config.New() + require.NoError(t, err) + defer func() { require.NoError(t, cfg.Close()) }() + + configMediator.RegisterOption("test.env.host", configMediator.String, "default-host", "TEST_ENV_HOST_OVERRIDE") + opt := configMediator.GetOption("test.env.host") + + // No env, no stored value -> default, not overridden. + v, envVar := effectiveValue(cfg, opt) + assert.Equal(t, "default-host", v) + assert.Equal(t, "", envVar) + + // Stored value wins when env is not set. + require.NoError(t, cfg.Set("test.env.host", "user-host")) + v, envVar = effectiveValue(cfg, opt) + assert.Equal(t, "user-host", v) + assert.Equal(t, "", envVar) + + // Env override wins over the stored value and reports the source var. + t.Setenv("TEST_ENV_HOST_OVERRIDE", "env-host") + v, envVar = effectiveValue(cfg, opt) + assert.Equal(t, "env-host", v) + assert.Equal(t, "TEST_ENV_HOST_OVERRIDE", envVar) + + // An empty env var is treated as not set, falling back to the stored value. + t.Setenv("TEST_ENV_HOST_OVERRIDE", "") + v, envVar = effectiveValue(cfg, opt) + assert.Equal(t, "user-host", v) + assert.Equal(t, "", envVar) +} + +// TestGetEnvOverride verifies `state config get ` reports the environment override value. +func TestGetEnvOverride(t *testing.T) { + cfg, err := config.New() + require.NoError(t, err) + defer func() { require.NoError(t, cfg.Close()) }() + + configMediator.RegisterOption("test.env.get", configMediator.String, "", "TEST_ENV_GET_OVERRIDE") + require.NoError(t, cfg.Set("test.env.get", "stored-value")) + + outputer := outputhelper.NewCatcher() + get := &Get{outputer, cfg} + + // Without the env var, the stored value is reported. + require.NoError(t, get.Run(GetParams{Key: Key("test.env.get")})) + assert.Contains(t, outputer.CombinedOutput(), "stored-value") + + // With the env var set, the override is reported instead. + t.Setenv("TEST_ENV_GET_OVERRIDE", "env-value") + outputer = outputhelper.NewCatcher() + get = &Get{outputer, cfg} + require.NoError(t, get.Run(GetParams{Key: Key("test.env.get")})) + assert.Contains(t, outputer.CombinedOutput(), "env-value") +} diff --git a/internal/runners/publish/build.go b/internal/runners/publish/build.go index 4c2881534c..93dae93168 100644 --- a/internal/runners/publish/build.go +++ b/internal/runners/publish/build.go @@ -28,6 +28,9 @@ func (r *Runner) generateEncryptedArtifact(params *Params) (cleanup func(), rerr if r.project == nil { return nil, locale.NewInputError("err_publish_build_no_project", "The '[ACTIONABLE]--build[/RESET]' flag requires a project so the organization can be determined.") } + if !fileutils.DirExists(params.Build) { + return nil, locale.NewInputError("err_publish_build_dir_not_found", "The '[ACTIONABLE]--build[/RESET]' source directory does not exist: [ACTIONABLE]{{.V0}}[/RESET]", params.Build) + } meta, err := wheel.ResolveMetadata(params.Build, wheel.Metadata{Name: params.Name, Version: params.Version}) if err != nil { @@ -95,12 +98,13 @@ func buildWrappedArtifact(srcDir string, meta wheel.Metadata, key []byte, keyID if err != nil { return "", nil, errs.Wrap(err, "Could not create temp dir") } - cleanup = func() { _ = os.RemoveAll(tmpDir) } + removeTmpDir := func() { _ = os.RemoveAll(tmpDir) } defer func() { if rerr != nil { - cleanup() + removeTmpDir() } }() + cleanup = removeTmpDir wheelPath, err := wheel.Pack(srcDir, meta, tmpDir) if err != nil { diff --git a/internal/updater/checker.go b/internal/updater/checker.go index b11c2789c0..3d520169c8 100644 --- a/internal/updater/checker.go +++ b/internal/updater/checker.go @@ -35,7 +35,7 @@ var ( ) func init() { - configMediator.RegisterOption(constants.UpdateInfoEndpointConfig, configMediator.String, "") + configMediator.RegisterOption(constants.UpdateInfoEndpointConfig, configMediator.String, "", constants.TestUpdateInfoURLEnvVarName) } type Checker struct { diff --git a/internal/updater/updater.go b/internal/updater/updater.go index b3a7f4bed8..0b5fe7bd44 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -36,7 +36,7 @@ const ( ) func init() { - configMediator.RegisterOption(constants.UpdateEndpointConfig, configMediator.String, "") + configMediator.RegisterOption(constants.UpdateEndpointConfig, configMediator.String, "", constants.TestUpdateURLEnvVarName) } type ErrorInProgress struct{ *locale.LocalizedError } diff --git a/pkg/platform/api/buildlogstream/streamer.go b/pkg/platform/api/buildlogstream/streamer.go index 619a13508c..cc5288a6db 100644 --- a/pkg/platform/api/buildlogstream/streamer.go +++ b/pkg/platform/api/buildlogstream/streamer.go @@ -1,6 +1,7 @@ package buildlogstream import ( + "errors" "net/http" "github.com/gorilla/websocket" @@ -18,6 +19,16 @@ import ( // keeping the token out of proxy/browser response logs. const wsSubprotocol = "build-log-streamer.activestate.com.v1" +// StreamDeniedError indicates the server refused the build-log stream. +type StreamDeniedError struct { + *errs.WrapperError +} + +func IsStreamDenied(err error) bool { + var e *StreamDeniedError + return errors.As(err, &e) +} + // Connect opens the build-log-streamer WebSocket. When jwt is non-empty it is // offered via Sec-WebSocket-Protocol as `bearer.` (alongside // wsSubprotocol, which the server echoes back) so the server can authorize the @@ -40,8 +51,11 @@ func Connect(ctx context.Context, jwt string) (*websocket.Conn, error) { } logging.Debug("Creating websocket for %s (origin: %s)", url.String(), header.Get("Origin")) - conn, _, err := dialer.DialContext(ctx, url.String(), header) + conn, resp, err := dialer.DialContext(ctx, url.String(), header) if err != nil { + if resp != nil && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) { + return nil, &StreamDeniedError{errs.Wrap(err, "build-log-streamer WebSocket Upgrade denied with status %d", resp.StatusCode)} + } return nil, errs.Wrap(err, "Could not create websocket dialer") } return conn, nil diff --git a/pkg/platform/api/buildlogstream/streamer_test.go b/pkg/platform/api/buildlogstream/streamer_test.go index 66e72545fa..599c835156 100644 --- a/pkg/platform/api/buildlogstream/streamer_test.go +++ b/pkg/platform/api/buildlogstream/streamer_test.go @@ -2,6 +2,7 @@ package buildlogstream import ( "context" + "errors" "net/http" "net/http/httptest" "strings" @@ -9,11 +10,20 @@ import ( "time" "github.com/ActiveState/cli/internal/constants" + "github.com/ActiveState/cli/internal/errs" "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestIsStreamDenied(t *testing.T) { + denied := &StreamDeniedError{errs.New("denied")} + assert.True(t, IsStreamDenied(denied), "a StreamDeniedError must be recognized") + assert.True(t, IsStreamDenied(errs.Wrap(denied, "wrapped")), "denial must be recognized through wrapping") + assert.False(t, IsStreamDenied(errs.New("some other failure")), "an unrelated error must not be a denial") + assert.False(t, IsStreamDenied(nil), "nil must not be a denial") +} + // upgradeRequest captures the headers the build-log-streamer server saw on the // WS Upgrade. The mock handler writes the fields from the server goroutine and // closes recorded; callers must await() before reading the fields so there's a @@ -93,6 +103,46 @@ func TestConnect_ForwardsJWTViaSubprotocol(t *testing.T) { "client must send the versioned State Tool User-Agent so the server can monitor versions") } +// startDenyingBLS stands up a server that refuses the WS Upgrade with the given +// HTTP status, and redirects Connect's resolved service URL at it. +func startDenyingBLS(t *testing.T, status int) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + })) + t.Cleanup(srv.Close) + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + t.Setenv(constants.APIServiceOverrideEnvVarName+"BUILDLOG_STREAMER", wsURL) +} + +func TestConnect_UpgradeDeniedReturnsTypedError(t *testing.T) { + for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} { + status := status + t.Run(http.StatusText(status), func(t *testing.T) { + startDenyingBLS(t, status) + + _, err := Connect(context.Background(), "header.payload.signature") + require.Error(t, err) + var denied *StreamDeniedError + assert.Truef(t, errors.As(err, &denied), + "a %d Upgrade response must be classified as denial, got: %v", status, err) + }) + } +} + +func TestConnect_NonAuthDialErrorNotDenied(t *testing.T) { + // A handshake failure that isn't an auth rejection is a genuine error and + // must not be mistaken for a denial (the run should still surface it). + startDenyingBLS(t, http.StatusInternalServerError) + + _, err := Connect(context.Background(), "") + require.Error(t, err) + var denied *StreamDeniedError + assert.False(t, errors.As(err, &denied), + "a non-auth handshake failure must not be classified as denial") +} + func TestConnect_AnonymousOffersNoBearer(t *testing.T) { got := startMockBLS(t) diff --git a/pkg/platform/api/settings.go b/pkg/platform/api/settings.go index 947f6ff367..25e936bf6d 100644 --- a/pkg/platform/api/settings.go +++ b/pkg/platform/api/settings.go @@ -15,7 +15,7 @@ import ( ) func init() { - configMediator.RegisterOption(constants.APIHostConfig, configMediator.String, "") + configMediator.RegisterOption(constants.APIHostConfig, configMediator.String, "", constants.APIHostEnvVarName) } // Service records available api services diff --git a/pkg/runtime/decrypt_test.go b/pkg/runtime/decrypt_test.go index 14facf07e1..463f5b67d2 100644 --- a/pkg/runtime/decrypt_test.go +++ b/pkg/runtime/decrypt_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/ActiveState/cli/internal/artifactcrypto" + "github.com/ActiveState/cli/internal/errs" "github.com/go-openapi/strfmt" ) @@ -157,7 +158,7 @@ func TestDecryptPayload(t *testing.T) { writeFile(t, filepath.Join(dir, "runtime.json"), []byte(`{"installDir":"."}`)) writeFile(t, filepath.Join(dir, artifactcrypto.PayloadFilename), encryptToBytes(t, payload, key)) - s := &setup{opts: &Opts{OrgKey: key}} + s := &setup{opts: &Opts{OrgKey: func() ([]byte, error) { return key, nil }}} outcome, err := s.decryptPayload("pkg", dir) if err != nil { t.Fatalf("decryptPayload: %v", err) @@ -207,12 +208,26 @@ func TestDecryptPayload(t *testing.T) { } }) + t.Run("key fetch error skips", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, artifactcrypto.PayloadFilename), encryptToBytes(t, payload, key)) + + s := &setup{opts: &Opts{OrgKey: func() ([]byte, error) { return nil, errs.New("key service unreachable") }}} + outcome, err := s.decryptPayload("pkg", dir) + if err != nil { + t.Fatalf("decryptPayload: %v", err) + } + if outcome != decryptSkipped { + t.Fatalf("outcome = %v, want decryptSkipped", outcome) + } + }) + t.Run("wrong key fails closed", func(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, artifactcrypto.PayloadFilename), encryptToBytes(t, payload, key)) wrong := make([]byte, artifactcrypto.KeySize) // all zeros - s := &setup{opts: &Opts{OrgKey: wrong}} + s := &setup{opts: &Opts{OrgKey: func() ([]byte, error) { return wrong, nil }}} _, err := s.decryptPayload("pkg", dir) if err == nil { t.Fatal("expected a wrong-key error, got nil") @@ -222,7 +237,7 @@ func TestDecryptPayload(t *testing.T) { t.Run("plaintext artifact is untouched", func(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "runtime.json"), []byte(`{"installDir":"."}`)) - s := &setup{opts: &Opts{OrgKey: key}} + s := &setup{opts: &Opts{OrgKey: func() ([]byte, error) { return key, nil }}} outcome, err := s.decryptPayload("pkg", dir) if err != nil { t.Fatal(err) @@ -238,7 +253,7 @@ func TestDecryptPayload(t *testing.T) { writeFile(t, filepath.Join(dir, "runtime.json"), []byte(`{"installDir":"installdir"}`)) writeFile(t, filepath.Join(dir, "installdir", artifactcrypto.PayloadFilename), encryptToBytes(t, payload, key)) - s := &setup{opts: &Opts{OrgKey: key}} + s := &setup{opts: &Opts{OrgKey: func() ([]byte, error) { return key, nil }}} outcome, err := s.decryptPayload("pkg", dir) if err != nil { t.Fatalf("decryptPayload: %v", err) diff --git a/pkg/runtime/internal/buildlog/buildlog.go b/pkg/runtime/internal/buildlog/buildlog.go index 94a7c29501..f28d92ca67 100644 --- a/pkg/runtime/internal/buildlog/buildlog.go +++ b/pkg/runtime/internal/buildlog/buildlog.go @@ -2,6 +2,7 @@ package buildlog import ( "context" + "errors" "fmt" "os" "strings" @@ -102,6 +103,10 @@ func (b *BuildLog) OnArtifactReady(id strfmt.UUID, cb func()) { func (b *BuildLog) Wait(ctx context.Context) error { conn, err := buildlogstream.Connect(ctx, b.authToken) if err != nil { + var denied *buildlogstream.StreamDeniedError + if errors.As(err, &denied) { + return denied + } return errs.Wrap(err, "Could not connect to build-log streamer build updates") } @@ -124,6 +129,10 @@ func (b *BuildLog) Wait(ctx context.Context) error { if err == nil { continue } + var denied *buildlogstream.StreamDeniedError + if errors.As(err, &denied) { + return denied // this is a singular error that arrives alone + } if rerr == nil { rerr = errs.New("failed build") } @@ -223,16 +232,24 @@ func (b *BuildLog) waitForBuildLog(ctx context.Context, conn *websocket.Conn, er } } + receivedFrame := false var artifactErr error for { var msg Message err := conn.ReadJSON(&msg) if err != nil { + // At this time, the server can either deny the websocket connect, or accept it and close it + // without sending any frames. We handle the latter here. + if !receivedFrame && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + errCh <- &buildlogstream.StreamDeniedError{errs.Wrap(err, "build-log stream soft-closed with no frames")} + return + } // This should bubble up and logging it is just an extra measure to help with debugging logging.Debug("Encountered error: %s", errs.JoinMessage(err)) errCh <- err return } + receivedFrame = true if verboseLogging { logging.Debug("Received response: %s", msg.MessageTypeValue()) } diff --git a/pkg/runtime/internal/buildlog/buildlog_denial_test.go b/pkg/runtime/internal/buildlog/buildlog_denial_test.go new file mode 100644 index 0000000000..ec0d2e6c5e --- /dev/null +++ b/pkg/runtime/internal/buildlog/buildlog_denial_test.go @@ -0,0 +1,49 @@ +package buildlog + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ActiveState/cli/internal/constants" + "github.com/ActiveState/cli/pkg/buildplan" + "github.com/ActiveState/cli/pkg/platform/api/buildlogstream" + "github.com/go-openapi/strfmt" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestWait_SoftCloseIsDenial drives Wait against a server that accepts the +// Upgrade then soft-closes with no frames (one of the two deny shapes), and +// asserts the run degrades to a denial instead of surfacing a build failure. +func TestWait_SoftCloseIsDenial(t *testing.T) { + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("mock build-log-streamer failed to upgrade: %v", err) + return + } + // Drain the client's recipe request, then close normally with no build + // frames -- the server-side soft-close denial shape. + _, _, _ = conn.ReadMessage() + _ = conn.WriteControl(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), + time.Now().Add(5*time.Second)) + _ = conn.Close() + })) + t.Cleanup(srv.Close) + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + t.Setenv(constants.APIServiceOverrideEnvVarName+"BUILDLOG_STREAMER", wsURL) + + blog := New(strfmt.UUID("00000000-0000-0000-0000-000000000000"), buildplan.ArtifactIDMap{}, "") + + err := blog.Wait(context.Background()) + require.Error(t, err) + assert.Truef(t, buildlogstream.IsStreamDenied(err), "a soft-close with no frames must be recognized as a denial, got: %v", err) +} diff --git a/pkg/runtime/options.go b/pkg/runtime/options.go index 63431b74a7..fe34bd6c2a 100644 --- a/pkg/runtime/options.go +++ b/pkg/runtime/options.go @@ -1,6 +1,7 @@ package runtime import ( + "github.com/ActiveState/cli/pkg/buildplan" "github.com/ActiveState/cli/pkg/runtime/events" "github.com/go-openapi/strfmt" ) @@ -15,12 +16,16 @@ func WithAuthToken(token string) SetOpt { return func(opts *Opts) { opts.AuthToken = token } } -// WithDecryptionKey supplies the organization AES-256 key (and its id) used to -// decrypt private artifacts during install. -func WithDecryptionKey(key []byte, keyID string) SetOpt { +// WithBuildPlanPoller polls for a buildplan when the build-log stream is unavailable. +func WithBuildPlanPoller(poll func() (*buildplan.BuildPlan, error)) SetOpt { + return func(opts *Opts) { opts.PollBuildPlan = poll } +} + +// WithDecryptionKey supplies a function that lazily fetches the organization +// AES-256 key used to decrypt private artifacts during install. +func WithDecryptionKey(fetch func() ([]byte, error)) SetOpt { return func(opts *Opts) { - opts.OrgKey = key - opts.OrgKeyID = keyID + opts.OrgKey = fetch } } diff --git a/pkg/runtime/setup.go b/pkg/runtime/setup.go index 322fad1203..21471da447 100644 --- a/pkg/runtime/setup.go +++ b/pkg/runtime/setup.go @@ -29,6 +29,7 @@ import ( "github.com/ActiveState/cli/internal/svcctl" "github.com/ActiveState/cli/internal/unarchiver" "github.com/ActiveState/cli/pkg/buildplan" + "github.com/ActiveState/cli/pkg/platform/api/buildlogstream" "github.com/ActiveState/cli/pkg/platform/api/buildplanner/types" "github.com/ActiveState/cli/pkg/platform/model" "github.com/ActiveState/cli/pkg/runtime/events" @@ -61,11 +62,12 @@ type Opts struct { // the server can authorize the stream. Empty for unauthenticated callers. AuthToken string - // OrgKey is the organization AES-256 key used to decrypt private artifacts - // during install, with OrgKeyID identifying which key it is. Both are empty - // when the runtime has no private ingredients. - OrgKey []byte - OrgKeyID string + // PollBuildPlan waits for an in-progress build when the build-log stream is unavailable. + PollBuildPlan func() (*buildplan.BuildPlan, error) + + // OrgKey lazily fetches the organization AES-256 key used to decrypt private + // artifacts during install. It is nil when no key service is configured. + OrgKey func() ([]byte, error) FromArchive *fromArchive @@ -303,7 +305,12 @@ func (s *setup) update() error { // Wait for build to finish if !s.buildplan.IsBuildReady() && len(s.toBuild) > 0 { if err := blog.Wait(context.Background()); err != nil { - return errs.Wrap(err, "errors occurred during buildlog streaming") + if !buildlogstream.IsStreamDenied(err) { + return errs.Wrap(err, "errors occurred during buildlog streaming") + } + if err := s.completeWithoutStream(wp); err != nil { + return err + } } } @@ -348,6 +355,55 @@ func (s *setup) update() error { return nil } +// completeWithoutStream finishes an in-progress build without the build-log +// stream: it polls the build to completion, reads the resolved artifact +// download URLs, and drives the normal download/install path off them. +func (s *setup) completeWithoutStream(wp *workerpool.WorkerPool) error { + if s.opts.PollBuildPlan == nil { + return errs.New("no build plan poller configured") + } + + logging.Debug("completing the in-progress build without the build-log stream") + resolved, err := s.opts.PollBuildPlan() + if err != nil { + return errs.Wrap(err, "Could not complete the in-progress build without the build-log stream") + } + + toObtain, err := s.resolveDownloads(resolved.Artifacts().ToIDMap()) + if err != nil { + return err + } + for _, a := range toObtain { + wp.Submit(func() error { + if err := s.obtain(a); err != nil { + return errs.Wrap(err, "obtain failed") + } + return nil + }) + } + return nil +} + +// resolveDownloads dresses each still-building artifact with the download URL +// from the completed build plan and returns the artifacts to obtain. Artifacts +// that weren't waiting on the build are left untouched (they were obtained +// already). It errors if a still-building artifact has no resolved URL. +func (s *setup) resolveDownloads(resolved buildplan.ArtifactIDMap) ([]*buildplan.Artifact, error) { + var toObtain []*buildplan.Artifact + for _, a := range s.toUnpack { + if _, building := s.toBuild[a.ArtifactID]; !building { + continue + } + ra, ok := resolved[a.ArtifactID] + if !ok || ra.URL == "" { + return nil, errs.New("completed build plan is missing a download URL for artifact %s", a.ArtifactID.String()) + } + a.SetDownload(ra.URL, ra.Checksum) + toObtain = append(toObtain, a) + } + return toObtain, nil +} + func (s *setup) onArtifactBuildReady(blog *buildlog.BuildLog, artifact *buildplan.Artifact, cb func()) { if _, ok := s.toBuild[artifact.ArtifactID]; !ok { // No need to build, artifact can already be downloaded @@ -566,7 +622,15 @@ func (s *setup) decryptPayload(artifactName, unpackPath string) (outcome decrypt } logging.Debug("Detected encrypted payload in artifact %s", artifactName) - if len(s.opts.OrgKey) == 0 { + if s.opts.OrgKey == nil { + return decryptSkipped, nil + } + key, err := s.opts.OrgKey() + if err != nil { + logging.Debug("Could not obtain org key for artifact %s; skipping: %v", artifactName, errs.JoinMessage(err)) + return decryptSkipped, nil + } + if len(key) == 0 { return decryptSkipped, nil } @@ -575,7 +639,7 @@ func (s *setup) decryptPayload(artifactName, unpackPath string) (outcome decrypt if err != nil { return decryptNotEncrypted, errs.Wrap(err, "could not read encrypted payload header") } - if err := header.CheckKey(s.opts.OrgKey); err != nil { + if err := header.CheckKey(key); err != nil { return decryptNotEncrypted, errs.Wrap(err, "org key does not match encrypted artifact %s", artifactName) } @@ -595,7 +659,7 @@ func (s *setup) decryptPayload(artifactName, unpackPath string) (outcome decrypt if err != nil { return decryptNotEncrypted, errs.Wrap(err, "could not open encrypted payload") } - err = artifactcrypto.Decrypt(src, archivePath, s.opts.OrgKey) + err = artifactcrypto.Decrypt(src, archivePath, key) if cerr := src.Close(); cerr != nil { err = errs.Pack(err, errs.Wrap(cerr, "could not close encrypted payload")) } diff --git a/pkg/runtime/setup_denial_test.go b/pkg/runtime/setup_denial_test.go new file mode 100644 index 0000000000..07ab214010 --- /dev/null +++ b/pkg/runtime/setup_denial_test.go @@ -0,0 +1,73 @@ +package runtime + +import ( + "strings" + "testing" + + "github.com/ActiveState/cli/internal/chanutils/workerpool" + "github.com/ActiveState/cli/internal/errs" + "github.com/ActiveState/cli/pkg/buildplan" + "github.com/go-openapi/strfmt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestResolveDownloads verifies that the non-stream fallback copies download +// URLs from the completed build plan onto the artifacts that were waiting on +// the build, and leaves already-downloadable artifacts alone. +func TestResolveDownloads(t *testing.T) { + building := strfmt.UUID("11111111-1111-1111-1111-111111111111") + notBuilding := strfmt.UUID("22222222-2222-2222-2222-222222222222") + + buildingArt := &buildplan.Artifact{ArtifactID: building} + notBuildingArt := &buildplan.Artifact{ArtifactID: notBuilding} + + s := &setup{ + toUnpack: buildplan.ArtifactIDMap{building: buildingArt, notBuilding: notBuildingArt}, + toBuild: buildplan.ArtifactIDMap{building: buildingArt}, + } + + resolved := buildplan.ArtifactIDMap{ + building: &buildplan.Artifact{ArtifactID: building, URL: "https://dl/building", Checksum: "sha256:abc"}, + notBuilding: &buildplan.Artifact{ArtifactID: notBuilding, URL: "https://dl/other"}, + } + + toObtain, err := s.resolveDownloads(resolved) + require.NoError(t, err) + + require.Len(t, toObtain, 1, "only the still-building artifact needs obtaining") + assert.Equal(t, building, toObtain[0].ArtifactID) + assert.Equal(t, "https://dl/building", buildingArt.URL, "building artifact must get its resolved download URL") + assert.Equal(t, "sha256:abc", buildingArt.Checksum) + assert.Empty(t, notBuildingArt.URL, "an artifact that wasn't being built must be left untouched") +} + +// TestResolveDownloads_MissingURL verifies that a completed build plan missing a +// still-building artifact's URL is an error rather than a silent no-download. +func TestResolveDownloads_MissingURL(t *testing.T) { + building := strfmt.UUID("11111111-1111-1111-1111-111111111111") + buildingArt := &buildplan.Artifact{ArtifactID: building} + + s := &setup{ + toUnpack: buildplan.ArtifactIDMap{building: buildingArt}, + toBuild: buildplan.ArtifactIDMap{building: buildingArt}, + } + resolved := buildplan.ArtifactIDMap{building: &buildplan.Artifact{ArtifactID: building}} // no URL + + _, err := s.resolveDownloads(resolved) + require.Error(t, err) +} + +// TestCompleteWithoutStream_PollError verifies that a build that fails while +// polling (surfaced by the poller) is reported as a failure, not swallowed. +func TestCompleteWithoutStream_PollError(t *testing.T) { + s := &setup{opts: &Opts{ + PollBuildPlan: func() (*buildplan.BuildPlan, error) { + return nil, errs.New("build failed while polling") + }, + }} + err := s.completeWithoutStream(workerpool.New(1)) + require.Error(t, err) + assert.Contains(t, strings.ToLower(errs.JoinMessage(err)), "build failed while polling", + "the underlying build failure must be preserved") +} diff --git a/scripts/orgkeyserver.py b/scripts/orgkeyserver.py new file mode 100644 index 0000000000..b57c9d4b38 --- /dev/null +++ b/scripts/orgkeyserver.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Minimal org-key service for locally testing private ingredients. + +Serves the organization encryption key over HTTPS at GET /v1/org-key in the +contract the State Tool expects. For local testing only; not a shipped artifact. + +A self-signed certificate/key pair (SAN covering localhost + 127.0.0.1) is +generated automatically into test/ssl/ on startup, so no openssl binary is +needed. + +Run: + + python3 scripts/orgkeyserver.py + +Point the State Tool at it (the base URL only; the tool appends /v1/org-key). +Use the absolute cert path the server prints on startup, e.g.: + + state config set privateingredient.key_service_url https://127.0.0.1:8443 + state config set privateingredient.key_service_ca /path/to/test/ssl/cert.pem + # Optional bearer auth (start the server with --token ): + state config set privateingredient.bearer_token_env ORGKEY_TOKEN + export ORGKEY_TOKEN= + +`state publish --build` and the later install must share the same key, so reuse +the printed --key value across runs. +""" + +import argparse +import base64 +import hashlib +import json +import os +import secrets +import ssl +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer + +KEY_SIZE = 32 # AES-256 +ENDPOINT = "/v1/org-key" +CERT_DIR = "test/ssl" + + +def generate_self_signed(host, cert_dir=CERT_DIR): + """Write a self-signed cert/key pair into cert_dir; return their paths. + + Uses the 'cryptography' package (bundled in the project runtime) so no + external openssl binary is required. + """ + import datetime + import ipaddress + + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + cert_dir = os.path.abspath(cert_dir) + cert_path = os.path.join(cert_dir, "cert.pem") + key_path = os.path.join(cert_dir, "key.pem") + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) + dns_names = ["localhost"] + ip_addrs = ["127.0.0.1"] + try: + ipaddress.ip_address(host) + if host not in ip_addrs: + ip_addrs.append(host) + except ValueError: + if host not in dns_names: + dns_names.append(host) + alt_names = [x509.DNSName(n) for n in dns_names] + alt_names += [x509.IPAddress(ipaddress.ip_address(ip)) for ip in ip_addrs] + now = datetime.datetime.utcnow() + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=1)) + .not_valid_after(now + datetime.timedelta(days=365)) + .add_extension(x509.SubjectAlternativeName(alt_names), critical=False) + .sign(key, hashes.SHA256()) + ) + + if cert_dir: + os.makedirs(cert_dir, exist_ok=True) + with open(key_path, "wb") as f: + f.write(key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + )) + # Restrict the private key to owner-only; no-op-ish on Windows. + try: + os.chmod(key_path, 0o600) + except OSError: + pass + with open(cert_path, "wb") as f: + f.write(cert.public_bytes(serialization.Encoding.PEM)) + + return cert_path, key_path + + +def build_contract(org, key_id, raw_key): + return { + "schema": "activestate.pim.orgkey/v1", + "org": org, + "key_id": key_id, + "algorithm": "AES-256-GCM", + "encoding": "base64", + "key": "b64:" + base64.standard_b64encode(raw_key).decode("ascii"), + "fingerprint": "sha256:" + hashlib.sha256(raw_key).hexdigest(), + } + + +def make_handler(contract, token): + body = json.dumps(contract).encode("utf-8") + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path != ENDPOINT: + self.send_error(404) + return + if token and self.headers.get("Authorization") != "Bearer " + token: + self.send_error(401) + return + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + return Handler + + +def parse_key(encoded): + raw = base64.standard_b64decode(encoded) + if len(raw) != KEY_SIZE: + raise SystemExit(f"--key must decode to {KEY_SIZE} bytes, got {len(raw)}") + return raw + + +def main(): + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("--org", default="ActiveState-CLI-Testing", + help="organization the key belongs to; must match the project owner") + p.add_argument("--key", help="base64-encoded 32-byte AES key; generated and printed if omitted") + p.add_argument("--key-id", default="orgkey-test", help="opaque key identifier") + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, default=8443) + p.add_argument("--token", help="if set, require this value as a bearer token") + args = p.parse_args() + + if args.key: + raw_key = parse_key(args.key) + else: + raw_key = secrets.token_bytes(KEY_SIZE) + print("--key", base64.standard_b64encode(raw_key).decode("ascii"), file=sys.stderr) + + cert_path, key_path = generate_self_signed(args.host) + print("key_service_ca", cert_path, file=sys.stderr) + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 + ctx.load_cert_chain(certfile=cert_path, keyfile=key_path) + + handler = make_handler(build_contract(args.org, args.key_id, raw_key), args.token) + httpd = HTTPServer((args.host, args.port), handler) + httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True) + print(f"serving {ENDPOINT} for org {args.org!r} on https://{args.host}:{args.port}", file=sys.stderr) + try: + httpd.serve_forever() + except KeyboardInterrupt: + httpd.shutdown() + + +if __name__ == "__main__": + main() diff --git a/test/integration/config_int_test.go b/test/integration/config_int_test.go index ea5972d39d..b85c48789e 100644 --- a/test/integration/config_int_test.go +++ b/test/integration/config_int_test.go @@ -96,6 +96,7 @@ func (suite *ConfigIntegrationTestSuite) TestList() { cp := ts.Spawn("config") cp.Expect("Key") cp.Expect("Value") + cp.Expect("Source") cp.Expect("Default") cp.Expect("optin.buildscripts") cp.Expect("false") @@ -108,14 +109,52 @@ func (suite *ConfigIntegrationTestSuite) TestList() { cp = ts.Spawn("config") cp.Expect("Key") cp.Expect("Value") + cp.Expect("Source") cp.Expect("Default") cp.Expect("optin.buildscripts") - cp.Expect("true*") + // The value is set locally (via `state config set`), so the Source column reads "local". + cp.Expect("true") + cp.Expect("local") cp.ExpectExitCode(0) suite.Require().NotContains(cp.Snapshot(), constants.AsyncRuntimeConfig) } +func (suite *ConfigIntegrationTestSuite) TestListEnvSource() { + suite.OnlyRunForTags(tagsuite.Config) + ts := e2e.New(suite.T(), false) + defer ts.Close() + + // optin.buildscripts defaults to false. Override it via its canonical environment variable + // (without ever running `state config set`), so any non-default value proves the environment + // override took effect. + envVar := "ACTIVESTATE_CONFIG_OPTIN_BUILDSCRIPTS" + + // Human-readable table: the value reflects the environment override. + cp := ts.SpawnWithOpts( + e2e.OptArgs("config"), + e2e.OptAppendEnv(envVar+"=true"), + ) + cp.Expect("Key") + cp.Expect("Value") + cp.Expect("Source") + cp.Expect("Default") + cp.Expect("optin.buildscripts") + cp.Expect("true") + cp.ExpectExitCode(0) + + // JSON output reliably exposes the source metadata (the table wraps long env var names). The + // "environment" source and the overriding variable name are both reported. + cp = ts.SpawnWithOpts( + e2e.OptArgs("config", "-o", "json"), + e2e.OptAppendEnv(envVar+"=true"), + ) + cp.Expect(`"source":"environment"`) + cp.Expect(`"env":"` + envVar + `"`) + cp.ExpectExitCode(0) + AssertValidJSON(suite.T(), cp) +} + func (suite *ConfigIntegrationTestSuite) TestAPIHostConfig() { suite.OnlyRunForTags(tagsuite.Config) ts := e2e.New(suite.T(), false) diff --git a/version.txt b/version.txt index c325dfb59d..116e4d868e 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.48.1-RC2 +0.48.1-RC3