Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions internal/cli/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,18 @@ func newAwsCmd(g *GlobalOpts) *cobra.Command {
"LocalStack/MinIO host port and prepends --endpoint-url/--region plus dev\n" +
"credentials (via the child environment). It does not reimplement any AWS call.\n\n" +
"Example: devstack aws -- s3 ls",
Args: cobra.MinimumNArgs(1),
Args: cobra.ArbitraryArgs,
DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error {
args = stripLeadingDashDash(args)
if len(args) == 0 {
return fmt.Errorf("usage: devstack aws -- <args...> (e.g. devstack aws -- s3 ls)")
// Bare `aws` or a leading -h/--help is a request for THIS shim's help —
// short-circuit before constructing any docker/S3 client or touching the
// daemon. (DisableFlagParsing means cobra does not intercept --help itself,
// so a help token would otherwise be forwarded to the real aws endpoint.)
// A help flag AFTER a subcommand (e.g. `aws -- s3 --help`) passes through
// to the user's aws unchanged.
if len(args) == 0 || isHelpFlag(args[0]) {
return cmd.Help()
}
awsPath, err := lookupAws()
if err != nil {
Expand Down Expand Up @@ -71,6 +77,16 @@ func lookupAws() (string, error) {
return p, nil
}

// isHelpFlag reports whether tok is a help request (-h/--help/help) meant for the
// shim itself rather than the forwarded aws command.
func isHelpFlag(tok string) bool {
switch tok {
case "-h", "--help", "help":
return true
}
return false
}

// stripLeadingDashDash drops a leading "--" separator (cobra with
// DisableFlagParsing keeps it in args).
func stripLeadingDashDash(args []string) []string {
Expand Down
22 changes: 22 additions & 0 deletions internal/cli/db_s3_aws_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,28 @@ func TestAwsEnvInjectsCredsNotArgv(t *testing.T) {
}
}

func TestAwsHelpShortCircuits(t *testing.T) {
// In an empty dir there is no workspace, so buildUpDeps + any daemon access
// would fail. `aws --help` (and bare `aws`) must still exit 0 by printing the
// shim's own help BEFORE constructing any docker/S3 client.
for _, args := range [][]string{{"aws", "--help"}, {"aws", "-h"}, {"aws"}} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
t.Chdir(t.TempDir())
var out strings.Builder
root := NewRootCmd(Options{})
root.SetArgs(args)
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatalf("`devstack %s` should exit 0 via help, got: %v", strings.Join(args, " "), err)
}
if !strings.Contains(out.String(), "aws") || !strings.Contains(out.String(), "Usage") {
t.Errorf("help output missing usage text: %q", out.String())
}
})
}
}

