Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Select the profile for `secretspec import` with `--profile` or `-P`,
including when moving secrets with `--delete-source`.

- Pin static binary installations using a version in the installer URL, such
as `https://install.secretspec.dev/v0.21.1`. The URL without a version still
installs the latest release.
Expand Down
17 changes: 17 additions & 0 deletions docs/src/content/docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,23 @@ $ secretspec import <FROM_PROVIDER> [--delete-source]

The destination provider and profile are determined from your configuration. Secrets that already exist in the destination provider will not be overwritten.

#### Profile selection

<VersionCompatibility version="0.22" />

Use `-P, --profile <PROFILE>` to import the selected profile's secrets. The
selected profile applies to both source and destination addresses, including
secrets inherited from the default profile. The flag overrides
`SECRETSPEC_PROFILE` and the configured default profile. Without the flag,
profile selection is unchanged.

```bash
# Import into the production profile (SecretSpec 0.22+)
$ secretspec import dotenv:.env.production --profile production
```

#### Import behavior

In SecretSpec 0.19+, the source and destination resolve their addresses
independently. A source alias can use its own [provider `ref` template or
per-secret scoped ref](/concepts/references/#different-coordinates-per-provider-019),
Expand Down
10 changes: 9 additions & 1 deletion secretspec/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,9 @@ enum Commands {
/// Provider backend to import from (secrets will be imported to the default provider)
#[arg(add = clap_complete::ArgValueCompleter::new(completion::providers))]
from_provider: String,
/// Profile to import secrets into (0.22+)
#[arg(short = 'P', long, env = "SECRETSPEC_PROFILE", add = clap_complete::ArgValueCompleter::new(completion::profiles))]
profile: Option<String>,
/// Delete a source value only after the destination contains the same value (0.18+)
#[arg(long)]
delete_source: bool,
Expand Down Expand Up @@ -1813,9 +1816,13 @@ pub fn main() -> Result<()> {
// Import secrets from one provider to another
Commands::Import {
from_provider,
profile,
delete_source,
} => {
let app = load_secrets(&cli.file, &cli.reason, &caller)?;
let mut app = load_secrets(&cli.file, &cli.reason, &caller)?;
if let Some(profile) = profile {
app.set_profile(profile);
}
if delete_source {
app.import_with_delete_source(&from_provider)
.into_diagnostic()
Expand Down Expand Up @@ -2864,6 +2871,7 @@ API_KEY = { description = "Existing" }
Commands::Import {
from_provider,
delete_source: true,
..
} if from_provider == "dotenv://.env"
));
}
Expand Down
147 changes: 147 additions & 0 deletions secretspec/tests/import_profile.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;

fn env_values(path: &Path) -> HashMap<String, String> {
dotenv::EnvLoader::with_path(path)
.sequence(dotenv::EnvSequence::InputOnly)
.load()
.unwrap()
.into_iter()
.collect()
}

#[test]
fn import_profile_selection_controls_secrets_and_both_provider_addresses() {
for (args, environment_profile, configured_profile, expected_profile) in [
(
vec!["--profile", "production"],
Some("development"),
Some("default"),
"production",
),
(
vec!["-P", "production"],
None,
Some("development"),
"production",
),
(
vec!["--profile", "production", "--delete-source"],
Some("development"),
Some("default"),
"production",
),
(
vec![],
Some("production"),
Some("development"),
"production",
),
(vec![], None, Some("production"), "production"),
(vec![], None, None, "default"),
(
vec!["--profile", "default"],
Some("production"),
Some("production"),
"default",
),
] {
let temp = TempDir::new().unwrap();
let project = temp.path();
fs::write(
project.join("secretspec.toml"),
r#"
[project]
name = "import-profile"
revision = "1.0"
require_reason = false

[providers]
source = { uri = "dotenv:.env.source", ref = { item = "SOURCE_{profile}_{key}" } }
target = { uri = "dotenv:.env.target", ref = { item = "TARGET_{profile}_{key}" } }

[profiles.default]
SHARED = { description = "Inherited secret" }

[profiles.production]
TOKEN = { description = "Production token" }

[profiles.development]
DEV_ONLY = { description = "Development token" }
"#,
)
.unwrap();
let config_home = project.join("config");
let config_dir = if cfg!(windows) {
config_home.join("secretspec/config")
} else {
config_home.join("secretspec")
};
fs::create_dir_all(&config_dir).unwrap();
let profile_setting = configured_profile
.map(|profile| format!("profile = \"{profile}\"\n"))
.unwrap_or_default();
fs::write(
config_dir.join("config.toml"),
format!("[defaults]\nprovider = \"target\"\n{profile_setting}"),
)
.unwrap();
let source_path = project.join(".env.source");
fs::write(
&source_path,
"SOURCE_default_SHARED=default-value\n\
SOURCE_production_SHARED=production-shared\n\
SOURCE_production_TOKEN=production-token\n\
SOURCE_development_SHARED=development-shared\n\
SOURCE_development_DEV_ONLY=development-token\n",
)
.unwrap();
let original_source = env_values(&source_path);
let mut command = Command::new(env!("CARGO_BIN_EXE_secretspec"));
command
.args(["import", "source"])
.args(&args)
.current_dir(project)
.env("HOME", project)
.env("XDG_CONFIG_HOME", &config_home)
.env("APPDATA", &config_home)
.env("XDG_STATE_HOME", project.join("state"))
.env("LOCALAPPDATA", project.join("state"))
.env_remove("SECRETSPEC_PROVIDER")
.env_remove("SECRETSPEC_PROFILE")
.env_remove("SECRETSPEC_SCOPE")
.env_remove("SECRETSPEC_REASON");
if let Some(profile) = environment_profile {
command.env("SECRETSPEC_PROFILE", profile);
}
let output = command.output().unwrap();
assert!(
output.status.success(),
"args {args:?}, env {environment_profile:?}, config {configured_profile:?}: {}",
String::from_utf8_lossy(&output.stderr)
);

let expected_keys = if expected_profile == "production" {
vec!["SHARED", "TOKEN"]
} else {
vec!["SHARED"]
};
let mut expected_source = original_source.clone();
let mut expected_target = HashMap::new();
for key in expected_keys {
let source_key = format!("SOURCE_{expected_profile}_{key}");
expected_target.insert(
format!("TARGET_{expected_profile}_{key}"),
original_source[&source_key].clone(),
);
if args.contains(&"--delete-source") {
expected_source.remove(&source_key);
}
}
assert_eq!(env_values(&project.join(".env.target")), expected_target);
assert_eq!(env_values(&source_path), expected_source);
}
}
Loading