Skip to content

Commit 862f638

Browse files
feat: add config-as-code CLI mix tasks for GitOps workflows
Add mix sentinel.config.{export,apply,diff} tasks wrapping the existing ConfigExport module, wire the config export controller into the router, and fix YAML serialization (replace non-existent YamlElixir.dump with Ymlr.document).
1 parent b4abdf0 commit 862f638

9 files changed

Lines changed: 620 additions & 47 deletions

File tree

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
defmodule Mix.Tasks.Sentinel.Config.Apply do
2+
@moduledoc """
3+
Applies a configuration file to a project, creating or updating resources.
4+
5+
## Usage
6+
7+
mix sentinel.config.apply <project_slug> <file> [options]
8+
9+
## Options
10+
11+
* `--dry-run` - Show diff without applying changes
12+
* `--yes` - Skip confirmation prompt
13+
14+
## Examples
15+
16+
mix sentinel.config.apply my-project config.yaml
17+
mix sentinel.config.apply my-project config.json --yes
18+
mix sentinel.config.apply my-project config.yaml --dry-run
19+
"""
20+
21+
use Mix.Task
22+
23+
@shortdoc "Apply a configuration file to a project"
24+
25+
@switches [dry_run: :boolean, yes: :boolean]
26+
@aliases [y: :yes]
27+
28+
@impl Mix.Task
29+
def run(args) do
30+
{opts, argv, _} = OptionParser.parse(args, switches: @switches, aliases: @aliases)
31+
32+
case argv do
33+
[slug, file] ->
34+
Mix.Task.run("app.start")
35+
apply_config(slug, file, opts)
36+
37+
_ ->
38+
Mix.shell().error("Usage: mix sentinel.config.apply <project_slug> <file> [--dry-run] [--yes]")
39+
exit({:shutdown, 1})
40+
end
41+
end
42+
43+
defp apply_config(slug, file, opts) do
44+
project = resolve_project!(slug)
45+
config = parse_config_file!(file)
46+
47+
{:ok, changes} = SentinelCp.ConfigExport.diff(project.id, config)
48+
49+
case changes do
50+
[] ->
51+
Mix.shell().info("No changes detected.")
52+
53+
changes ->
54+
print_changes(changes)
55+
56+
if Keyword.get(opts, :dry_run, false) do
57+
Mix.shell().info("\nDry run — no changes applied.")
58+
else
59+
if Keyword.get(opts, :yes, false) || confirm?() do
60+
do_apply(project, config)
61+
else
62+
Mix.shell().info("Aborted.")
63+
end
64+
end
65+
end
66+
end
67+
68+
defp do_apply(project, config) do
69+
{:ok, summary} = SentinelCp.ConfigExport.import_config(project.id, config)
70+
71+
Mix.shell().info("\nApplied successfully:")
72+
Mix.shell().info(" Created: #{summary.created}")
73+
Mix.shell().info(" Updated: #{summary.updated}")
74+
Mix.shell().info(" Skipped: #{summary.skipped}")
75+
76+
if summary.errors != [] do
77+
Mix.shell().error(" Errors: #{length(summary.errors)}")
78+
79+
for {type, name, reason} <- summary.errors do
80+
Mix.shell().error(" #{type} #{name}: #{inspect(reason)}")
81+
end
82+
end
83+
end
84+
85+
defp print_changes(changes) do
86+
Mix.shell().info("Changes detected:\n")
87+
88+
for {action, resource_type, name} <- changes do
89+
prefix =
90+
case action do
91+
:add -> " + "
92+
:remove -> " - "
93+
:modify -> " ~ "
94+
end
95+
96+
Mix.shell().info("#{prefix}#{resource_type}: #{name}")
97+
end
98+
end
99+
100+
defp confirm? do
101+
Mix.shell().yes?("\nApply these changes?")
102+
end
103+
104+
defp resolve_project!(slug) do
105+
case SentinelCp.Projects.get_project_by_slug(slug) do
106+
nil ->
107+
Mix.shell().error("Project not found: #{slug}")
108+
exit({:shutdown, 1})
109+
110+
project ->
111+
project
112+
end
113+
end
114+
115+
defp parse_config_file!(path) do
116+
unless File.exists?(path) do
117+
Mix.shell().error("File not found: #{path}")
118+
exit({:shutdown, 1})
119+
end
120+
121+
content = File.read!(path)
122+
123+
cond do
124+
String.ends_with?(path, [".yml", ".yaml"]) ->
125+
case YamlElixir.read_from_string(content) do
126+
{:ok, parsed} -> parsed
127+
{:error, reason} ->
128+
Mix.shell().error("Failed to parse YAML: #{inspect(reason)}")
129+
exit({:shutdown, 1})
130+
end
131+
132+
String.ends_with?(path, ".json") ->
133+
case Jason.decode(content) do
134+
{:ok, parsed} -> parsed
135+
{:error, reason} ->
136+
Mix.shell().error("Failed to parse JSON: #{inspect(reason)}")
137+
exit({:shutdown, 1})
138+
end
139+
140+
true ->
141+
# Try YAML first, fall back to JSON
142+
case YamlElixir.read_from_string(content) do
143+
{:ok, parsed} ->
144+
parsed
145+
146+
{:error, _} ->
147+
case Jason.decode(content) do
148+
{:ok, parsed} -> parsed
149+
{:error, _} ->
150+
Mix.shell().error("Unable to parse file as YAML or JSON: #{path}")
151+
exit({:shutdown, 1})
152+
end
153+
end
154+
end
155+
end
156+
end
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
defmodule Mix.Tasks.Sentinel.Config.Diff do
2+
@moduledoc """
3+
Shows the diff between a project's current configuration and a config file.
4+
5+
## Usage
6+
7+
mix sentinel.config.diff <project_slug> <file>
8+
9+
## Examples
10+
11+
mix sentinel.config.diff my-project config.yaml
12+
mix sentinel.config.diff my-project config.json
13+
"""
14+
15+
use Mix.Task
16+
17+
@shortdoc "Diff project configuration against a file"
18+
19+
@impl Mix.Task
20+
def run(args) do
21+
{_opts, argv, _} = OptionParser.parse(args, switches: [])
22+
23+
case argv do
24+
[slug, file] ->
25+
Mix.Task.run("app.start")
26+
diff(slug, file)
27+
28+
_ ->
29+
Mix.shell().error("Usage: mix sentinel.config.diff <project_slug> <file>")
30+
exit({:shutdown, 1})
31+
end
32+
end
33+
34+
defp diff(slug, file) do
35+
project = resolve_project!(slug)
36+
config = parse_config_file!(file)
37+
38+
{:ok, changes} = SentinelCp.ConfigExport.diff(project.id, config)
39+
40+
case changes do
41+
[] ->
42+
Mix.shell().info("No differences found.")
43+
44+
changes ->
45+
Mix.shell().info("Differences:\n")
46+
47+
for {action, resource_type, name} <- changes do
48+
line =
49+
case action do
50+
:add -> IO.ANSI.format([:green, " + #{resource_type}: #{name}"])
51+
:remove -> IO.ANSI.format([:red, " - #{resource_type}: #{name}"])
52+
:modify -> IO.ANSI.format([:yellow, " ~ #{resource_type}: #{name}"])
53+
end
54+
55+
Mix.shell().info(line)
56+
end
57+
58+
additions = Enum.count(changes, fn {a, _, _} -> a == :add end)
59+
removals = Enum.count(changes, fn {a, _, _} -> a == :remove end)
60+
modifications = Enum.count(changes, fn {a, _, _} -> a == :modify end)
61+
62+
Mix.shell().info(
63+
"\n#{additions} addition(s), #{removals} removal(s), #{modifications} modification(s)"
64+
)
65+
end
66+
end
67+
68+
defp resolve_project!(slug) do
69+
case SentinelCp.Projects.get_project_by_slug(slug) do
70+
nil ->
71+
Mix.shell().error("Project not found: #{slug}")
72+
exit({:shutdown, 1})
73+
74+
project ->
75+
project
76+
end
77+
end
78+
79+
defp parse_config_file!(path) do
80+
unless File.exists?(path) do
81+
Mix.shell().error("File not found: #{path}")
82+
exit({:shutdown, 1})
83+
end
84+
85+
content = File.read!(path)
86+
87+
cond do
88+
String.ends_with?(path, [".yml", ".yaml"]) ->
89+
case YamlElixir.read_from_string(content) do
90+
{:ok, parsed} -> parsed
91+
{:error, reason} ->
92+
Mix.shell().error("Failed to parse YAML: #{inspect(reason)}")
93+
exit({:shutdown, 1})
94+
end
95+
96+
String.ends_with?(path, ".json") ->
97+
case Jason.decode(content) do
98+
{:ok, parsed} -> parsed
99+
{:error, reason} ->
100+
Mix.shell().error("Failed to parse JSON: #{inspect(reason)}")
101+
exit({:shutdown, 1})
102+
end
103+
104+
true ->
105+
case YamlElixir.read_from_string(content) do
106+
{:ok, parsed} ->
107+
parsed
108+
109+
{:error, _} ->
110+
case Jason.decode(content) do
111+
{:ok, parsed} -> parsed
112+
{:error, _} ->
113+
Mix.shell().error("Unable to parse file as YAML or JSON: #{path}")
114+
exit({:shutdown, 1})
115+
end
116+
end
117+
end
118+
end
119+
end
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
defmodule Mix.Tasks.Sentinel.Config.Export do
2+
@moduledoc """
3+
Exports a project's configuration as YAML or JSON.
4+
5+
## Usage
6+
7+
mix sentinel.config.export <project_slug> [options]
8+
9+
## Options
10+
11+
* `--format` - Output format: `yaml` (default) or `json`
12+
* `--output` - Write to file instead of stdout
13+
14+
## Examples
15+
16+
mix sentinel.config.export my-project
17+
mix sentinel.config.export my-project --format json
18+
mix sentinel.config.export my-project --format yaml --output config.yaml
19+
"""
20+
21+
use Mix.Task
22+
23+
@shortdoc "Export project configuration as YAML or JSON"
24+
25+
@switches [format: :string, output: :string]
26+
@aliases [f: :format, o: :output]
27+
28+
@impl Mix.Task
29+
def run(args) do
30+
{opts, argv, _} = OptionParser.parse(args, switches: @switches, aliases: @aliases)
31+
32+
case argv do
33+
[slug] ->
34+
Mix.Task.run("app.start")
35+
export(slug, opts)
36+
37+
_ ->
38+
Mix.shell().error("Usage: mix sentinel.config.export <project_slug> [--format yaml|json] [--output file]")
39+
exit({:shutdown, 1})
40+
end
41+
end
42+
43+
defp export(slug, opts) do
44+
format = Keyword.get(opts, :format, "yaml")
45+
output = Keyword.get(opts, :output)
46+
47+
case SentinelCp.Projects.get_project_by_slug(slug) do
48+
nil ->
49+
Mix.shell().error("Project not found: #{slug}")
50+
exit({:shutdown, 1})
51+
52+
project ->
53+
{:ok, config} = SentinelCp.ConfigExport.export(project.id)
54+
content = serialize(config, format)
55+
56+
if output do
57+
File.write!(output, content)
58+
Mix.shell().info("Configuration exported to #{output}")
59+
else
60+
Mix.shell().info(content)
61+
end
62+
end
63+
end
64+
65+
defp serialize(config, "json"), do: Jason.encode!(config, pretty: true)
66+
defp serialize(config, _), do: Ymlr.document!(config)
67+
end

lib/sentinel_cp/config_export.ex

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,16 +59,9 @@ defmodule SentinelCp.ConfigExport do
5959
"""
6060
def export_yaml(project_id) do
6161
case export(project_id) do
62-
{:ok, config} -> {:ok, YamlElixir.dump(config)}
62+
{:ok, config} -> {:ok, Ymlr.document!(config)}
6363
error -> error
6464
end
65-
rescue
66-
_ ->
67-
# Fallback to JSON if YAML dump is not available
68-
case export(project_id) do
69-
{:ok, config} -> {:ok, Jason.encode!(config, pretty: true)}
70-
error -> error
71-
end
7265
end
7366

7467
@doc """

0 commit comments

Comments
 (0)