func TestAwsAbsentBinaryError(t *testing.T) {
t.Setenv("PATH", t.TempDir()) // an empty dir → no `aws` on PATH
if _, err := lookupAws(); err == nil {
Expand Down
36 changes: 35 additions & 1 deletion internal/cli/destroy.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func newWorkspaceDestroyCmd(g *GlobalOpts) *cobra.Command {
if !yes {
dataLine := "Volumes and databases are PRESERVED."
if purgeData {
dataLine = "WARNING: --purge-data DROPS every provisioned database/bucket/etc (DATA DESTROYED)."
dataLine = "WARNING: --purge-data DROPS every provisioned database/bucket/etc AND removes the shared volumes (DATA DESTROYED)."
}
prompt := fmt.Sprintf(
"This tears down workspace %q (%d project stack(s)) and releases its refs/ports.\n"+
Expand Down Expand Up @@ -94,6 +94,9 @@ func newWorkspaceDestroyCmd(g *GlobalOpts) *cobra.Command {
for _, p := range res.PurgedResources {
fmt.Fprintf(w, "[ok] dropped %s %s\n", p["kind"], p["name"])
}
for _, v := range res.PurgedVolumes {
fmt.Fprintf(w, "[ok] removed volumes for %s\n", v)
}
for _, e := range res.Errors {
fmt.Fprintf(w, "[warn] %s\n", e)
}
Expand Down Expand Up @@ -121,6 +124,7 @@ type DestroyResult struct {
Projects []string `json:"projects"` // project stacks brought down
SharedStopped []string `json:"shared_stopped"` // orphaned shared services warm-stopped
PurgedResources []map[string]string `json:"purged_resources,omitempty"` // --purge-data: resources dropped
PurgedVolumes []string `json:"purged_volumes,omitempty"` // --purge-data: shared volumes removed (compose down -v)
Errors []string `json:"errors,omitempty"`
}

Expand Down Expand Up @@ -208,6 +212,36 @@ func destroyWorkspace(ctx context.Context, d orchestrate.UpDeps, projects []stri
res.SharedStopped = gc.Stopped
}

// 3b. --purge-data ALSO removes the shared stack's named volumes (the postgres
// PGDATA, MinIO object store, …) via `compose down -v`. This is gated on the
// shared stack being fully orphaned (no ref rows remain from ANY workspace), so
// destroy can never drop data another workspace still depends on. The
// data-preserving default never reaches here.
if purgeData {
remaining, err := d.DB.AllRefs()
if err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("check remaining refs: %v", err))
} else if len(remaining) == 0 {
outDir := filepath.Join(d.Model.Root, generate.GenDir, "shared")
composeFile := filepath.Join(outDir, generate.ComposeFile)
if _, err := os.Stat(composeFile); err != nil {
composeFile = "" // label-driven `compose -p devstack-shared down -v`
}
cp := docker.Compose{Project: generate.SharedStackName, File: composeFile, Dir: outDir, Runner: runner}
if err := cp.Down(ctx, true); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("purge shared volumes: %v", err))
} else {
// Report the services whose volumes were removed (fall back to the
// stack name if nothing was warm-stopped this pass).
if len(res.SharedStopped) > 0 {
res.PurgedVolumes = append(res.PurgedVolumes, res.SharedStopped...)
} else {
res.PurgedVolumes = append(res.PurgedVolumes, generate.SharedStackName)
}
}
}
}

// 4. remove generated artifacts (.devstack): the workspace-root shared dir and
// each project's dir. Best-effort — a missing dir is fine.
_ = os.RemoveAll(filepath.Join(d.Model.Root, generate.GenDir))
Expand Down
50 changes: 50 additions & 0 deletions internal/cli/destroy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,53 @@ func TestDestroyWorkspaceTeardown(t *testing.T) {
t.Error("project .devstack/ should be removed")
}
}

func TestDestroyWorkspacePurgeDataRemovesSharedVolumes(t *testing.T) {
d, fr := destroyFixture(t)
ctx := context.Background()

if err := d.Manager.RegisterUp(ctx, "app"); err != nil {
t.Fatalf("register up: %v", err)
}

// --purge-data: once this workspace's refs are released the shared stack is
// fully orphaned, so its volumes are removed via `compose down -v`.
res := destroyWorkspace(ctx, d, []string{"app"}, true)
if len(res.Errors) != 0 {
t.Fatalf("destroy errors: %v", res.Errors)
}
if !fr.saw("-p "+generate.SharedStackName, "down", "--volumes") {
t.Errorf("purge-data must `compose down -v` the shared stack: %v", fr.cmds)
}
if len(res.PurgedVolumes) == 0 {
t.Errorf("PurgedVolumes should be populated, got %v", res.PurgedVolumes)
}
}

func TestDestroyWorkspacePurgeDataKeepsVolumesWhenReferenced(t *testing.T) {
d, fr := destroyFixture(t)
ctx := context.Background()

if err := d.Manager.RegisterUp(ctx, "app"); err != nil {
t.Fatalf("register up: %v", err)
}
// A SECOND project is genuinely LIVE (a running container) and still references
// the shared postgres, so reconcile keeps its ref and even --purge-data must NOT
// remove the shared volumes (data another consumer depends on survives).
mc := d.Docker.(*docker.MockClient)
mc.Containers = append(mc.Containers, docker.Container{
ID: "other1", Name: "devstack-other-web-1", State: "running",
Labels: map[string]string{generate.LabelManaged: "true", generate.LabelProject: "other"},
})
if err := d.DB.AddRef("other", "web", "shared-postgres"); err != nil {
t.Fatalf("seed foreign ref: %v", err)
}

res := destroyWorkspace(ctx, d, []string{"app"}, true)
if fr.saw(generate.SharedStackName, "down", "--volumes") {
t.Errorf("must NOT down -v the shared stack while another project refs it: %v", fr.cmds)
}
if len(res.PurgedVolumes) != 0 {
t.Errorf("PurgedVolumes must be empty when refs remain, got %v", res.PurgedVolumes)
}
}
5 changes: 5 additions & 0 deletions internal/cli/s3.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ func newS3RbCmd(g *GlobalOpts) *cobra.Command {
}
}
r := resource.Resource{Engine: "minio", Kind: "bucket", Name: bucket, Owner: proj}
if force {
// Recursively purge every object before removing the bucket, so a
// non-empty bucket can be deleted (the provisioner empties it first).
r.Params = map[string]any{"force": true}
}
if err := orchestrate.DropResource(cmd.Context(), d, r, true); err != nil {
return err
}
Expand Down
32 changes: 32 additions & 0 deletions internal/orchestrate/resource_ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,33 @@ import (
// CLI (internal/cli) is a thin wrapper over these; they are tested here with the
// same mock docker client + injected Postgres connector the saga tests use.

// generatedCredPath is the deterministic backend identifier a generated
// credential is pushed to: devstack/<owner>/<engine>/<kind>/<name>. Stable across
// re-provisions of the same resource so a rotation overwrites in place.
func generatedCredPath(r resource.Resource) string {
name := r.Name
if name == "" {
name = r.Owner
}
return fmt.Sprintf("devstack/%s/%s/%s/%s", r.Owner, r.Engine, r.Kind, name)
}

// pushGeneratedCred writes a resource's generated credential to the configured
// secrets Pusher (aws-sm/ssm/infisical). It is a no-op when no Pusher is wired —
// the value is still used to provision but is not persisted, and it is NEVER
// written to a generated file. The value travels via the Pusher's stdin/env path,
// never argv (spec 04 §7.5).
func pushGeneratedCred(ctx context.Context, d UpDeps, r resource.Resource, value string) error {
if d.CredPusher == nil {
return nil
}
entry := secrets.SecretEntry{Path: generatedCredPath(r), Value: value}
if err := d.CredPusher.Push(ctx, []secrets.SecretEntry{entry}); err != nil {
return fmt.Errorf("push generated credential for %s/%s: %w", r.Kind, r.Name, err)
}
return nil
}

// ResourceRegistry exposes the engine→Provisioner registry (Postgres live; other
// engines land in Full scope), wired with the injected connector.
func ResourceRegistry(connect PgConnector) *resource.Registry {
Expand Down Expand Up @@ -118,6 +145,11 @@ func CreateResource(ctx context.Context, d UpDeps, r resource.Resource) (resourc
r.Params = map[string]any{}
}
r.Params["password"] = pw
// Route the generated value through the secrets Pusher (when configured)
// so it is persisted to a backend, never a generated file (spec 04).
if err := pushGeneratedCred(ctx, d, r, pw); err != nil {
return nil, err
}
}
}
target, err := engineTarget(ctx, d, r.Engine, instance)
Expand Down
62 changes: 62 additions & 0 deletions internal/orchestrate/resource_ops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,75 @@ package orchestrate

