fix(cf): dnsproxy name support and dnsadd on/off

This commit is contained in:
2026-03-19 19:56:27 +08:00
parent 36f11fa846
commit 73a829a4e9
9 changed files with 636 additions and 0 deletions

132
cmd/ops-runner/main.go Normal file
View File

@@ -0,0 +1,132 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"ops-assistant/internal/core/ops"
)
func main() {
if len(os.Args) < 4 {
fmt.Println("usage: ops-runner <db_path> <base_dir> <command_text>")
os.Exit(2)
}
dbPath := os.Args[1]
baseDir := os.Args[2]
cmd := os.Args[3]
parts := strings.Fields(cmd)
if len(parts) < 2 {
fmt.Println("ERR: invalid command")
os.Exit(2)
}
switch {
case len(parts) >= 2 && parts[0] == "/cf" && parts[1] == "dnsadd":
if len(parts) < 4 {
fmt.Println("ERR: /cf dnsadd <name> <content> [on|off] [type]")
os.Exit(2)
}
inputs := map[string]string{
"name": parts[2],
"content": parts[3],
"type": "A",
"proxied": "false",
}
if len(parts) >= 5 {
switch strings.ToLower(parts[4]) {
case "on":
inputs["proxied"] = "true"
if len(parts) >= 6 {
inputs["type"] = parts[5]
}
case "off":
inputs["proxied"] = "false"
if len(parts) >= 6 {
inputs["type"] = parts[5]
}
case "true":
inputs["proxied"] = "true"
if len(parts) >= 6 {
inputs["type"] = parts[5]
}
case "false":
inputs["proxied"] = "false"
if len(parts) >= 6 {
inputs["type"] = parts[5]
}
default:
inputs["type"] = parts[4]
}
}
jobID, _, err := ops.RunOnce(dbPath, filepath.Clean(baseDir), cmd, "cf_dns_add", 1, inputs)
if err != nil {
fmt.Printf("ERR: %v\n", err)
os.Exit(1)
}
fmt.Printf("OK job=%d\n", jobID)
case len(parts) >= 2 && parts[0] == "/cf" && parts[1] == "dnsproxy":
if len(parts) < 4 {
fmt.Println("ERR: /cf dnsproxy <record_id|name> on|off")
os.Exit(2)
}
mode := strings.ToLower(parts[3])
if mode != "on" && mode != "off" {
fmt.Println("ERR: /cf dnsproxy <record_id|name> on|off")
os.Exit(2)
}
proxied := "false"
if mode == "on" {
proxied = "true"
}
target := parts[2]
inputs := map[string]string{
"proxied": proxied,
"record_id": "__empty__",
"name": "__empty__",
}
if strings.Contains(target, ".") {
inputs["name"] = target
} else {
inputs["record_id"] = target
}
jobID, _, err := ops.RunOnce(dbPath, filepath.Clean(baseDir), cmd, "cf_dns_proxy", 1, inputs)
if err != nil {
fmt.Printf("ERR: %v\n", err)
os.Exit(1)
}
fmt.Printf("OK job=%d\n", jobID)
case len(parts) >= 2 && parts[0] == "/cpa" && parts[1] == "status":
jobID, _, err := ops.RunOnce(dbPath, filepath.Clean(baseDir), cmd, "cpa_status", 1, map[string]string{})
if err != nil {
fmt.Printf("ERR: %v\n", err)
os.Exit(1)
}
fmt.Printf("OK job=%d\n", jobID)
case len(parts) >= 3 && parts[0] == "/cpa" && parts[1] == "usage" && parts[2] == "backup":
jobID, _, err := ops.RunOnce(dbPath, filepath.Clean(baseDir), cmd, "cpa_usage_backup", 1, map[string]string{})
if err != nil {
fmt.Printf("ERR: %v\n", err)
os.Exit(1)
}
fmt.Printf("OK job=%d\n", jobID)
case len(parts) >= 4 && parts[0] == "/cpa" && parts[1] == "usage" && parts[2] == "restore":
inputs := map[string]string{
"backup_id": parts[3],
}
jobID, _, err := ops.RunOnce(dbPath, filepath.Clean(baseDir), cmd, "cpa_usage_restore", 1, inputs)
if err != nil {
fmt.Printf("ERR: %v\n", err)
os.Exit(1)
}
fmt.Printf("OK job=%d\n", jobID)
default:
fmt.Println("ERR: unsupported command")
os.Exit(2)
}
}

