Skip to content
Open
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
24 changes: 22 additions & 2 deletions apisix/admin/standalone.lua
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ local core = require("apisix.core")
local config_local = require("apisix.core.config_local")
local config_yaml = require("apisix.core.config_yaml")
local config_validate = require("apisix.admin.config_validate")
local file = require("apisix.cli.file")

local shared_dict = ngx.shared["standalone-config"]
local status_shared_dict = ngx.shared["standalone-status"]
Expand Down Expand Up @@ -275,8 +276,18 @@ local function update(ctx)
end

-- parse the request body
local data
if core.string.has_prefix(content_type, "application/yaml") then
-- ${{VAR}} references are resolved the way the config file resolves them:
-- in the raw text for YAML, so that quoting decides the type, and after
-- decoding for JSON, whose types are explicit.
local is_yaml = core.string.has_prefix(content_type, "application/yaml")
local data, resolve_err
if is_yaml then
req_body, resolve_err = file.resolve_conf_var_in_text(req_body)
if not req_body then
return core.response.exit(400, {
error_msg = "failed to resolve variables in config: " .. resolve_err
})
end
local ok, result = pcall(yaml.load, req_body, { all = false })
-- a null document (`~`) loads as lyaml's sentinel table, which would
-- otherwise pass as an empty object and clear every resource
Expand All @@ -293,6 +304,15 @@ local function update(ctx)
core.log.error("invalid request body, err: ", err)
return core.response.exit(400, {error_msg = "invalid request body: " .. err})
end
if not is_yaml and type(data) == "table" then
local ok
ok, resolve_err = file.resolve_conf_var(data)
if not ok then
return core.response.exit(400, {
error_msg = "failed to resolve variables in config: " .. resolve_err
})
end
end
req_body = data

local config, err = get_config()
Expand Down
129 changes: 129 additions & 0 deletions t/admin/standalone.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -944,4 +944,133 @@ describe('Validate API - Standalone', () => {
});
});
});

describe('Variable resolution', () => {
it('resolves ${{VAR}} references in config pushed via the Admin API', async () => {
mockDigest += 1;
const config = {
routes: [
{
id: 'r_var',
uri: '/r_var',
upstream: {
nodes: { '127.0.0.1:1980': 1 },
type: 'roundrobin',
},
// The proxy-rewrite uri uses a ${{VAR:=default}} reference. The
// gateway must resolve it to the default ("/hello"); if it were
// left literal, the upstream would receive "${{...}}" instead of
// "/hello" and would not return the hello body.
plugins: {
'proxy-rewrite': { uri: '${{STANDALONE_ENV_TEST:=/hello}}' },
},
},
],
};
const putResp = await client.put(ENDPOINT, config, {
headers: { [HEADER_DIGEST]: mockDigest },
});
expect(putResp.status).toEqual(202);

const resp = await client.get('/r_var');
expect(resp.status).toEqual(200);
expect(resp.data).toEqual('hello world\n');
});

it('coerces a resolved ${{VAR}} to its native type before validation', async () => {
mockDigest += 1;
const config = {
routes: [
{
id: 'r_var_typed',
uri: '/r_var_typed',
upstream: {
nodes: { '127.0.0.1:1980': 1 },
type: 'roundrobin',
// retries is an integer field. The reference resolves to the
// default "2", which resolve_conf_var coerces to the number 2;
// a literal string "2" would fail integer schema validation
// with 400, so a 202 proves the value was coerced.
retries: '${{STANDALONE_ENV_RETRIES:=2}}',
},
plugins: {
'proxy-rewrite': { uri: '/hello' },
},
},
],
};
const putResp = await client.put(ENDPOINT, config, {
headers: { [HEADER_DIGEST]: mockDigest },
});
expect(putResp.status).toEqual(202);

const resp = await client.get('/r_var_typed');
expect(resp.status).toEqual(200);
expect(resp.data).toEqual('hello world\n');
});

it('resolves ${{VAR}} in a YAML body before parsing, so quoting decides the type', async () => {
mockDigest += 1;
// An unquoted reference becomes the integer retries requires, while a
// quoted one stays a string even though its value looks like a number.
const body = [
'routes:',
' - id: r_var_yaml',
' uri: /r_var_yaml',
' desc: "${{STANDALONE_ENV_DESC:=123}}"',
' upstream:',
' nodes:',
' "127.0.0.1:1980": 1',
' type: roundrobin',
' retries: ${{STANDALONE_ENV_RETRIES:=2}}',
' plugins:',
' proxy-rewrite:',
' uri: ${{STANDALONE_ENV_TEST:=/hello}}',
'',
].join('\n');
const putResp = await client.put(ENDPOINT, body, {
headers: {
'Content-Type': 'application/yaml',
[HEADER_DIGEST]: mockDigest,
},
});
expect(putResp.status).toEqual(202);

const dump = await client.get(ENDPOINT);
const route = dump.data.routes.find((r) => r.id === 'r_var_yaml');
expect(route.desc).toEqual('123');
expect(route.upstream.retries).toEqual(2);

const resp = await client.get('/r_var_yaml');
expect(resp.status).toEqual(200);
expect(resp.data).toEqual('hello world\n');
});

it('rejects config with an unresolvable ${{VAR}} reference', async () => {
mockDigest += 1;
const config = {
routes: [
{
id: 'r_var_bad',
uri: '/r_var_bad',
upstream: {
nodes: { '127.0.0.1:1980': 1 },
type: 'roundrobin',
},
// No matching environment variable and no ":=default", so
// resolution fails and the push is rejected with 400 rather than
// storing a literal "${{...}}".
plugins: {
'proxy-rewrite': { uri: '${{STANDALONE_ENV_UNDEFINED}}' },
},
},
],
};
const resp = await client
.put(ENDPOINT, config, { headers: { [HEADER_DIGEST]: mockDigest } })
.catch((err) => err.response);
expect(resp.status).toEqual(400);
expect(resp.data.error_msg).toContain('failed to resolve variables in config');
});
});
});
Loading