import (
"context"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"testing"

"github.com/open-source-cloud/devstack/internal/resource"
"github.com/open-source-cloud/devstack/internal/secrets"
)

// fakePusher records the entries a generated credential is routed to, so a test
// can assert the value went to the Pusher (a backend) and never to disk.
type fakePusher struct{ entries []secrets.SecretEntry }

func (f *fakePusher) Push(_ context.Context, entries []secrets.SecretEntry) error {
f.entries = append(f.entries, entries...)
return nil
}

func TestCreateResourceGeneratedCredPushedNeverOnDisk(t *testing.T) {
d, _, _ := upFixture(t)
rp := &recordingPg{}
d.PgConnect = rp.connect
fp := &fakePusher{}
d.CredPusher = fp

_, err := CreateResource(context.Background(), d, resource.Resource{
Engine: "postgres", Kind: "database", Name: "reports", Owner: "app",
CredKind: resource.CredGenerated,
})
if err != nil {
t.Fatalf("CreateResource: %v", err)
}

// The generated value was routed through the Pusher exactly once.
if len(fp.entries) != 1 {
t.Fatalf("pusher received %d entries, want 1: %+v", len(fp.entries), fp.entries)
}
val := fp.entries[0].Value
if len(val) != 24 {
t.Errorf("generated value len = %d, want 24 (%q)", len(val), val)
}
for _, r := range val {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')) {
t.Fatalf("generated value not alphanumeric: %q", val)
}
}
if fp.entries[0].Path == "" {
t.Error("pushed entry must carry a backend path")
}

