Skip to content

Commit 7bca0cb

Browse files
committed
fix(cli): resolve deploy ${VAR} secrets from the project root for config/deploy.yml
ConfigLoader built its lazy SecretResolver with the YAML file's own directory as projectRoot, so the standard config/deploy.yml layout looked for config/.kamal/secrets — a file nothing creates — and the .kamal/secrets leg of ${VAR} interpolation silently never fired (masked by the process-env fallback). $projectRootFor() now steps up to the parent of a directory named config, so interpolation reads the project-root .kamal/secrets that wheels deploy init scaffolds and that DeploySecretsCli and registry login already use. A deploy.yml outside a config/ directory keeps resolving .kamal/secrets alongside itself (existing spec unchanged). Fixes #3084 Signed-off-by: Peter Amiri <peter@alurium.com>
1 parent bb98ffe commit 7bca0cb

3 files changed

Lines changed: 67 additions & 3 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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))

cli/lucli/services/deploy/config/ConfigLoader.cfc

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,15 @@ component {
4848
}
4949

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

69+
/**
70+
* Derive the secrets project root from a deploy.yml path.
71+
*
72+
* When the YAML sits inside a directory named `config` (the standard
73+
* `wheels deploy init` layout: <root>/config/deploy.yml), the project
74+
* root is the PARENT of that directory, so `.kamal/secrets` resolves
75+
* from the project root — agreeing with DeploySecretsCli and the
76+
* registry-login SecretResolver default. Any other layout keeps the
77+
* YAML's own directory as the root (`.kamal/secrets` alongside it).
78+
*/
79+
public string function $projectRootFor(required string path) {
80+
var dir = getDirectoryFromPath(arguments.path);
81+
var lastChar = right(dir, 1);
82+
var trimmed = (len(dir) > 1 && (lastChar == "/" || lastChar == "\")) ? left(dir, len(dir) - 1) : dir;
83+
if (len(trimmed) > 1 && listLast(trimmed, "/\") == "config") {
84+
var parent = getDirectoryFromPath(trimmed);
85+
if (len(parent)) return parent;
86+
}
87+
return dir;
88+
}
89+
6590
/**
6691
* Build the destination-overlay filename from a base path.
6792
*

cli/lucli/tests/specs/deploy/config/ConfigLoaderSpec.cfc

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,44 @@ component extends="wheels.wheelstest.system.BaseSpec" {
6969
directoryDelete(tmpRoot, true);
7070
});
7171

72+
it("resolves ${VAR} from project-root .kamal/secrets when deploy.yml lives in config/ (##3084)", () => {
73+
var tmpRoot = getTempDirectory() & "/wheels-deploy-cfg-root-" & createUUID();
74+
directoryCreate(tmpRoot & "/.kamal", true, true);
75+
directoryCreate(tmpRoot & "/config", true, true);
76+
fileWrite(tmpRoot & "/.kamal/secrets", "ROOT_SECRET_VAR=fromProjectRoot");
77+
78+
var yml = tmpRoot & "/config/deploy.yml";
79+
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]}");
80+
81+
var cfg = new cli.lucli.services.deploy.config.ConfigLoader().load(yml);
82+
expect(cfg.image()).toBe("acme/fromProjectRoot");
83+
84+
directoryDelete(tmpRoot, true);
85+
});
86+
87+
it("resolves destination-overlay secrets from the project root for the config/ layout", () => {
88+
var tmpRoot = getTempDirectory() & "/wheels-deploy-cfg-dest-" & createUUID();
89+
directoryCreate(tmpRoot & "/.kamal", true, true);
90+
directoryCreate(tmpRoot & "/config", true, true);
91+
fileWrite(tmpRoot & "/.kamal/secrets", "DEST_SECRET_VAR=fromBase");
92+
fileWrite(tmpRoot & "/.kamal/secrets.production", "DEST_SECRET_VAR=fromProductionOverlay");
93+
94+
var yml = tmpRoot & "/config/deploy.yml";
95+
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]}");
96+
97+
var cfg = new cli.lucli.services.deploy.config.ConfigLoader().load(yml, {destination: "production"});
98+
expect(cfg.image()).toBe("acme/fromProductionOverlay");
99+
100+
directoryDelete(tmpRoot, true);
101+
});
102+
103+
it("$projectRootFor steps up to the parent of a config/ directory and leaves other layouts alone", () => {
104+
var loader = new cli.lucli.services.deploy.config.ConfigLoader();
105+
expect(loader.$projectRootFor("/srv/myapp/config/deploy.yml")).toBe("/srv/myapp/");
106+
expect(loader.$projectRootFor("/srv/myapp/deploy.yml")).toBe("/srv/myapp/");
107+
expect(loader.$projectRootFor("/config/deploy.yml")).toBe("/");
108+
});
109+
72110
});
73111

74112
}

0 commit comments

Comments
 (0)