carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "encoding/json"
5 "fmt"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12 "testing"
13)
14
15// selectDestination sets --destination for one test, and puts it back.
16func selectDestination(t *testing.T, name string) {
17 t.Helper()
18 prev := destinationFlag
19 destinationFlag = name
20 t.Cleanup(func() { destinationFlag = prev })
21}
22
23const twoDestinations = `{
24 "artifact": "./bin/server",
25 "update_check": "off",
26 "destinations": {
27 "exciting": {"console": "https://carlos.exciting.example/", "app": "shop"},
28 "tito": {"console": "https://tito.example", "app": "shop-tito", "artifact": "./dist"}
29 }
30}`
31
32// Selection, the file's default, the only block, and the refusals — each
33// resolved through loadConfig, which every per-key default reads.
34func TestLoadConfigDestinations(t *testing.T) {
35 useIsolatedConfig(t)
36 dir := t.TempDir()
37 p := writeConfig(t, dir, twoDestinations)
38
39 // Several blocks, nothing selected, no default: refused, naming them.
40 if _, err := loadConfig(p); err == nil || !strings.Contains(err.Error(), "exciting, tito") {
41 t.Fatalf("ambiguous file = %v, want a refusal naming both destinations", err)
42 }
43
44 // Selected: the block wins, the top level fills what it leaves out,
45 // and preferences stay top-level.
46 selectDestination(t, "exciting")
47 cfg, err := loadConfig(p)
48 if err != nil {
49 t.Fatal(err)
50 }
51 if cfg.Console != "https://carlos.exciting.example" || cfg.App != "shop" ||
52 cfg.Artifact != "./bin/server" || cfg.UpdateCheck != "off" || cfg.Destination != "exciting" {
53 t.Fatalf("exciting = %+v", cfg)
54 }
55 selectDestination(t, "tito")
56 if cfg, _ = loadConfig(p); cfg.Artifact != "./dist" || cfg.App != "shop-tito" {
57 t.Fatalf("tito's own artifact must beat the top level: %+v", cfg)
58 }
59
60 // Selected but not defined here: this file describes other stacks, so
61 // only its preferences survive.
62 selectDestination(t, "staging")
63 if cfg, err = loadConfig(p); err != nil || cfg.Console != "" || cfg.App != "" ||
64 cfg.Artifact != "" || cfg.UpdateCheck != "off" || cfg.Destination != "" {
65 t.Fatalf("undefined here = %+v, %v; want preferences only", cfg, err)
66 }
67 // Same for a flat file with no destinations at all.
68 flat := writeConfig(t, t.TempDir(), `{"console": "https://flat.example", "app": "x"}`)
69 if cfg, err = loadConfig(flat); err != nil || cfg.Console != "" || cfg.App != "" {
70 t.Fatalf("flat file under a selection = %+v, %v; want nothing", cfg, err)
71 }
72
73 // Nothing selected: the file's default.
74 selectDestination(t, "")
75 var withDefault map[string]any
76 json.Unmarshal([]byte(twoDestinations), &withDefault)
77 withDefault["default"] = "tito"
78 body, _ := json.Marshal(withDefault)
79 os.WriteFile(p, body, 0o644)
80 if cfg, err = loadConfig(p); err != nil || cfg.Destination != "tito" || cfg.Console != "https://tito.example" {
81 t.Fatalf("default = %+v, %v", cfg, err)
82 }
83 // ...which the flag overrides.
84 selectDestination(t, "exciting")
85 if cfg, _ = loadConfig(p); cfg.Destination != "exciting" {
86 t.Fatalf("flag over default = %+v", cfg)
87 }
88 // A default naming no block is an error, not a fall-through.
89 selectDestination(t, "")
90 withDefault["default"] = "tit"
91 body, _ = json.Marshal(withDefault)
92 os.WriteFile(p, body, 0o644)
93 if _, err = loadConfig(p); err == nil || !strings.Contains(err.Error(), `"tit"`) {
94 t.Fatalf("bad default = %v, want an error naming it", err)
95 }
96
97 // Exactly one block needs no default.
98 os.WriteFile(p, []byte(`{"destinations": {"only": {"console": "https://only.example"}}}`), 0o644)
99 if cfg, err = loadConfig(p); err != nil || cfg.Destination != "only" || cfg.Console != "https://only.example" {
100 t.Fatalf("single block = %+v, %v", cfg, err)
101 }
102
103 // A block's console is validated like a top-level one.
104 os.WriteFile(p, []byte(`{"destinations": {"bad": {"console": "ftp://nope"}}}`), 0o644)
105 if _, err = loadConfig(p); err == nil || !strings.Contains(err.Error(), p) {
106 t.Fatalf("bad block console = %v, want an error naming %s", err, p)
107 }
108}
109
110// A selected destination that no file defines is refused by the walk —
111// never "no default", which would fall through to whatever single
112// credential this machine holds. The walk still finds a destination
113// defined only in the global file past a project file that lacks it.
114func TestWalkRefusesUndefinedDestination(t *testing.T) {
115 home := t.TempDir()
116 t.Setenv("HOME", home)
117 t.Setenv("CARLOS_CONFIG", "")
118 t.Setenv("CARLOS_CONSOLE", "")
119 t.Setenv("CARLOS_DESTINATION", "")
120 project := filepath.Join(home, "project")
121 writeConfig(t, project, `{"destinations": {"tito": {"console": "https://tito.example"}}}`)
122 t.Chdir(project)
123
124 selectDestination(t, "titi")
125 if _, _, err := configDefault(); err == nil || !strings.Contains(err.Error(), `"titi"`) {
126 t.Fatalf("typo'd destination = %v, want a refusal naming it", err)
127 }
128 // The environment variable selects too.
129 selectDestination(t, "")
130 t.Setenv("CARLOS_DESTINATION", "titi")
131 if _, _, err := configDefault(); err == nil {
132 t.Fatal("CARLOS_DESTINATION typo must be refused too")
133 }
134
135 writeConfig(t, home, `{"destinations": {"titi": {"console": "https://global.example"}}}`)
136 if c, _, err := configDefault(); err != nil || c != "https://global.example" {
137 t.Fatalf("global-only destination = %q %v", c, err)
138 }
139 t.Setenv("CARLOS_DESTINATION", "")
140 selectDestination(t, "tito")
141 if c, _, err := configDefault(); err != nil || c != "https://tito.example" {
142 t.Fatalf("project destination = %q %v", c, err)
143 }
144}
145
146// Saves land where the read resolved: the selected block, else the
147// default/only block, else the top level.
148func TestWriteConfigKeysIntoDestination(t *testing.T) {
149 useIsolatedConfig(t)
150 p := writeConfig(t, t.TempDir(), twoDestinations)
151 read := func() map[string]any {
152 t.Helper()
153 raw, _ := os.ReadFile(p)
154 var m map[string]any
155 if err := json.Unmarshal(raw, &m); err != nil {
156 t.Fatal(err)
157 }
158 return m
159 }
160 block := func(m map[string]any, name string) map[string]any {
161 return m["destinations"].(map[string]any)[name].(map[string]any)
162 }
163
164 if err := writeConfigKeys(p, map[string]any{"account": "a"}, 0o755); err == nil {
165 t.Fatal("several blocks and no selection must refuse the write")
166 }
167
168 selectDestination(t, "tito")
169 if err := saveConfigAccount(p, "https://tito.example", "tito-acct", 0o755); err != nil {
170 t.Fatal(err)
171 }
172 m := read()
173 if block(m, "tito")["account"] != "tito-acct" || m["account"] != nil {
174 t.Fatalf("selected write landed wrong: %v", m)
175 }
176 if block(m, "exciting")["app"] != "shop" || m["update_check"] != "off" {
177 t.Fatalf("unrelated keys lost: %v", m)
178 }
179
180 // A new name creates its block.
181 selectDestination(t, "staging")
182 if err := writeConfigKeys(p, map[string]any{"app": "shop-staging"}, 0o755); err != nil {
183 t.Fatal(err)
184 }
185 if block(read(), "staging")["app"] != "shop-staging" {
186 t.Fatal("new destination block not created")
187 }
188
189 // No selection, a default: into the default.
190 selectDestination(t, "")
191 m = read()
192 m["default"] = "exciting"
193 body, _ := json.Marshal(m)
194 os.WriteFile(p, body, 0o644)
195 if err := writeConfigKeys(p, map[string]any{"account": "ex"}, 0o755); err != nil {
196 t.Fatal(err)
197 }
198 if block(read(), "exciting")["account"] != "ex" {
199 t.Fatal("default-block write landed wrong")
200 }
201
202 // A flat file stays flat.
203 flat := writeConfig(t, t.TempDir(), `{"console": "https://flat.example"}`)
204 if err := writeConfigKeys(flat, map[string]any{"app": "x"}, 0o755); err != nil {
205 t.Fatal(err)
206 }
207 raw, _ := os.ReadFile(flat)
208 if strings.Contains(string(raw), "destinations") || !strings.Contains(string(raw), `"app": "x"`) {
209 t.Fatalf("flat write = %s", raw)
210 }
211}
212
213// -d and --destination both select, and a second flag set built later in
214// the same run (deploy re-running auth login) does not clear the choice.
215func TestDestinationFlag(t *testing.T) {
216 for _, args := range [][]string{{"-d", "tito"}, {"--destination", "tito"}, {"-destination=tito"}, {"--d=tito"}} {
217 selectDestination(t, "")
218 fs := quietFlagSet("t")
219 consoleOption(fs, "")
220 if err := fs.Parse(args); err != nil {
221 t.Fatalf("%v: %v", args, err)
222 }
223 if selectedDestination() != "tito" {
224 t.Fatalf("%v: selected %q", args, selectedDestination())
225 }
226 consoleOption(quietFlagSet("again"), "")
227 if selectedDestination() != "tito" {
228 t.Fatalf("%v: a later flag set cleared the destination", args)
229 }
230 }
231}
232
233// End to end through the real binary: two consoles, one checkout. -d picks
234// the console, the default answers a bare run, and the "using" line names
235// the destination. Each console refuses the deploy at its instance list
236// (none), which is enough to prove which one was asked.
237func TestDeployDestinationsSubprocess(t *testing.T) {
238 bin := buildCarlos(t)
239 servers := map[string]string{}
240 for _, name := range []string{"exciting", "tito"} {
241 servers[name] = zeroInstanceConsole(t, "acct-"+name, "app-"+name)
242 }
243 project := t.TempDir()
244 cfg := map[string]any{
245 "default": "exciting",
246 "kind": "binary",
247 "artifact": "./bin/server",
248 "destinations": map[string]any{
249 "exciting": map[string]any{"console": servers["exciting"], "account": "acct-exciting", "app": "app-exciting"},
250 "tito": map[string]any{"console": servers["tito"], "account": "acct-tito", "app": "app-tito"},
251 },
252 }
253 body, _ := json.Marshal(cfg)
254 writeConfig(t, project, string(body))
255 os.MkdirAll(filepath.Join(project, "bin"), 0o755)
256 os.WriteFile(filepath.Join(project, "bin", "server"), []byte("bin"), 0o755)
257 credPath := filepath.Join(t.TempDir(), "credentials")
258 creds := `{"version":1,"credentials":[` +
259 `{"console":"` + servers["exciting"] + `","address":"me@exciting","token":"t1"},` +
260 `{"console":"` + servers["tito"] + `","address":"me@tito","token":"t2"}]}`
261 os.WriteFile(credPath, []byte(creds), 0o600)
262
263 run := func(args ...string) string {
264 t.Helper()
265 cmd := exec.Command(bin, append([]string{"deploy", "-version", "x1"}, args...)...)
266 cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath)
267 cmd.Dir = project
268 out, err := cmd.CombinedOutput()
269 if err == nil {
270 t.Fatalf("%v: no-instances fixture must refuse, got success:\n%s", args, out)
271 }
272 return string(out)
273 }
274 for _, c := range []struct {
275 args []string
276 want string
277 }{
278 {nil, "exciting"},
279 {[]string{"-d", "tito"}, "tito"},
280 {[]string{"--destination", "exciting"}, "exciting"},
281 } {
282 out := run(c.args...)
283 if !strings.Contains(out, "using "+servers[c.want]+" as me@"+c.want+" (destination "+c.want+")") ||
284 !strings.Contains(out, "the "+c.want+" destination in") ||
285 !strings.Contains(out, "no instances") {
286 t.Fatalf("%v: want destination %s, got:\n%s", c.args, c.want, out)
287 }
288 }
289 if out := run("-d", "staging"); !strings.Contains(out, `destination "staging"`) {
290 t.Fatalf("undefined destination = %s", out)
291 }
292}
293
294// zeroInstanceConsole is a fake console that knows one account owning one
295// app with no instances — deploy's first refusal after resolution.
296func zeroInstanceConsole(t *testing.T, account, app string) string {
297 t.Helper()
298 mux := http.NewServeMux()
299 mux.HandleFunc("GET /api/cli/apps", func(w http.ResponseWriter, r *http.Request) {
300 fmt.Fprintf(w, `{"accounts":[{"sqid":"s-%s","name":%q,"apps":[%q]}]}`, account, account, app)
301 })
302 mux.HandleFunc("GET /api/cli/apps/", func(w http.ResponseWriter, r *http.Request) {
303 w.Write([]byte(`{"instances":[]}`))
304 })
305 ts := httptest.NewServer(mux)
306 t.Cleanup(ts.Close)
307 return ts.URL
308}
309