#Nailing the CLI experience
1 messages · Page 1 of 1 (latest)
Sorry to be a bother guys. I would love to get your thoughts on this proposal - and more generally on how to go from "90% great" to "100% great" in the Zenith CLI experience
I feel like this would be going down the route of a "shell SDK", which I'm not sure is something that would ever be a great experience; shell is just too limiting and hard to work with inherently.
If there's use cases that could only ever be satisfied by it, then sure, but I would rather users that require something beyond a single CLI command go to using one of the SDKs, either by writing module code or the whole idea of being able to use language-native snippets from the CLI directly
In terms of polishing the CLI experience, there's a ton obviously. We have a parent issue in Linear for everything CLI related with quite a few subissues already, but I'm sure it's still incomplete
The biggest one that's top of mind for me right now is the problem around with* apis translating awkwardly to the CLI https://github.com/dagger/dagger/issues/6115
The pattern of writing modules that use With* functions to enable updates of object state in a builder-pattern/"fluent" style works pretty well for codegen clients, but looks clunky in th...
But just in your example snippet above there's some more ideas already 🙂 dagger push is a pretty obvious gap right now, would make perfect sense to be able to publish container images
To be clear, the goal would not be specifically to make the CLI more modular, scriptable etc. The main goal would be to make it more intuitive. I'm seeing some confusion on what exactly our different commands do, and when to use which
RE: confusion about call, shell, up, I think shell and up are pretty straightforward but call is definitely confusing.
This is partially because it's a "catch all"; we just execute the function and try to display the result of it in some reasonable way, but for types like Container/Directory/etc. I think it's just sort of confusing because there's actually many ways a user might want to see the result of functions that return those.
That reminds me of the issue you opened about support for core types in the CLI; if dagger call instead just let you chain more apis off those types rather than choosing how to display them for you, that would be a lot more consistent and comprehensible
Some of the confusion is around "running the pipeline" vs. "running the thing returned by the pipeline"
Another, distinct confusion is around shell, up and the exec behavior of call, being variations of "run the container"
Right, so if we updated call to only be capable of returning scalars or objects consisting of scalars I think that could go a long way potentially.
So if your function returns a Container, today you do
dagger call build-container and then we default to just showing the stdout of the most recent exec.
But instead if we allowed you to use the core api you'd be able to do something like dagger build-container stdout or dagger build-container rootfs entries, etc.
Then call is a way of getting scalar return values, rather than a murky way of invoking a container or something
here build-container is the example module's function?
Yes, just something that returns a container
I think we should either:
- go all in on a generic verb -> collapse everything into
call+ various flags - go all in on specialized verbs -> remove
call
or 3) decouple them into distinct stages ->call | <specialized verb>, aka my proposal
It's too confusing IMO to have a generic verb + a bunch of specialized ones, and have to navigate when to use which
Just to make the case for option 3:
Decoupling a) the definition of a pipeline from b) its execution, might be the ideal UX, because it maps to reality. It may not be possible to sweep that reality under the rug without making things more confusing one way or the other.
I would do it like this:
-
Define a pipeline with
dagger pipeline. This outputs a low-level pipeline code (aka an ID). You can save this pipeline code for later, or pipe it into another command to actually run it. -
Run the pipeline. There are various commands to do that, depending on what you want to do with the result.
dagger execexpects a container, and will execute a process inside it;dagger savewill save the result to your filesystem;dagger printwill print it; etc.
The fact that it works great with a unix pipeline is a UX bonus (familiarity for shell users) but it's not the primary objective. The goal is not to have you overload unix pipelines to program dagger pipelines, aka "bash SDK".
I think it has the potential of making our CLI UX simpler, because it removes some of the weirdness. For example, we wouldn't have to preface every example snippet with "here I'm using call, but you can replace this with any of the other verbs which take the same kinds of arguments". Instead there would only be one subcommand that works that way: dagger pipeline (or whatever we want to call it)
My second favorite option is option 1: just collapse everything into a single command (and I kind of want to call it dagger run if we go down that road: run the specified).
In this option, dagger run would have a bunch of flags to control what to do with the result:
-
dagger run --print-> just print information about the result to the terminal -
dagger run --export TARGET-> save the result to the local filesystem, or a remote registry -
dagger run --exec-> execute the result (default behavior for container results) -
dagger run --exec-command-> change the command to exec -
dagger run --exec-interactive-> change whether to attach an interactive terminal on exec -
dagger run --exec-port PORT-> forward ports on exec -
dagger run --up-> bring a service up (redundant with exec?)
I like that this option has less commands to learn. I think the number of flags could be kept relatively small, so it's a viable option.
I do think the exact behavior of flags would be confusing in some cases. Especially around the "run the pipeline" vs "run the result container" part. For example, when I'm forwarding ports, does that apply to the pipeline itself, or the resulting container? Not obvious from looking at this monolithic all-in-one command. That's where option 3 shines IMO.
For 3, the problems with saving IDs/pipeline-code go deeper than just having a stable ID; for local dirs for example the choice is between
A) we encode the instructions for "load this dir from this location on the filesystem", in which case they are at minimum not reproducible and at worst pretty dangerous (not security per-se but in terms of using the data you expect)
B) we load the local dir at the time of creating the pipeline code and then store the hash of it (how it works today); but then once your session ends buildkit is free to prune that cached dir that was loaded if nothing else is depending on it
C) we embed the entire compressed tar of the local dir in the ID, which is probably inadvisable for multiple reasons 😄
So trying to make it safe for IDs to be re-used across sessions is a very complex rabbit hole unless you limit it to 100% reproducible sources, which means no local dirs and is thus highly limiting.
At the very least it makes it not practical as a short term UX
(I now realize)
I'd love to keep the door open to it as an extra benefit of IDv2, when we get around to that. My impression is that IDv2 tackles a lot of the challenges you're describing (or at least that's the goal)
No I'm actually saying that there's no good solution to ever being able to both A) support local dirs and B) make IDs re-usable across a single session; it's inherently intractable. IDsv2 help with a lot but that problem is more just with the mutable nature of the world 😄
For example, when I'm forwarding ports, does that apply to the pipeline itself, or the resulting container? Not obvious from looking at this monolithic all-in-one command.
I totally see what you're saying here though
But wait! What if we flipped it around. What if instead of the CLI building on hypothetical future IDv2, it was the CLI that defined its own format, specifically for the purpose of the CLI UX - and IDv2 could potentially adopt parts of it, or use it as an API facade
In other words, what if dagger pipeline didn't output an ID at all (either v1 nor v2), but just a CLI-specific blob that encodes a pipeline call from the CLI's perspective.
That would give us way more latitude to solve the problems you mentioned
(because we wouldn't be messing with the bearing wall that is IDs)
For example:
we encode the instructions for "load this dir from this location on the filesystem", in which case they are at minimum not reproducible and at worst pretty dangerous (not security per-se but in terms of using the data you expect)
I would address this by 1) always encoding the full path, and 2) encoding the hostname + maybe 24h TTL into the pipelinecode as a crude "salt" to discourage unsafe use
So it would be barely more than a JSON-encoding of the CLI arguments
But it would be enough to make the UX a more accurate model of how things work (which I argue would make it better)
I'm not 100% convinced of this yet - I also kind of like dagger run for everything
(but worry about a flag indigestion_)
That would be my vote too, though I agree also that we need to carefully think through the interaction between all the flags.
My cat is screaming at me to go on a walk but before I go other misc thoughts I didn't mention yet:
- Pipelines sometimes need to get run for side effects only (i.e. they do a deployment or just make a request to some internet service in order to trigger something); if we only have specialized verbs we would need to somehow fit it in there. This feels like a good argument for just "
dagger runfor all" - Food for thought: I recently was thinking about a module for building openwrt, but when I do that today w/ a makefile I have to run
make menuconfig, which drops you into a TUI for configuring the kernel and everything and then at the end after you exit it spits out a configuration file of everything you set up in that tui.
- This is interesting because it calls for a hybrid of
dagger download(to get the generated config) anddagger shell(to execute the interactive program). I was thinking at the time maybe it would be better if our commands were less separate, which is pretty much the same lines as making everythingdagger run. Maybe, worth more thought at least
That make menuconfig use case is quite a puzzle... How do you allow for the pipeline itself to require an interactive session, without causing mayhem?
Maybe there could be an API for specifically requesting an interactive terminal on exec, with a user attached to it - possibly it would be a flag to WithExec. But then, we'll have to decide how to handle it on the client side. What happens when you run it headless, from a CI runner? Will the pipeline fail with "pipeline requires an interactive terminal"? Is it OK that this is done dynamically, in other words you never know in advance if you CI pipeline will fail or not?
Then, even when you do have an interactive terminal, now you have a UX that may present MULTIPLE terminals to the user. That's actually very interesting, and maybe it can just work: when you dagger run in a terminal, you should be prepared to be prompted several times in a row, unless you run --no-interactive or something.
Bonus: this could be applied to debugging with breakpoints: just open an interactive shell at each function call, wait for the user to exit the shell, move to the next breakpoint, etc
So on my walk I thought about it more and realized that an even better way of supporting this would be the same thing we'd want to support fully daggerized dev environments: mutagen-style continuous local dir syncing. Makes sense in multiple ways, one of the primary being that it supports the use case without having to deal with murky questions around whether we should cache the results of an interactive session (which is not exactly hermetic). I'd be able to open an interactive shell that executes make menuconfig on top of my synced local dir. Essentially, if you think of menuconfig in the same way as an IDE like neovim, it falls into place better.
All of which is fine and good and also totally orthogonal to the CLI UX, so can deal with it separately.
The debugging w/ breakpoints feature is definitely relevant to the CLI UX though
I also realized while walking that Secret is another huge wrench in trying to make any sort of re-usable IDs or "saved instructions" of any format. Any way of dealing with it would require another 10 hoops to jump through on top of the others mentioned
So yeah overall my feelings are that:
- At the utterly bare minimum, it would make sense to clarify
dagger callby requiring the pipeline returns a scalar (which in turn requires support for using the core api from the cli) - Better would be finding a clean way of combining everything into one
dagger runcommand, which is mostly a matter of sorting out the interaction between various flags
I do totally see the point about whether a given flag should apply to the whole pipeline or just the final returned object... it's interesting because the docker cli doesn't have this problem as a result of build+run being completely distinct. The fact that they aren't distinct for us is actually a long-term enormous benefit I think, but it creates new questions like these
Not really IMO because secrets should be passed by name and not by value, so the “just cli plumbing” approach would work just fine
eg —token env://MYTOKEN
if we are saying that the only way to pass them is by env var sure, but I couldn't do --token "$(cat ~/.secrets/my-token)" anymore
—token file:~/.secrets/my-token is better imo 🙂
(doesn’t mean option 3 is necessarily best, I’m just bikeshedding all options in parallel at thus point 😁
Sure sure if we have a whole syntax for that it could work. And honestly that's another improvement to make to the CLI not matter what (need an issue for that one).
It's just another caveat though where the output of dagger pipeline would be "kind of re-usable but not entirely"
lol I was hoping you just created that but it was 2 weeks ago... I have looked through that list probably 5 times in the last few days and didn't notice 😵💫
another interesting thing I just realized is that by supporting the core api in the CLI you would be able to already do dagger call my-container publish --address docker.io/foo/bar:latest I think, which would perhaps invalidate the need for another dagger push verb.
Or if everything becomes dagger run it could invalidate the need for a --push flag
Which is kind of neat
I guess it's a result of putting verbs in our API... which I generally don't love but in this particular case almost kind of works out in a way?
it’s a big list 😀
My $.02 from related experience with Bass: I initially went with something close to option 3), where you would do ./bass/build | bass --export (where ./bass/build emits a thunk or thunk path JSON to stdout). But I noticed this led to an explosion of tiny Bash scripts wrapping my Bass scripts, so I pivoted away from that and just added (export), (publish), and (write) in v0.12 instead: https://github.com/vito/bass/releases/tag/v0.12.0
So it seems like there's a balancing act here; it's obviously great to have an expressive CLI, but at the same time I don't really want to need a thin layer of Bash around Dagger everywhere I use it. If I need to use a pipe or anything more complicated than dagger <command> <obvious-flags>, I'm definitely gonna wrap it in a ./hack/ script, and now I've got glue on my glue.
This feels possibly related to the "main" vs "library" module distinction @sterile latch raised recently (can't remember in what context).
So I guess my point is I'd rather just codify all of this - especially those routine operations like image publishing - directly into my module, rather than chain commands together. It's a one-liner anyway; I can still have a method that returns a *Container, and just have another one that calls .Publish on it. In fact that's what I ended up doing for Daggerverse without a second thought. I would probably never use dagger push for example if it made me type out the image ref every time.
@sleek glade so your point is that we should narrow the focus of CLI verbs & flags, to things you cannot do inside the module - like writing to local filesystem for example; attaching an interactive shell; etc
Right?
Yeah pretty much. I think my stance could be summed up as: if there's an operation I do repeatedly in my project, I'll always want to implement in <language of choice> with an entrypoint so simple that I would never have to script it.
I would go one step further and say that might include being able to write to the host filesystem, in as safe a way as possible. In Bass for example you can only write beneath directories you've been given; you can't write to arbitrary paths. Maybe this has something to do with the idea of main vs library modules.
That one scares me because I don't think we know enough (yet) to add an additional layer of entrypoints
I think cutting that extra layer of convenience, even though they all made sense (it's not like those entrypoints were bad or not useful), was a great decision and we haven't fully reaped the benefits of it yet
Maybe it's not main vs library but some sort of writable Directory type instead? 🤔
Not saying I disagree with that necessarily, but I don't want to gate fixing / finishing our CLI UX on designing big new features in the DX
Messing with the Directory type in particular feels like a pretty big change to take on
Yeah fair - I think my only fear is that we design the hell out of the CLI and reach peak composability, but end up putting people right back where they started (pile of Bash scripts on top of CI). Still a better world, since those scripts should pretty much be one-liners, but even I ended up confused by the sheer sprawl of tiny scripts in Bass when I came back to it after stepping away for a few months
Yes I agree with that.
I think in the short term, we should design the CLI with these priorities:
- It should be a great entrypoint: an intuitive and painless way to run pipelines with Dagger, even if you're not an expert.
- It should be a good swiss army knife for power users who want to poke at a module without writing code. Debugging, troubleshooting, inspecting, etc.
Non-goal: extreme composability, ie. your shell is now a Dagger SDK
Because of priority 2 (swiss army knife) I do think separating "define this pipeline" and "do something with it" is a plausible design, but I don't mind shelving it for now
Right now, the most urgent UX problem I think, revolves around execution of containers from the CLI:
- Confusion 1: what am I running? A pipeline, a container returned by the pipeline, or both?
- Confusion 2: how many ways are there to run a container, and which one should I use?
Does most of that confusion stem from dagger call actually running the returned container? Or are there other cases? It seems to me that dagger call makes sense as a general 'run this and show me the result, and what I can do with it' , otherwise the verbs make sense to me, and I don't think I'd feel the need to wrap something like dagger download go-generate in a script
I think it's only one part of a bigger thing. Even if we replaced the current call behavior with, say exec, that would still be 3 different ways to run a container: exec, shell and up.
I still think these are the 3 options
Confusion 1: what am I running? A pipeline, a container returned by the pipeline, or both?
Confusion 2: how many ways are there to run a container, and which one should I use?
I think the framing should be that you are executing a dagger API call. That's true in a literal sense but I think it makes sense on a higher level too.
The real point of dagger isn't to run containers; containers are a means to an end. The implication is that dagger call should error out if all you do is call something that returns a container and end there.
That in turn implies that we need to support the core api in the CLI so that if you want to make an api call that involves a function that returns a container, then you have to select something from the container api. The point isn't to run the container, the point is to get something useful out of the container or do something useful with it. Same for the rest of the core types like directory, file, etc.
- If
testis a function that returns a container where tests are executed:dagger call test stdout - If
buildis a function that returns a directorydagger call build file --path /path/to/some/file export --path ./where/i/want/the/file
The last implication is that shell and up are debugging+development tools, not docker run replacements for "just run this container". Honestly, that's a point that isn't exactly set in stone but at least that's what the overall intention has been so far (right?). But if so, maybe that also means shell and up should be open to working with types that aren't containers (i.e. shell on a directory gives you an interactive shell where your working dir is that directly and you just have coreutils available, etc.)
Also, none of the above is contradictory with changing to have everything be dagger run or getting rid of dagger call, etc.. It's more just about whether we should frame dagger as a tool for running containers at all
I'll just throw out a hot take suggestion based on the above (meant more to be food for thought than a real proposal per-se).
We collapse everything into two commands:
dagger call- for executing dagger API calls (with the above changes to support the core api too so we get host export, container publishing, etc. w/out the need for separate commands/flags)dagger debug- for debugging your pipelines. We collapse the existing functionality ofshellandupand anything else we might want in the future in this area here
Being able to use the core api (stdout, publish) etc from dagger call would be really great! Today, it could get confusing to the module developer when deciding where to put the entrypoints to the module but the ability to always return a core type and being able to act on it from the CLI helps clear some things up. It could get overwhelming for a newcomer so that's something to watch out for but I don't think that's any different today.
Flagging this because there's an important design decision to be made here, with consensus, but no issue
It's high on my list but I've been held up on other things. Haven't read this whole thread yet, just need to fix an issue before shifting focus.
Started making an issue for it this morning to summarize the thread and related issues, will post in a few.
This week I'm doing interface support, but the next thing on my docket starting next week has been to look at all the various issues around CLI interaction w/ modules (with* apis being awkward, host env passing not scaling well, refactoring the commands, among a few others) and think through an overall plan for fixing them. It's entirely possible they end up being independent but I have a feeling they are all interrelated and might have overlap in terms of solutions too.
So point being we should work together on all that next week 🙂
Related discord thread: #1174585230649217055 message Problem We've accumulated quite a few new CLI commands as part of module support:...