BIN
dist/ops-assistant-v0.0.1-linux-amd64 vendored Executable file

Binary file not shown.

View File

@@ -0,0 +1 @@
55bfe12944a42957532b9f63492d9ed8ca600419c4352ffa35344a62598bc019 dist/ops-assistant-v0.0.1-linux-amd64

View File

@@ -0,0 +1,79 @@
# CF DNS 命令修复与扩展记录2026-03-19
## 背景
用户要求:
- `/cf dnsproxy` 支持直接用域名,例如:`/cf dnsproxy ima.good.xx.kg on`
- `/cf dnsadd` 最后参数用 `on/off` 表示是否开启代理
线上报错:
- `yaml: line 8: did not find expected key`
- `/cf dnsproxy` 解析失败bash: bad substitution
## 改动概览
1) **命令解析**
- `internal/module/cf/commands.go`
- `/cf dnsproxy` 支持 `record_id|name`
- `/cf dnsadd` 支持 `on/off`(兼容 true/false当未提供 on/off 时把第4参数视为类型
2) **帮助文案**
- `internal/module/cf/module.go`
- `internal/core/ops/service.go`
- 更新 `/cf dnsadd``/cf dnsproxy` 的参数示例
3) **runbook 修复**
- `runbooks/cf_dns_proxy.yaml`
- 解决 YAML 行内命令渲染与变量替换问题
- 修复 `${env.INPUT_RECORD_ID}` 未替换导致 bash 报错
- 加入占位值 `__empty__`,避免空变量导致替换缺失
- `update_dns` 中 JSON 通过单引号包裹,避免 shell 分词/换行破坏
4) **ops-runner 支持**
- `cmd/ops-runner/main.go`
- 增加 `/cf dnsproxy` 支持
- `/cf dnsadd` 参数改为 on/off
## 问题与修复记录
### 1. YAML 解析错误
- 现象:`yaml: line 8: did not find expected key`
- 原因runbook 中 command 复杂引号/换行组合导致 YAML 解析失败
- 修复:重写 `cf_dns_proxy.yaml` command 区块
### 2. dnsproxy 变量替换失败
- 现象:`bash: ${env.INPUT_RECORD_ID}: bad substitution`
- 原因输入为空时没有替换占位shell 直接解析 `${env.INPUT_RECORD_ID}`
- 修复InputsFn 总是注入 `record_id/name` 占位值runbook 将 `__empty__` 转为空
### 3. dnsproxy update 失败JSON 被 shell 吞掉)
- 现象:`bash: line 1: true,: command not found`
- 原因:`${steps.resolve_dns.output}` 未加引号JSON 被 shell 拆分
- 修复:`INPUT_JSON='${steps.resolve_dns.output}'`
### 4. dnsadd on/off 支持
- 现象:`DNS record type "on" is invalid`
- 原因:解析逻辑未识别 on/off误当作类型
- 修复InputsFn 与 ops-runner 同步支持 `on/off`
### 5. 测试记录创建失败127.0.0.1
- 现象:`Target 127.0.0.1 is not allowed for a proxied record`
- 处理:改用公网 IP 199.188.198.12
## 测试结果
1) 新增测试记录
```
/cf dnsadd test001.good.xx.kg 199.188.198.12 on
```
- 成功创建proxied=true
2) 代理切换
```
/cf dnsproxy ima.good.xx.kg on
```
- 成功更新proxied=true
## 产物
- 修复代码与 runbook
- 版本化二进制输出dist/ 目录)
## 注意事项
- proxied=on 不能指向 127.0.0.1 等内网回环地址
- runbook command 中 JSON 建议统一使用单引号包裹

View File

