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
1 change: 1 addition & 0 deletions changelog.d/deploy-secrets-project-root.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `wheels deploy` `${VAR}` interpolation now resolves `.kamal/secrets` from the project root for the standard `config/deploy.yml` layout, agreeing with `wheels deploy secrets print` and registry login. Previously it looked for `config/.kamal/secrets` — a file nothing creates — so secrets-file interpolation silently never fired ([#3084](https://github.com/wheels-dev/wheels/issues/3084))
31 changes: 28 additions & 3 deletions cli/lucli/services/deploy/config/ConfigLoader.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,15 @@ component {
}

// Build a SecretResolver lazily if the caller didn't inject one.
// Project root defaults to the directory containing the YAML file —
// this lets `.kamal/secrets` alongside `deploy.yml` resolve naturally.
// Project root is derived from the YAML path via $projectRootFor():
// the standard `config/deploy.yml` layout resolves `.kamal/secrets`
// at the PROJECT ROOT (the parent of config/) — the same root
// `wheels deploy init` scaffolds and DeploySecretsCli reads — while
// a deploy.yml outside a config/ directory keeps resolving
// `.kamal/secrets` alongside itself. See issue 3084.
if (!isObject(variables.secretResolver)) {
variables.secretResolver = new modules.wheels.services.deploy.lib.SecretResolver({
projectRoot: getDirectoryFromPath(arguments.path),
projectRoot: $projectRootFor(arguments.path),
destination: dest
});
}
Expand All @@ -62,6 +66,27 @@ component {
return new Config(raw, {destination: dest});
}

/**
* Derive the secrets project root from a deploy.yml path.
*
* When the YAML sits inside a directory named `config` (the standard
* `wheels deploy init` layout: <root>/config/deploy.yml), the project
* root is the PARENT of that directory, so `.kamal/secrets` resolves
* from the project root — agreeing with DeploySecretsCli and the
* registry-login SecretResolver default. Any other layout keeps the
* YAML's own directory as the root (`.kamal/secrets` alongside it).
*/
public string function $projectRootFor(required string path) {
var dir = getDirectoryFromPath(arguments.path);
var lastChar = right(dir, 1);
var trimmed = (len(dir) > 1 && (lastChar == "/" || lastChar == "\")) ? left(dir, len(dir) - 1) : dir;
if (len(trimmed) > 1 && listLast(trimmed, "/\") == "config") {
var parent = getDirectoryFromPath(trimmed);
if (len(parent)) return parent;
}
return dir;
}

/**
* Build the destination-overlay filename from a base path.
*
Expand Down
38 changes: 38 additions & 0 deletions cli/lucli/tests/specs/deploy/config/ConfigLoaderSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,44 @@ component extends="wheels.wheelstest.system.BaseSpec" {
directoryDelete(tmpRoot, true);
});

it("resolves ${VAR} from project-root .kamal/secrets when deploy.yml lives in config/ (##3084)", () => {
var tmpRoot = getTempDirectory() & "/wheels-deploy-cfg-root-" & createUUID();
directoryCreate(tmpRoot & "/.kamal", true, true);
directoryCreate(tmpRoot & "/config", true, true);
fileWrite(tmpRoot & "/.kamal/secrets", "ROOT_SECRET_VAR=fromProjectRoot");

var yml = tmpRoot & "/config/deploy.yml";
fileWrite(yml, "service: demo#chr(10)#image: acme/${ROOT_SECRET_VAR}#chr(10)#servers: [1.2.3.4]#chr(10)#registry: {username: u, password: [X]}");

var cfg = new cli.lucli.services.deploy.config.ConfigLoader().load(yml);
expect(cfg.image()).toBe("acme/fromProjectRoot");

directoryDelete(tmpRoot, true);
});

it("resolves destination-overlay secrets from the project root for the config/ layout", () => {
var tmpRoot = getTempDirectory() & "/wheels-deploy-cfg-dest-" & createUUID();
directoryCreate(tmpRoot & "/.kamal", true, true);
directoryCreate(tmpRoot & "/config", true, true);
fileWrite(tmpRoot & "/.kamal/secrets", "DEST_SECRET_VAR=fromBase");
fileWrite(tmpRoot & "/.kamal/secrets.production", "DEST_SECRET_VAR=fromProductionOverlay");

var yml = tmpRoot & "/config/deploy.yml";
fileWrite(yml, "service: demo#chr(10)#image: acme/${DEST_SECRET_VAR}#chr(10)#servers: [1.2.3.4]#chr(10)#registry: {username: u, password: [X]}");

var cfg = new cli.lucli.services.deploy.config.ConfigLoader().load(yml, {destination: "production"});
expect(cfg.image()).toBe("acme/fromProductionOverlay");

directoryDelete(tmpRoot, true);
});

it("$projectRootFor steps up to the parent of a config/ directory and leaves other layouts alone", () => {
var loader = new cli.lucli.services.deploy.config.ConfigLoader();
expect(loader.$projectRootFor("/srv/myapp/config/deploy.yml")).toBe("/srv/myapp/");
expect(loader.$projectRootFor("/srv/myapp/deploy.yml")).toBe("/srv/myapp/");
expect(loader.$projectRootFor("/config/deploy.yml")).toBe("/");
});

});

}
Expand Down
Loading