diff --git a/go.mod b/go.mod index 0baba12..1cb0709 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,10 @@ require ( charm.land/lipgloss/v2 v2.0.1 filippo.io/age v1.3.1 github.com/adrg/xdg v0.5.3 + github.com/aws/aws-sdk-go-v2 v1.42.0 + github.com/aws/aws-sdk-go-v2/credentials v1.19.25 + github.com/aws/aws-sdk-go-v2/service/s3 v1.104.1 + github.com/aws/smithy-go v1.27.1 github.com/compose-spec/compose-go/v2 v2.12.1 github.com/go-playground/validator/v10 v10.30.3 github.com/goccy/go-yaml v1.19.2 @@ -31,6 +35,14 @@ require ( filippo.io/hpke v0.4.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/atotto/clipboard v0.1.4 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.30 // indirect github.com/catppuccin/go v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect diff --git a/go.sum b/go.sum index 6cdba28..6821687 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,30 @@ github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= +github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls= +github.com/aws/aws-sdk-go-v2/credentials v1.19.25 h1:TzPVjfUZ1hsKafvYE+DIzKXIik2KufQxsPHanlkttbo= +github.com/aws/aws-sdk-go-v2/credentials v1.19.25/go.mod h1:K4hw0buguVvtC74HnVfTRr0LzQQHAWPqJbBU9QGk2Pg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.30 h1:4HbXxyipSYxexU0juMIpdS05dilL6dbB2VQHxxN2vGU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.30/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.104.1 h1:yb03KevaOAG5e8suo79Af74vjIQvoeKmjl79WQchLrs= +github.com/aws/aws-sdk-go-v2/service/s3 v1.104.1/go.mod h1:mreYODw0Y4yv7xeczvqC6vciwFao8lPE9k1l1ulfY6E= +github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8= +github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= diff --git a/internal/cli/aws.go b/internal/cli/aws.go new file mode 100644 index 0000000..dd37439 --- /dev/null +++ b/internal/cli/aws.go @@ -0,0 +1,100 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "os/exec" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/orchestrate" +) + +// newAwsCmd wires the thin `devstack aws -- ` shim (spec 29 §aws): a pure +// argv passthrough over the user's own `aws` binary with --endpoint-url + +// --region prepended and dev creds injected via the child ENV (never argv). It +// never reimplements any AWS call; it just removes the endpoint/port lookup. The +// aws binary is an external dependency — absence is a clear error, never a panic. +func newAwsCmd(g *GlobalOpts) *cobra.Command { + return &cobra.Command{ + Use: "aws -- ", + Short: "Run the host `aws` CLI against the local LocalStack/MinIO endpoint", + Long: "A thin argv shim over your own `aws` binary: devstack resolves the\n" + + "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), + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + args = stripLeadingDashDash(args) + if len(args) == 0 { + return fmt.Errorf("usage: devstack aws -- (e.g. devstack aws -- s3 ls)") + } + awsPath, err := lookupAws() + if err != nil { + return err + } + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + endpoint, region, access, secret, err := orchestrate.ResolveAwsEndpoint(cmd.Context(), d) + if err != nil { + return err + } + argv := awsArgs(endpoint, region, args) + child := exec.CommandContext(cmd.Context(), awsPath, argv...) + child.Env = awsEnv(os.Environ(), access, secret, region) + child.Stdin = cmd.InOrStdin() + child.Stdout = cmd.OutOrStdout() + child.Stderr = cmd.ErrOrStderr() + if err := child.Run(); err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + return fmt.Errorf("aws exited with code %d", ee.ExitCode()) + } + return fmt.Errorf("run aws: %w", err) + } + return nil + }, + } +} + +// lookupAws resolves the host `aws` binary or returns an install remediation. +func lookupAws() (string, error) { + p, err := exec.LookPath("aws") + if err != nil { + return "", fmt.Errorf("the `aws` CLI is not installed or not on PATH — install it (https://aws.amazon.com/cli/) to use `devstack aws --`") + } + return p, nil +} + +// stripLeadingDashDash drops a leading "--" separator (cobra with +// DisableFlagParsing keeps it in args). +func stripLeadingDashDash(args []string) []string { + if len(args) > 0 && args[0] == "--" { + return args[1:] + } + return args +} + +// awsArgs prepends the endpoint + region flags to the user's args (creds go in the +// env, never argv, per spec 29 / §7.5 secret hygiene). +func awsArgs(endpoint, region string, args []string) []string { + out := []string{"--endpoint-url=" + endpoint, "--region=" + region} + return append(out, args...) +} + +// awsEnv layers the LocalStack/MinIO dev credentials over the parent environment. +func awsEnv(parent []string, access, secret, region string) []string { + env := append([]string{}, parent...) + env = append(env, + "AWS_ACCESS_KEY_ID="+access, + "AWS_SECRET_ACCESS_KEY="+secret, + "AWS_DEFAULT_REGION="+region, + "AWS_REGION="+region, + ) + return env +} diff --git a/internal/cli/db.go b/internal/cli/db.go new file mode 100644 index 0000000..d89e9ea --- /dev/null +++ b/internal/cli/db.go @@ -0,0 +1,398 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/orchestrate" + "github.com/open-source-cloud/devstack/internal/resource" + "github.com/open-source-cloud/devstack/internal/state" +) + +// newDbCmd wires the `devstack db` group (spec 29 §databases): tenant-scoped +// Postgres database + role/grant verbs on the shared engine. create/user/grant/ +// drop/gc mirror the up-saga provision flow (lock → overlay → provisioner → +// ledger → event) via internal/orchestrate; list is a lock-free ledger read. This +// graduates the reserved `db` stub. snapshot/restore/reset/pull stay v2 stubs. +func newDbCmd(g *GlobalOpts) *cobra.Command { + cmd := &cobra.Command{ + Use: "db", + Short: "Tenant-scoped databases, roles and grants on the shared Postgres", + } + cmd.AddCommand( + newDbCreateCmd(g), + newDbUserCmd(g), + newDbGrantCmd(g), + newDbListCmd(g), + newDbDropCmd(g), + newDbGcCmd(g), + // v2 data-lifecycle verbs (spec 15) reserved as stubs. + stub("snapshot", "Snapshot a project's database", "v2 (spec 15)"), + stub("restore", "Restore a project's database from a snapshot", "v2 (spec 15)"), + stub("reset", "Drop and re-provision a project's database", "v2 (spec 15)"), + stub("pull", "Pull a database snapshot from a shared store", "v2 (spec 15)"), + ) + return cmd +} + +// sanitizePg maps a name to a safe Postgres identifier (hyphens → underscores), +// matching provision.pgIdent so ledger + physical names line up. +func sanitizePg(s string) string { return strings.ReplaceAll(s, "-", "_") } + +// pgPrefixed computes the tenant-scoped physical identifier: _ +// (spec 29 §tenant naming), unless --no-prefix keeps the literal name. +func pgPrefixed(project, name string, noPrefix bool) string { + if noPrefix { + return sanitizePg(name) + } + return sanitizePg(project + "_" + name) +} + +func newDbCreateCmd(g *GlobalOpts) *cobra.Command { + var project, owner string + var noPrefix bool + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a tenant database owned by the project role (idempotent)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + proj := project + if proj == "" { + proj = defaultProject(d) + } + ownerRole := owner + if ownerRole == "" { + ownerRole = sanitizePg(proj) + } + physical := pgPrefixed(proj, args[0], noPrefix) + r := resource.Resource{ + Engine: "postgres", Kind: "database", Name: physical, Owner: proj, + Params: map[string]any{"owner": ownerRole}, CredKind: resource.CredPredictable, + } + attrs, err := orchestrate.CreateResource(cmd.Context(), d, r) + if err != nil { + return err + } + dsn := fmt.Sprintf("postgres://%s:%s@%s:%s/%s", attrs["user"], attrs["password"], attrs["host"], attrs["port"], attrs["database"]) + if g.JSON { + return writeJSON(cmd, map[string]any{ + "kind": "database", "name": physical, "project": proj, "owner": ownerRole, "dsn": dsn, + }) + } + fmt.Fprintf(cmd.OutOrStdout(), "created database %q owned by %q\n%s\n", physical, ownerRole, dsn) + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "owner project (default: the workspace's single/first project)") + cmd.Flags().StringVar(&owner, "owner", "", "owning role (default: the project role)") + cmd.Flags().BoolVar(&noPrefix, "no-prefix", false, "use the literal name (skip the _ prefix)") + return cmd +} + +// newDbUserCmd hosts `db user create`. +func newDbUserCmd(g *GlobalOpts) *cobra.Command { + cmd := &cobra.Command{Use: "user", Short: "Manage tenant login roles"} + cmd.AddCommand(newDbUserCreateCmd(g)) + return cmd +} + +func newDbUserCreateCmd(g *GlobalOpts) *cobra.Command { + var project, db, role, password string + var generate, noPrefix bool + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a tenant login role, optionally granted on a database", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if role == "" { + role = "read" + } + if !validGrantLevel(role) { + return fmt.Errorf("invalid --role %q (want read|write|admin)", role) + } + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + proj := project + if proj == "" { + proj = defaultProject(d) + } + physical := pgPrefixed(proj, args[0], noPrefix) + params := map[string]any{"level": role} + if db != "" { + params["db"] = db + } + cred := resource.CredPredictable + if generate { + cred = resource.CredGenerated + } + if password != "" { + params["password"] = password + } + r := resource.Resource{ + Engine: "postgres", Kind: "role", Name: physical, Owner: proj, + Params: params, CredKind: cred, + } + attrs, err := orchestrate.CreateResource(cmd.Context(), d, r) + if err != nil { + return err + } + if g.JSON { + out := map[string]any{"kind": "role", "name": physical, "project": proj, "role": role} + if db != "" { + out["database"] = attrs["database"] + } + if generate { + out["password"] = attrs["password"] + } + return writeJSON(cmd, out) + } + w := cmd.OutOrStdout() + fmt.Fprintf(w, "created role %q (%s)\n", physical, role) + if generate { + fmt.Fprintf(w, "password: %s\n", attrs["password"]) + } + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "owner project") + cmd.Flags().StringVar(&db, "db", "", "database to grant the role on (physical name)") + cmd.Flags().StringVar(&role, "role", "read", "privilege tier: read|write|admin") + cmd.Flags().StringVar(&password, "password", "", "explicit password (default: predictable dev cred == role name)") + cmd.Flags().BoolVar(&generate, "generate", false, "mint a random password via crypto/rand") + cmd.Flags().BoolVar(&noPrefix, "no-prefix", false, "use the literal role name (skip the _ prefix)") + return cmd +} + +func newDbGrantCmd(g *GlobalOpts) *cobra.Command { + var project, on, as string + cmd := &cobra.Command{ + Use: "grant ", + Short: "Grant a privilege tier to an existing role on a database", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if on == "" { + return fmt.Errorf("--on is required") + } + if as == "" { + as = "read" + } + if !validGrantLevel(as) { + return fmt.Errorf("invalid --as %q (want read|write|admin)", as) + } + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + proj := project + if proj == "" { + proj = defaultProject(d) + } + // The role argument is the literal (already-tenant-scoped) role name. + r := resource.Resource{ + Engine: "postgres", Kind: "role", Name: sanitizePg(args[0]), Owner: proj, + Params: map[string]any{"db": on, "level": as, "grant_only": "1"}, + CredKind: resource.CredPredictable, + } + if _, err := orchestrate.CreateResource(cmd.Context(), d, r); err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"granted": as, "role": sanitizePg(args[0]), "on": sanitizePg(on)}) + } + fmt.Fprintf(cmd.OutOrStdout(), "granted %s on %q to %q\n", as, sanitizePg(on), sanitizePg(args[0])) + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "owner project") + cmd.Flags().StringVar(&on, "on", "", "database to grant on (physical name)") + cmd.Flags().StringVar(&as, "as", "read", "privilege tier: read|write|admin") + return cmd +} + +func newDbListCmd(g *GlobalOpts) *cobra.Command { + var project, kind string + cmd := &cobra.Command{ + Use: "list", + Short: "List provisioned databases and roles (lock-free)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + mgr, closeFn, err := buildManager(cmd) + if err != nil { + return err + } + defer closeFn() + wantKind := dbKindAlias(kind) + var rows []state.Provisioned + if project != "" { + rows, err = mgr.DB.ProvisionedFor(project) + } else { + rows, err = mgr.DB.AllProvisioned() + } + if err != nil { + return err + } + var filtered []state.Provisioned + for _, r := range rows { + if r.Kind != "database" && r.Kind != "role" { + continue + } + if wantKind != "" && r.Kind != wantKind { + continue + } + filtered = append(filtered, r) + } + if g.JSON { + return writeJSON(cmd, map[string]any{"databases": filtered}) + } + w := cmd.OutOrStdout() + if len(filtered) == 0 { + fmt.Fprintln(w, "no databases or roles provisioned") + return nil + } + for _, r := range filtered { + fmt.Fprintf(w, "%-10s %-10s %-24s %s\n", r.Project, r.Kind, r.Name, r.CreatedAt) + } + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "only this project's databases/roles") + cmd.Flags().StringVar(&kind, "kind", "", "filter by kind: db|role") + return cmd +} + +func newDbDropCmd(g *GlobalOpts) *cobra.Command { + var project, kind string + var yes, noPrefix bool + cmd := &cobra.Command{ + Use: "drop ", + Short: "Drop a tenant database or role (destructive; confirm required)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if g.JSON && !yes { + return fmt.Errorf("refusing to drop without --yes for --json/non-interactive use") + } + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + proj := project + if proj == "" { + proj = defaultProject(d) + } + realKind := dbKindAlias(kind) + if realKind == "" { + realKind = "database" + } + physical := pgPrefixed(proj, args[0], noPrefix) + if !yes { + if !confirm(cmd, fmt.Sprintf("This DROPS %s %q (data destroyed). Type 'yes' to continue: ", realKind, physical)) { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + } + r := resource.Resource{Engine: "postgres", Kind: realKind, Name: physical, Owner: proj} + if err := orchestrate.DropResource(cmd.Context(), d, r, true); err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"dropped": map[string]string{"kind": realKind, "name": physical, "project": proj}}) + } + fmt.Fprintf(cmd.OutOrStdout(), "dropped %s %q (project %q)\n", realKind, physical, proj) + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "owner project") + cmd.Flags().StringVar(&kind, "kind", "", "db|role (default: db)") + cmd.Flags().BoolVar(&yes, "yes", false, "skip the confirmation prompt") + cmd.Flags().BoolVar(&noPrefix, "no-prefix", false, "use the literal name (skip the _ prefix)") + return cmd +} + +// newDbGcCmd graduates the spec-13 `db gc` verb across ALL kinds (database, role, +// bucket, lifecycle, …): it reaps ledger rows whose owner project left the +// workspace, dropping the underlying engine object via the provisioner. +func newDbGcCmd(g *GlobalOpts) *cobra.Command { + var yes bool + cmd := &cobra.Command{ + Use: "gc", + Short: "Reclaim resources whose owner project left the workspace (all kinds)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if g.JSON && !yes { + return fmt.Errorf("refusing to gc without --yes for --json/non-interactive use") + } + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + active := map[string]bool{} + for _, p := range sortedProjectNames(d.Model) { + active[p] = true + } + orphans, err := d.DB.OrphanedProvisioned(active) + if err != nil { + return err + } + if len(orphans) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "no orphaned resources to reclaim") + return nil + } + if !yes { + if !confirm(cmd, fmt.Sprintf("This DROPS %d orphaned resource(s) (data destroyed). Type 'yes' to continue: ", len(orphans))) { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + } + res, err := orchestrate.GCResources(cmd.Context(), d, active) + if err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, res) + } + w := cmd.OutOrStdout() + for _, r := range res.Reaped { + fmt.Fprintf(w, "reaped %s %s (project %s)\n", r["kind"], r["name"], r["project"]) + } + for _, s := range res.Skipped { + fmt.Fprintf(w, "skipped %s %s: %s\n", s["kind"], s["name"], s["reason"]) + } + return nil + }, + } + cmd.Flags().BoolVar(&yes, "yes", false, "skip the confirmation prompt (required for --json)") + return cmd +} + +// dbKindAlias maps the user-facing `--kind db|role` to the stored ledger kind. +func dbKindAlias(k string) string { + switch k { + case "db", "database": + return "database" + case "role", "user": + return "role" + default: + return "" + } +} + +func validGrantLevel(s string) bool { + switch s { + case "read", "write", "admin": + return true + } + return false +} diff --git a/internal/cli/db_s3_aws_test.go b/internal/cli/db_s3_aws_test.go new file mode 100644 index 0000000..a86d750 --- /dev/null +++ b/internal/cli/db_s3_aws_test.go @@ -0,0 +1,187 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestDbGroupRegistered(t *testing.T) { + root := NewRootCmd(Options{}) + // db graduated from a stub: create/user/grant/list/drop/gc are real commands. + for _, path := range [][]string{ + {"db", "create"}, {"db", "user", "create"}, {"db", "grant"}, + {"db", "list"}, {"db", "drop"}, {"db", "gc"}, + } { + c, _, err := root.Find(path) + if err != nil || c.RunE == nil { + t.Fatalf("db %v not registered as a real command: %v", path, err) + } + } + // The stub `db` must be gone (no more "planned for v2" placeholder parent). + c, _, _ := root.Find([]string{"db"}) + if strings.Contains(c.Short, "snapshot/restore") { + t.Errorf("db still looks like the old stub: %q", c.Short) + } +} + +func TestDbCreateFlags(t *testing.T) { + root := NewRootCmd(Options{}) + c, _, err := root.Find([]string{"db", "create"}) + if err != nil { + t.Fatal(err) + } + for _, f := range []string{"project", "owner", "no-prefix"} { + if c.Flags().Lookup(f) == nil { + t.Errorf("db create missing --%s", f) + } + } +} + +func TestDbUserCreateFlags(t *testing.T) { + root := NewRootCmd(Options{}) + c, _, err := root.Find([]string{"db", "user", "create"}) + if err != nil { + t.Fatal(err) + } + for _, f := range []string{"db", "role", "password", "generate", "no-prefix"} { + if c.Flags().Lookup(f) == nil { + t.Errorf("db user create missing --%s", f) + } + } +} + +func TestS3GroupRegistered(t *testing.T) { + root := NewRootCmd(Options{}) + for _, path := range [][]string{ + {"s3", "mb"}, {"s3", "rb"}, {"s3", "ls"}, + {"s3", "lifecycle", "set"}, {"s3", "lifecycle", "get"}, {"s3", "lifecycle", "rm"}, + {"s3", "versioning"}, {"s3", "policy", "set"}, {"s3", "policy", "get"}, + {"s3", "cors", "set"}, {"s3", "cors", "get"}, + } { + c, _, err := root.Find(path) + if err != nil || c.RunE == nil { + t.Fatalf("s3 %v not registered as a real command: %v", path, err) + } + } +} + +func TestS3MbFlags(t *testing.T) { + root := NewRootCmd(Options{}) + c, _, err := root.Find([]string{"s3", "mb"}) + if err != nil { + t.Fatal(err) + } + for _, f := range []string{"versioning", "no-prefix", "project"} { + if c.Flags().Lookup(f) == nil { + t.Errorf("s3 mb missing --%s", f) + } + } +} + +func TestAwsShimRegistered(t *testing.T) { + root := NewRootCmd(Options{}) + c, _, err := root.Find([]string{"aws"}) + if err != nil || c.RunE == nil { + t.Fatalf("aws shim not registered as a real command: %v", err) + } + if !c.DisableFlagParsing { + t.Error("aws shim must disable flag parsing to pass args through verbatim") + } +} + +func TestAwsArgsPassthrough(t *testing.T) { + got := awsArgs("http://127.0.0.1:49000", "us-east-1", []string{"s3", "ls", "--recursive"}) + want := []string{"--endpoint-url=http://127.0.0.1:49000", "--region=us-east-1", "s3", "ls", "--recursive"} + if len(got) != len(want) { + t.Fatalf("awsArgs = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("awsArgs[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestAwsEnvInjectsCredsNotArgv(t *testing.T) { + env := awsEnv([]string{"PATH=/usr/bin"}, "test-key", "test-secret", "us-east-1") + var sawKey, sawSecret bool + for _, e := range env { + if e == "AWS_ACCESS_KEY_ID=test-key" { + sawKey = true + } + if e == "AWS_SECRET_ACCESS_KEY=test-secret" { + sawSecret = true + } + } + if !sawKey || !sawSecret { + t.Errorf("aws creds not injected into env: %v", env) + } + // Creds must never leak into argv. + for _, a := range awsArgs("http://x", "us-east-1", []string{"s3", "ls"}) { + if strings.Contains(a, "test-secret") { + t.Errorf("secret leaked into argv: %q", a) + } + } +} + +func TestAwsAbsentBinaryError(t *testing.T) { + t.Setenv("PATH", t.TempDir()) // an empty dir → no `aws` on PATH + if _, err := lookupAws(); err == nil { + t.Fatal("lookupAws must error when the aws binary is absent") + } else if !strings.Contains(err.Error(), "aws") { + t.Errorf("error should mention the aws CLI: %v", err) + } +} + +func TestDbKindAliasAndGrantLevel(t *testing.T) { + if dbKindAlias("db") != "database" || dbKindAlias("role") != "role" || dbKindAlias("bogus") != "" { + t.Error("dbKindAlias mapping wrong") + } + if !validGrantLevel("read") || !validGrantLevel("write") || !validGrantLevel("admin") || validGrantLevel("owner") { + t.Error("validGrantLevel wrong") + } +} + +func TestPrefixHelpers(t *testing.T) { + if got := pgPrefixed("api", "orders", false); got != "api_orders" { + t.Errorf("pgPrefixed = %q, want api_orders", got) + } + if got := pgPrefixed("my-app", "orders", false); got != "my_app_orders" { + t.Errorf("pgPrefixed hyphen sanitize = %q, want my_app_orders", got) + } + if got := pgPrefixed("api", "shared_orders", true); got != "shared_orders" { + t.Errorf("pgPrefixed --no-prefix = %q, want shared_orders", got) + } + if got := bucketPrefixed("web", "uploads", false); got != "web-uploads" { + t.Errorf("bucketPrefixed = %q, want web-uploads", got) + } + if got := bucketPrefixed("web", "external-contract", true); got != "external-contract" { + t.Errorf("bucketPrefixed --no-prefix = %q, want external-contract", got) + } +} + +func TestParseTransition(t *testing.T) { + days, tier, err := parseTransition("days=90,tier=GLACIER") + if err != nil || days != 90 || tier != "GLACIER" { + t.Fatalf("parseTransition = %d,%q,%v", days, tier, err) + } + if _, _, err := parseTransition("days=90"); err == nil { + t.Error("transition without tier must error") + } + if _, _, err := parseTransition("garbage"); err == nil { + t.Error("malformed transition must error") + } +} + +func TestParseCORS(t *testing.T) { + rules, err := parseCORS([]byte(`[{"AllowedMethods":["GET","PUT"],"AllowedOrigins":["*"],"MaxAgeSeconds":3000}]`)) + if err != nil || len(rules) != 1 { + t.Fatalf("parseCORS = %+v err=%v", rules, err) + } + if len(rules[0].AllowedMethods) != 2 || rules[0].MaxAgeSeconds == nil || *rules[0].MaxAgeSeconds != 3000 { + t.Errorf("cors rule not parsed: %+v", rules[0]) + } + if _, err := parseCORS([]byte(`{bad`)); err == nil { + t.Error("malformed cors json must error") + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index d1a001d..f7de9ed 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -92,6 +92,9 @@ func NewRootCmd(opts Options) *cobra.Command { newTemplateCmd(g), newSharedCmd(g), newResourceCmd(g), + newDbCmd(g), + newS3Cmd(g), + newAwsCmd(g), newWsCmd(g), newWorkspaceCmd(g), newUninstallCmd(g), diff --git a/internal/cli/s3.go b/internal/cli/s3.go new file mode 100644 index 0000000..60daf2a --- /dev/null +++ b/internal/cli/s3.go @@ -0,0 +1,562 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + + "github.com/spf13/cobra" + + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + + "github.com/open-source-cloud/devstack/internal/orchestrate" + "github.com/open-source-cloud/devstack/internal/resource" +) + +// newS3Cmd wires the `devstack s3` group (spec 29 §object storage): tenant-scoped +// bucket lifecycle/versioning/policy/cors on the shared MinIO (LocalStack S3 is an +// endpoint swap later). mb/rb/mutations go through the flock via internal/ +// orchestrate; ls/get are lock-free reads. Bucket names are project-PREFIXED for +// global uniqueness unless --no-prefix. +func newS3Cmd(g *GlobalOpts) *cobra.Command { + cmd := &cobra.Command{ + Use: "s3", + Short: "Tenant-scoped object-storage buckets on the shared MinIO", + } + cmd.AddCommand( + newS3MbCmd(g), + newS3RbCmd(g), + newS3LsCmd(g), + newS3LifecycleCmd(g), + newS3VersioningCmd(g), + newS3PolicyCmd(g), + newS3CorsCmd(g), + ) + return cmd +} + +// bucketPrefixed computes the DNS-safe tenant-scoped bucket name - +// (spec 29 §tenant naming), unless --no-prefix keeps the literal name. +func bucketPrefixed(project, name string, noPrefix bool) string { + if noPrefix { + return name + } + return project + "-" + name +} + +func newS3MbCmd(g *GlobalOpts) *cobra.Command { + var project string + var versioning, noPrefix bool + cmd := &cobra.Command{ + Use: "mb ", + Short: "Make a tenant bucket (idempotent)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + proj := project + if proj == "" { + proj = defaultProject(d) + } + bucket := bucketPrefixed(proj, args[0], noPrefix) + params := map[string]any{} + if versioning { + params["versioning"] = true + } + r := resource.Resource{ + Engine: "minio", Kind: "bucket", Name: bucket, Owner: proj, + Params: params, CredKind: resource.CredPredictable, + } + attrs, err := orchestrate.CreateResource(cmd.Context(), d, r) + if err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{ + "kind": "bucket", "name": bucket, "project": proj, + "endpoint": attrs["endpoint"], "versioning": versioning, + }) + } + fmt.Fprintf(cmd.OutOrStdout(), "made bucket %q (%s)\n", bucket, attrs["endpoint"]) + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "owner project") + cmd.Flags().BoolVar(&versioning, "versioning", false, "enable object versioning") + cmd.Flags().BoolVar(&noPrefix, "no-prefix", false, "use the literal bucket name (skip the - prefix)") + return cmd +} + +func newS3RbCmd(g *GlobalOpts) *cobra.Command { + var project string + var force, yes, noPrefix bool + cmd := &cobra.Command{ + Use: "rb ", + Short: "Remove a tenant bucket (destructive; confirm required)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if g.JSON && !yes { + return fmt.Errorf("refusing to remove a bucket without --yes for --json/non-interactive use") + } + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + proj := project + if proj == "" { + proj = defaultProject(d) + } + bucket := bucketPrefixed(proj, args[0], noPrefix) + if !yes { + if !confirm(cmd, fmt.Sprintf("This REMOVES bucket %q (objects destroyed). Type 'yes' to continue: ", bucket)) { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + } + r := resource.Resource{Engine: "minio", Kind: "bucket", Name: bucket, Owner: proj} + if err := orchestrate.DropResource(cmd.Context(), d, r, true); err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"removed": map[string]string{"kind": "bucket", "name": bucket, "project": proj}}) + } + fmt.Fprintf(cmd.OutOrStdout(), "removed bucket %q\n", bucket) + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "owner project") + cmd.Flags().BoolVar(&force, "force", false, "remove even if the bucket is non-empty") + cmd.Flags().BoolVar(&yes, "yes", false, "skip the confirmation prompt") + cmd.Flags().BoolVar(&noPrefix, "no-prefix", false, "use the literal bucket name (skip the - prefix)") + return cmd +} + +func newS3LsCmd(g *GlobalOpts) *cobra.Command { + var project string + var all bool + cmd := &cobra.Command{ + Use: "ls", + Short: "List the project's buckets (lock-free)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + proj := project + if proj == "" { + proj = defaultProject(d) + } + prefix := proj + "-" + if all { + prefix = "" + } + names, err := orchestrate.ListBuckets(cmd.Context(), d, prefix) + if err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"buckets": names}) + } + w := cmd.OutOrStdout() + if len(names) == 0 { + fmt.Fprintln(w, "no buckets") + return nil + } + for _, n := range names { + fmt.Fprintln(w, n) + } + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "owner project") + cmd.Flags().BoolVar(&all, "all", false, "list every bucket, not just this project's") + return cmd +} + +func newS3LifecycleCmd(g *GlobalOpts) *cobra.Command { + cmd := &cobra.Command{Use: "lifecycle", Short: "Manage bucket object-lifecycle rules"} + cmd.AddCommand(newS3LifecycleSetCmd(g), newS3LifecycleGetCmd(g), newS3LifecycleRmCmd(g)) + return cmd +} + +func newS3LifecycleSetCmd(g *GlobalOpts) *cobra.Command { + var project, transition, prefix string + var expireDays int + cmd := &cobra.Command{ + Use: "set ", + Short: "Set an expiry (+optional transition) rule on a bucket", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + rule := resource.LifecycleRule{ExpireDays: expireDays, Prefix: prefix} + if transition != "" { + days, tier, err := parseTransition(transition) + if err != nil { + return err + } + rule.TransitionDays, rule.TransitionTier = days, tier + } + if rule.ExpireDays <= 0 && rule.TransitionDays <= 0 { + return fmt.Errorf("specify --expire-days N (and/or --transition days=N,tier=T)") + } + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + proj := project + if proj == "" { + proj = defaultProject(d) + } + if err := orchestrate.SetBucketLifecycle(cmd.Context(), d, proj, args[0], rule); err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"bucket": args[0], "expire_days": rule.ExpireDays, "transition_days": rule.TransitionDays, "transition_tier": rule.TransitionTier}) + } + fmt.Fprintf(cmd.OutOrStdout(), "lifecycle set on %q (expire=%dd)\n", args[0], rule.ExpireDays) + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "owner project") + cmd.Flags().IntVar(&expireDays, "expire-days", 0, "expire objects after N days") + cmd.Flags().StringVar(&transition, "transition", "", "transition rule days=N,tier=STANDARD_IA") + cmd.Flags().StringVar(&prefix, "prefix", "", "apply only to keys under this prefix") + return cmd +} + +func newS3LifecycleGetCmd(g *GlobalOpts) *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Show a bucket's lifecycle rules (lock-free)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + rules, err := orchestrate.GetBucketLifecycle(cmd.Context(), d, args[0]) + if err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"rules": rules}) + } + w := cmd.OutOrStdout() + if len(rules) == 0 { + fmt.Fprintln(w, "no lifecycle rules") + return nil + } + for _, r := range rules { + fmt.Fprintf(w, "%v\n", r) + } + return nil + }, + } + return cmd +} + +func newS3LifecycleRmCmd(g *GlobalOpts) *cobra.Command { + var project string + cmd := &cobra.Command{ + Use: "rm ", + Short: "Remove a bucket's lifecycle configuration", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + proj := project + if proj == "" { + proj = defaultProject(d) + } + if err := orchestrate.RemoveBucketLifecycle(cmd.Context(), d, proj, args[0]); err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"lifecycle_removed": args[0]}) + } + fmt.Fprintf(cmd.OutOrStdout(), "lifecycle removed from %q\n", args[0]) + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "owner project") + return cmd +} + +func newS3VersioningCmd(g *GlobalOpts) *cobra.Command { + var project string + cmd := &cobra.Command{ + Use: "versioning on|off", + Short: "Enable or suspend bucket versioning", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + var enabled bool + switch strings.ToLower(args[1]) { + case "on", "enable", "enabled": + enabled = true + case "off", "suspend", "suspended": + enabled = false + default: + return fmt.Errorf("want on|off, got %q", args[1]) + } + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + proj := project + if proj == "" { + proj = defaultProject(d) + } + if err := orchestrate.SetBucketVersioning(cmd.Context(), d, proj, args[0], enabled); err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"bucket": args[0], "versioning": enabled}) + } + state := "suspended" + if enabled { + state = "enabled" + } + fmt.Fprintf(cmd.OutOrStdout(), "versioning %s on %q\n", state, args[0]) + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "owner project") + return cmd +} + +func newS3PolicyCmd(g *GlobalOpts) *cobra.Command { + cmd := &cobra.Command{Use: "policy", Short: "Manage a bucket policy"} + cmd.AddCommand(newS3PolicySetCmd(g), newS3PolicyGetCmd(g)) + return cmd +} + +// publicReadPolicy is the canned anonymous-read policy for --public-read. +func publicReadPolicy(bucket string) string { + return fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::%s/*"]}]}`, bucket) +} + +func newS3PolicySetCmd(g *GlobalOpts) *cobra.Command { + var project, file string + var publicRead bool + cmd := &cobra.Command{ + Use: "set ", + Short: "Set a bucket policy (--public-read or --file policy.json)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var policy string + switch { + case publicRead: + policy = publicReadPolicy(args[0]) + case file != "": + b, err := os.ReadFile(file) + if err != nil { + return err + } + policy = string(b) + default: + return fmt.Errorf("specify --public-read or --file policy.json") + } + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + proj := project + if proj == "" { + proj = defaultProject(d) + } + if err := orchestrate.SetBucketPolicy(cmd.Context(), d, proj, args[0], policy); err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"bucket": args[0], "policy_set": true}) + } + fmt.Fprintf(cmd.OutOrStdout(), "policy set on %q\n", args[0]) + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "owner project") + cmd.Flags().StringVar(&file, "file", "", "policy JSON file") + cmd.Flags().BoolVar(&publicRead, "public-read", false, "apply a canned anonymous read policy") + return cmd +} + +func newS3PolicyGetCmd(g *GlobalOpts) *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Print a bucket's policy JSON (lock-free)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + policy, err := orchestrate.GetBucketPolicy(cmd.Context(), d, args[0]) + if err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"bucket": args[0], "policy": policy}) + } + if policy == "" { + fmt.Fprintln(cmd.OutOrStdout(), "no policy set") + return nil + } + fmt.Fprintln(cmd.OutOrStdout(), policy) + return nil + }, + } + return cmd +} + +func newS3CorsCmd(g *GlobalOpts) *cobra.Command { + cmd := &cobra.Command{Use: "cors", Short: "Manage bucket CORS rules"} + cmd.AddCommand(newS3CorsSetCmd(g), newS3CorsGetCmd(g)) + return cmd +} + +func newS3CorsSetCmd(g *GlobalOpts) *cobra.Command { + var project, file string + cmd := &cobra.Command{ + Use: "set ", + Short: "Set bucket CORS rules from a JSON file (an array of CORS rules)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if file == "" { + return fmt.Errorf("--file cors.json is required") + } + b, err := os.ReadFile(file) + if err != nil { + return err + } + rules, err := parseCORS(b) + if err != nil { + return err + } + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + proj := project + if proj == "" { + proj = defaultProject(d) + } + if err := orchestrate.SetBucketCORS(cmd.Context(), d, proj, args[0], rules); err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"bucket": args[0], "cors_rules": len(rules)}) + } + fmt.Fprintf(cmd.OutOrStdout(), "cors set on %q (%d rule(s))\n", args[0], len(rules)) + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "owner project") + cmd.Flags().StringVar(&file, "file", "", "CORS JSON file") + return cmd +} + +func newS3CorsGetCmd(g *GlobalOpts) *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Print a bucket's CORS rules (lock-free)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + rules, err := orchestrate.GetBucketCORS(cmd.Context(), d, args[0]) + if err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"bucket": args[0], "rules": rules}) + } + w := cmd.OutOrStdout() + if len(rules) == 0 { + fmt.Fprintln(w, "no cors rules") + return nil + } + for _, r := range rules { + fmt.Fprintf(w, "methods=%v origins=%v\n", r.AllowedMethods, r.AllowedOrigins) + } + return nil + }, + } + return cmd +} + +// --- parsing helpers ---------------------------------------------------------- + +// parseTransition parses a `days=N,tier=T` transition spec. +func parseTransition(s string) (int, string, error) { + var days int + var tier string + for _, part := range strings.Split(s, ",") { + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + return 0, "", fmt.Errorf("invalid --transition %q (want days=N,tier=T)", s) + } + switch strings.TrimSpace(kv[0]) { + case "days": + n, err := strconv.Atoi(strings.TrimSpace(kv[1])) + if err != nil { + return 0, "", fmt.Errorf("invalid transition days %q: %w", kv[1], err) + } + days = n + case "tier", "class": + tier = strings.TrimSpace(kv[1]) + default: + return 0, "", fmt.Errorf("unknown --transition key %q (want days|tier)", kv[0]) + } + } + if days <= 0 || tier == "" { + return 0, "", fmt.Errorf("--transition needs both days=N and tier=T") + } + return days, tier, nil +} + +// parseCORS unmarshals a JSON array of CORS rules into the SDK type. +func parseCORS(b []byte) ([]s3types.CORSRule, error) { + var raw []struct { + AllowedMethods []string `json:"AllowedMethods"` + AllowedOrigins []string `json:"AllowedOrigins"` + AllowedHeaders []string `json:"AllowedHeaders"` + ExposeHeaders []string `json:"ExposeHeaders"` + MaxAgeSeconds int32 `json:"MaxAgeSeconds"` + } + if err := json.Unmarshal(b, &raw); err != nil { + return nil, fmt.Errorf("parse cors json (want an array of rules): %w", err) + } + var rules []s3types.CORSRule + for _, r := range raw { + rule := s3types.CORSRule{ + AllowedMethods: r.AllowedMethods, + AllowedOrigins: r.AllowedOrigins, + AllowedHeaders: r.AllowedHeaders, + ExposeHeaders: r.ExposeHeaders, + } + if r.MaxAgeSeconds > 0 { + rule.MaxAgeSeconds = &r.MaxAgeSeconds + } + rules = append(rules, rule) + } + return rules, nil +} diff --git a/internal/cli/stubs.go b/internal/cli/stubs.go index 19f2cc0..b52f38e 100644 --- a/internal/cli/stubs.go +++ b/internal/cli/stubs.go @@ -29,21 +29,13 @@ func rootName(c *cobra.Command) string { return c.Root().Name() } // addStubCommands reserves the post-1.0 command surface from spec 07 as // milestone-tagged placeholders so `--help`/completions stay consistent (exit 0, // clear notice). `shell` has GRADUATED to a real command (spec 26); `logs` stays a -// stub, re-tagged to v2 (its full read-only-SDK design is owned by spec 16). +// stub, re-tagged to v2 (its full read-only-SDK design is owned by spec 16). `db` +// has GRADUATED to a real command group (spec 29, see db.go). func addStubCommands(root *cobra.Command, _ *GlobalOpts) { root.AddCommand( stub("logs", "Stream service logs", "v2 (spec 16)"), stub("dashboard", "Live TUI cockpit", "v2 (spec 16)"), stub("ide", "Generate devcontainer/.code-workspace/launch configs", "v2 (spec 17)"), stub("telemetry", "Opt-in usage telemetry (default OFF)", "a later release (spec 20)"), - // db: parent hosting `gc` (v1, spec 13) + snapshot|restore|reset|list|pull (v2, spec 15). - stub("db", "Database snapshot/restore/reset lifecycle", "v2 (spec 15)", - stub("gc", "Reclaim orphaned provisioned databases/roles/buckets", "v1 (spec 13)"), - stub("snapshot", "Snapshot a project's database", "v2 (spec 15)"), - stub("restore", "Restore a project's database from a snapshot", "v2 (spec 15)"), - stub("reset", "Drop and re-provision a project's database", "v2 (spec 15)"), - stub("list", "List available database snapshots", "v2 (spec 15)"), - stub("pull", "Pull a database snapshot from a shared store", "v2 (spec 15)"), - ), ) } diff --git a/internal/orchestrate/minio_ops.go b/internal/orchestrate/minio_ops.go new file mode 100644 index 0000000..23f68db --- /dev/null +++ b/internal/orchestrate/minio_ops.go @@ -0,0 +1,185 @@ +package orchestrate + +import ( + "context" + "fmt" + + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + + "github.com/open-source-cloud/devstack/internal/generate" + "github.com/open-source-cloud/devstack/internal/lock" + "github.com/open-source-cloud/devstack/internal/resource" +) + +// This file is the imperative object-storage surface behind the `s3` CLI verbs +// that go beyond generic create/drop (lifecycle / versioning / policy / cors / +// list). Each mutation mirrors the resource_ops flow (resolve instance → overlay +// → provisioner → lock → engine call → event); `ls`/`get` are lock-free reads. +// They resolve the minio provisioner from the registry as a BucketController. + +// bucketController resolves the minio instance + a host-reachable Target + the +// BucketController provisioner. Shared by every s3 helper. +func bucketController(ctx context.Context, d UpDeps) (resource.BucketController, resource.Target, string, error) { + instance, ok := ResolveInstance(d.Model, "minio") + if !ok { + return nil, resource.Target{}, "", fmt.Errorf("no shared \"minio\" instance in this workspace (declare one under workspace.shared and run `devstack up`)") + } + prov, ok := buildRegistry(d).For("minio") + if !ok { + return nil, resource.Target{}, "", fmt.Errorf("no minio provisioner registered") + } + bc, ok := prov.(resource.BucketController) + if !ok { + return nil, resource.Target{}, "", fmt.Errorf("minio provisioner does not support bucket controls") + } + target, err := engineTarget(ctx, d, "minio", instance) + if err != nil { + return nil, resource.Target{}, "", err + } + return bc, target, instance, nil +} + +// ListBuckets returns the project's buckets (prefix-filtered). Lock-free read; it +// still applies the overlay so the endpoint is reachable, but records nothing. +func ListBuckets(ctx context.Context, d UpDeps, prefix string) ([]string, error) { + bc, target, _, err := bucketController(ctx, d) + if err != nil { + return nil, err + } + return bc.ListBuckets(ctx, target, prefix) +} + +// SetBucketLifecycle applies an expiry(+transition) rule and records a lifecycle +// ownership row for the project (idempotent, under the flock). +func SetBucketLifecycle(ctx context.Context, d UpDeps, project, bucket string, rule resource.LifecycleRule) error { + bc, target, instance, err := bucketController(ctx, d) + if err != nil { + return err + } + return lock.WithLock(ctx, d.LockPath, func() error { + if err := bc.SetLifecycle(ctx, target, bucket, rule); err != nil { + return err + } + if err := d.DB.RecordProvisioned(project, "lifecycle", bucket); err != nil { + return err + } + d.DB.LogEvent("provision", project, "lifecycle on "+generate.SharedAlias(instance)) + return nil + }) +} + +// GetBucketLifecycle returns the bucket's lifecycle rules (lock-free read). +func GetBucketLifecycle(ctx context.Context, d UpDeps, bucket string) ([]map[string]any, error) { + bc, target, _, err := bucketController(ctx, d) + if err != nil { + return nil, err + } + return bc.GetLifecycle(ctx, target, bucket) +} + +// RemoveBucketLifecycle deletes the bucket's lifecycle config and un-tracks the +// lifecycle ownership row (under the flock). +func RemoveBucketLifecycle(ctx context.Context, d UpDeps, project, bucket string) error { + bc, target, instance, err := bucketController(ctx, d) + if err != nil { + return err + } + return lock.WithLock(ctx, d.LockPath, func() error { + if err := bc.RemoveLifecycle(ctx, target, bucket); err != nil { + return err + } + if err := d.DB.RemoveProvisioned(project, "lifecycle", bucket); err != nil { + return err + } + d.DB.LogEvent("gc.drop", project, "lifecycle "+bucket+" removed from "+generate.SharedAlias(instance)) + return nil + }) +} + +// SetBucketVersioning toggles versioning (under the flock; a bucket attribute, no +// new ledger row). +func SetBucketVersioning(ctx context.Context, d UpDeps, project, bucket string, enabled bool) error { + bc, target, instance, err := bucketController(ctx, d) + if err != nil { + return err + } + return lock.WithLock(ctx, d.LockPath, func() error { + if err := bc.SetVersioning(ctx, target, bucket, enabled); err != nil { + return err + } + state := "suspended" + if enabled { + state = "enabled" + } + d.DB.LogEvent("provision", project, "versioning "+state+" on "+bucket+"@"+generate.SharedAlias(instance)) + return nil + }) +} + +// SetBucketPolicy sets a raw JSON bucket policy (under the flock). +func SetBucketPolicy(ctx context.Context, d UpDeps, project, bucket, policyJSON string) error { + bc, target, instance, err := bucketController(ctx, d) + if err != nil { + return err + } + return lock.WithLock(ctx, d.LockPath, func() error { + if err := bc.SetPolicy(ctx, target, bucket, policyJSON); err != nil { + return err + } + d.DB.LogEvent("provision", project, "policy on "+bucket+"@"+generate.SharedAlias(instance)) + return nil + }) +} + +// GetBucketPolicy returns the bucket policy JSON, "" when none (lock-free read). +func GetBucketPolicy(ctx context.Context, d UpDeps, bucket string) (string, error) { + bc, target, _, err := bucketController(ctx, d) + if err != nil { + return "", err + } + return bc.GetPolicy(ctx, target, bucket) +} + +// SetBucketCORS sets the bucket CORS rules (under the flock). +func SetBucketCORS(ctx context.Context, d UpDeps, project, bucket string, rules []s3types.CORSRule) error { + bc, target, instance, err := bucketController(ctx, d) + if err != nil { + return err + } + return lock.WithLock(ctx, d.LockPath, func() error { + if err := bc.SetCORS(ctx, target, bucket, rules); err != nil { + return err + } + d.DB.LogEvent("provision", project, "cors on "+bucket+"@"+generate.SharedAlias(instance)) + return nil + }) +} + +// GetBucketCORS returns the bucket CORS rules (lock-free read). +func GetBucketCORS(ctx context.Context, d UpDeps, bucket string) ([]s3types.CORSRule, error) { + bc, target, _, err := bucketController(ctx, d) + if err != nil { + return nil, err + } + return bc.GetCORS(ctx, target, bucket) +} + +// ResolveAwsEndpoint resolves the host-reachable S3-compatible endpoint for the +// `aws --` shim: it prefers a LocalStack instance, falling back to MinIO, applies +// the loopback overlay, and returns the endpoint URL + region + dev creds. Errors +// clearly when neither engine is in the workspace. +func ResolveAwsEndpoint(ctx context.Context, d UpDeps) (endpoint, region string, access, secret string, err error) { + for _, engine := range []string{"localstack", "minio"} { + inst, ok := ResolveInstance(d.Model, engine) + if !ok { + continue + } + t, terr := engineTarget(ctx, d, engine, inst) + if terr != nil { + return "", "", "", "", terr + } + return fmt.Sprintf("http://%s:%d", t.Host, t.Port), "us-east-1", + t.AdminEnv["user"], t.AdminEnv["password"], nil + } + return "", "", "", "", fmt.Errorf("no localstack/minio shared instance in this workspace (declare one under workspace.shared and run `devstack up`)") +} diff --git a/internal/orchestrate/minio_ops_test.go b/internal/orchestrate/minio_ops_test.go new file mode 100644 index 0000000..60e1532 --- /dev/null +++ b/internal/orchestrate/minio_ops_test.go @@ -0,0 +1,149 @@ +package orchestrate + +import ( + "context" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/docker" + "github.com/open-source-cloud/devstack/internal/generate" + "github.com/open-source-cloud/devstack/internal/resource" + "github.com/open-source-cloud/devstack/internal/state" + "github.com/open-source-cloud/devstack/internal/template" + "github.com/open-source-cloud/devstack/internal/workspace" + "github.com/open-source-cloud/devstack/templates" +) + +// stubS3 embeds the S3API interface (nil) so the type satisfies it; only the +// methods the tests exercise are overridden. Others panic if unexpectedly called. +type stubS3 struct { + resource.S3API + buckets map[string]bool + created []string + lifecycle map[string][]s3types.LifecycleRule +} + +func (s *stubS3) HeadBucket(_ context.Context, in *s3.HeadBucketInput, _ ...func(*s3.Options)) (*s3.HeadBucketOutput, error) { + if s.buckets[aws.ToString(in.Bucket)] { + return &s3.HeadBucketOutput{}, nil + } + return nil, &s3types.NotFound{} +} + +func (s *stubS3) CreateBucket(_ context.Context, in *s3.CreateBucketInput, _ ...func(*s3.Options)) (*s3.CreateBucketOutput, error) { + s.created = append(s.created, aws.ToString(in.Bucket)) + s.buckets[aws.ToString(in.Bucket)] = true + return &s3.CreateBucketOutput{}, nil +} + +func (s *stubS3) PutBucketLifecycleConfiguration(_ context.Context, in *s3.PutBucketLifecycleConfigurationInput, _ ...func(*s3.Options)) (*s3.PutBucketLifecycleConfigurationOutput, error) { + if s.lifecycle == nil { + s.lifecycle = map[string][]s3types.LifecycleRule{} + } + s.lifecycle[aws.ToString(in.Bucket)] = in.LifecycleConfiguration.Rules + return &s3.PutBucketLifecycleConfigurationOutput{}, nil +} + +func minioFixture(t *testing.T) (UpDeps, *fakeRunner, *state.DB, *stubS3) { + t.Helper() + root := t.TempDir() + write := func(rel, body string) { + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("workspace.yaml", "apiVersion: devstack/v1\nkind: Workspace\nname: demo\nshared:\n minio: { template: minio }\nprojects:\n - { name: web, path: web }\n") + write("web/devstack.yaml", `apiVersion: devstack/v1 +kind: Project +name: web +services: + app: + template: node.vite + uses: [workspace.shared.minio] +`) + m, err := config.LoadAt(root) + if err != nil { + t.Fatalf("load: %v", err) + } + db, err := state.Open(context.Background(), filepath.Join(root, "state"), "ctx") + if err != nil { + t.Fatalf("state: %v", err) + } + t.Cleanup(func() { db.Close() }) + + mc := &docker.MockClient{ + Containers: []docker.Container{{ + ID: "m1", Name: "devstack-shared-minio-1", State: "running", + Labels: map[string]string{generate.LabelManaged: "true", generate.LabelShared: "minio"}, + }}, + Details: map[string]docker.ContainerDetails{ + "m1": {ID: "m1", State: "running", Running: true, Health: docker.HealthHealthy}, + }, + } + src := template.NewFSSource(templates.FS) + lockPath := filepath.Join(root, "lock") + mgr := &workspace.Manager{Model: m, DB: db, Docker: mc, Source: src, LockPath: lockPath} + fr := &fakeRunner{} + stub := &stubS3{buckets: map[string]bool{}} + d := UpDeps{ + Model: m, DB: db, Docker: mc, Manager: mgr, Source: src, + LockPath: lockPath, Runner: fr, Env: map[string]string{}, + S3Factory: func(context.Context, resource.Target) (resource.S3API, error) { return stub, nil }, + } + return d, fr, db, stub +} + +func TestCreateBucketImperative(t *testing.T) { + d, fr, db, stub := minioFixture(t) + attrs, err := CreateResource(context.Background(), d, resource.Resource{ + Engine: "minio", Kind: "bucket", Name: "web-uploads", Owner: "web", + Params: map[string]any{"expire_days": 30}, CredKind: resource.CredPredictable, + }) + if err != nil { + t.Fatalf("CreateResource bucket: %v", err) + } + if !slices.Contains(stub.created, "web-uploads") { + t.Errorf("bucket not created via S3 client: %v", stub.created) + } + if len(stub.lifecycle["web-uploads"]) != 1 { + t.Errorf("expiry rule not applied: %+v", stub.lifecycle) + } + if attrs["bucket"] != "web-uploads" { + t.Errorf("attrs[bucket] = %q", attrs["bucket"]) + } + // Ledger recorded the bucket ownership row. + rows, _ := db.ProvisionedFor("web") + var found bool + for _, r := range rows { + if r.Kind == "bucket" && r.Name == "web-uploads" { + found = true + } + } + if !found { + t.Errorf("bucket ownership row not recorded: %v", rows) + } + // The minio loopback overlay was applied publishing :9000 (not 5432). + overlay := filepath.Join(d.Model.Root, generate.GenDir, "shared", "compose.provision.yaml") + body, err := os.ReadFile(overlay) + if err != nil { + t.Fatalf("overlay not written: %v", err) + } + if !strings.Contains(string(body), ":9000") { + t.Errorf("minio overlay must publish container port 9000, got:\n%s", body) + } + if !fr.saw("-p "+generate.SharedStackName, "compose.provision.yaml") { + t.Errorf("overlay not applied via compose up: %v", fr.cmds) + } +} diff --git a/internal/orchestrate/provision.go b/internal/orchestrate/provision.go index e954bca..d5da744 100644 --- a/internal/orchestrate/provision.go +++ b/internal/orchestrate/provision.go @@ -100,9 +100,11 @@ func provInstanceList(targets []provTarget) []string { } // writeProvisionOverlay writes the up-time compose overlay that publishes each -// provisioned Postgres instance on 127.0.0.1:. Returns the overlay path. -// Loopback-only so nothing is exposed beyond the host (spec 03 / no host ports). -func writeProvisionOverlay(root string, ports map[string]int) (string, error) { +// provisioned instance on 127.0.0.1::. Returns the overlay +// path. Loopback-only so nothing is exposed beyond the host (spec 03 / no host +// ports). containerPort is the engine's in-container port (5432 postgres / 9000 +// minio); every instance in ports shares one engine, so one container port covers all. +func writeProvisionOverlay(root string, ports map[string]int, containerPort int) (string, error) { var b strings.Builder b.WriteString("services:\n") insts := make([]string, 0, len(ports)) @@ -111,7 +113,7 @@ func writeProvisionOverlay(root string, ports map[string]int) (string, error) { } sort.Strings(insts) for _, inst := range insts { - fmt.Fprintf(&b, " %s:\n ports:\n - \"127.0.0.1:%d:5432\"\n", inst, ports[inst]) + fmt.Fprintf(&b, " %s:\n ports:\n - \"127.0.0.1:%d:%d\"\n", inst, ports[inst], containerPort) } dir := filepath.Join(root, generate.GenDir, "shared") if err := os.MkdirAll(dir, 0o755); err != nil { diff --git a/internal/orchestrate/resource_ops.go b/internal/orchestrate/resource_ops.go index f1d9823..5873bc1 100644 --- a/internal/orchestrate/resource_ops.go +++ b/internal/orchestrate/resource_ops.go @@ -22,7 +22,9 @@ import ( // 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 { return resourceRegistry(connect) } +func ResourceRegistry(connect PgConnector) *resource.Registry { + return resource.NewRegistry(resource.Postgres{Connect: toResourceConnector(connect)}) +} // ResolveInstance returns the shared instance name serving engine (first shared // service whose template == engine, matching the pgInstances convention). @@ -35,20 +37,30 @@ func ResolveInstance(m *config.Model, engine string) (string, bool) { return "", false } +// engineDefaultAdmin is the per-engine fallback root credential when the shared +// instance's params don't set rootUser/rootPassword (matching the engine +// templates: postgres → devstack, minio → devstackadmin). +func engineDefaultAdmin(engine string) string { + if engine == "minio" { + return "devstackadmin" + } + return "devstack" +} + // engineTarget resolves the host-reachable admin endpoint for an instance: it // allocates/looks up the ledger port, writes+applies the per-engine 127.0.0.1 // overlay via `compose up -d ` (idempotent, no recreate), and returns the -// Target with the instance's admin creds. Postgres is the only overlay wired. +// Target with the instance's admin creds. Postgres + MinIO overlays are wired. func engineTarget(ctx context.Context, d UpDeps, engine, instance string) (resource.Target, error) { ov, ok := engineOverlays[engine] if !ok { - return resource.Target{}, fmt.Errorf("engine %q has no host-reachability overlay (only postgres in this milestone)", engine) + return resource.Target{}, fmt.Errorf("engine %q has no host-reachability overlay (postgres/minio in this milestone)", engine) } port, err := d.Manager.FreeHostPort(ctx, generate.SharedAlias(instance), ov.purpose, ov.portBase) if err != nil { return resource.Target{}, fmt.Errorf("allocate host port for %s: %w", instance, err) } - overlay, err := writeProvisionOverlay(d.Model.Root, map[string]int{instance: port}) + overlay, err := writeProvisionOverlay(d.Model.Root, map[string]int{instance: port}, ov.containerPort) if err != nil { return resource.Target{}, err } @@ -66,11 +78,12 @@ func engineTarget(ctx context.Context, d UpDeps, engine, instance string) (resou return resource.Target{}, fmt.Errorf("apply host overlay for %s: %w", instance, err) } params := d.Model.Workspace.Shared[instance].Params + def := engineDefaultAdmin(engine) return resource.Target{ Instance: instance, Host: "127.0.0.1", Port: port, AdminEnv: map[string]string{ - "user": paramString(params, "rootUser", "devstack"), - "password": paramString(params, "rootPassword", "devstack"), + "user": paramString(params, "rootUser", def), + "password": paramString(params, "rootPassword", def), }, }, nil } @@ -82,7 +95,7 @@ func CreateResource(ctx context.Context, d UpDeps, r resource.Resource) (resourc if !ok { return nil, fmt.Errorf("no shared %q instance in this workspace (declare one under workspace.shared and run `devstack up`)", r.Engine) } - reg := ResourceRegistry(d.PgConnect) + reg := buildRegistry(d) prov, ok := reg.For(r.Engine) if !ok { return nil, fmt.Errorf("no provisioner for engine %q (lands in spec 27 Full scope)", r.Engine) @@ -141,7 +154,7 @@ func DropResource(ctx context.Context, d UpDeps, r resource.Resource, purge bool if !ok { return fmt.Errorf("no shared %q instance to drop %s/%s from", r.Engine, r.Kind, r.Name) } - reg := ResourceRegistry(d.PgConnect) + reg := buildRegistry(d) prov, ok := reg.For(r.Engine) if !ok { return fmt.Errorf("no provisioner for engine %q (cannot --purge-data)", r.Engine) @@ -200,7 +213,7 @@ func GCResources(ctx context.Context, d UpDeps, active map[string]bool) (GCResul if err != nil { return res, err } - reg := ResourceRegistry(d.PgConnect) + reg := buildRegistry(d) for _, o := range orphans { engine, instance, prov, ok := engineForRow(d, reg, o.Kind) if !ok { diff --git a/internal/orchestrate/resources.go b/internal/orchestrate/resources.go index b6bfb67..270f13b 100644 --- a/internal/orchestrate/resources.go +++ b/internal/orchestrate/resources.go @@ -112,15 +112,22 @@ func collectResourceDecls(m *config.Model, active profile.Active) []resDecl { return out } -// resourceRegistry builds the engine→Provisioner registry the phase/commands use, -// wired with the injected Postgres connector so provisioning is daemon-free in -// tests. Only Postgres is live in this milestone. -func resourceRegistry(connect PgConnector) *resource.Registry { - var pgConn resource.PgConnector - if connect != nil { - pgConn = resource.PgConnector(connect) +// toResourceConnector adapts an orchestrate PgConnector to the resource one. +func toResourceConnector(connect PgConnector) resource.PgConnector { + if connect == nil { + return nil } - return resource.NewRegistry(resource.Postgres{Connect: pgConn}) + return resource.PgConnector(connect) +} + +// buildRegistry builds the engine→Provisioner registry the phase/commands use, +// wired with the injected Postgres connector + S3 factory so provisioning is +// daemon/endpoint-free in tests. Postgres + MinIO are live in this milestone. +func buildRegistry(d UpDeps) *resource.Registry { + return resource.NewRegistry( + resource.Postgres{Connect: toResourceConnector(d.PgConnect)}, + resource.MinIO{Factory: d.S3Factory}, + ) } // resourcesPhase provisions declared resources idempotently and reports drift. @@ -138,7 +145,7 @@ func resourcesPhase(d UpDeps, decls []resDecl) Phase { return Fingerprint(append([]string{"resources"}, keys...)...), nil }, Run: func(ctx context.Context) (any, error) { - reg := resourceRegistry(d.PgConnect) + reg := buildRegistry(d) // Resolve each instance's published host port (idempotent — returns the // port the shared/provision phase already allocated). diff --git a/internal/orchestrate/up.go b/internal/orchestrate/up.go index 2ca0969..866d99f 100644 --- a/internal/orchestrate/up.go +++ b/internal/orchestrate/up.go @@ -15,6 +15,7 @@ import ( "github.com/open-source-cloud/devstack/internal/hooks" "github.com/open-source-cloud/devstack/internal/lock" "github.com/open-source-cloud/devstack/internal/profile" + "github.com/open-source-cloud/devstack/internal/resource" "github.com/open-source-cloud/devstack/internal/secrets" "github.com/open-source-cloud/devstack/internal/state" "github.com/open-source-cloud/devstack/internal/template" @@ -59,6 +60,10 @@ type UpDeps struct { // PgConnect opens an admin Postgres connection for the provision phase; nil → // the pgx-backed default. Injected for tests (so provisioning runs daemon-free). PgConnect PgConnector + // S3Factory builds the admin S3 client for the minio/localstack provisioners; + // nil → the real pure-Go aws-sdk-go-v2 path-style client. Injected for tests so + // bucket ops run without a live endpoint. + S3Factory resource.S3Factory Build bool // compose up --build (honors the generate ledger's selective-rebuild hash) Rebuild bool // force `compose build --no-cache` before up (spec 26 --rebuild) @@ -375,7 +380,7 @@ func sharedPhase(d UpDeps, projects, names, provInstances []string) Phase { } ports[inst] = port } - overlay, err := writeProvisionOverlay(d.Model.Root, ports) + overlay, err := writeProvisionOverlay(d.Model.Root, ports, 5432) if err != nil { return nil, err } diff --git a/internal/provision/provision.go b/internal/provision/provision.go index f85c3f0..c7fdf2a 100644 --- a/internal/provision/provision.go +++ b/internal/provision/provision.go @@ -84,6 +84,101 @@ func (Postgres) EnsureProject(ctx context.Context, conn Conn, project, password return Credentials{Role: role, Database: db, Password: password}, nil } +// GrantLevel is the privilege tier a role receives on a database (spec 29 §db +// role/grant). read = SELECT; write = +INSERT/UPDATE/DELETE; admin = ALL. +type GrantLevel string + +const ( + // GrantRead grants CONNECT + USAGE + SELECT (a reporting/read-replica role). + GrantRead GrantLevel = "read" + // GrantWrite grants read + INSERT/UPDATE/DELETE (an application role). + GrantWrite GrantLevel = "write" + // GrantAdmin grants ALL on the database + schema (an owner-equivalent role). + GrantAdmin GrantLevel = "admin" +) + +// EnsureRole idempotently ensures a LOGIN role exists with the given password, +// keeping the password in sync on re-run. Existence-guarded because CREATE ROLE is +// not idempotent (DECISIONS D8); the SQL runs under the caller's flock. Returns the +// sanitized role identifier (hyphens → underscores). +func (Postgres) EnsureRole(ctx context.Context, conn Conn, role, password string) (string, error) { + r := pgIdent(role) + exists, err := conn.Exists(ctx, `SELECT 1 FROM pg_roles WHERE rolname = $1`, r) + if err != nil { + return "", fmt.Errorf("check role %q: %w", r, err) + } + if exists { + if err := conn.Exec(ctx, `ALTER ROLE `+quoteIdent(r)+` WITH LOGIN PASSWORD `+quoteLiteral(password)); err != nil { + return "", fmt.Errorf("alter role %q: %w", r, err) + } + } else { + if err := conn.Exec(ctx, `CREATE ROLE `+quoteIdent(r)+` WITH LOGIN PASSWORD `+quoteLiteral(password)); err != nil { + return "", fmt.Errorf("create role %q: %w", r, err) + } + } + return r, nil +} + +// EnsureDatabase idempotently ensures database db exists, owned by ownerRole. +// Existence-guarded (CREATE DATABASE is not idempotent and cannot run in a +// transaction). Unlike EnsureProject it creates NO new role — the owner is an +// existing role (typically the project role), matching `db create ` where +// the owner is the project (spec 29). Returns the sanitized database identifier. +func (Postgres) EnsureDatabase(ctx context.Context, conn Conn, db, ownerRole string) (string, error) { + d := pgIdent(db) + owner := pgIdent(ownerRole) + exists, err := conn.Exists(ctx, `SELECT 1 FROM pg_database WHERE datname = $1`, d) + if err != nil { + return "", fmt.Errorf("check database %q: %w", d, err) + } + if !exists { + if err := conn.Exec(ctx, `CREATE DATABASE `+quoteIdent(d)+` OWNER `+quoteIdent(owner)); err != nil { + return "", fmt.Errorf("create database %q: %w", d, err) + } + } + return d, nil +} + +// Grant applies a privilege tier to role on database db. GRANT is idempotent so +// this is safe to re-run. The schema/table grants apply to the CURRENTLY-CONNECTED +// database's public schema, so the caller must connect to db before calling Grant +// (the resource.Postgres provisioner does). The database-level CONNECT/ALL grants +// apply regardless of the connected database. +func (Postgres) Grant(ctx context.Context, conn Conn, role, db string, level GrantLevel) error { + r := quoteIdent(pgIdent(role)) + d := quoteIdent(pgIdent(db)) + stmts := []string{`GRANT CONNECT ON DATABASE ` + d + ` TO ` + r} + switch level { + case GrantRead: + stmts = append(stmts, + `GRANT USAGE ON SCHEMA public TO `+r, + `GRANT SELECT ON ALL TABLES IN SCHEMA public TO `+r, + `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO `+r, + ) + case GrantWrite: + stmts = append(stmts, + `GRANT USAGE ON SCHEMA public TO `+r, + `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO `+r, + `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO `+r, + ) + case GrantAdmin: + stmts = append(stmts, + `GRANT ALL PRIVILEGES ON DATABASE `+d+` TO `+r, + `GRANT ALL ON SCHEMA public TO `+r, + `GRANT ALL ON ALL TABLES IN SCHEMA public TO `+r, + `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO `+r, + ) + default: + return fmt.Errorf("unknown grant level %q (want read|write|admin)", level) + } + for _, s := range stmts { + if err := conn.Exec(ctx, s); err != nil { + return fmt.Errorf("grant %s on %q to %q: %w", level, db, role, err) + } + } + return nil +} + // pgIdent maps a (dsname-validated) project name to a safe unquoted-friendly // Postgres identifier: hyphens become underscores. The result is still quoted at // use so any residual characters are handled. diff --git a/internal/provision/roles_test.go b/internal/provision/roles_test.go new file mode 100644 index 0000000..0e08968 --- /dev/null +++ b/internal/provision/roles_test.go @@ -0,0 +1,107 @@ +package provision + +import ( + "context" + "strings" + "testing" +) + +func TestEnsureRoleCreatePath(t *testing.T) { + m := &mockConn{existing: map[string]bool{}} + id, err := Postgres{}.EnsureRole(context.Background(), m, "api-reports", "pw123") + if err != nil { + t.Fatal(err) + } + if id != "api_reports" { + t.Fatalf("role ident = %q, want api_reports (hyphen sanitized)", id) + } + sql := m.joined() + if !strings.Contains(sql, `CREATE ROLE "api_reports" WITH LOGIN PASSWORD 'pw123'`) { + t.Fatalf("missing guarded CREATE ROLE: %s", sql) + } + if strings.Contains(sql, "ALTER ROLE") { + t.Fatalf("fresh role must not ALTER: %s", sql) + } +} + +func TestEnsureRoleIdempotentAlter(t *testing.T) { + m := &mockConn{existing: map[string]bool{"api_reports": true}} + if _, err := (Postgres{}).EnsureRole(context.Background(), m, "api_reports", "newpw"); err != nil { + t.Fatal(err) + } + sql := m.joined() + if strings.Contains(sql, "CREATE ROLE") { + t.Fatalf("existing role must not be re-created: %s", sql) + } + if !strings.Contains(sql, `ALTER ROLE "api_reports" WITH LOGIN PASSWORD 'newpw'`) { + t.Fatalf("existing role must have password synced: %s", sql) + } +} + +func TestEnsureDatabaseGuarded(t *testing.T) { + // Fresh database → guarded CREATE with the owner; no new role. + m := &mockConn{existing: map[string]bool{}} + id, err := Postgres{}.EnsureDatabase(context.Background(), m, "api-orders", "api") + if err != nil { + t.Fatal(err) + } + if id != "api_orders" { + t.Fatalf("db ident = %q, want api_orders", id) + } + sql := m.joined() + if !strings.Contains(sql, `CREATE DATABASE "api_orders" OWNER "api"`) { + t.Fatalf("missing CREATE DATABASE ... OWNER: %s", sql) + } + if strings.Contains(sql, "CREATE ROLE") { + t.Fatalf("db create must not create a role: %s", sql) + } + + // Existing database → no CREATE (idempotent). + m2 := &mockConn{existing: map[string]bool{"api_orders": true}} + if _, err := (Postgres{}).EnsureDatabase(context.Background(), m2, "api_orders", "api"); err != nil { + t.Fatal(err) + } + if strings.Contains(m2.joined(), "CREATE DATABASE") { + t.Fatalf("existing db must not be re-created: %s", m2.joined()) + } +} + +func TestGrantLevels(t *testing.T) { + tests := []struct { + level GrantLevel + wantAny []string + wantNone []string + }{ + {GrantRead, []string{"GRANT CONNECT ON DATABASE", "GRANT SELECT ON ALL TABLES"}, []string{"INSERT", "GRANT ALL"}}, + {GrantWrite, []string{"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES"}, []string{"GRANT ALL PRIVILEGES ON DATABASE"}}, + {GrantAdmin, []string{"GRANT ALL PRIVILEGES ON DATABASE", "GRANT ALL ON SCHEMA public"}, []string{"GRANT SELECT ON ALL TABLES"}}, + } + for _, tc := range tests { + t.Run(string(tc.level), func(t *testing.T) { + m := &mockConn{} + if err := (Postgres{}).Grant(context.Background(), m, "api-reports", "api-orders", tc.level); err != nil { + t.Fatal(err) + } + sql := m.joined() + if !strings.Contains(sql, `TO "api_reports"`) || !strings.Contains(sql, `DATABASE "api_orders"`) { + t.Fatalf("grant idents not sanitized/quoted: %s", sql) + } + for _, w := range tc.wantAny { + if !strings.Contains(sql, w) { + t.Errorf("%s grant missing %q: %s", tc.level, w, sql) + } + } + for _, n := range tc.wantNone { + if strings.Contains(sql, n) { + t.Errorf("%s grant should not contain %q: %s", tc.level, n, sql) + } + } + }) + } +} + +func TestGrantUnknownLevel(t *testing.T) { + if err := (Postgres{}).Grant(context.Background(), &mockConn{}, "r", "d", GrantLevel("bogus")); err == nil { + t.Fatal("unknown grant level must error") + } +} diff --git a/internal/resource/minio.go b/internal/resource/minio.go new file mode 100644 index 0000000..409566d --- /dev/null +++ b/internal/resource/minio.go @@ -0,0 +1,498 @@ +package resource + +import ( + "context" + "errors" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + smithy "github.com/aws/smithy-go" +) + +// This file is the MinIO (S3) Provisioner: per-project bucket isolation on the +// shared MinIO, plus the object-lifecycle / versioning / policy / CORS verbs +// (spec 29 §object storage). It uses the PURE-GO aws-sdk-go-v2 S3 client in-process +// (CGO-free, so it stays inside the single static binary — unlike the mc/aws +// external tools) pointed at MinIO with PATH-STYLE addressing and the root creds. +// +// The SDK sits behind the small S3API seam so unit/race tests run without a live +// MinIO: inject MinIO.Factory with a fake. Bucket names are transparently +// PROJECT-PREFIXED for global uniqueness (the shared MinIO is one flat namespace); +// callers escape with --no-prefix. The same code path targets a LocalStack S3 +// endpoint later — it is an endpoint swap, never a branch. + +// S3API is the subset of the aws-sdk-go-v2 S3 client the MinIO provisioner uses. +// *s3.Client satisfies it; tests inject a fake. Every signature matches the SDK +// (including the variadic option funcs) so the real client is a drop-in. +type S3API interface { + CreateBucket(context.Context, *s3.CreateBucketInput, ...func(*s3.Options)) (*s3.CreateBucketOutput, error) + HeadBucket(context.Context, *s3.HeadBucketInput, ...func(*s3.Options)) (*s3.HeadBucketOutput, error) + DeleteBucket(context.Context, *s3.DeleteBucketInput, ...func(*s3.Options)) (*s3.DeleteBucketOutput, error) + ListBuckets(context.Context, *s3.ListBucketsInput, ...func(*s3.Options)) (*s3.ListBucketsOutput, error) + PutBucketVersioning(context.Context, *s3.PutBucketVersioningInput, ...func(*s3.Options)) (*s3.PutBucketVersioningOutput, error) + GetBucketVersioning(context.Context, *s3.GetBucketVersioningInput, ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) + PutBucketLifecycleConfiguration(context.Context, *s3.PutBucketLifecycleConfigurationInput, ...func(*s3.Options)) (*s3.PutBucketLifecycleConfigurationOutput, error) + GetBucketLifecycleConfiguration(context.Context, *s3.GetBucketLifecycleConfigurationInput, ...func(*s3.Options)) (*s3.GetBucketLifecycleConfigurationOutput, error) + DeleteBucketLifecycle(context.Context, *s3.DeleteBucketLifecycleInput, ...func(*s3.Options)) (*s3.DeleteBucketLifecycleOutput, error) + PutBucketPolicy(context.Context, *s3.PutBucketPolicyInput, ...func(*s3.Options)) (*s3.PutBucketPolicyOutput, error) + GetBucketPolicy(context.Context, *s3.GetBucketPolicyInput, ...func(*s3.Options)) (*s3.GetBucketPolicyOutput, error) + PutBucketCors(context.Context, *s3.PutBucketCorsInput, ...func(*s3.Options)) (*s3.PutBucketCorsOutput, error) + GetBucketCors(context.Context, *s3.GetBucketCorsInput, ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) +} + +// S3Factory builds an S3API for a resolved Target (the 127.0.0.1 overlay endpoint +// + the instance's root creds). Injectable so the provisioner is endpoint-free in +// tests; nil selects the real aws-sdk-go-v2 client. +type S3Factory func(ctx context.Context, t Target) (S3API, error) + +// BucketController is the object-storage surface the s3 CLI depends on beyond the +// generic Provisioner (list/lifecycle/versioning/policy/cors). MinIO implements it; +// the orchestrate helpers type-assert the registered provisioner to it. +type BucketController interface { + Provisioner + ListBuckets(ctx context.Context, t Target, prefix string) ([]string, error) + SetVersioning(ctx context.Context, t Target, bucket string, enabled bool) error + GetVersioning(ctx context.Context, t Target, bucket string) (string, error) + SetLifecycle(ctx context.Context, t Target, bucket string, rule LifecycleRule) error + GetLifecycle(ctx context.Context, t Target, bucket string) ([]map[string]any, error) + RemoveLifecycle(ctx context.Context, t Target, bucket string) error + SetPolicy(ctx context.Context, t Target, bucket, policyJSON string) error + GetPolicy(ctx context.Context, t Target, bucket string) (string, error) + SetCORS(ctx context.Context, t Target, bucket string, rules []s3types.CORSRule) error + GetCORS(ctx context.Context, t Target, bucket string) ([]s3types.CORSRule, error) +} + +// MinIO is the minio/S3 Provisioner. Factory nil → the real path-style client. +type MinIO struct { + Factory S3Factory +} + +// Ensure MinIO satisfies both contracts at compile time. +var ( + _ Provisioner = MinIO{} + _ BucketController = MinIO{} +) + +// Engine reports the shared-template capability this provisioner serves. +func (MinIO) Engine() string { return "minio" } + +// Kinds are the resource kinds this provisioner can create. +func (MinIO) Kinds() []string { return []string{"bucket", "lifecycle", "access_key"} } + +// LifecycleRule is the portable object-lifecycle intent (spec 29): a mandatory +// expiry in days plus an optional storage-class transition. `--expire-days` is +// portable across MinIO and S3; `--transition` is engine-conditional. +type LifecycleRule struct { + ExpireDays int // objects expire after N days (0 → no expiry rule) + Prefix string // object-key prefix the rule applies to ("" → whole bucket) + TransitionDays int // transition to TransitionTier after N days (0 → none) + TransitionTier string // e.g. STANDARD_IA, GLACIER (S3 storage class) +} + +// s3Endpoint is the loopback admin endpoint for the overlay-published instance. +func s3Endpoint(t Target) string { return fmt.Sprintf("http://%s:%d", t.Host, t.Port) } + +// client resolves the S3 client for a target (the injected factory or the real +// path-style aws-sdk-go-v2 client with the instance root creds). +func (m MinIO) client(ctx context.Context, t Target) (S3API, error) { + if m.Factory != nil { + return m.Factory(ctx, t) + } + return defaultS3Client(ctx, t) +} + +// defaultS3Client builds a pure-Go path-style S3 client against the MinIO endpoint +// with the instance's root credentials (accessKey=rootUser, secret=rootPassword). +func defaultS3Client(_ context.Context, t Target) (S3API, error) { + access := t.AdminEnv["user"] + secret := t.AdminEnv["password"] + cfg := aws.Config{ + Region: "us-east-1", + Credentials: credentials.NewStaticCredentialsProvider(access, secret, ""), + } + return s3.NewFromConfig(cfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(s3Endpoint(t)) + o.UsePathStyle = true // MinIO does not do virtual-host buckets + }), nil +} + +// bucketExists reports whether the bucket already exists (HeadBucket 2xx). +func bucketExists(ctx context.Context, c S3API, bucket string) (bool, error) { + _, err := c.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(bucket)}) + if err == nil { + return true, nil + } + if isNotFound(err) { + return false, nil + } + return false, err +} + +// Ensure idempotently creates the bucket (existence-guarded — CreateBucket errors +// if it already exists) and applies any requested versioning / lifecycle from +// Params. Returns the connection facts (endpoint + bucket + creds). +func (m MinIO) Ensure(ctx context.Context, t Target, r Resource) (Attrs, error) { + c, err := m.client(ctx, t) + if err != nil { + return nil, err + } + bucket := r.Name + if bucket == "" { + bucket = r.Owner + } + + if r.Kind == "lifecycle" { + rule := lifecycleFromParams(r.Params) + if err := m.putLifecycle(ctx, c, bucket, rule); err != nil { + return nil, err + } + return m.attrs(t, bucket), nil + } + + exists, err := bucketExists(ctx, c, bucket) + if err != nil { + return nil, fmt.Errorf("head bucket %q: %w", bucket, err) + } + if !exists { + if _, err := c.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(bucket)}); err != nil { + if !isAlreadyOwned(err) { + return nil, fmt.Errorf("create bucket %q: %w", bucket, err) + } + } + } + if boolParam(r.Params, "versioning") { + if err := m.setVersioning(ctx, c, bucket, true); err != nil { + return nil, err + } + } + if rule := lifecycleFromParams(r.Params); rule.ExpireDays > 0 || rule.TransitionDays > 0 { + if err := m.putLifecycle(ctx, c, bucket, rule); err != nil { + return nil, err + } + } + return m.attrs(t, bucket), nil +} + +// Drop removes the bucket (or, for the lifecycle kind, just its lifecycle config). +// Idempotent: a missing bucket/config is not an error. It never touches the shared +// MinIO container — only the tenant object. +func (m MinIO) Drop(ctx context.Context, t Target, r Resource) error { + c, err := m.client(ctx, t) + if err != nil { + return err + } + bucket := r.Name + if bucket == "" { + bucket = r.Owner + } + if r.Kind == "lifecycle" { + _, err := c.DeleteBucketLifecycle(ctx, &s3.DeleteBucketLifecycleInput{Bucket: aws.String(bucket)}) + if err != nil && !isNotFound(err) { + return fmt.Errorf("delete lifecycle on %q: %w", bucket, err) + } + return nil + } + if _, err := c.DeleteBucket(ctx, &s3.DeleteBucketInput{Bucket: aws.String(bucket)}); err != nil { + if isNotFound(err) { + return nil + } + return fmt.Errorf("delete bucket %q: %w", bucket, err) + } + return nil +} + +// Preflight verifies the endpoint is reachable and the creds are valid (a +// ListBuckets round-trip). Absence degrades only the s3 verbs, never `up`. +func (m MinIO) Preflight(ctx context.Context, t Target) error { + c, err := m.client(ctx, t) + if err != nil { + return err + } + _, err = c.ListBuckets(ctx, &s3.ListBucketsInput{}) + return err +} + +// --- BucketController extras (used by the s3 CLI beyond create/drop) ---------- + +// ListBuckets returns bucket names, optionally filtered to a project prefix (so a +// tenant sees only its own buckets). A lock-free read. +func (m MinIO) ListBuckets(ctx context.Context, t Target, prefix string) ([]string, error) { + c, err := m.client(ctx, t) + if err != nil { + return nil, err + } + out, err := c.ListBuckets(ctx, &s3.ListBucketsInput{}) + if err != nil { + return nil, err + } + var names []string + for _, b := range out.Buckets { + name := aws.ToString(b.Name) + if prefix != "" && !strings.HasPrefix(name, prefix) { + continue + } + names = append(names, name) + } + sort.Strings(names) + return names, nil +} + +// SetVersioning enables or suspends bucket versioning. +func (m MinIO) SetVersioning(ctx context.Context, t Target, bucket string, enabled bool) error { + c, err := m.client(ctx, t) + if err != nil { + return err + } + return m.setVersioning(ctx, c, bucket, enabled) +} + +// GetVersioning reports the bucket's versioning status ("Enabled"/"Suspended"/""). +func (m MinIO) GetVersioning(ctx context.Context, t Target, bucket string) (string, error) { + c, err := m.client(ctx, t) + if err != nil { + return "", err + } + out, err := c.GetBucketVersioning(ctx, &s3.GetBucketVersioningInput{Bucket: aws.String(bucket)}) + if err != nil { + return "", err + } + return string(out.Status), nil +} + +// SetLifecycle applies a single expiry (+optional transition) rule to the bucket. +func (m MinIO) SetLifecycle(ctx context.Context, t Target, bucket string, rule LifecycleRule) error { + c, err := m.client(ctx, t) + if err != nil { + return err + } + return m.putLifecycle(ctx, c, bucket, rule) +} + +// GetLifecycle returns the bucket's lifecycle rules as a human summary (id→days). +func (m MinIO) GetLifecycle(ctx context.Context, t Target, bucket string) ([]map[string]any, error) { + c, err := m.client(ctx, t) + if err != nil { + return nil, err + } + out, err := c.GetBucketLifecycleConfiguration(ctx, &s3.GetBucketLifecycleConfigurationInput{Bucket: aws.String(bucket)}) + if err != nil { + if isNotFound(err) { + return nil, nil + } + return nil, err + } + var rules []map[string]any + for _, r := range out.Rules { + row := map[string]any{"id": aws.ToString(r.ID), "status": string(r.Status)} + if r.Expiration != nil && r.Expiration.Days != nil { + row["expire_days"] = int(*r.Expiration.Days) + } + for _, tr := range r.Transitions { + if tr.Days != nil { + row["transition_days"] = int(*tr.Days) + row["transition_tier"] = string(tr.StorageClass) + } + } + rules = append(rules, row) + } + return rules, nil +} + +// RemoveLifecycle deletes the bucket's lifecycle configuration (idempotent). +func (m MinIO) RemoveLifecycle(ctx context.Context, t Target, bucket string) error { + return m.Drop(ctx, t, Resource{Engine: "minio", Kind: "lifecycle", Name: bucket}) +} + +// SetPolicy sets the bucket policy from a raw JSON document. +func (m MinIO) SetPolicy(ctx context.Context, t Target, bucket, policyJSON string) error { + c, err := m.client(ctx, t) + if err != nil { + return err + } + _, err = c.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{ + Bucket: aws.String(bucket), Policy: aws.String(policyJSON), + }) + if err != nil { + return fmt.Errorf("put policy on %q: %w", bucket, err) + } + return nil +} + +// GetPolicy returns the bucket policy JSON ("" when none is set). +func (m MinIO) GetPolicy(ctx context.Context, t Target, bucket string) (string, error) { + c, err := m.client(ctx, t) + if err != nil { + return "", err + } + out, err := c.GetBucketPolicy(ctx, &s3.GetBucketPolicyInput{Bucket: aws.String(bucket)}) + if err != nil { + if isNotFound(err) || apiCode(err) == "NoSuchBucketPolicy" { + return "", nil + } + return "", err + } + return aws.ToString(out.Policy), nil +} + +// SetCORS sets the bucket CORS configuration from raw JSON (a []CORSRule). +func (m MinIO) SetCORS(ctx context.Context, t Target, bucket string, rules []s3types.CORSRule) error { + c, err := m.client(ctx, t) + if err != nil { + return err + } + _, err = c.PutBucketCors(ctx, &s3.PutBucketCorsInput{ + Bucket: aws.String(bucket), + CORSConfiguration: &s3types.CORSConfiguration{CORSRules: rules}, + }) + if err != nil { + return fmt.Errorf("put cors on %q: %w", bucket, err) + } + return nil +} + +// GetCORS returns the bucket CORS rules (nil when none). +func (m MinIO) GetCORS(ctx context.Context, t Target, bucket string) ([]s3types.CORSRule, error) { + c, err := m.client(ctx, t) + if err != nil { + return nil, err + } + out, err := c.GetBucketCors(ctx, &s3.GetBucketCorsInput{Bucket: aws.String(bucket)}) + if err != nil { + if isNotFound(err) || apiCode(err) == "NoSuchCORSConfiguration" { + return nil, nil + } + return nil, err + } + return out.CORSRules, nil +} + +// --- internals ---------------------------------------------------------------- + +func (MinIO) attrs(t Target, bucket string) Attrs { + return Attrs{ + "endpoint": s3Endpoint(t), + "host": sharedHost(t.Instance), + "port": "9000", + "bucket": bucket, + "accessKey": t.AdminEnv["user"], + "secretKey": t.AdminEnv["password"], + } +} + +func (MinIO) setVersioning(ctx context.Context, c S3API, bucket string, enabled bool) error { + status := s3types.BucketVersioningStatusSuspended + if enabled { + status = s3types.BucketVersioningStatusEnabled + } + _, err := c.PutBucketVersioning(ctx, &s3.PutBucketVersioningInput{ + Bucket: aws.String(bucket), + VersioningConfiguration: &s3types.VersioningConfiguration{Status: status}, + }) + if err != nil { + return fmt.Errorf("set versioning on %q: %w", bucket, err) + } + return nil +} + +func (MinIO) putLifecycle(ctx context.Context, c S3API, bucket string, rule LifecycleRule) error { + lr := s3types.LifecycleRule{ + ID: aws.String("devstack-" + bucket), + Status: s3types.ExpirationStatusEnabled, + Filter: &s3types.LifecycleRuleFilter{Prefix: aws.String(rule.Prefix)}, + } + if rule.ExpireDays > 0 { + lr.Expiration = &s3types.LifecycleExpiration{Days: aws.Int32(int32(rule.ExpireDays))} + } + if rule.TransitionDays > 0 && rule.TransitionTier != "" { + lr.Transitions = []s3types.Transition{{ + Days: aws.Int32(int32(rule.TransitionDays)), + StorageClass: s3types.TransitionStorageClass(rule.TransitionTier), + }} + } + _, err := c.PutBucketLifecycleConfiguration(ctx, &s3.PutBucketLifecycleConfigurationInput{ + Bucket: aws.String(bucket), + LifecycleConfiguration: &s3types.BucketLifecycleConfiguration{Rules: []s3types.LifecycleRule{lr}}, + }) + if err != nil { + return fmt.Errorf("put lifecycle on %q: %w", bucket, err) + } + return nil +} + +func lifecycleFromParams(p map[string]any) LifecycleRule { + return LifecycleRule{ + ExpireDays: intParam(p, "expire_days"), + Prefix: paramStr(p, "prefix"), + TransitionDays: intParam(p, "transition_days"), + TransitionTier: paramStr(p, "transition_tier"), + } +} + +func boolParam(p map[string]any, key string) bool { + switch v := p[key].(type) { + case bool: + return v + case string: + return v == "true" || v == "1" || v == "yes" + } + return false +} + +func intParam(p map[string]any, key string) int { + switch v := p[key].(type) { + case int: + return v + case int32: + return int(v) + case int64: + return int(v) + case float64: + return int(v) + case string: + n, _ := strconv.Atoi(v) + return n + } + return 0 +} + +// apiCode extracts a smithy API error code, or "" if err is not an API error. +func apiCode(err error) string { + var ae smithy.APIError + if errors.As(err, &ae) { + return ae.ErrorCode() + } + return "" +} + +// isNotFound reports the standard "no such bucket / 404 / not found" family. +func isNotFound(err error) bool { + var nsb *s3types.NoSuchBucket + if errors.As(err, &nsb) { + return true + } + switch apiCode(err) { + case "NoSuchBucket", "NotFound", "404", "NoSuchLifecycleConfiguration": + return true + } + return false +} + +// isAlreadyOwned reports the idempotent "bucket already exists and is yours" case. +func isAlreadyOwned(err error) bool { + var owned *s3types.BucketAlreadyOwnedByYou + var exists *s3types.BucketAlreadyExists + if errors.As(err, &owned) || errors.As(err, &exists) { + return true + } + switch apiCode(err) { + case "BucketAlreadyOwnedByYou", "BucketAlreadyExists": + return true + } + return false +} diff --git a/internal/resource/minio_test.go b/internal/resource/minio_test.go new file mode 100644 index 0000000..e0307f6 --- /dev/null +++ b/internal/resource/minio_test.go @@ -0,0 +1,275 @@ +package resource + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +// fakeS3 is an in-memory S3API for the MinIO provisioner tests (no live endpoint). +type fakeS3 struct { + buckets map[string]bool + versioning map[string]string + lifecycle map[string][]s3types.LifecycleRule + policy map[string]string + cors map[string][]s3types.CORSRule + calls []string +} + +func newFakeS3() *fakeS3 { + return &fakeS3{ + buckets: map[string]bool{}, + versioning: map[string]string{}, + lifecycle: map[string][]s3types.LifecycleRule{}, + policy: map[string]string{}, + cors: map[string][]s3types.CORSRule{}, + } +} + +func (f *fakeS3) CreateBucket(_ context.Context, in *s3.CreateBucketInput, _ ...func(*s3.Options)) (*s3.CreateBucketOutput, error) { + f.calls = append(f.calls, "CreateBucket:"+aws.ToString(in.Bucket)) + if f.buckets[aws.ToString(in.Bucket)] { + return nil, &s3types.BucketAlreadyOwnedByYou{} + } + f.buckets[aws.ToString(in.Bucket)] = true + return &s3.CreateBucketOutput{}, nil +} + +func (f *fakeS3) HeadBucket(_ context.Context, in *s3.HeadBucketInput, _ ...func(*s3.Options)) (*s3.HeadBucketOutput, error) { + if f.buckets[aws.ToString(in.Bucket)] { + return &s3.HeadBucketOutput{}, nil + } + return nil, &s3types.NotFound{} +} + +func (f *fakeS3) DeleteBucket(_ context.Context, in *s3.DeleteBucketInput, _ ...func(*s3.Options)) (*s3.DeleteBucketOutput, error) { + f.calls = append(f.calls, "DeleteBucket:"+aws.ToString(in.Bucket)) + if !f.buckets[aws.ToString(in.Bucket)] { + return nil, &s3types.NoSuchBucket{} + } + delete(f.buckets, aws.ToString(in.Bucket)) + return &s3.DeleteBucketOutput{}, nil +} + +func (f *fakeS3) ListBuckets(_ context.Context, _ *s3.ListBucketsInput, _ ...func(*s3.Options)) (*s3.ListBucketsOutput, error) { + out := &s3.ListBucketsOutput{} + for name := range f.buckets { + out.Buckets = append(out.Buckets, s3types.Bucket{Name: aws.String(name)}) + } + return out, nil +} + +func (f *fakeS3) PutBucketVersioning(_ context.Context, in *s3.PutBucketVersioningInput, _ ...func(*s3.Options)) (*s3.PutBucketVersioningOutput, error) { + f.versioning[aws.ToString(in.Bucket)] = string(in.VersioningConfiguration.Status) + return &s3.PutBucketVersioningOutput{}, nil +} + +func (f *fakeS3) GetBucketVersioning(_ context.Context, in *s3.GetBucketVersioningInput, _ ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) { + return &s3.GetBucketVersioningOutput{Status: s3types.BucketVersioningStatus(f.versioning[aws.ToString(in.Bucket)])}, nil +} + +func (f *fakeS3) PutBucketLifecycleConfiguration(_ context.Context, in *s3.PutBucketLifecycleConfigurationInput, _ ...func(*s3.Options)) (*s3.PutBucketLifecycleConfigurationOutput, error) { + f.lifecycle[aws.ToString(in.Bucket)] = in.LifecycleConfiguration.Rules + return &s3.PutBucketLifecycleConfigurationOutput{}, nil +} + +func (f *fakeS3) GetBucketLifecycleConfiguration(_ context.Context, in *s3.GetBucketLifecycleConfigurationInput, _ ...func(*s3.Options)) (*s3.GetBucketLifecycleConfigurationOutput, error) { + r, ok := f.lifecycle[aws.ToString(in.Bucket)] + if !ok { + return nil, &s3types.NoSuchBucket{} + } + return &s3.GetBucketLifecycleConfigurationOutput{Rules: r}, nil +} + +func (f *fakeS3) DeleteBucketLifecycle(_ context.Context, in *s3.DeleteBucketLifecycleInput, _ ...func(*s3.Options)) (*s3.DeleteBucketLifecycleOutput, error) { + delete(f.lifecycle, aws.ToString(in.Bucket)) + return &s3.DeleteBucketLifecycleOutput{}, nil +} + +func (f *fakeS3) PutBucketPolicy(_ context.Context, in *s3.PutBucketPolicyInput, _ ...func(*s3.Options)) (*s3.PutBucketPolicyOutput, error) { + f.policy[aws.ToString(in.Bucket)] = aws.ToString(in.Policy) + return &s3.PutBucketPolicyOutput{}, nil +} + +func (f *fakeS3) GetBucketPolicy(_ context.Context, in *s3.GetBucketPolicyInput, _ ...func(*s3.Options)) (*s3.GetBucketPolicyOutput, error) { + p, ok := f.policy[aws.ToString(in.Bucket)] + if !ok { + return nil, &s3types.NoSuchBucket{} + } + return &s3.GetBucketPolicyOutput{Policy: aws.String(p)}, nil +} + +func (f *fakeS3) PutBucketCors(_ context.Context, in *s3.PutBucketCorsInput, _ ...func(*s3.Options)) (*s3.PutBucketCorsOutput, error) { + f.cors[aws.ToString(in.Bucket)] = in.CORSConfiguration.CORSRules + return &s3.PutBucketCorsOutput{}, nil +} + +func (f *fakeS3) GetBucketCors(_ context.Context, in *s3.GetBucketCorsInput, _ ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) { + c, ok := f.cors[aws.ToString(in.Bucket)] + if !ok { + return nil, &s3types.NoSuchBucket{} + } + return &s3.GetBucketCorsOutput{CORSRules: c}, nil +} + +func minioTarget() Target { + return Target{ + Instance: "minio", Host: "127.0.0.1", Port: 49000, + AdminEnv: map[string]string{"user": "devstackadmin", "password": "devstackadmin"}, + } +} + +func fakeMinIO(f *fakeS3) MinIO { + return MinIO{Factory: func(context.Context, Target) (S3API, error) { return f, nil }} +} + +func TestMinIOEngineAndKinds(t *testing.T) { + m := MinIO{} + if m.Engine() != "minio" { + t.Errorf("Engine() = %q, want minio", m.Engine()) + } + if got := m.Kinds(); len(got) == 0 || got[0] != "bucket" { + t.Errorf("Kinds() = %v, want bucket first", got) + } +} + +func TestMinIOEnsureBucketWithVersioningAndLifecycle(t *testing.T) { + f := newFakeS3() + m := fakeMinIO(f) + attrs, err := m.Ensure(context.Background(), minioTarget(), Resource{ + Engine: "minio", Kind: "bucket", Name: "web-uploads", Owner: "web", + Params: map[string]any{"versioning": true, "expire_days": 30}, + }) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + if !f.buckets["web-uploads"] { + t.Errorf("bucket web-uploads not created: %v", f.buckets) + } + if f.versioning["web-uploads"] != "Enabled" { + t.Errorf("versioning not enabled: %v", f.versioning) + } + rules := f.lifecycle["web-uploads"] + if len(rules) != 1 || rules[0].Expiration == nil || aws.ToInt32(rules[0].Expiration.Days) != 30 { + t.Errorf("expiry rule not set to 30 days: %+v", rules) + } + if attrs["bucket"] != "web-uploads" || attrs["endpoint"] != "http://127.0.0.1:49000" { + t.Errorf("attrs = %v", attrs) + } + if attrs["accessKey"] != "devstackadmin" { + t.Errorf("accessKey not surfaced from root creds: %v", attrs) + } +} + +func TestMinIOEnsureBucketIdempotent(t *testing.T) { + f := newFakeS3() + f.buckets["web-uploads"] = true // already exists + m := fakeMinIO(f) + if _, err := m.Ensure(context.Background(), minioTarget(), Resource{ + Engine: "minio", Kind: "bucket", Name: "web-uploads", Owner: "web", + }); err != nil { + t.Fatalf("Ensure idempotent: %v", err) + } + // HeadBucket short-circuits — CreateBucket must not be called for an existing one. + for _, c := range f.calls { + if c == "CreateBucket:web-uploads" { + t.Errorf("existing bucket must not be re-created: %v", f.calls) + } + } +} + +func TestMinIOLifecycleKind(t *testing.T) { + f := newFakeS3() + f.buckets["web-uploads"] = true + m := fakeMinIO(f) + if _, err := m.Ensure(context.Background(), minioTarget(), Resource{ + Engine: "minio", Kind: "lifecycle", Name: "web-uploads", + Params: map[string]any{"expire_days": 7, "transition_days": 3, "transition_tier": "STANDARD_IA"}, + }); err != nil { + t.Fatalf("Ensure lifecycle: %v", err) + } + rules := f.lifecycle["web-uploads"] + if len(rules) != 1 || len(rules[0].Transitions) != 1 { + t.Fatalf("lifecycle+transition not set: %+v", rules) + } + if string(rules[0].Transitions[0].StorageClass) != "STANDARD_IA" { + t.Errorf("transition tier = %q, want STANDARD_IA", rules[0].Transitions[0].StorageClass) + } +} + +func TestMinIOVersioningToggle(t *testing.T) { + f := newFakeS3() + f.buckets["web-data"] = true + m := fakeMinIO(f) + if err := m.SetVersioning(context.Background(), minioTarget(), "web-data", true); err != nil { + t.Fatal(err) + } + if got, _ := m.GetVersioning(context.Background(), minioTarget(), "web-data"); got != "Enabled" { + t.Errorf("versioning = %q, want Enabled", got) + } + if err := m.SetVersioning(context.Background(), minioTarget(), "web-data", false); err != nil { + t.Fatal(err) + } + if got, _ := m.GetVersioning(context.Background(), minioTarget(), "web-data"); got != "Suspended" { + t.Errorf("versioning = %q, want Suspended", got) + } +} + +func TestMinIOPolicyAndCORS(t *testing.T) { + f := newFakeS3() + f.buckets["web-pub"] = true + m := fakeMinIO(f) + pol := `{"Version":"2012-10-17","Statement":[]}` + if err := m.SetPolicy(context.Background(), minioTarget(), "web-pub", pol); err != nil { + t.Fatal(err) + } + if got, _ := m.GetPolicy(context.Background(), minioTarget(), "web-pub"); got != pol { + t.Errorf("policy = %q, want %q", got, pol) + } + // No policy on an untouched bucket → empty, not an error. + if got, err := m.GetPolicy(context.Background(), minioTarget(), "web-pub2"); err != nil || got != "" { + t.Errorf("absent policy: got=%q err=%v, want empty/no-error", got, err) + } + rules := []s3types.CORSRule{{AllowedMethods: []string{"GET"}, AllowedOrigins: []string{"*"}}} + if err := m.SetCORS(context.Background(), minioTarget(), "web-pub", rules); err != nil { + t.Fatal(err) + } + got, err := m.GetCORS(context.Background(), minioTarget(), "web-pub") + if err != nil || len(got) != 1 || got[0].AllowedMethods[0] != "GET" { + t.Errorf("cors round-trip: %+v err=%v", got, err) + } +} + +func TestMinIOListBucketsPrefixIsolation(t *testing.T) { + f := newFakeS3() + f.buckets["web-uploads"] = true + f.buckets["web-assets"] = true + f.buckets["api-orders"] = true // another tenant + m := fakeMinIO(f) + names, err := m.ListBuckets(context.Background(), minioTarget(), "web-") + if err != nil { + t.Fatal(err) + } + if len(names) != 2 || names[0] != "web-assets" || names[1] != "web-uploads" { + t.Errorf("prefix filter should return only web- buckets sorted, got %v", names) + } +} + +func TestMinIODropBucket(t *testing.T) { + f := newFakeS3() + f.buckets["web-uploads"] = true + m := fakeMinIO(f) + if err := m.Drop(context.Background(), minioTarget(), Resource{Engine: "minio", Kind: "bucket", Name: "web-uploads"}); err != nil { + t.Fatalf("Drop: %v", err) + } + if f.buckets["web-uploads"] { + t.Error("bucket not deleted") + } + // Idempotent: dropping a missing bucket is not an error. + if err := m.Drop(context.Background(), minioTarget(), Resource{Engine: "minio", Kind: "bucket", Name: "web-uploads"}); err != nil { + t.Errorf("drop of missing bucket must be idempotent: %v", err) + } +} diff --git a/internal/resource/postgres.go b/internal/resource/postgres.go index 6d70375..aebb10e 100644 --- a/internal/resource/postgres.go +++ b/internal/resource/postgres.go @@ -63,11 +63,59 @@ func (Postgres) password(r Resource) string { return r.Owner } -// Ensure idempotently provisions the role+database for the resource, wrapping -// provision.EnsureProject (the identity defaults to the owner project, matching -// the implicit up-time provisioning byte-for-byte). Returns the connection facts; -// the secret password is included so callers can surface/mask it. +// rolePassword resolves a role/user credential: an explicit generated value when +// present, else the predictable dev cred (== the role NAME, spec 29 §db user). +func (Postgres) rolePassword(r Resource, role string) string { + if v, ok := r.Params["password"].(string); ok && v != "" { + return v + } + return role +} + +// paramStr reads a string param (empty when absent or non-string). +func paramStr(params map[string]any, key string) string { + if v, ok := params[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// dsnDB builds the admin DSN for the target instance, connected to database db +// (falling back to the admin database when db is empty). Used by the role/user +// path so schema/table grants land on the tenant database, not the admin one. +func (Postgres) dsnDB(t Target, db string) string { + user := t.AdminEnv["user"] + pass := t.AdminEnv["password"] + if db == "" { + db = t.AdminEnv["database"] + if db == "" { + db = user + } + } + return provision.DSN(t.Host, t.Port, user, pass, db) +} + +// Ensure idempotently provisions the resource. Dispatch by kind: +// +// - role|user: a tenant-scoped LOGIN role, optionally GRANTed on a target +// database (Params["db"] + Params["level"]); nothing else is created. +// - database with Params["owner"]: just CREATE DATABASE … OWNER (the +// owner is an existing role, typically the project) — `db create `. +// - database (no owner): the legacy role+database pair via EnsureProject (the +// `resource create postgres database` substrate behaviour, byte-for-byte). +// +// Returns the connection facts; the secret password is included so callers mask it. func (p Postgres) Ensure(ctx context.Context, t Target, r Resource) (Attrs, error) { + switch r.Kind { + case "role", "user": + return p.ensureRole(ctx, t, r) + case "database": + if owner := paramStr(r.Params, "owner"); owner != "" { + return p.ensureDatabase(ctx, t, r, owner) + } + } identity := r.Name if identity == "" { identity = r.Owner @@ -93,6 +141,82 @@ func (p Postgres) Ensure(ctx context.Context, t Target, r Resource) (Attrs, erro }, nil } +// ensureDatabase creates just a database owned by an existing role (no new role), +// so `db create orders` on project api yields `api_orders OWNER api`. It returns no +// "role" attr, so the caller records only the database ownership row. +func (p Postgres) ensureDatabase(ctx context.Context, t Target, r Resource, owner string) (Attrs, error) { + db := r.Name + if db == "" { + db = r.Owner + } + conn, closeConn, err := p.connect(ctx, p.dsn(t)) + if err != nil { + return nil, fmt.Errorf("connect to shared %s on %s:%d: %w", t.Instance, t.Host, t.Port, err) + } + defer func() { _ = closeConn() }() + + dbIdent, err := provision.Postgres{}.EnsureDatabase(ctx, conn, db, owner) + if err != nil { + return nil, err + } + return Attrs{ + "host": sharedHost(t.Instance), + "port": "5432", + "user": pgIdent(owner), + "database": dbIdent, + "owner": pgIdent(owner), + "password": owner, // predictable dev-cred == owner project name + }, nil +} + +// ensureRole creates a tenant-scoped LOGIN role (existence-guarded), optionally +// granting it a privilege tier on a target database. When Params["grant_only"] is +// set the role is assumed to exist (`db grant`) and only the GRANT runs — the +// password is never reset. Connects to the target database so schema/table grants +// land on the right database, not the admin one. +func (p Postgres) ensureRole(ctx context.Context, t Target, r Resource) (Attrs, error) { + role := r.Name + if role == "" { + role = r.Owner + } + db := paramStr(r.Params, "db") + conn, closeConn, err := p.connect(ctx, p.dsnDB(t, db)) + if err != nil { + return nil, fmt.Errorf("connect to shared %s on %s:%d: %w", t.Instance, t.Host, t.Port, err) + } + defer func() { _ = closeConn() }() + + pass := p.rolePassword(r, role) + roleIdent := pgIdent(role) + grantOnly := paramStr(r.Params, "grant_only") != "" + if !grantOnly { + roleIdent, err = (provision.Postgres{}).EnsureRole(ctx, conn, role, pass) + if err != nil { + return nil, err + } + } + if db != "" { + level := provision.GrantLevel(paramStr(r.Params, "level")) + if level == "" { + level = provision.GrantRead + } + if err := (provision.Postgres{}).Grant(ctx, conn, role, db, level); err != nil { + return nil, err + } + } + attrs := Attrs{ + "host": sharedHost(t.Instance), + "port": "5432", + "user": roleIdent, + "role": roleIdent, + "password": pass, + } + if db != "" { + attrs["database"] = pgIdent(db) + } + return attrs, nil +} + // Drop removes the resource's database and/or role, guarded so it is idempotent // and never bounces the shared container. For a database it terminates the // tenant's own sessions before DROP DATABASE (never other tenants'), then drops diff --git a/internal/resource/postgres_roles_test.go b/internal/resource/postgres_roles_test.go new file mode 100644 index 0000000..fc60a0f --- /dev/null +++ b/internal/resource/postgres_roles_test.go @@ -0,0 +1,81 @@ +package resource + +import ( + "context" + "strings" + "testing" +) + +func TestPostgresEnsureUserWithGrant(t *testing.T) { + c := &mockConn{} + p := Postgres{Connect: mockConnector(c)} + attrs, err := p.Ensure(context.Background(), target(), Resource{ + Engine: "postgres", Kind: "role", Name: "api_reports", Owner: "api", + Params: map[string]any{"db": "api_orders", "level": "read"}, + }) + if err != nil { + t.Fatalf("Ensure user: %v", err) + } + joined := strings.Join(c.execs, " | ") + if !strings.Contains(joined, "CREATE ROLE") { + t.Errorf("user create must run CREATE ROLE: %s", joined) + } + if !strings.Contains(joined, "GRANT SELECT ON ALL TABLES") { + t.Errorf("read grant missing: %s", joined) + } + if strings.Contains(joined, "CREATE DATABASE") { + t.Errorf("user create must not create a database: %s", joined) + } + if attrs["role"] != "api_reports" || attrs["database"] != "api_orders" { + t.Errorf("attrs = %v, want role api_reports on database api_orders", attrs) + } + // Predictable dev-cred for a role == the role name. + if attrs["password"] != "api_reports" { + t.Errorf("role password should default to the role name, got %q", attrs["password"]) + } +} + +func TestPostgresGrantOnly(t *testing.T) { + // grant_only: the role already exists; only the GRANT runs, no ALTER/CREATE. + c := &mockConn{} + p := Postgres{Connect: mockConnector(c)} + _, err := p.Ensure(context.Background(), target(), Resource{ + Engine: "postgres", Kind: "role", Name: "api_reports", Owner: "api", + Params: map[string]any{"db": "api_orders", "level": "write", "grant_only": "1"}, + }) + if err != nil { + t.Fatalf("Ensure grant_only: %v", err) + } + joined := strings.Join(c.execs, " | ") + if strings.Contains(joined, "CREATE ROLE") || strings.Contains(joined, "ALTER ROLE") { + t.Errorf("grant_only must not create/alter the role: %s", joined) + } + if !strings.Contains(joined, "INSERT, UPDATE, DELETE") { + t.Errorf("write grant missing: %s", joined) + } +} + +func TestPostgresEnsureDatabaseWithOwner(t *testing.T) { + c := &mockConn{} + p := Postgres{Connect: mockConnector(c)} + attrs, err := p.Ensure(context.Background(), target(), Resource{ + Engine: "postgres", Kind: "database", Name: "api_orders", Owner: "api", + Params: map[string]any{"owner": "api"}, + }) + if err != nil { + t.Fatalf("Ensure database with owner: %v", err) + } + joined := strings.Join(c.execs, " | ") + if !strings.Contains(joined, `CREATE DATABASE "api_orders" OWNER "api"`) { + t.Errorf("owner-based db create missing: %s", joined) + } + if strings.Contains(joined, "CREATE ROLE") { + t.Errorf("owner-based db create must not create a role: %s", joined) + } + if _, hasRole := attrs["role"]; hasRole { + t.Errorf("owner-based create must not surface a new role attr: %v", attrs) + } + if attrs["database"] != "api_orders" || attrs["owner"] != "api" { + t.Errorf("attrs = %v, want database api_orders owner api", attrs) + } +}