@@ -0,0 +1,100 @@
package ops
import (
"fmt"
"strings"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"ops-assistant/internal/core/command"
coremodule "ops-assistant/internal/core/module"
"ops-assistant/internal/core/registry"
)
type Service struct {
dbPath string
baseDir string
registry *registry.Registry
db *gorm.DB
}
func NewService(dbPath, baseDir string, reg *registry.Registry) *Service {
db, _ := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
return &Service{dbPath: dbPath, baseDir: baseDir, registry: reg, db: db}
}
func (s *Service) Handle(userID int64, text string) (bool, string) {
if !strings.HasPrefix(strings.TrimSpace(text), "/") {
return false, ""
}
cmd, _, err := command.ParseWithInputs(text)
if err != nil {
return false, ""
}
// 通用帮助
if cmd.Module == "help" || cmd.Name == "/help" || cmd.Name == "/start" {
return true, s.helpText()
}
if cmd.Module == "ops" && (len(cmd.Args) == 0 || cmd.Args[0] == "help") {
return true, s.helpText()
}
if cmd.Module == "ops" && len(cmd.Args) > 0 && cmd.Args[0] == "modules" {
return true, s.modulesStatusText()
}
if cmd.Module != "" && cmd.Module != "ops" && s.db != nil {
if !coremodule.IsEnabled(s.db, cmd.Module) {
return true, fmt.Sprintf("[ERR_FEATURE_DISABLED] 模块未启用: %s开关: enable_module_%s", cmd.Module, cmd.Module)
}
}
out, handled, err := s.registry.Handle(userID, cmd)
if !handled {
return false, ""
}
if err != nil {
return true, "❌ OPS 执行失败: " + err.Error()
}
return true, out
}
func (s *Service) helpText() string {
lines := []string{
"🛠️ OPS 交互命令:",
"- /ops modules (查看模块启用状态)",
"- /cpa help",
"- /cpa status",
"- /cpa usage backup",
"- /cpa usage restore <backup_id> [--confirm YES_RESTORE] [--dry-run]",
"- /cf status (需要 enable_module_cf",
"- /cf zones (需要 enable_module_cf",
"- /cf dns list <zone_id> (需要 enable_module_cf",
"- /cf dns update <zone_id> <record_id> <type> <name> <content> [ttl] [proxied:true|false] (需要 enable_module_cf",
"- /cf dnsadd <name> <content> [on|off] [type] (需要 enable_module_cf",
"- /cf dnsset <record_id> <content> [true] (需要 enable_module_cf",
"- /cf dnsdel <record_id> YES (需要 enable_module_cf",
"- /cf dnsproxy <record_id|name> on|off (需要 enable_module_cf",
"- /cf workers list (需要 enable_module_cf",
"- /mail status (需要 enable_module_mail",
}
return strings.Join(lines, "\n")
}
func (s *Service) modulesStatusText() string {
mods := s.registry.ListModules()
if len(mods) == 0 {
return "暂无已注册模块"
}
lines := []string{"🧩 模块状态:"}
for _, m := range mods {
enabled := false
if s.db != nil {
enabled = coremodule.IsEnabled(s.db, m)
}
state := "disabled"
if enabled {
state = "enabled"
}
lines = append(lines, fmt.Sprintf("- %s: %s", m, state))
}
lines = append(lines, "\n可用命令/ops modules")
return strings.Join(lines, "\n")
}

View File

