deploy: --assets, --no-assets and the saved assets default (edge asset cache plan 1, task 3)
carlos deploy takes the repeatable --assets (the shared EAC-1 help) and --no-assets (EAC-2). deployAssets resolves them: both together is EAC-20, --no-assets declares none, --assets replaces the saved list, and a static release refuses a flag (EAC-8) but drops a saved list with a notice (EAC-9). The saved list is cliConfig.Assets, the first list-valued config key: trimmed on load, laid over by a destination block (an empty list included), and kept by the other config rewrites. A zero-arg deploy reads it; a --assets run at a terminal offers to save it. Copy gate: EAC-2, EAC-9, EAC-20.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
5 files changed,
+476
−2
approved_asset_cache_copy_test.go+1 −0config.go+20 −0config_test.go+70 −0deploy.go+85 −2deploy_assets_test.go+300 −0
diff --git a/approved_asset_cache_copy_test.go b/approved_asset_cache_copy_test.go| index 43273793a..9e93f0f85 100644 |
| --- a/approved_asset_cache_copy_test.go |
| +++ b/approved_asset_cache_copy_test.go |
| @@ -47,6 +47,7 @@ var assetCacheCopyBuilt = []string{ |
| "EAC-8@internal/release/release.go", |
| "EAC-1", "EAC-8@cli.go", "EAC-8@internal/console/apiship_assets.go", |
| "EAC-21", "EAC-22", |
| + "EAC-2", "EAC-9", "EAC-20", |
| } |
| // assetCacheBuiltRows turns built entries into the rows to check: one per |
diff --git a/config.go b/config.go| index 5dd1c58c1..3192ee065 100644 |
| --- a/config.go |
| +++ b/config.go |
| @@ -63,6 +63,11 @@ type cliConfig struct { |
| Kind string `json:"kind"` |
| Artifact string `json:"artifact"` |
| Target string `json:"target"` |
| + // Assets is the saved asset folders a zero-arg deploy declares on every |
| + // release (edge asset cache spec): the first list-valued key. loadConfig |
| + // trims each entry and drops empties; the grammar is checked at deploy, |
| + // where a bad entry can be refused. Absent means declare nothing. |
| + Assets []string `json:"assets,omitempty"` |
| // UpdateCheck: "off" disables the once-a-day newer-release hint |
| // (update.go). Any other value — including absent — leaves it on. |
| UpdateCheck string `json:"update_check"` |
| @@ -290,6 +295,15 @@ func loadConfig(path string) (cliConfig, error) { |
| cfg.Kind = strings.TrimSpace(cfg.Kind) |
| cfg.Artifact = strings.TrimSpace(cfg.Artifact) |
| cfg.Target = strings.TrimSpace(cfg.Target) |
| + if cfg.Assets != nil { |
| + trimmed := make([]string, 0, len(cfg.Assets)) |
| + for _, a := range cfg.Assets { |
| + if a = strings.TrimSpace(a); a != "" { |
| + trimmed = append(trimmed, a) |
| + } |
| + } |
| + cfg.Assets = trimmed |
| + } |
| return cfg, nil |
| } |
| @@ -312,6 +326,12 @@ func overlayDestination(top, block cliConfig) cliConfig { |
| *kv.dst = kv.v |
| } |
| } |
| + // The list is the one key that is not a string. A block that names it, |
| + // even as [], replaces the top level's: an empty list is how a |
| + // destination says it declares nothing. |
| + if block.Assets != nil { |
| + top.Assets = block.Assets |
| + } |
| return top |
| } |
diff --git a/config_test.go b/config_test.go| index 0dc9e251a..9f8b8ec0d 100644 |
| --- a/config_test.go |
| +++ b/config_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "encoding/json" |
| "os" |
| "path/filepath" |
| + "reflect" |
| "strings" |
| "testing" |
| "time" |
| @@ -462,3 +463,72 @@ func TestConfigRewritesPreserveUnknownFields(t *testing.T) { |
| t.Fatal("saving an invalid console must fail") |
| } |
| } |
| + |
| +// TestConfigLoadsAssetsList: "assets" is the first list-valued key. Entries |
| +// are trimmed and empties dropped, a destination's list replaces the top |
| +// level's (an empty list included), and a destination the file does not define |
| +// carries none. |
| +func TestConfigLoadsAssetsList(t *testing.T) { |
| + useTempCredentials(t) |
| + write := func(body string) string { |
| + t.Helper() |
| + p := filepath.Join(t.TempDir(), "config") |
| + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + return p |
| + } |
| + cfg, err := loadConfig(write(`{"console":"https://tito.example","assets":[" /assets/ ","","/img/"]}`)) |
| + if err != nil || !reflect.DeepEqual(cfg.Assets, []string{"/assets/", "/img/"}) { |
| + t.Fatalf("flat = %#v, %v", cfg.Assets, err) |
| + } |
| + |
| + blocks := write(`{"console":"https://tito.example","assets":["/top/"],"destinations":{` + |
| + `"prod":{"assets":["/prod/"]},"plain":{"app":"shop"},"none":{"assets":[]}}}`) |
| + for dest, want := range map[string][]string{"prod": {"/prod/"}, "plain": {"/top/"}, "none": nil} { |
| + t.Setenv("CARLOS_DESTINATION", dest) |
| + cfg, err := loadConfig(blocks) |
| + if err != nil || len(cfg.Assets) != len(want) || (len(want) > 0 && !reflect.DeepEqual(cfg.Assets, want)) { |
| + t.Fatalf("destination %s = %#v, %v, want %#v", dest, cfg.Assets, err, want) |
| + } |
| + } |
| + t.Setenv("CARLOS_DESTINATION", "elsewhere") |
| + if cfg, err := loadConfig(blocks); err != nil || len(cfg.Assets) != 0 { |
| + t.Fatalf("an undefined destination = %#v, %v, want none", cfg.Assets, err) |
| + } |
| + t.Setenv("CARLOS_DESTINATION", "") |
| + |
| + if _, err := loadConfig(write(`{"assets":"/assets/"}`)); err == nil { |
| + t.Fatal("a string where the list belongs was accepted") |
| + } |
| +} |
| + |
| +// TestConfigRewritesPreserveAssets: the other rewrites keep a saved list, and |
| +// writeConfigKeys writes one that loadConfig reads back. |
| +func TestConfigRewritesPreserveAssets(t *testing.T) { |
| + useTempCredentials(t) |
| + path := filepath.Join(t.TempDir(), ".carlos", "config") |
| + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, []byte(`{"console":"https://tito.example","assets":["/assets/","/img/"]}`), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := saveConfigAccount(path, "https://tito.example", "abc", 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := writeConfigKeys(path, map[string]any{"app": "shop"}, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg, err := loadConfig(path) |
| + if err != nil || !reflect.DeepEqual(cfg.Assets, []string{"/assets/", "/img/"}) || cfg.App != "shop" || cfg.Account != "abc" { |
| + t.Fatalf("after rewrites = %+v, %v", cfg, err) |
| + } |
| + if err := writeConfigKeys(path, map[string]any{"assets": []string{"/static/"}}, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg, err = loadConfig(path) |
| + if err != nil || !reflect.DeepEqual(cfg.Assets, []string{"/static/"}) || cfg.App != "shop" { |
| + t.Fatalf("after writing assets = %+v, %v", cfg, err) |
| + } |
| +} |
diff --git a/deploy.go b/deploy.go| index edcf2f4b7..faf3a1779 100644 |
| --- a/deploy.go |
| +++ b/deploy.go |
| @@ -18,6 +18,7 @@ import ( |
| "os" |
| "os/exec" |
| "path/filepath" |
| + "slices" |
| "sort" |
| "strings" |
| "sync" |
| @@ -55,6 +56,54 @@ func deployRegionPreflight(res apiRegionsResult, readErr error, app, asked strin |
| return errors.New(deployRegionMismatch(app, res.AppDefault.Name)) |
| } |
| +// The deploy-only asset sentences (edge asset cache copy record). The --assets |
| +// help (EAC-1) and the static refusal (EAC-8) are cli.go's shared constants. |
| +const ( |
| + deployNoAssetsFlagHelp = "ship with no asset folders, ignoring any saved in .carlos/config" // EAC-2 |
| + deployAssetsSavedStatic = "ignoring the asset folders saved in .carlos/config, because a static site is already served from every region." // EAC-9 |
| + deployAssetsBoth = "--assets and --no-assets cannot be used together. pick one." // EAC-20 |
| +) |
| + |
| +// deployAssets resolves the asset folders a deploy declares. flagSet says |
| +// --assets was given (flag holds its values); noAssets is --no-assets; saved |
| +// is the project's saved list; kind is the release kind, final by now. |
| +// |
| +// - both flags together are refused (EAC-20), whatever the kind; |
| +// - --no-assets declares none, and a saved list is not even looked at; |
| +// - --assets replaces the saved list for the run, and is refused on a static |
| +// release (EAC-8); |
| +// - otherwise the saved list answers, except on a static release, where it is |
| +// dropped with a notice (EAC-9) rather than refused: the person typed |
| +// nothing wrong. |
| +// |
| +// Every list that will be declared meets release.ValidAssetPaths. The result |
| +// is nil, never empty, when nothing is declared, so the ship meta carries no |
| +// assets key. |
| +func deployAssets(flagSet bool, flag []string, noAssets bool, saved []string, kind string) (paths []string, notice string, err error) { |
| + if flagSet && noAssets { |
| + return nil, "", errors.New(deployAssetsBoth) |
| + } |
| + if noAssets { |
| + return nil, "", nil |
| + } |
| + if flagSet { |
| + if err := checkShipAssets(kind, flag); err != nil { |
| + return nil, "", err |
| + } |
| + return flag, "", nil |
| + } |
| + if len(saved) == 0 { |
| + return nil, "", nil |
| + } |
| + if kind == "static" { |
| + return nil, deployAssetsSavedStatic, nil |
| + } |
| + if err := release.ValidAssetPaths(saved); err != nil { |
| + return nil, "", err |
| + } |
| + return saved, "", nil |
| +} |
| + |
| // runDeploy is `carlos deploy`. API-mode only (spec §6): direct-bucket |
| // operators keep ship + promote as separate acts; deploy is the member path |
| // that ships, promotes to the channel the app's instances already follow, |
| @@ -87,11 +136,14 @@ func runDeploy(args []string) { |
| ttlFlag := fs.String("ttl", "", "how long a temporary app lives, 1h to 30d (default 7d); implies --temporary") // C11.22 |
| regionFlag := fs.String("region", "", flagRegionDeploy) |
| swap := fs.String("swap", "", shipSwapFlagHelp) |
| + var assets assetPathsFlag |
| + fs.Var(&assets, "assets", shipAssetsFlagHelp) |
| + noAssets := fs.Bool("no-assets", false, deployNoAssetsFlagHelp) |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| usage := "usage: carlos deploy --app <a> [--version <sha>] [--kind binary|static] [--target linux-arm64] " + |
| "[--account <sqid-or-name>] [--account-id <sqid>] [--console <origin> | -d <destination>] [--capabilities <file.json>] " + |
| - "[--channel <ch>] [--host <host>] [--region <code>] [--label <text>] [--notes <text>] [--swap hold] [--temporary] [--ttl 7d] [--timeout 120s] <files-or-site-dir>\n" + // C11.23 |
| + "[--channel <ch>] [--host <host>] [--region <code>] [--label <text>] [--notes <text>] [--swap hold] [--assets <folder>]... [--no-assets] [--temporary] [--ttl 7d] [--timeout 120s] <files-or-site-dir>\n" + // C11.23 |
| "or, in a project with saved defaults (or at a terminal, to create them): carlos deploy [-d <destination>]" |
| temp, ttl, terr := temporaryDeployFlags(*temporary, flagWasSet(fs, "ttl"), *ttlFlag, *hostFlag, flagWasSet(fs, "kind"), *kind, fs.NArg()) |
| if terr != nil { |
| @@ -100,6 +152,14 @@ func runDeploy(args []string) { |
| if temp { |
| *kind = "static" |
| } |
| + // The two asset flags are refused together, and a bad folder or any |
| + // folder on a static release is refused, before the console is asked |
| + // anything. The saved list is only known (and the kind only final) in the |
| + // zero-arg branch below, which resolves again. |
| + flagAssets := flagWasSet(fs, "assets") |
| + if _, _, aerr := deployAssets(flagAssets, assets, *noAssets, nil, *kind); aerr != nil { |
| + log.Fatalf("deploy: %v", aerr) |
| + } |
| if *ver == "" { |
| *ver = gitShortHead() |
| } |
| @@ -196,7 +256,12 @@ func runDeploy(args []string) { |
| printTemporaryExpiry(ctx, sess, acct, *app) |
| } |
| - if fs.NArg() == 0 { |
| + // The project's saved asset folders belong to the zero-arg deploy, like |
| + // the rest of the saved deploy block: set below, from the config that |
| + // answered. |
| + var savedAssets []string |
| + zeroArg := fs.NArg() == 0 |
| + if zeroArg { |
| // Zero-arg: the saved deploy block answers, else the first-run |
| // wizard names the artifact once — never a silent guess. |
| artifact := "" |
| @@ -221,6 +286,7 @@ func runDeploy(args []string) { |
| if !flagWasSet(fs, "target") && dcfg.Target != "" { |
| *target = dcfg.Target |
| } |
| + savedAssets = dcfg.Assets |
| from := dsrc |
| if dcfg.Destination != "" { |
| from = "the " + dcfg.Destination + " destination in " + dsrc |
| @@ -262,6 +328,23 @@ func runDeploy(args []string) { |
| // the deploy's shape instead of being shipped (or refused). |
| fs.Parse([]string{"--", artifact}) |
| } |
| + // The kind is final here, whether a flag, the config or the wizard chose |
| + // it, so this is where a static release drops the saved list (EAC-9). |
| + assetPaths, assetsNotice, aerr := deployAssets(flagAssets, assets, *noAssets, savedAssets, *kind) |
| + if aerr != nil { |
| + log.Fatalf("deploy: %v", aerr) |
| + } |
| + if assetsNotice != "" { |
| + mutedf(stdout, "%s", assetsNotice) |
| + } |
| + sess.shipAssets = assetPaths |
| + // --assets is a choice made on the command line, but it is the one flag |
| + // worth keeping: a later zero-arg deploy that forgot it would turn the |
| + // cache off for the release. Offer it, on the zero-arg path (the only one |
| + // that reads the saved list), when it differs from what is saved. |
| + if zeroArg && flagAssets && len(assetPaths) > 0 && !slices.Equal(assetPaths, savedAssets) { |
| + newly["assets"] = assetPaths |
| + } |
| if len(newly) > 0 && interactiveTerminal() { |
| offerSaveProjectDefaults(sess.console, sess.accountName(acct), newly, |
diff --git a/deploy_assets_test.go b/deploy_assets_test.go| new file mode 100644 |
| index 000000000..c083f0367 |
| --- /dev/null |
| +++ b/deploy_assets_test.go |
| @@ -0,0 +1,300 @@ |
| +package main |
| + |
| +import ( |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "reflect" |
| + "strings" |
| + "sync" |
| + "testing" |
| +) |
| + |
| +// The approved copy rows this file pins (docs/superpowers/plans/ |
| +// 2026-10-24-edge-asset-cache-copy.md). EAC-1 and EAC-8 are cli.go's shared |
| +// constants, used here by name. |
| +const ( |
| + eac2Text = "ship with no asset folders, ignoring any saved in .carlos/config" |
| + eac9Text = "ignoring the asset folders saved in .carlos/config, because a static site is already served from every region." |
| + eac20Text = "--assets and --no-assets cannot be used together. pick one." |
| +) |
| + |
| +// TestDeployAssetsResolution: the flag replaces the saved list, --no-assets |
| +// declares none, the two flags together are EAC-20, the saved list answers |
| +// when neither is given, and a static release refuses a flag (EAC-8) but only |
| +// ignores a saved list, saying so (EAC-9). |
| +func TestDeployAssetsResolution(t *testing.T) { |
| + saved := []string{"/saved/"} |
| + flagged := []string{"/assets/", "/img/"} |
| + cases := []struct { |
| + name string |
| + flagSet bool |
| + flag []string |
| + noAssets bool |
| + saved []string |
| + kind string |
| + wantPaths []string |
| + wantNotice string |
| + wantErr string |
| + }{ |
| + {name: "flag beats saved", flagSet: true, flag: flagged, saved: saved, kind: "binary", wantPaths: flagged}, |
| + {name: "flag with nothing saved", flagSet: true, flag: flagged, kind: "binary", wantPaths: flagged}, |
| + {name: "no-assets beats saved", noAssets: true, saved: saved, kind: "binary"}, |
| + {name: "both flags together", flagSet: true, flag: flagged, noAssets: true, saved: saved, kind: "binary", wantErr: eac20Text}, |
| + {name: "both flags together on static", flagSet: true, flag: flagged, noAssets: true, kind: "static", wantErr: eac20Text}, |
| + {name: "saved list answers", saved: saved, kind: "binary", wantPaths: saved}, |
| + {name: "nothing anywhere", kind: "binary"}, |
| + {name: "static refuses a flag", flagSet: true, flag: flagged, saved: saved, kind: "static", wantErr: shipAssetsStatic}, |
| + {name: "static ignores a saved list and says so", saved: saved, kind: "static", wantNotice: eac9Text}, |
| + {name: "static with no-assets says nothing", noAssets: true, saved: saved, kind: "static"}, |
| + {name: "static with nothing saved says nothing", kind: "static"}, |
| + {name: "a bad flag is the grammar's refusal", flagSet: true, flag: []string{"/"}, kind: "binary", |
| + wantErr: "asset path / would cache every page on the site. name the folder your build writes to, for example /assets/."}, |
| + {name: "a bad saved list is the grammar's refusal", saved: []string{"assets"}, kind: "binary", |
| + wantErr: "asset path assets must start and end with /. name the folder your build writes to, for example /assets/."}, |
| + {name: "a bad saved list does not matter when no-assets is given", noAssets: true, saved: []string{"assets"}, kind: "binary"}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + paths, notice, err := deployAssets(c.flagSet, c.flag, c.noAssets, c.saved, c.kind) |
| + if c.wantErr != "" { |
| + if err == nil || err.Error() != c.wantErr { |
| + t.Fatalf("err = %v, want %q", err, c.wantErr) |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatalf("err = %v", err) |
| + } |
| + if !reflect.DeepEqual(paths, c.wantPaths) { |
| + t.Fatalf("paths = %#v, want %#v", paths, c.wantPaths) |
| + } |
| + if notice != c.wantNotice { |
| + t.Fatalf("notice = %q, want %q", notice, c.wantNotice) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestDeployAssetsCopyIsApproved: the flag help and the two sentences deploy |
| +// owns are the approved rows byte for byte. |
| +func TestDeployAssetsCopyIsApproved(t *testing.T) { |
| + if deployNoAssetsFlagHelp != eac2Text { |
| + t.Errorf("--no-assets help = %q", deployNoAssetsFlagHelp) |
| + } |
| + if deployAssetsSavedStatic != eac9Text { |
| + t.Errorf("saved-list notice = %q", deployAssetsSavedStatic) |
| + } |
| + if deployAssetsBoth != eac20Text { |
| + t.Errorf("both-flags refusal = %q", deployAssetsBoth) |
| + } |
| +} |
| + |
| +// TestDeployWithoutSavedAssetsDeclaresNone: a config written by the CLI |
| +// before this feature has no assets key, and a deploy under it declares |
| +// nothing, whatever the destinations around it say. |
| +func TestDeployWithoutSavedAssetsDeclaresNone(t *testing.T) { |
| + dir := t.TempDir() |
| + for name, body := range map[string]string{ |
| + "flat": `{"console":"https://console.example.com","account":"abc","app":"shop","kind":"binary","artifact":"./bin/server","target":"linux-arm64"}`, |
| + "destinations": `{"default":"prod","destinations":{"prod":{"console":"https://console.example.com","app":"shop","artifact":"./bin/server"}}}`, |
| + } { |
| + path := filepath.Join(dir, name) |
| + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg, err := loadConfig(path) |
| + if err != nil { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + if cfg.Assets != nil { |
| + t.Fatalf("%s: assets = %#v, want nil", name, cfg.Assets) |
| + } |
| + paths, notice, err := deployAssets(false, nil, false, cfg.Assets, "binary") |
| + if paths != nil || notice != "" || err != nil { |
| + t.Fatalf("%s: deployAssets = %#v %q %v, want nothing", name, paths, notice, err) |
| + } |
| + } |
| +} |
| + |
| +// deployFixture is a stand-in console just deep enough for a zero-arg deploy |
| +// to reach its ship: one account, one instance, a release door that keeps the |
| +// meta part, and a promote that refuses (the test is about what was shipped). |
| +type deployFixture struct { |
| + mu sync.Mutex |
| + meta string |
| + hits int |
| + url string |
| +} |
| + |
| +func newDeployFixture(t *testing.T) *deployFixture { |
| + t.Helper() |
| + f := &deployFixture{} |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /api/cli/apps", func(w http.ResponseWriter, r *http.Request) { |
| + f.touch() |
| + w.Write([]byte(`{"accounts":[{"sqid":"abc","name":"abc@example.com","apps":["shop"]}]}`)) |
| + }) |
| + mux.HandleFunc("GET /api/cli/apps/", func(w http.ResponseWriter, r *http.Request) { |
| + f.touch() |
| + w.Write([]byte(`{"instances":[{"host":"shop.example.com","channel":"stable","kind":"instance"}]}`)) |
| + }) |
| + mux.HandleFunc("POST /api/cli/apps/{acct}/{app}/releases", func(w http.ResponseWriter, r *http.Request) { |
| + f.touch() |
| + mr, err := r.MultipartReader() |
| + if err != nil { |
| + t.Error(err) |
| + return |
| + } |
| + p, err := mr.NextPart() |
| + if err != nil { |
| + t.Error(err) |
| + return |
| + } |
| + b, _ := io.ReadAll(p) |
| + var in struct { |
| + Assets json.RawMessage `json:"assets"` |
| + } |
| + json.Unmarshal(b, &in) |
| + f.mu.Lock() |
| + f.meta = string(b) |
| + f.mu.Unlock() |
| + echo := "" |
| + if len(in.Assets) > 0 { |
| + echo = `,"assets":` + string(in.Assets) |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + w.WriteHeader(http.StatusCreated) |
| + io.WriteString(w, `{"result":"shipped","version":"x1"`+echo+`}`) |
| + }) |
| + mux.HandleFunc("POST /api/cli/apps/{acct}/{app}/promote", func(w http.ResponseWriter, r *http.Request) { |
| + f.touch() |
| + http.Error(w, `{"error":"promote is not under test"}`, http.StatusInternalServerError) |
| + }) |
| + ts := httptest.NewServer(mux) |
| + t.Cleanup(ts.Close) |
| + f.url = ts.URL |
| + return f |
| +} |
| + |
| +func (f *deployFixture) touch() { |
| + f.mu.Lock() |
| + f.hits++ |
| + f.mu.Unlock() |
| +} |
| + |
| +// runDeploy runs `carlos deploy -version x1 <args>` in a project whose |
| +// .carlos/config carries extraConfig after the usual defaults, and returns |
| +// the combined output. |
| +func (f *deployFixture) runDeploy(t *testing.T, extraConfig string, args ...string) string { |
| + t.Helper() |
| + bin := buildCarlos(t) |
| + project := t.TempDir() |
| + if err := os.MkdirAll(filepath.Join(project, ".carlos"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg := `{"console":"` + f.url + `","account":"abc@example.com","app":"shop","kind":"binary","artifact":"./bin/server","target":"linux-arm64"` + extraConfig + `}` |
| + if err := os.WriteFile(filepath.Join(project, ".carlos", "config"), []byte(cfg), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(filepath.Join(project, "bin"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(project, "bin", "server"), []byte("bin"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + credPath := filepath.Join(t.TempDir(), "credentials") |
| + creds := `{"version":1,"credentials":[{"console":"` + f.url + `","address":"abc@example.com","token":"tok"}]}` |
| + if err := os.WriteFile(credPath, []byte(creds), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + cmd := exec.Command(bin, append([]string{"deploy", "-version", "x1"}, args...)...) |
| + cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath) |
| + cmd.Dir = project |
| + out, _ := cmd.CombinedOutput() // the promote refuses on purpose |
| + return string(out) |
| +} |
| + |
| +func (f *deployFixture) shippedMeta() string { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + return f.meta |
| +} |
| + |
| +// TestZeroArgDeployCarriesSavedAssets: a zero-arg deploy sends the project's |
| +// saved asset folders in the ship meta, --assets replaces them for the run, |
| +// and --no-assets sends none. |
| +func TestZeroArgDeployCarriesSavedAssets(t *testing.T) { |
| + t.Parallel() |
| + const saved = `,"assets":["/assets/","/img/"]` |
| + for _, c := range []struct { |
| + name, config string |
| + args []string |
| + want string // the assets object the meta must carry; "" means no key at all |
| + }{ |
| + {"saved list", saved, nil, `"assets":{"paths":["/assets/","/img/"]}`}, |
| + {"flag replaces it", saved, []string{"--assets", "/static/"}, `"assets":{"paths":["/static/"]}`}, |
| + {"no-assets sends none", saved, []string{"--no-assets"}, ""}, |
| + {"nothing saved sends none", "", nil, ""}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + t.Parallel() |
| + f := newDeployFixture(t) |
| + out := f.runDeploy(t, c.config, c.args...) |
| + meta := f.shippedMeta() |
| + if meta == "" { |
| + t.Fatalf("the deploy never shipped:\n%s", out) |
| + } |
| + if c.want == "" && strings.Contains(meta, "assets") { |
| + t.Fatalf("meta = %s, want no assets key", meta) |
| + } |
| + if c.want != "" && !strings.Contains(meta, c.want) { |
| + t.Fatalf("meta = %s, want it to carry %s", meta, c.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestDeployRefusesBothAssetFlagsBeforeTheConsole: --assets with --no-assets |
| +// is EAC-20 at the terminal, with the console never asked. |
| +func TestDeployRefusesBothAssetFlagsBeforeTheConsole(t *testing.T) { |
| + t.Parallel() |
| + f := newDeployFixture(t) |
| + out := f.runDeploy(t, "", "--assets", "/assets/", "--no-assets") |
| + if !strings.Contains(out, eac20Text) { |
| + t.Fatalf("output does not carry the approved refusal %q:\n%s", eac20Text, out) |
| + } |
| + f.mu.Lock() |
| + hits := f.hits |
| + f.mu.Unlock() |
| + if hits != 0 { |
| + t.Fatalf("the console was asked %d times before the refusal", hits) |
| + } |
| +} |
| + |
| +// TestDeployStaticSavedAssetsAreIgnoredAloud: a static deploy under a config |
| +// that saves folders ships none and says EAC-9; --assets on a static deploy is |
| +// EAC-8 and nothing is shipped. |
| +func TestDeployStaticSavedAssetsAreIgnoredAloud(t *testing.T) { |
| + t.Parallel() |
| + f := newDeployFixture(t) |
| + out := f.runDeploy(t, `,"assets":["/assets/"]`, "--kind", "static") |
| + if !strings.Contains(out, eac9Text) { |
| + t.Fatalf("output does not carry the approved notice %q:\n%s", eac9Text, out) |
| + } |
| + if meta := f.shippedMeta(); strings.Contains(meta, "assets") { |
| + t.Fatalf("a static deploy declared folders: %s", meta) |
| + } |
| + g := newDeployFixture(t) |
| + out = g.runDeploy(t, "", "--kind", "static", "--assets", "/assets/") |
| + if !strings.Contains(out, shipAssetsStatic) { |
| + t.Fatalf("output does not carry the approved refusal %q:\n%s", shipAssetsStatic, out) |
| + } |
| + if meta := g.shippedMeta(); meta != "" { |
| + t.Fatalf("a refused deploy shipped: %s", meta) |
| + } |
| +} |