// Leak assertion: the generated value must appear in NO file under the workspace
// (generated compose, overlays, the ledger — nothing).
_ = filepath.WalkDir(d.Model.Root, func(path string, de fs.DirEntry, werr error) error {
if werr != nil || de.IsDir() {
return nil
}
b, rerr := os.ReadFile(path)
if rerr != nil {
return nil
}
if strings.Contains(string(b), val) {
t.Errorf("generated credential leaked into %s", path)
}
return nil
})
}

func TestCreateResourceImperative(t *testing.T) {
d, fr, db := upFixture(t)
rp := &recordingPg{}
Expand Down
23 changes: 13 additions & 10 deletions internal/orchestrate/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,19 +204,22 @@ func resourcesPhase(d UpDeps, decls []resDecl) Phase {
Params: r.params, CredKind: r.cred,
}
// A `generated` credential gets a random value here so a consumer
// never sees a predictable password; Pusher delivery to a provider
// lands in Full scope, so the value is currently held only for the
// provisioner call (never written to a generated file).
// never sees a predictable password. When a secrets Pusher is wired
// (d.CredPusher) the value is delivered to the backend; either way it
// is never written to a generated file (spec 04 §7.5).
if r.cred == resource.CredGenerated {
pw, err := secrets.RandomPassword(24)
if err != nil {
return err
}
if res.Params == nil {
res.Params = map[string]any{}
}
if _, set := res.Params["password"]; !set {
pw, err := secrets.RandomPassword(24)
if err != nil {
return err
}
if res.Params == nil {
res.Params = map[string]any{}
}
res.Params["password"] = pw
if err := pushGeneratedCred(ctx, d, res, pw); err != nil {
return err
}
}
}
params := d.Model.Workspace.Shared[r.instance].Params
Expand Down
6 changes: 6 additions & 0 deletions internal/orchestrate/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ type UpDeps struct {
// Secrets resolves secret:// refs; nil → built from workspace.secrets.providers
// with the built-in factories (SOPS+age). Injected for tests.
Secrets *secrets.Registry
// CredPusher, when non-nil, receives a resource's `generated` credential so the
// random value is written to a secrets backend (aws-sm/ssm/infisical) instead of
// only living in-process for the provisioner call. The plaintext never lands in a
// generated file (spec 04 valueless-env coupling); nil → no push (the value is
// still generated and used to provision, but not persisted anywhere).
CredPusher secrets.Pusher
// Trust installs the local CA when network.proxy.httpsLocal; nil → trust.New().
// Injected for tests (the trust phase is fenced — failure never aborts up).
Trust *trust.Trust
Expand Down
Loading
Loading