@@ -0,0 +1,254 @@
package cf
import (
"fmt"
"strings"
"ops-assistant/internal/core/ecode"
coremodule "ops-assistant/internal/core/module"
)
func commandSpecs() []coremodule.CommandSpec {
return []coremodule.CommandSpec{
{
Prefixes: []string{"/cf status"},
Template: coremodule.CommandTemplate{
RunbookName: "cf_status",
Gate: coremodule.Gate{
NeedFlag: "enable_module_cf",
AllowDryRun: true,
},
DryRunMsg: "🧪 dry-run: 将执行 /cf status未实际执行",
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf status 已执行job=%d", jobID) },
},
ErrPrefix: "/cf status 执行失败: ",
},
{
Prefixes: []string{"/cf zones"},
Template: coremodule.CommandTemplate{
RunbookName: "cf_zones",
Gate: coremodule.Gate{
NeedFlag: "enable_module_cf",
AllowDryRun: true,
},
DryRunMsg: "🧪 dry-run: 将执行 /cf zones未实际执行",
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf zones 已执行job=%d", jobID) },
},
ErrPrefix: "/cf zones 执行失败: ",
},
{
Prefixes: []string{"/cf dns list"},
Template: coremodule.CommandTemplate{
RunbookName: "cf_dns_list",
InputsFn: func(_ string, parts []string) (map[string]string, error) {
if len(parts) < 4 {
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dns list <zone_id>"))
}
return map[string]string{"zone_id": parts[3]}, nil
},
Gate: coremodule.Gate{
NeedFlag: "enable_module_cf",
AllowDryRun: true,
},
DryRunMsg: "🧪 dry-run: 将执行 /cf dns list <zone_id>(未实际执行)",
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dns list 已执行job=%d", jobID) },
},
ErrPrefix: "/cf dns list 执行失败: ",
ErrHint: "/cf dns list <zone_id>",
},
{
Prefixes: []string{"/cf dns update"},
Template: coremodule.CommandTemplate{
RunbookName: "cf_dns_update",
InputsFn: func(_ string, parts []string) (map[string]string, error) {
if len(parts) < 8 {
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dns update <zone_id> <record_id> <type> <name> <content> [ttl] [proxied]"))
}
inputs := map[string]string{
"zone_id": parts[3],
"record_id": parts[4],
"type": parts[5],
"name": parts[6],
"content": parts[7],
}
if len(parts) >= 9 {
inputs["ttl"] = parts[8]
}
if len(parts) >= 10 {
inputs["proxied"] = parts[9]
}
return inputs, nil
},
Gate: coremodule.Gate{
NeedFlag: "enable_module_cf",
AllowDryRun: true,
},
DryRunMsg: "🧪 dry-run: 将执行 /cf dns update未实际执行",
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dns update 已执行job=%d", jobID) },
},
ErrPrefix: "/cf dns update 执行失败: ",
ErrHint: "/cf dns update <zone_id> <record_id> <type> <name> <content> [ttl] [proxied:true|false]",
},
{
Prefixes: []string{"/cf dnsadd"},
Template: coremodule.CommandTemplate{
RunbookName: "cf_dns_add",
InputsFn: func(_ string, parts []string) (map[string]string, error) {
if len(parts) < 4 {
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dnsadd <name> <content> [on|off] [type]"))
}
name := parts[2]
content := parts[3]
proxied := "false"
recType := "A"
if len(parts) >= 5 {
switch strings.ToLower(parts[4]) {
case "on":
proxied = "true"
if len(parts) >= 6 {
recType = parts[5]
}
case "off":
proxied = "false"
if len(parts) >= 6 {
recType = parts[5]
}
case "true":
proxied = "true"
if len(parts) >= 6 {
recType = parts[5]
}
case "false":
proxied = "false"
if len(parts) >= 6 {
recType = parts[5]
}
default:
// treat as type when no on/off provided
recType = parts[4]
}
}
inputs := map[string]string{
"name": name,
"content": content,
"type": strings.ToUpper(recType),
"proxied": strings.ToLower(proxied),
}
return inputs, nil
},
Gate: coremodule.Gate{
NeedFlag: "enable_module_cf",
AllowDryRun: true,
},
DryRunMsg: "🧪 dry-run: 将执行 /cf dnsadd未实际执行",
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dnsadd 已执行job=%d", jobID) },
},
ErrPrefix: "/cf dnsadd 执行失败: ",
ErrHint: "/cf dnsadd <name> <content> [on|off] [type]",
},
{
Prefixes: []string{"/cf dnsset"},
Template: coremodule.CommandTemplate{
RunbookName: "cf_dns_set",
InputsFn: func(_ string, parts []string) (map[string]string, error) {
if len(parts) < 4 {
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dnsset <record_id> <content> [true]"))
}
proxied := "false"
if len(parts) >= 5 && strings.EqualFold(parts[4], "true") {
proxied = "true"
}
return map[string]string{
"record_id": parts[2],
"content": parts[3],
"proxied": strings.ToLower(proxied),
}, nil
},
Gate: coremodule.Gate{
NeedFlag: "enable_module_cf",
AllowDryRun: true,
},
DryRunMsg: "🧪 dry-run: 将执行 /cf dnsset未实际执行",
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dnsset 已执行job=%d", jobID) },
},
ErrPrefix: "/cf dnsset 执行失败: ",
ErrHint: "/cf dnsset <record_id> <content> [true]",
},
{
Prefixes: []string{"/cf dnsdel"},
Template: coremodule.CommandTemplate{
RunbookName: "cf_dns_del",
InputsFn: func(_ string, parts []string) (map[string]string, error) {
if len(parts) < 4 {
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dnsdel <record_id> YES"))
}
if len(parts) < 4 || !strings.EqualFold(parts[3], "YES") {
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "缺少确认词 YES示例/cf dnsdel <record_id> YES"))
}
return map[string]string{
"record_id": parts[2],
}, nil
},
Gate: coremodule.Gate{
NeedFlag: "enable_module_cf",
AllowDryRun: false,
},
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dnsdel 已执行job=%d", jobID) },
},
ErrPrefix: "/cf dnsdel 执行失败: ",
ErrHint: "/cf dnsdel <record_id> YES",
},
{
Prefixes: []string{"/cf dnsproxy"},
Template: coremodule.CommandTemplate{
RunbookName: "cf_dns_proxy",
InputsFn: func(_ string, parts []string) (map[string]string, error) {
if len(parts) < 4 {
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dnsproxy <record_id|name> on|off"))
}
mode := strings.ToLower(parts[3])
if mode != "on" && mode != "off" {
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数无效,示例:/cf dnsproxy <record_id|name> on|off"))
}
proxied := "false"
if mode == "on" {
proxied = "true"
}
inputs := map[string]string{
"proxied": proxied,
"record_id": "__empty__",
"name": "__empty__",
}
target := parts[2]
if strings.Contains(target, ".") {
inputs["name"] = target
} else {
inputs["record_id"] = target
}
return inputs, nil
},
Gate: coremodule.Gate{
NeedFlag: "enable_module_cf",
AllowDryRun: true,
},
DryRunMsg: "🧪 dry-run: 将执行 /cf dnsproxy未实际执行",
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dnsproxy 已执行job=%d", jobID) },
},
ErrPrefix: "/cf dnsproxy 执行失败: ",
ErrHint: "/cf dnsproxy <record_id|name> on|off",
},
{
Prefixes: []string{"/cf workers list"},
Template: coremodule.CommandTemplate{
RunbookName: "cf_workers_list",
Gate: coremodule.Gate{
NeedFlag: "enable_module_cf",
AllowDryRun: true,
},
DryRunMsg: "🧪 dry-run: 将执行 /cf workers list未实际执行",
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf workers list 已执行job=%d", jobID) },
},
ErrPrefix: "/cf workers list 执行失败: ",
},
}
}

