diff --git a/.gitignore b/.gitignore index 9ea3f86..3f2ee73 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,9 @@ coverage.txt .DS_Store .idea/ .vscode/ + +# Exception: the spec-17 IDE generation goldens ARE editor configs — the +# .vscode/.devcontainer fixtures under test must stay tracked (a parent dir must +# be re-included before its files, so negate the directories explicitly). +!internal/ide/testdata/golden/**/.vscode/ +!internal/ide/testdata/golden/**/.vscode/** diff --git a/internal/cli/ide.go b/internal/cli/ide.go new file mode 100644 index 0000000..ffaad5e --- /dev/null +++ b/internal/cli/ide.go @@ -0,0 +1,112 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/ide" +) + +// newIdeCmd wires `devstack ide` — the spec-17 editor/IDE generation sink. It loads +// the workspace and authors, deterministically, the artifacts that point editors at +// devstack's already-generated compose stacks: per-repo .devcontainer/devcontainer.json, +// a multi-root .code-workspace, and per-repo .vscode/{launch,settings}.json. +// +// It is pure file authorship (no Docker, no ledger, no flock), so it mirrors the +// generate command's --check/--json contract. Targets are selected with +// --devcontainer / --vscode; --all (the no-flag default) emits both. +func newIdeCmd(g *GlobalOpts) *cobra.Command { + var ( + devcontainer bool + vscode bool + all bool + check bool + ) + cmd := &cobra.Command{ + Use: "ide", + Short: "Generate devcontainer/.code-workspace/launch editor configs", + Long: "ide authors, from the same resolved config as `generate`, the editor artifacts\n" + + "that point at devstack's already-generated compose stacks:\n\n" + + " * /.devcontainer/devcontainer.json (attach the IDE to the SAME\n" + + " devstack- compose project + shared network devstack up runs)\n" + + " * /.code-workspace (VS Code multi-root)\n" + + " * /.vscode/{launch,settings}.json (debugger + schema-map stubs)\n\n" + + "Select targets with --devcontainer / --vscode; --all (the default when no target\n" + + "flag is given) emits both. Output is byte-deterministic (writeIfChanged); --check\n" + + "reports drift without writing (CI-friendly).", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + m, err := loadWorkspace() + if err != nil { + return err + } + targets := ide.Targets{Devcontainer: devcontainer, VSCode: vscode} + if all || (!devcontainer && !vscode) { + targets = ide.All() + } + gen := ide.New(m) + arts, err := gen.Build(targets) + if err != nil { + return err + } + if check { + return reportIdeCheck(cmd, g, arts) + } + results, err := ide.Write(arts) + if err != nil { + return err + } + return reportIdeWrite(cmd, g, results) + }, + } + cmd.Flags().BoolVar(&devcontainer, "devcontainer", false, "emit per-repo .devcontainer/devcontainer.json only") + cmd.Flags().BoolVar(&vscode, "vscode", false, "emit the .code-workspace + per-repo .vscode/ configs only") + cmd.Flags().BoolVar(&all, "all", false, "emit every target (default when no target flag is given)") + cmd.Flags().BoolVar(&check, "check", false, "report drift without writing (CI)") + return cmd +} + +func reportIdeWrite(cmd *cobra.Command, g *GlobalOpts, results []ide.WriteResult) error { + if g.JSON { + return writeJSON(cmd, map[string]any{"ok": true, "artifacts": results}) + } + if g.Quiet { + return nil + } + w := cmd.OutOrStdout() + for _, r := range results { + state := "unchanged" + if r.Changed { + state = "updated" + } + fmt.Fprintf(w, "%s → %s (%s)\n", r.Kind, r.Path, state) + } + return nil +} + +func reportIdeCheck(cmd *cobra.Command, g *GlobalOpts, arts []ide.Artifact) error { + upToDate := ide.UpToDate(arts) + type entry struct { + Path string `json:"path"` + Kind string `json:"kind"` + } + paths := make([]entry, 0, len(arts)) + for _, a := range arts { + paths = append(paths, entry{Path: a.Rel, Kind: a.Kind}) + } + if g.JSON { + if err := writeJSON(cmd, map[string]any{"ok": upToDate, "artifacts": paths}); err != nil { + return err + } + } else if !g.Quiet { + w := cmd.OutOrStdout() + for _, p := range paths { + fmt.Fprintf(w, "%s: %s\n", p.Kind, p.Path) + } + } + if !upToDate { + return fmt.Errorf("IDE artifacts are stale; run `%s ide`", rootName(cmd)) + } + return nil +} diff --git a/internal/cli/root.go b/internal/cli/root.go index db61bea..e6fc527 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -91,6 +91,7 @@ func NewRootCmd(opts Options) *cobra.Command { newConfigCmd(g), newInitCmd(g), newGenerateCmd(g), + newIdeCmd(g), newTemplateCmd(g), newSharedCmd(g), newResourceCmd(g), diff --git a/internal/cli/stubs.go b/internal/cli/stubs.go index fc90311..f55ad7c 100644 --- a/internal/cli/stubs.go +++ b/internal/cli/stubs.go @@ -28,11 +28,11 @@ 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` and -// `dashboard` have GRADUATED to real commands (spec 16, see logs.go/dashboard.go). -// `db` has GRADUATED to a real command group (spec 29, see db.go). +// clear notice). Every verb that once lived here has GRADUATED to a real command: +// `shell` (spec 26), `logs`/`dashboard` (spec 16), `telemetry` (spec 20), `ide` +// (spec 17), and the `db` group (spec 15/29). None remain in this root list; the +// `stub` helper still backs the `db reset`/`db pull` and template-registry +// (spec 19) placeholders. Kept as a no-op seam for the next reserved verb. func addStubCommands(root *cobra.Command, _ *GlobalOpts) { - root.AddCommand( - stub("ide", "Generate devcontainer/.code-workspace/launch configs", "v2 (spec 17)"), - ) + root.AddCommand() } diff --git a/internal/generate/names.go b/internal/generate/names.go index 95df4c1..3d1cc42 100644 --- a/internal/generate/names.go +++ b/internal/generate/names.go @@ -43,6 +43,11 @@ const StateFile = "state.json" // projectStackName is the compose project name for a project stack. func projectStackName(project string) string { return "devstack-" + project } +// ProjectStackName is the exported compose project name for a project stack. IDE +// artifacts (internal/ide, spec 17) reference it so the editor's `compose up` +// lands in the SAME tool-owned project as `devstack up` — never a forked one. +func ProjectStackName(project string) string { return projectStackName(project) } + // sharedAlias is the stable DNS alias a shared service is reached by over the // shared network (never the bare service name — the collision guardrail). func sharedAlias(name string) string { return "shared-" + name } diff --git a/internal/ide/devcontainer.go b/internal/ide/devcontainer.go new file mode 100644 index 0000000..9f07426 --- /dev/null +++ b/internal/ide/devcontainer.go @@ -0,0 +1,69 @@ +package ide + +import ( + "fmt" + "path/filepath" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/generate" +) + +// devcontainer is the typed devcontainer.json model (spec 17 "Devcontainer +// model"). Fields are emitted in declaration order for byte-stable output. It uses +// the dockerComposeFile+service+workspaceFolder ATTACH form — not image/build — so +// the IDE joins the exact container devstack runs (same shared infra, provisioned +// DB, secret env). It never carries a resolved secret value (spec 17 gotcha / +// ARCHITECTURE §7.5): secrets reach the container only via the compose it points at. +type devcontainer struct { + Schema string `json:"$schema"` + Name string `json:"name"` + DockerComposeFile []string `json:"dockerComposeFile"` + Service string `json:"service"` + RunServices []string `json:"runServices"` + WorkspaceFolder string `json:"workspaceFolder"` + ForwardPorts []int `json:"forwardPorts,omitempty"` + // OverrideCommand:false and ShutdownAction:"none" keep the IDE from hijacking + // the entrypoint or tearing down the shared stack (spec 17). + OverrideCommand bool `json:"overrideCommand"` + ShutdownAction string `json:"shutdownAction"` + PostCreateCommand string `json:"postCreateCommand"` +} + +// devcontainerSchema is the well-known Dev Containers metadata schema URL (the +// authoring aid for devcontainer.json itself, distinct from the devstack config +// schema modeline). +const devcontainerSchema = "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.base.schema.json" + +// buildDevcontainer authors /.devcontainer/devcontainer.json for one project. +func (g *Generator) buildDevcontainer(name string, p config.Project, dir string) (Artifact, error) { + // dockerComposeFile is relative to the .devcontainer/ directory and points at + // the generated project compose (which carries `name: devstack-` and the + // external devstack_shared network — the devcontainer inherits both). + composeRel := filepath.ToSlash(filepath.Join("..", generate.GenDir, generate.ComposeFile)) + + dc := devcontainer{ + Schema: devcontainerSchema, + Name: generate.ProjectStackName(name), + DockerComposeFile: []string{composeRel}, + Service: primaryService(name, p), + RunServices: sortedServiceNames(p), + WorkspaceFolder: workspaceFolder, + ForwardPorts: forwardPorts(p), + OverrideCommand: false, + ShutdownAction: "none", + // Opt-in reconcile if the folder was opened cold (spec 17): devstack still + // owns network-ensure + shared services; this only re-registers refs. + PostCreateCommand: fmt.Sprintf("devstack up %s --skip-clone --no-hooks", name), + } + data, err := marshalJSON(dc) + if err != nil { + return Artifact{}, err + } + abs := filepath.Join(dir, ".devcontainer", "devcontainer.json") + return Artifact{Path: abs, Rel: g.rel(abs), Kind: "devcontainer", Data: data}, nil +} + +// workspaceFolder is the in-container mount the IDE opens. devstack's project +// templates bind the repo to /workspace; a template that mounts elsewhere is a +// future refinement (spec 17: derive from the typed mount, not a default). +const workspaceFolder = "/workspace" diff --git a/internal/ide/ide.go b/internal/ide/ide.go new file mode 100644 index 0000000..f12dc96 --- /dev/null +++ b/internal/ide/ide.go @@ -0,0 +1,188 @@ +// Package ide is the editor/IDE generation sink (spec 17). It rides the existing +// deterministic generation pipeline (spec 02) and the workspace service graph +// (spec 01/03) to author, from the same resolved config, the artifacts that point +// editors at devstack's already-generated compose stacks: +// +// - /.devcontainer/devcontainer.json — the dockerComposeFile+service+ +// workspaceFolder attach form, so the IDE lands in the SAME tool-owned compose +// project (devstack-) and shared external network as `devstack up`. +// - /.code-workspace — the VS Code multi-root file listing +// every repo folder (declared order) plus the generated .devstack/ tree. +// - /.vscode/launch.json + settings.json — per-repo editor stubs +// (schema-map + a debugger-attach scaffold). +// +// It is purely a generation sink: no Docker, no ledger, no flock — just typed +// struct → stable JSON marshal → atomic writeIfChanged, exactly like +// internal/generate. Byte-identical config yields byte-identical artifacts +// (spec 17 acceptance #4); a golden + idempotence test asserts it. +package ide + +import ( + "fmt" + "path/filepath" + "sort" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/version" +) + +// Targets selects which editor artifact families to emit. A zero Targets emits +// nothing; the CLI defaults an empty selection to All (both families). +type Targets struct { + Devcontainer bool // per-repo .devcontainer/devcontainer.json + VSCode bool // .code-workspace + per-repo .vscode/{launch,settings}.json +} + +// All is the target set produced by `ide --all` (and the no-flag default). +func All() Targets { return Targets{Devcontainer: true, VSCode: true} } + +// Generator authors IDE artifacts for one loaded workspace. It is created once and +// asked to Build the selected targets; it never touches Docker/the ledger. +type Generator struct { + model *config.Model + schemaVersion string +} + +// Option configures a Generator. +type Option func(*Generator) + +// WithSchemaVersion pins the $schema modeline URL to a specific schema version +// (defaults to the running binary's version). Tests inject a fixed value so the +// golden files do not drift with the build stamp. +func WithSchemaVersion(v string) Option { + return func(g *Generator) { + if v != "" { + g.schemaVersion = v + } + } +} + +// New builds a Generator over the loaded workspace model. +func New(m *config.Model, opts ...Option) *Generator { + g := &Generator{model: m, schemaVersion: version.Version} + for _, o := range opts { + o(g) + } + return g +} + +// Artifact is one file the generator would author. Data is the exact bytes; Rel is +// the workspace-root-relative slash path used for stable JSON manifests + logging. +type Artifact struct { + // Path is the absolute filesystem destination. + Path string `json:"-"` + // Rel is Path relative to the workspace root, slash-separated (stable output). + Rel string `json:"path"` + // Kind is the artifact family: "devcontainer" | "code-workspace" | + // "launch" | "settings". + Kind string `json:"kind"` + // Data is the fully-marshaled file content (byte-deterministic). + Data []byte `json:"-"` +} + +// Build assembles every selected artifact in deterministic order WITHOUT touching +// disk. The caller writes them via Write (or inspects Data for --check/golden). +func (g *Generator) Build(t Targets) ([]Artifact, error) { + var arts []Artifact + + // Per-repo artifacts, in declared project order (stable diffs). + for _, pr := range g.model.Workspace.Projects { + p, ok := g.model.Projects[pr.Name] + if !ok { + continue + } + dir := g.model.ProjectDir(pr.Name) + if dir == "" { + return nil, fmt.Errorf("project %q has no resolved directory", pr.Name) + } + if t.Devcontainer { + a, err := g.buildDevcontainer(pr.Name, p, dir) + if err != nil { + return nil, err + } + arts = append(arts, a) + } + if t.VSCode { + launch, err := g.buildLaunch(pr.Name, p, dir) + if err != nil { + return nil, err + } + settings, err := g.buildSettings(dir) + if err != nil { + return nil, err + } + arts = append(arts, launch, settings) + } + } + + // One workspace-root artifact: the multi-root file. + if t.VSCode { + a, err := g.buildCodeWorkspace() + if err != nil { + return nil, err + } + arts = append(arts, a) + } + return arts, nil +} + +// rel makes an absolute path workspace-root-relative + slash-separated. +func (g *Generator) rel(abs string) string { + r, err := filepath.Rel(g.model.Root, abs) + if err != nil { + return filepath.ToSlash(abs) + } + return filepath.ToSlash(r) +} + +// schemaURL is the published JSON-Schema URL pinned to the binary's schema +// version, used as the editor authoring aid (yaml-language-server / yaml.schemas). +// The Go validator remains the source of truth (spec 17, DECISIONS D16). +func (g *Generator) schemaURL() string { + return fmt.Sprintf( + "https://raw.githubusercontent.com/open-source-cloud/devstack/v%s/schemas/devstack.schema.json", + g.schemaVersion, + ) +} + +// primaryService picks the service the devcontainer attaches to: the service whose +// name matches the project (the app container) when present, else the first service +// alphabetically. Deterministic and rename-safe (name index, not string guess). +func primaryService(name string, p config.Project) string { + if _, ok := p.Services[name]; ok { + return name + } + names := sortedServiceNames(p) + if len(names) == 0 { + return name + } + return names[0] +} + +// sortedServiceNames returns a project's service names sorted. +func sortedServiceNames(p config.Project) []string { + out := make([]string, 0, len(p.Services)) + for s := range p.Services { + out = append(out, s) + } + sort.Strings(out) + return out +} + +// forwardPorts is the sorted, de-duplicated set of declared container ports across +// a project's services — the host ports an editor should forward. +func forwardPorts(p config.Project) []int { + seen := map[int]bool{} + var out []int + for _, s := range sortedServiceNames(p) { + svc := p.Services[s] + for _, port := range svc.Ports { + if port > 0 && !seen[port] { + seen[port] = true + out = append(out, port) + } + } + } + sort.Ints(out) + return out +} diff --git a/internal/ide/ide_test.go b/internal/ide/ide_test.go new file mode 100644 index 0000000..6610d26 --- /dev/null +++ b/internal/ide/ide_test.go @@ -0,0 +1,266 @@ +package ide + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/config" +) + +// fixedSchemaVersion pins the modeline URL so goldens do not drift with the build +// stamp (version.Version is "dev" during `go test`). +const fixedSchemaVersion = "1.2.3" + +// newGen loads the shared two-file config fixture and returns a hermetic Generator +// with a fixed schema version. +func newGen(t *testing.T) *Generator { + t.Helper() + m, err := config.LoadAt(filepath.Join("..", "config", "testdata", "valid")) + if err != nil { + t.Fatalf("load fixture: %v", err) + } + return New(m, WithSchemaVersion(fixedSchemaVersion)) +} + +// TestGolden asserts every IDE artifact for the fixture workspace matches its +// committed golden byte-for-byte. Re-materialize after an intentional change with: +// +// UPDATE_GOLDEN=1 go test ./internal/ide -run TestGolden +func TestGolden(t *testing.T) { + g := newGen(t) + arts, err := g.Build(All()) + if err != nil { + t.Fatal(err) + } + update := os.Getenv("UPDATE_GOLDEN") == "1" + for _, a := range arts { + golden := filepath.Join("testdata", "golden", filepath.FromSlash(a.Rel)) + if update { + if err := os.MkdirAll(filepath.Dir(golden), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(golden, a.Data, 0o644); err != nil { + t.Fatal(err) + } + continue + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("missing golden %s (run UPDATE_GOLDEN=1): %v", golden, err) + } + if string(want) != string(a.Data) { + t.Errorf("%s: artifact does not match golden\n--- got ---\n%s", a.Rel, a.Data) + } + } +} + +// TestDeterministic — two independent builds of the same config produce byte-identical +// artifacts (spec 17 acceptance #4). +func TestDeterministic(t *testing.T) { + a1, err := newGen(t).Build(All()) + if err != nil { + t.Fatal(err) + } + a2, err := newGen(t).Build(All()) + if err != nil { + t.Fatal(err) + } + if len(a1) != len(a2) { + t.Fatalf("artifact count differs: %d vs %d", len(a1), len(a2)) + } + for i := range a1 { + if a1[i].Rel != a2[i].Rel { + t.Fatalf("artifact[%d] path differs: %q vs %q", i, a1[i].Rel, a2[i].Rel) + } + if string(a1[i].Data) != string(a2[i].Data) { + t.Errorf("artifact %s not byte-deterministic", a1[i].Rel) + } + } +} + +// TestIdempotentWrite — writing artifacts to a fresh workspace copy reports every +// file as changed the first time and NOTHING the second time (spec 17 #4). +func TestIdempotentWrite(t *testing.T) { + root := copyFixture(t) + m, err := config.LoadAt(root) + if err != nil { + t.Fatalf("load copied fixture: %v", err) + } + g := New(m, WithSchemaVersion(fixedSchemaVersion)) + arts, err := g.Build(All()) + if err != nil { + t.Fatal(err) + } + + first, err := Write(arts) + if err != nil { + t.Fatal(err) + } + for _, r := range first { + if !r.Changed { + t.Errorf("first write of %s reported unchanged", r.Path) + } + } + + second, err := Write(arts) + if err != nil { + t.Fatal(err) + } + for _, r := range second { + if r.Changed { + t.Errorf("second write of %s reported a spurious change", r.Path) + } + } + if !UpToDate(arts) { + t.Error("UpToDate false after a write with no config change") + } +} + +// TestTargetSelection — the --devcontainer / --vscode / --all flag paths emit only +// the requested artifact families. +func TestTargetSelection(t *testing.T) { + g := newGen(t) + cases := []struct { + name string + targets Targets + want map[string]int // kind -> expected count + }{ + {"devcontainer-only", Targets{Devcontainer: true}, map[string]int{"devcontainer": 2}}, + {"vscode-only", Targets{VSCode: true}, map[string]int{"launch": 2, "settings": 2, "code-workspace": 1}}, + {"all", All(), map[string]int{"devcontainer": 2, "launch": 2, "settings": 2, "code-workspace": 1}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + arts, err := g.Build(tc.targets) + if err != nil { + t.Fatal(err) + } + got := map[string]int{} + for _, a := range arts { + got[a.Kind]++ + } + for kind, n := range tc.want { + if got[kind] != n { + t.Errorf("kind %q: got %d, want %d", kind, got[kind], n) + } + } + // No unexpected kinds. + for kind := range got { + if _, ok := tc.want[kind]; !ok { + t.Errorf("unexpected artifact kind %q emitted", kind) + } + } + }) + } +} + +// TestDevcontainerWiring — the devcontainer.json points at the SAME tool-owned +// project/compose/network as `devstack up`, and forwards the declared host port. +func TestDevcontainerWiring(t *testing.T) { + g := newGen(t) + arts, err := g.Build(Targets{Devcontainer: true}) + if err != nil { + t.Fatal(err) + } + var api devcontainer + found := false + for _, a := range arts { + if a.Rel == "services/api/.devcontainer/devcontainer.json" { + if err := json.Unmarshal(a.Data, &api); err != nil { + t.Fatal(err) + } + found = true + } + } + if !found { + t.Fatal("api devcontainer not emitted") + } + if api.Name != "devstack-api" { + t.Errorf("devcontainer name = %q, want devstack-api (tool-owned project)", api.Name) + } + if api.Service != "api" { + t.Errorf("service = %q, want api", api.Service) + } + if len(api.DockerComposeFile) != 1 || api.DockerComposeFile[0] != "../.devstack/docker-compose.yaml" { + t.Errorf("dockerComposeFile = %v, want the generated project compose", api.DockerComposeFile) + } + if api.ShutdownAction != "none" || api.OverrideCommand { + t.Errorf("must not hijack entrypoint / tear down shared stack: shutdownAction=%q overrideCommand=%v", api.ShutdownAction, api.OverrideCommand) + } + if len(api.ForwardPorts) != 1 || api.ForwardPorts[0] != 8080 { + t.Errorf("forwardPorts = %v, want [8080] from the declared http port", api.ForwardPorts) + } +} + +// TestServiceRenameReflows — the artifacts derive references from the name index, +// so renaming a service re-flows into the devcontainer's `service` field. +func TestServiceRenameReflows(t *testing.T) { + m, err := config.LoadAt(filepath.Join("..", "config", "testdata", "valid")) + if err != nil { + t.Fatal(err) + } + // Rename web's sole service; primaryService falls back to the sorted first name. + web := m.Projects["web"] + renamed := map[string]config.Service{} + for _, svc := range web.Services { + renamed["frontend"] = svc + } + web.Services = renamed + m.Projects["web"] = web + + got := primaryService("web", m.Projects["web"]) + if got != "frontend" { + t.Errorf("primaryService after rename = %q, want frontend", got) + } +} + +// TestNoSecretLeak — no resolved secret value (and no containerEnv/remoteEnv sink) +// ever lands in an IDE artifact (spec 17 gotcha / ARCHITECTURE §7.5). +func TestNoSecretLeak(t *testing.T) { + g := newGen(t) + arts, err := g.Build(All()) + if err != nil { + t.Fatal(err) + } + for _, a := range arts { + s := string(a.Data) + for _, forbidden := range []string{"secret://", "containerEnv", "remoteEnv"} { + if strings.Contains(s, forbidden) { + t.Errorf("%s must not contain %q (secret-leak / value-copy sink)", a.Rel, forbidden) + } + } + } +} + +// copyFixture copies the valid config fixture into a fresh temp dir so writes do not +// touch the committed testdata tree. +func copyFixture(t *testing.T) string { + t.Helper() + src := filepath.Join("..", "config", "testdata", "valid") + dst := t.TempDir() + err := filepath.WalkDir(src, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, p) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + data, err := os.ReadFile(p) + if err != nil { + return err + } + return os.WriteFile(target, data, 0o644) + }) + if err != nil { + t.Fatalf("copy fixture: %v", err) + } + return dst +} diff --git a/internal/ide/testdata/golden/acme.code-workspace b/internal/ide/testdata/golden/acme.code-workspace new file mode 100644 index 0000000..890d5e7 --- /dev/null +++ b/internal/ide/testdata/golden/acme.code-workspace @@ -0,0 +1,29 @@ +{ + "folders": [ + { + "name": "api", + "path": "services/api" + }, + { + "name": "web", + "path": "services/web" + }, + { + "name": "devstack (generated)", + "path": ".devstack" + } + ], + "settings": { + "yaml.schemas": { + "https://raw.githubusercontent.com/open-source-cloud/devstack/v1.2.3/schemas/devstack.schema.json": [ + "workspace.yaml", + "**/devstack.yaml" + ] + } + }, + "extensions": { + "recommendations": [ + "ms-vscode-remote.remote-containers" + ] + } +} diff --git a/internal/ide/testdata/golden/services/api/.devcontainer/devcontainer.json b/internal/ide/testdata/golden/services/api/.devcontainer/devcontainer.json new file mode 100644 index 0000000..8836fa8 --- /dev/null +++ b/internal/ide/testdata/golden/services/api/.devcontainer/devcontainer.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.base.schema.json", + "name": "devstack-api", + "dockerComposeFile": [ + "../.devstack/docker-compose.yaml" + ], + "service": "api", + "runServices": [ + "api" + ], + "workspaceFolder": "/workspace", + "forwardPorts": [ + 8080 + ], + "overrideCommand": false, + "shutdownAction": "none", + "postCreateCommand": "devstack up api --skip-clone --no-hooks" +} diff --git a/internal/ide/testdata/golden/services/api/.vscode/launch.json b/internal/ide/testdata/golden/services/api/.vscode/launch.json new file mode 100644 index 0000000..e913111 --- /dev/null +++ b/internal/ide/testdata/golden/services/api/.vscode/launch.json @@ -0,0 +1,4 @@ +{ + "version": "0.2.0", + "configurations": [] +} diff --git a/internal/ide/testdata/golden/services/api/.vscode/settings.json b/internal/ide/testdata/golden/services/api/.vscode/settings.json new file mode 100644 index 0000000..1228ebf --- /dev/null +++ b/internal/ide/testdata/golden/services/api/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "yaml.schemas": { + "https://raw.githubusercontent.com/open-source-cloud/devstack/v1.2.3/schemas/devstack.schema.json": [ + "devstack.yaml" + ] + } +} diff --git a/internal/ide/testdata/golden/services/web/.devcontainer/devcontainer.json b/internal/ide/testdata/golden/services/web/.devcontainer/devcontainer.json new file mode 100644 index 0000000..85ac651 --- /dev/null +++ b/internal/ide/testdata/golden/services/web/.devcontainer/devcontainer.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.base.schema.json", + "name": "devstack-web", + "dockerComposeFile": [ + "../.devstack/docker-compose.yaml" + ], + "service": "web", + "runServices": [ + "web" + ], + "workspaceFolder": "/workspace", + "overrideCommand": false, + "shutdownAction": "none", + "postCreateCommand": "devstack up web --skip-clone --no-hooks" +} diff --git a/internal/ide/testdata/golden/services/web/.vscode/launch.json b/internal/ide/testdata/golden/services/web/.vscode/launch.json new file mode 100644 index 0000000..e913111 --- /dev/null +++ b/internal/ide/testdata/golden/services/web/.vscode/launch.json @@ -0,0 +1,4 @@ +{ + "version": "0.2.0", + "configurations": [] +} diff --git a/internal/ide/testdata/golden/services/web/.vscode/settings.json b/internal/ide/testdata/golden/services/web/.vscode/settings.json new file mode 100644 index 0000000..1228ebf --- /dev/null +++ b/internal/ide/testdata/golden/services/web/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "yaml.schemas": { + "https://raw.githubusercontent.com/open-source-cloud/devstack/v1.2.3/schemas/devstack.schema.json": [ + "devstack.yaml" + ] + } +} diff --git a/internal/ide/workspace.go b/internal/ide/workspace.go new file mode 100644 index 0000000..85dd25e --- /dev/null +++ b/internal/ide/workspace.go @@ -0,0 +1,105 @@ +package ide + +import ( + "path/filepath" + + "github.com/open-source-cloud/devstack/internal/config" +) + +// codeWorkspace is the typed VS Code multi-root .code-workspace model (spec 17). +// It lists each repo folder in the workspace's DECLARED order (stable diffs) plus a +// virtual entry for the generated .devstack/ tree, and carries workspace-level +// yaml.schemas mappings + the Dev Containers extension recommendation. +type codeWorkspace struct { + Folders []cwFolder `json:"folders"` + Settings cwSettings `json:"settings"` + Extensions cwExtensions `json:"extensions"` +} + +type cwFolder struct { + Name string `json:"name"` + Path string `json:"path"` +} + +type cwSettings struct { + // YAMLSchemas maps the published schema URL to the config globs it validates. + YAMLSchemas map[string][]string `json:"yaml.schemas"` +} + +type cwExtensions struct { + Recommendations []string `json:"recommendations"` +} + +// devContainersExtension is the VS Code Dev Containers extension id. +const devContainersExtension = "ms-vscode-remote.remote-containers" + +// buildCodeWorkspace authors /.code-workspace. +func (g *Generator) buildCodeWorkspace() (Artifact, error) { + folders := make([]cwFolder, 0, len(g.model.Workspace.Projects)+1) + for _, pr := range g.model.Workspace.Projects { + folders = append(folders, cwFolder{Name: pr.Name, Path: filepath.ToSlash(pr.Path)}) + } + // The generated artifacts / shared logs tree, always last for a stable order. + folders = append(folders, cwFolder{Name: "devstack (generated)", Path: ".devstack"}) + + cw := codeWorkspace{ + Folders: folders, + Settings: cwSettings{ + YAMLSchemas: map[string][]string{ + g.schemaURL(): {"workspace.yaml", "**/devstack.yaml"}, + }, + }, + Extensions: cwExtensions{Recommendations: []string{devContainersExtension}}, + } + data, err := marshalJSON(cw) + if err != nil { + return Artifact{}, err + } + abs := filepath.Join(g.model.Root, g.model.Workspace.Name+".code-workspace") + return Artifact{Path: abs, Rel: g.rel(abs), Kind: "code-workspace", Data: data}, nil +} + +// launchConfig is the VS Code launch.json model (spec 17 debugger attach). This +// scoped build emits a valid, empty-configurations stub: debug-port allocation runs +// through the flock-guarded workspace allocator, which is out of this generation +// sink's scope (no ledger/flock here) — so per-service attach configs are a +// follow-up, and we never invent a wrong port. +type launchConfig struct { + Version string `json:"version"` + Configurations []map[string]any `json:"configurations"` +} + +// buildLaunch authors /.vscode/launch.json. +func (g *Generator) buildLaunch(name string, p config.Project, dir string) (Artifact, error) { + _ = name + _ = p + lc := launchConfig{Version: "0.2.0", Configurations: []map[string]any{}} + data, err := marshalJSON(lc) + if err != nil { + return Artifact{}, err + } + abs := filepath.Join(dir, ".vscode", "launch.json") + return Artifact{Path: abs, Rel: g.rel(abs), Kind: "launch", Data: data}, nil +} + +// vscodeSettings is the per-repo .vscode/settings.json model (spec 17). It wires +// yaml-language-server to the published schema for this repo's devstack.yaml — an +// authoring aid only; the Go validator stays the source of truth (DECISIONS D16). +type vscodeSettings struct { + YAMLSchemas map[string][]string `json:"yaml.schemas"` +} + +// buildSettings authors /.vscode/settings.json. +func (g *Generator) buildSettings(dir string) (Artifact, error) { + vs := vscodeSettings{ + YAMLSchemas: map[string][]string{ + g.schemaURL(): {"devstack.yaml"}, + }, + } + data, err := marshalJSON(vs) + if err != nil { + return Artifact{}, err + } + abs := filepath.Join(dir, ".vscode", "settings.json") + return Artifact{Path: abs, Rel: g.rel(abs), Kind: "settings", Data: data}, nil +} diff --git a/internal/ide/writeio.go b/internal/ide/writeio.go new file mode 100644 index 0000000..235ef21 --- /dev/null +++ b/internal/ide/writeio.go @@ -0,0 +1,113 @@ +package ide + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// marshalJSON renders v as stable, 2-space-indented JSON with a trailing newline. +// HTML escaping is disabled so schema URLs survive verbatim; struct field order is +// declaration order and map keys are sorted by encoding/json — both deterministic +// (spec 17 acceptance #4). +func marshalJSON(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +const tempPrefix = ".devstack-ide-tmp-" + +// writeIfChanged writes data to path only when the on-disk content differs, +// returning whether a write occurred. The write is atomic: a temp file in the same +// directory is fsync'd, chmod'd, then renamed over the target (spec 17: same +// writeIfChanged contract as internal/generate; a crash leaves old or new, never a +// half file). +func writeIfChanged(path string, data []byte) (bool, error) { + if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, data) { + return false, nil + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return false, fmt.Errorf("create %s: %w", dir, err) + } + sweepTemp(dir) + tmp, err := os.CreateTemp(dir, tempPrefix+"*") + if err != nil { + return false, fmt.Errorf("create temp in %s: %w", dir, err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return false, fmt.Errorf("write temp: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return false, fmt.Errorf("sync temp: %w", err) + } + if err := tmp.Close(); err != nil { + return false, fmt.Errorf("close temp: %w", err) + } + if err := os.Chmod(tmpName, 0o644); err != nil { + return false, fmt.Errorf("chmod temp: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return false, fmt.Errorf("rename %s -> %s: %w", tmpName, path, err) + } + return true, nil +} + +// sweepTemp removes stale temp files a previously-killed run left in dir. +func sweepTemp(dir string) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + if !e.IsDir() && strings.HasPrefix(e.Name(), tempPrefix) { + _ = os.Remove(filepath.Join(dir, e.Name())) + } + } +} + +// WriteResult reports what Write changed for one artifact. +type WriteResult struct { + Path string `json:"path"` + Kind string `json:"kind"` + Changed bool `json:"changed"` +} + +// Write materializes every artifact via the atomic writeIfChanged and reports what +// changed. Re-running with byte-identical config writes nothing (spec 17 #4). +func Write(arts []Artifact) ([]WriteResult, error) { + out := make([]WriteResult, 0, len(arts)) + for _, a := range arts { + changed, err := writeIfChanged(a.Path, a.Data) + if err != nil { + return out, err + } + out = append(out, WriteResult{Path: a.Rel, Kind: a.Kind, Changed: changed}) + } + return out, nil +} + +// UpToDate reports whether every artifact already matches disk (basis for --check). +func UpToDate(arts []Artifact) bool { + for _, a := range arts { + existing, err := os.ReadFile(a.Path) + if err != nil || !bytes.Equal(existing, a.Data) { + return false + } + } + return true +}