Skip to content
Merged
88 changes: 66 additions & 22 deletions cmd/modelith/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
package main

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/signal"
"path/filepath"
"runtime/debug"
"strings"
"syscall"
"time"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -71,10 +74,18 @@ func buildVersion() string {
var errBlocking = errors.New("blocking findings")

func main() {
if err := rootCmd().Execute(); err != nil {
if !errors.Is(err, errBlocking) {
fmt.Fprintln(os.Stderr, "error:", err)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

if err := rootCmd().ExecuteContext(ctx); err != nil {
if errors.Is(err, errBlocking) {
os.Exit(1)
}
if ctx.Err() != nil {
fmt.Fprintln(os.Stderr, "interrupted")
os.Exit(130)
}
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
Expand All @@ -86,6 +97,9 @@ func rootCmd() *cobra.Command {
SilenceUsage: true,
SilenceErrors: true,
Version: buildVersion(),
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return cmd.Context().Err()
},
}
root.AddCommand(lintCmd(), renderCmd(), schemaCmd(), depsCmd())
return root
Expand All @@ -109,25 +123,35 @@ that use the network; lint and render never do.`),
}

func depsImportCmd() *cobra.Command {
var ref string
var (
ref string
timeout time.Duration
)
cmd := &cobra.Command{
Use: "import <url> [dir]",
Short: "Vendor a model from another repository",
Long: strings.TrimSpace(`
Vendor a model from another repository into this one.

<url> is the address of the file as it appears in a browser on github.com. The
copy is written into [dir] (the working directory by default) with a provenance
header recording where it came from, and is verified against that header by
every later lint.
<url> is the address of the file as it appears in a browser. Both github.com
and dev.azure.com are supported. The copy is written into [dir] (the working
directory by default) with a provenance header recording where it came from,
and is verified against that header by every later lint.

For GitHub, fetching is delegated to the gh CLI, which must be installed and
authenticated. For Azure DevOps, fetching is delegated to the az CLI; run
'az login' first.

Each fetch is bounded by --timeout (default 60s, 0 disables the bound): a hung
CLI fails fast instead of stalling the import.

Fetching is delegated to the gh CLI, which must be installed and authenticated.
The imports list of the model that will reference this copy is yours to edit;
this command prints the entry to add.`),
Example: strings.TrimSpace(`
modelith deps import https://github.com/acme/billing/blob/main/docs/payments.modelith.yaml
modelith deps import https://github.com/acme/billing/blob/main/docs/payments.modelith.yaml docs/
modelith deps import --ref v2.1.0 https://github.com/acme/billing/blob/main/docs/payments.modelith.yaml`),
modelith deps import --ref v2.1.0 https://github.com/acme/billing/blob/main/docs/payments.modelith.yaml
modelith deps import "https://dev.azure.com/myorg/myproject/_git/myrepo?path=docs/payments.modelith.yaml&version=GBmain"`),
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
dir := "."
Expand All @@ -143,10 +167,11 @@ this command prints the entry to add.`),
}

res, err := deps.Import(cmd.Context(), deps.Options{
URL: args[0],
Dir: dir,
Ref: ref,
Now: time.Now(),
URL: args[0],
Dir: dir,
Ref: ref,
Now: time.Now(),
Timeout: timeout,
})
if err != nil {
return err
Expand All @@ -161,6 +186,8 @@ this command prints the entry to add.`),
},
}
cmd.Flags().StringVar(&ref, "ref", "", "ref to fetch, overriding the one in the URL (a tag pins the copy)")
cmd.Flags().DurationVar(&timeout, "timeout", 60*time.Second,
"abandon a delegated fetch (gh/az) that exceeds this duration; 0 disables the bound")
return cmd
}

Expand Down Expand Up @@ -218,6 +245,7 @@ func printTrustWarning(errOut io.Writer) {
}

func depsCheckCmd() *cobra.Command {
var timeout time.Duration
cmd := &cobra.Command{
Use: "check <file>...",
Short: "Report which vendored copies have fallen behind their origin",
Expand All @@ -236,14 +264,18 @@ A copy pinned to a tag is reported as up to date for as long as that tag points
where it did; modelith does not look for newer releases. Every line names the
ref it was checked against.

Fetching is delegated to the gh CLI, which must be installed and authenticated.
Nothing is written.`),
Fetching is delegated to the gh CLI for github.com origins and the az CLI for
dev.azure.com ones, each of which must be installed and authenticated. Nothing
is written.

Each fetch is bounded by --timeout (default 60s, 0 disables the bound): a hung
CLI fails fast instead of stalling the run.`),
Example: strings.TrimSpace(`
modelith deps check docs/payments.modelith.yaml
modelith deps check docs/*.modelith.yaml`),
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
reports, err := deps.Check(cmd.Context(), deps.CheckOptions{Paths: args})
reports, err := deps.Check(cmd.Context(), deps.CheckOptions{Paths: args, Timeout: timeout})
// A run that got nowhere has nothing to summarise, and "checked 0
// vendored copies" above the reason would read as the outcome.
blocking := len(reports) > 0 && printCheckReports(cmd.OutOrStdout(), cmd.ErrOrStderr(), reports)
Expand All @@ -256,11 +288,16 @@ Nothing is written.`),
return nil
},
}
cmd.Flags().DurationVar(&timeout, "timeout", 60*time.Second,
"abandon a delegated fetch (gh/az) that exceeds this duration; 0 disables the bound")
return cmd
}

func depsUpdateCmd() *cobra.Command {
var ref string
var (
ref string
timeout time.Duration
)
cmd := &cobra.Command{
Use: "update <file>...",
Short: "Bring vendored copies forward to what their origins serve",
Expand All @@ -281,17 +318,22 @@ imports list, and it does not lint: an item a copy used to define may have been
renamed or removed upstream, and modelith lint is what reports that from the
importing model's seat.

Fetching is delegated to the gh CLI, which must be installed and authenticated.`),
Fetching is delegated to the gh CLI for github.com origins and the az CLI for
dev.azure.com ones, each of which must be installed and authenticated.

Each fetch is bounded by --timeout (default 60s, 0 disables the bound): a hung
CLI fails fast instead of stalling the run.`),
Example: strings.TrimSpace(`
modelith deps update docs/payments.modelith.yaml
modelith deps update docs/*.modelith.yaml
modelith deps update --ref v2.2.0 docs/payments.modelith.yaml`),
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
reports, err := deps.Update(cmd.Context(), deps.UpdateOptions{
Paths: args,
Ref: ref,
Now: time.Now(),
Paths: args,
Ref: ref,
Now: time.Now(),
Timeout: timeout,
})
// See depsCheckCmd: nothing reached means nothing to summarise.
blocking := len(reports) > 0 && printUpdateReports(cmd.OutOrStdout(), cmd.ErrOrStderr(), reports)
Expand All @@ -305,6 +347,8 @@ Fetching is delegated to the gh CLI, which must be installed and authenticated.`
},
}
cmd.Flags().StringVar(&ref, "ref", "", "re-pin the copy to this ref (one file only)")
cmd.Flags().DurationVar(&timeout, "timeout", 60*time.Second,
"abandon a delegated fetch (gh/az) that exceeds this duration; 0 disables the bound")
return cmd
}

Expand Down
60 changes: 59 additions & 1 deletion cmd/modelith/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package main

import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/stacklok/modelith/internal/deps"
"github.com/stacklok/modelith/internal/lint"
Expand All @@ -23,7 +25,7 @@ func run(t *testing.T, args ...string) (string, error) {
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs(args)
err := root.Execute()
err := root.ExecuteContext(context.Background())
return buf.String(), err
}

Expand Down Expand Up @@ -840,3 +842,59 @@ func TestSchemaOutputsValidJSON(t *testing.T) {
t.Fatalf("schema output is not valid JSON: %v", err)
}
}

// TestDepsImportTimeoutFlagParses pins that --timeout is accepted with a
// duration and with 0 (the explicit opt-out), and that its default is 60s.
// The import fails on the unsupported host before any fetch, so no gh/az runs
// and the test needs no network.
func TestDepsImportTimeoutFlagParses(t *testing.T) {
dir := t.TempDir()
cmd := depsImportCmd()
if d, err := cmd.Flags().GetDuration("timeout"); err != nil || d != 60*time.Second {
t.Fatalf("default --timeout = %v (%v), want 60s", d, err)
}

for _, tc := range []struct{ name, val string }{
{"a duration", "5s"},
{"an explicit opt-out", "0"},
} {
t.Run(tc.name, func(t *testing.T) {
want, _ := time.ParseDuration(tc.val)
cmd := depsImportCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
cmd.SetArgs([]string{"--timeout", tc.val,
"https://gitlab.com/acme/billing/-/blob/main/m.modelith.yaml", dir})
err := cmd.ExecuteContext(context.Background())
if err == nil {
t.Fatal("expected the unsupported-host error, got nil")
}
if strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("--timeout %s was rejected: %v", tc.val, err)
}
if !strings.Contains(err.Error(), "github.com/stacklok/modelith/issues") {
t.Fatalf("want the unsupported-host error, got: %v", err)
}
if got, _ := cmd.Flags().GetDuration("timeout"); got != want {
t.Errorf("--timeout parsed as %v, want %v", got, want)
}
})
}
}

func TestExecuteContextInterrupted(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()

root := rootCmd()
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs([]string{"schema"})

err := root.ExecuteContext(ctx)
if err == nil {
t.Fatal("expected error when executing with canceled context, got nil")
}
}
45 changes: 29 additions & 16 deletions docs/07-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,44 +99,57 @@ semantics, and refresh behavior.

### `modelith deps import <url> [dir]`

Fetches a GitHub model and writes a vendored copy to `dir`, or the working
directory when omitted. The filename comes from the origin. It requires an
installed, authenticated [`gh`](https://cli.github.com) CLI and prints the
`imports:` entry to add; it does not edit your model.
Fetches a GitHub or Azure DevOps model and writes a vendored copy to `dir`, or
the working directory when omitted. The filename comes from the origin. GitHub
imports require an installed, authenticated [`gh`](https://cli.github.com) CLI.
Azure DevOps imports require the [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/)
and an `az login` session. The command prints the `imports:` entry to add; it
does not edit your model.

| Argument / flag | Meaning |
|---|---|
| `<url>` | The address of the file as it appears in a browser on github.com. |
| `<url>` | The browser URL of a file on github.com or dev.azure.com. |
| `[dir]` | Destination directory. |
| `--ref` | Ref to fetch, overriding the ref in the URL. A tag pins the copy. |
| `--timeout` | Maximum duration for each delegated `gh` or `az` fetch. Defaults to `60s`; `0` disables the limit. |

When a branch or tag contains `/`, pass `--ref` only when it names that same ref in
the URL: it tells modelith where the ref ends and the file path begins. For
example, use `--ref release/v2` with a URL containing
`/blob/release/v2/docs/payments.modelith.yaml`. For an ordinary single-segment
ref in the URL, a different `--ref` works. But `--ref` cannot both select a
different ref and disambiguate a URL whose ref itself contains `/`; in that
ambiguous case, copy the browser URL for the file at the target ref.
GitHub browser URLs can be ambiguous when a branch or tag contains `/`. Pass
`--ref` only when it names that same ref in the URL: it tells modelith where the
ref ends and the file path begins. For example, use `--ref release/v2` with a
URL containing `/blob/release/v2/docs/payments.modelith.yaml`. For an ordinary
single-segment ref in the URL, a different `--ref` works. But `--ref` cannot
both select a different ref and disambiguate a URL whose ref contains `/`; in
that case, copy the browser URL for the file at the target ref.

```sh
modelith deps import https://github.com/acme/billing/blob/main/docs/payments.modelith.yaml docs/
modelith deps import "https://dev.azure.com/acme/billing/_git/models?path=docs/payments.modelith.yaml&version=GBmain" docs/
```

### `modelith deps check <file>...`

Checks vendored copies against their origins and exits non-zero when a copy is
stale or cannot be reached. It writes nothing and skips files without provenance
headers.
headers. Copies from github.com and dev.azure.com are both checked.

| Flag | Default | Description |
|---|---|---|
| `--timeout` | `60s` | Maximum duration for each delegated `gh` or `az` fetch; `0` disables the limit. |

```sh
modelith deps check docs/*.modelith.yaml
```

### `modelith deps update [--ref <ref>] <file>...`

Updates vendored copies from their origins. `--ref` re-pins one copy to a tag or
branch; it accepts exactly one file. The command does not edit `imports:` or
lint the result.
Updates vendored copies from their origins, for copies from github.com and
dev.azure.com alike. `--ref` re-pins one copy to a tag or branch; it accepts
exactly one file. The command does not edit `imports:` or lint the result.

| Flag | Default | Description |
|---|---|---|
| `--ref` | header's ref | Re-pin one copy to this ref (a tag or branch). One file only. |
| `--timeout` | `60s` | Maximum duration for each delegated `gh` or `az` fetch; `0` disables the limit. |

```sh
modelith deps update docs/*.modelith.yaml
Expand Down
Loading
Loading