View File

@@ -0,0 +1,40 @@
package cf
import (
"strings"
"ops-assistant/internal/core/command"
"ops-assistant/internal/core/ecode"
coremodule "ops-assistant/internal/core/module"
"ops-assistant/internal/core/runbook"
"gorm.io/gorm"
)
type Module struct {
db *gorm.DB
exec *runbook.Executor
runner *coremodule.Runner
}
func New(db *gorm.DB, exec *runbook.Executor) *Module {
return &Module{db: db, exec: exec, runner: coremodule.NewRunner(db, exec)}
}
func (m *Module) Handle(userID int64, cmd *command.ParsedCommand) (string, error) {
text := strings.TrimSpace(cmd.Raw)
if text == "/cf" || strings.HasPrefix(text, "/cf help") {
return "CF 模块\n- /cf status [--dry-run]\n- /cf zones\n- /cf dns list <zone_id>\n- /cf dns update <zone_id> <record_id> <type> <name> <content> [ttl] [proxied:true|false]\n- /cf dnsadd <name> <content> [on|off] [type]\n- /cf dnsset <record_id> <content> [true]\n- /cf dnsdel <record_id> YES\n- /cf dnsproxy <record_id|name> on|off\n- /cf workers list", nil
}
specs := commandSpecs()
if sp, ok := coremodule.MatchCommand(text, specs); ok {
jobID, out, err := coremodule.ExecTemplate(m.runner, userID, cmd.Raw, sp.Template)
if err != nil {
return ecode.Tag(ecode.ErrStepFailed, coremodule.FormatExecError(sp, err)), nil
}
if out == "dry-run" {
return ecode.Tag("OK", coremodule.FormatDryRunMessage(sp.Template)), nil
}
return ecode.Tag("OK", coremodule.FormatSuccessMessage(sp.Template, jobID)), nil
}
return ecode.Tag(ecode.ErrStepFailed, "CF 模块已接入,当前支持:/cf status, /cf help"), nil
}

BIN
ops-runner Executable file

Binary file not shown.

View File

@@ -0,0 +1,30 @@
version: 1
name: cf_dns_proxy
description: 修改 DNS 代理开关(按 record_id 或 name
steps:
- id: resolve_dns
action: ssh.exec
on_fail: stop
with:
target: hwsg
command: "CF_API_TOKEN=${env_cf_api_token} INPUT_RECORD_ID=${env.INPUT_RECORD_ID} INPUT_NAME=${env.INPUT_NAME} python3 - <<'PY'\nimport os,requests,json\nrec=os.getenv('INPUT_RECORD_ID','').strip()\nname=os.getenv('INPUT_NAME','').strip()\nif rec=='__empty__':\n rec=''\nif name=='__empty__':\n name=''\ntoken=os.getenv('CF_API_TOKEN','')\nheaders={'Authorization':'Bearer '+token,'Content-Type':'application/json'}\nresp=requests.get('https://api.cloudflare.com/client/v4/zones?per_page=200', headers=headers, timeout=15)\nresp.raise_for_status()\nfor z in resp.json().get('result',[]):\n zid=z.get('id')\n if rec:\n r=requests.get(f'https://api.cloudflare.com/client/v4/zones/{zid}/dns_records/{rec}', headers=headers, timeout=15)\n if r.status_code==200:\n data=r.json()\n if data.get('success') and data.get('result'):\n out=data.get('result')\n out['_zone_id']=zid\n print(json.dumps({'success':True,'result':out}))\n raise SystemExit(0)\n continue\n if name:\n r=requests.get(f'https://api.cloudflare.com/client/v4/zones/{zid}/dns_records', headers=headers, params={'name': name, 'per_page': 100}, timeout=15)\n if r.status_code==200:\n data=r.json()\n if data.get('success') and data.get('result'):\n rec0=data['result'][0]\n rec0['_zone_id']=zid\n print(json.dumps({'success':True,'result':rec0}))\n raise SystemExit(0)\nprint(json.dumps({'success':False,'errors':['record_not_found']}))\nPY"
- id: assert_resolve
action: assert.json
on_fail: stop
with:
source_step: resolve_dns
required_paths:
- "success"
- id: update_dns
action: ssh.exec
on_fail: stop
with:
target: hwsg
command: "CF_API_TOKEN=${env_cf_api_token} INPUT_PROXIED=${env.INPUT_PROXIED} INPUT_JSON='${steps.resolve_dns.output}' python3 - <<'PY'\nimport os,requests,json\nproxied=os.getenv('INPUT_PROXIED','false').lower()=='true'\ntoken=os.getenv('CF_API_TOKEN','')\nheaders={'Authorization':'Bearer '+token,'Content-Type':'application/json'}\nraw=os.getenv('INPUT_JSON','')\ntry:\n data=json.loads(raw)\nexcept Exception:\n data={}\nres=data.get('result') or {}\nzone_id=res.get('_zone_id')\nrec_id=res.get('id')\nif not zone_id or not rec_id:\n print(json.dumps({'success':False,'errors':['record_not_found']}))\n raise SystemExit(1)\npayload={\n 'type': res.get('type'),\n 'name': res.get('name'),\n 'content': res.get('content'),\n 'proxied': proxied\n}\nresp=requests.put(f'https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{rec_id}', headers=headers, json=payload, timeout=15)\nprint(resp.text)\nPY"
- id: assert_update
action: assert.json
on_fail: stop
with:
source_step: update_dns
required_paths:
- "success"