diff --git a/cmd/deploy.go b/cmd/deploy.go index 8030d7e..f9a79eb 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -124,7 +124,7 @@ Examples: fmt.Fprintf(os.Stderr, format+"\n", args...) } if sreMode { - logf("[deploy] --sre requested; Docker is the default SRE runtime. After deployment, run: clanker sre install --target docker --apply") + logf("[deploy] --sre requested; planning a long-running Clanker SRE agent with heartbeat verification") } // 3. Resolve AWS profile/region early so intelligence pipeline can scan infra @@ -323,7 +323,7 @@ Examples: if planProvider == "" { planProvider = "aws" } - deployObjectiveContext := withOneClickDeployContext(baseQuestion, planProvider, intel.Architecture.Method, enforceImageDeploy) + deployObjectiveContext := withOneClickDeployContext(baseQuestion, planProvider, intel.Architecture.Method, enforceImageDeploy, sreMode) planningContext := compactPlanningContext(deployObjectiveContext, planProvider) projectSummaryForLLM := strings.TrimSpace(rp.Summary) if intel.DeepAnalysis != nil && strings.TrimSpace(intel.DeepAnalysis.AppDescription) != "" { @@ -1773,8 +1773,8 @@ func compactOneLine(raw string, limit int) string { var shellStylePlaceholderRe = regexp.MustCompile(`\$\{([A-Z0-9_]+)\}`) -func withOneClickDeployContext(base, provider, method string, enforceImageDeploy bool) string { - context := buildOneClickDeployObjective(provider, method, enforceImageDeploy) +func withOneClickDeployContext(base, provider, method string, enforceImageDeploy bool, sreMode bool) string { + context := buildOneClickDeployObjective(provider, method, enforceImageDeploy, sreMode) base = strings.TrimSpace(base) if base == "" { return context @@ -1782,11 +1782,18 @@ func withOneClickDeployContext(base, provider, method string, enforceImageDeploy return context + "\n\n" + base } -func buildOneClickDeployObjective(provider, method string, enforceImageDeploy bool) string { +func buildOneClickDeployObjective(provider, method string, enforceImageDeploy bool, sreMode bool) string { prov := strings.ToLower(strings.TrimSpace(provider)) if prov == "" { prov = "aws" } + if sreMode { + deployID := strings.TrimSpace(os.Getenv("CLANKER_SRE_DEPLOY_ID")) + if deployID == "" { + deployID = "$CLANKER_SRE_DEPLOY_ID" + } + return fmt.Sprintf("[one-click SRE deploy objective]\nGenerate command plan steps that deploy only the long-running Clanker SRE agent, not the analyzed app. Use provider=%s and the smallest practical always-on runtime for that provider. Prefer ghcr.io/bgdnvk/clanker:latest and run: clanker sre run --sre --target cloud-vm --provider %s --deploy-id %s. The runtime MUST set CLANKER_CEREBRO_URL, CLANKER_CEREBRO_INGEST_TOKEN, CLANKER_SRE_DEPLOY_ID, and CLANKER_SRE_PROVIDER. Tag or label every created resource with clanker-sre=true and clanker-sre-deploy-id=%s. Keep commands idempotent, preserve created resource IDs, and include a final non-secret health/verification command that checks the service/container is still running. Never print token values.", prov, prov, deployID, deployID) + } m := strings.ToLower(strings.TrimSpace(method)) if m == "" { m = "ec2" diff --git a/cmd/sre.go b/cmd/sre.go index ab5c0a0..f82e9f8 100644 --- a/cmd/sre.go +++ b/cmd/sre.go @@ -96,6 +96,7 @@ var sreRunCmd = &cobra.Command{ cerebroURL, _ := cmd.Flags().GetString("cerebro-url") ingestToken, _ := cmd.Flags().GetString("ingest-token") provider, _ := cmd.Flags().GetString("provider") + deployID, _ := cmd.Flags().GetString("deploy-id") interval, err := durationFlag(cmd, "interval", sre.DefaultInterval) if err != nil { return err @@ -114,6 +115,7 @@ var sreRunCmd = &cobra.Command{ Once: once, Writer: os.Stdout, Provider: provider, + DeployID: deployID, }) if errors.Is(err, context.Canceled) { return nil @@ -184,6 +186,8 @@ func addSREPlanFlags(cmd *cobra.Command) { cmd.Flags().String("name", sre.DefaultAgentName, "SRE bot name/container/service name") cmd.Flags().String("cerebro-url", "", "Cerebro API base URL, e.g. http://127.0.0.1:8080/api") cmd.Flags().String("ingest-token-env", sre.DefaultIngestTokenEnv, "Environment variable name that holds the Cerebro ingest token") + cmd.Flags().String("provider", "", "Cloud provider name for heartbeat identification (aws, gcp, azure, etc.)") + cmd.Flags().String("deploy-id", "", "Stable SRE deployment ID for heartbeat verification") cmd.Flags().String("interval", sre.DefaultInterval.String(), "Heartbeat/discovery interval") cmd.Flags().String("format", "text", "Output format: text or json") } @@ -195,6 +199,7 @@ func addSRERunFlags(cmd *cobra.Command) { cmd.Flags().String("cerebro-url", "", "Cerebro API base URL, e.g. http://127.0.0.1:8080/api") cmd.Flags().String("ingest-token", "", "Cerebro ingest token (or set CLANKER_CEREBRO_INGEST_TOKEN)") cmd.Flags().String("provider", "", "Cloud provider name for heartbeat identification (aws, gcp, azure, etc.)") + cmd.Flags().String("deploy-id", "", "Stable SRE deployment ID for heartbeat verification") cmd.Flags().String("interval", sre.DefaultInterval.String(), "Heartbeat/discovery interval") cmd.Flags().Bool("once", false, "Send one heartbeat and exit") } @@ -209,8 +214,10 @@ func buildSREPlan(cmd *cobra.Command) (sre.InstallPlan, error) { name, _ := cmd.Flags().GetString("name") cerebroURL, _ := cmd.Flags().GetString("cerebro-url") ingestTokenEnv, _ := cmd.Flags().GetString("ingest-token-env") + provider, _ := cmd.Flags().GetString("provider") + deployID, _ := cmd.Flags().GetString("deploy-id") discovery := sre.Discover(cmd.Context()) - return sre.BuildPlan(discovery, sre.PlanOptions{Target: target, Image: image, Name: name, BackendURL: cerebroURL, IngestTokenEnv: ingestTokenEnv, Interval: interval}), nil + return sre.BuildPlan(discovery, sre.PlanOptions{Target: target, Image: image, Name: name, BackendURL: cerebroURL, IngestTokenEnv: ingestTokenEnv, Provider: provider, DeployID: deployID, Interval: interval}), nil } func durationFlag(cmd *cobra.Command, name string, fallback time.Duration) (time.Duration, error) { diff --git a/internal/sre/sre.go b/internal/sre/sre.go index 41691fb..f72de0f 100644 --- a/internal/sre/sre.go +++ b/internal/sre/sre.go @@ -69,6 +69,8 @@ type PlanOptions struct { Name string BackendURL string IngestTokenEnv string + Provider string + DeployID string Interval time.Duration } @@ -99,6 +101,7 @@ type RunOptions struct { Once bool Writer io.Writer Provider string + DeployID string } func Discover(ctx context.Context) Discovery { @@ -174,23 +177,25 @@ func BuildPlan(discovery Discovery, opts PlanOptions) InstallPlan { if backendURL == "" { backendURL = firstNonEmpty(viper.GetString("sre.cerebro_url"), os.Getenv("CLANKER_CEREBRO_URL"), os.Getenv("CLANKER_CLOUD_API_BASE_URL"), viper.GetString("backend.url")) } + provider := strings.ToLower(strings.TrimSpace(firstNonEmpty(opts.Provider, os.Getenv("CLANKER_SRE_PROVIDER"), viper.GetString("sre.provider")))) + deployID := strings.TrimSpace(firstNonEmpty(opts.DeployID, os.Getenv("CLANKER_SRE_DEPLOY_ID"), viper.GetString("sre.deploy_id"))) plan := InstallPlan{Target: target, Discovery: discovery} switch target { case "docker": - plan = dockerPlan(discovery, image, name, backendURL, tokenEnv, interval) + plan = dockerPlan(discovery, image, name, backendURL, tokenEnv, provider, deployID, interval) case "local": - plan = localPlan(discovery, name, backendURL, tokenEnv, interval) + plan = localPlan(discovery, name, backendURL, tokenEnv, provider, deployID, interval) case "launchd": - plan = launchdPlan(discovery, name, backendURL, tokenEnv, interval) + plan = launchdPlan(discovery, name, backendURL, tokenEnv, provider, deployID, interval) case "systemd": - plan = systemdPlan(discovery, name, backendURL, tokenEnv, interval) + plan = systemdPlan(discovery, name, backendURL, tokenEnv, provider, deployID, interval) case "k8s", "kubernetes": - plan = k8sPlan(discovery, image, name, backendURL, tokenEnv, interval) + plan = k8sPlan(discovery, image, name, backendURL, tokenEnv, provider, deployID, interval) case "cloud-vm": - plan = cloudVMPlan(discovery, image, name, backendURL, tokenEnv, interval) + plan = cloudVMPlan(discovery, image, name, backendURL, tokenEnv, provider, deployID, interval) default: - plan = dockerPlan(discovery, image, name, backendURL, tokenEnv, interval) + plan = dockerPlan(discovery, image, name, backendURL, tokenEnv, provider, deployID, interval) plan.Warnings = append(plan.Warnings, "unknown target "+target+"; using docker plan") } plan.Target = target @@ -245,8 +250,9 @@ func Run(ctx context.Context, opts RunOptions) error { interval = DefaultInterval } agentID := strings.TrimSpace(opts.AgentID) + deployID := strings.TrimSpace(firstNonEmpty(opts.DeployID, viper.GetString("sre.deploy_id"), os.Getenv("CLANKER_SRE_DEPLOY_ID"))) if agentID == "" { - agentID = defaultAgentID() + agentID = firstNonEmpty(deployID, defaultAgentID()) } agentName := strings.TrimSpace(opts.AgentName) if agentName == "" { @@ -283,7 +289,8 @@ func PostHeartbeat(ctx context.Context, discovery Discovery, observations map[st token := strings.TrimSpace(firstNonEmpty(opts.IngestToken, viper.GetString("sre.ingest_token"), os.Getenv(DefaultIngestTokenEnv))) // Detect provider from RunOptions or discovery - provider := strings.ToLower(strings.TrimSpace(opts.Provider)) + deployID := strings.TrimSpace(firstNonEmpty(opts.DeployID, viper.GetString("sre.deploy_id"), os.Getenv("CLANKER_SRE_DEPLOY_ID"))) + provider := strings.ToLower(strings.TrimSpace(firstNonEmpty(opts.Provider, viper.GetString("sre.provider"), os.Getenv("CLANKER_SRE_PROVIDER")))) if provider == "" { // Infer primary provider from discovery if len(discovery.Providers) > 0 { @@ -302,6 +309,7 @@ func PostHeartbeat(ctx context.Context, discovery Discovery, observations map[st "message": fmt.Sprintf("%s heartbeat from %s (%d findings)", agentName, discovery.Hostname, len(BuildFindings(discovery, observations))), "agentId": agentID, "agentName": agentName, + "deployId": deployID, "provider": provider, "target": normalizeTarget(opts.Target), "recommendedTarget": discovery.RecommendedTarget, @@ -888,7 +896,54 @@ func detectTerraform(tools map[string]ToolStatus) CapabilityStatus { return CapabilityStatus{Available: false, Detail: "terraform not detected"} } -func dockerPlan(discovery Discovery, image string, name string, backendURL string, tokenEnv string, interval time.Duration) InstallPlan { +func sreRunArgs(target string, interval time.Duration, provider string, deployID string) []string { + args := []string{"sre", "run", "--sre", "--target", target, "--interval", interval.String()} + if strings.TrimSpace(provider) != "" { + args = append(args, "--provider", strings.TrimSpace(provider)) + } + if strings.TrimSpace(deployID) != "" { + args = append(args, "--deploy-id", strings.TrimSpace(deployID)) + } + return args +} + +func sreRunShell(target string, interval time.Duration, provider string, deployID string) string { + args := sreRunArgs(target, interval, provider, deployID) + quoted := make([]string, 0, len(args)) + for _, arg := range args { + quoted = append(quoted, fmt.Sprintf("%q", arg)) + } + return strings.Join(quoted, " ") +} + +func sreRunJSONArgs(target string, interval time.Duration, provider string, deployID string) string { + data, _ := json.Marshal(sreRunArgs(target, interval, provider, deployID)) + return string(data) +} + +func sreDockerEnvArgs(backendURL string, tokenEnv string, provider string, deployID string) string { + parts := []string{fmt.Sprintf("-e CLANKER_CEREBRO_URL=%q", backendURL), fmt.Sprintf("-e %s=\"$%s\"", tokenEnv, tokenEnv)} + if strings.TrimSpace(provider) != "" { + parts = append(parts, fmt.Sprintf("-e CLANKER_SRE_PROVIDER=%q", strings.TrimSpace(provider))) + } + if strings.TrimSpace(deployID) != "" { + parts = append(parts, fmt.Sprintf("-e CLANKER_SRE_DEPLOY_ID=%q", strings.TrimSpace(deployID))) + } + return strings.Join(parts, " ") +} + +func sreShellEnvPrefix(backendURL string, tokenEnv string, provider string, deployID string) string { + parts := []string{fmt.Sprintf("CLANKER_CEREBRO_URL=%q", backendURL), fmt.Sprintf("%s=\"$%s\"", tokenEnv, tokenEnv)} + if strings.TrimSpace(provider) != "" { + parts = append(parts, fmt.Sprintf("CLANKER_SRE_PROVIDER=%q", strings.TrimSpace(provider))) + } + if strings.TrimSpace(deployID) != "" { + parts = append(parts, fmt.Sprintf("CLANKER_SRE_DEPLOY_ID=%q", strings.TrimSpace(deployID))) + } + return strings.Join(parts, " ") +} + +func dockerPlan(discovery Discovery, image string, name string, backendURL string, tokenEnv string, provider string, deployID string, interval time.Duration) InstallPlan { available := discovery.Docker.Available warnings := []string{} if !available { @@ -900,28 +955,47 @@ func dockerPlan(discovery Discovery, image string, name string, backendURL strin commands := []string{ fmt.Sprintf("docker pull %s", image), fmt.Sprintf("docker rm -f %s 2>/dev/null || true", name), - fmt.Sprintf("docker run -d --name %s --restart unless-stopped -e CLANKER_CEREBRO_URL=%q -e %s=\"$%s\" -v $HOME/.clanker:/root/.clanker:ro -v $HOME/.aws:/root/.aws:ro -v $HOME/.kube:/root/.kube:ro %s sre run --sre --target docker --interval %s", name, backendURL, tokenEnv, tokenEnv, image, interval.String()), + fmt.Sprintf("docker run -d --name %s --restart unless-stopped %s -v $HOME/.clanker:/root/.clanker:ro -v $HOME/.aws:/root/.aws:ro -v $HOME/.kube:/root/.kube:ro %s %s", name, sreDockerEnvArgs(backendURL, tokenEnv, provider, deployID), image, sreRunShell("docker", interval, provider, deployID)), } - compose := fmt.Sprintf("services:\n %s:\n image: %s\n restart: unless-stopped\n environment:\n CLANKER_CEREBRO_URL: %q\n %s: ${%s}\n volumes:\n - ${HOME}/.clanker:/root/.clanker:ro\n - ${HOME}/.aws:/root/.aws:ro\n - ${HOME}/.kube:/root/.kube:ro\n command: [\"sre\", \"run\", \"--sre\", \"--target\", \"docker\", \"--interval\", %q]\n", name, image, backendURL, tokenEnv, tokenEnv, interval.String()) + composeEnv := fmt.Sprintf(" CLANKER_CEREBRO_URL: %q\n %s: ${%s}\n", backendURL, tokenEnv, tokenEnv) + if strings.TrimSpace(provider) != "" { + composeEnv += fmt.Sprintf(" CLANKER_SRE_PROVIDER: %q\n", strings.TrimSpace(provider)) + } + if strings.TrimSpace(deployID) != "" { + composeEnv += fmt.Sprintf(" CLANKER_SRE_DEPLOY_ID: %q\n", strings.TrimSpace(deployID)) + } + compose := fmt.Sprintf("services:\n %s:\n image: %s\n restart: unless-stopped\n environment:\n%s volumes:\n - ${HOME}/.clanker:/root/.clanker:ro\n - ${HOME}/.aws:/root/.aws:ro\n - ${HOME}/.kube:/root/.kube:ro\n command: %s\n", name, image, composeEnv, sreRunJSONArgs("docker", interval, provider, deployID)) return InstallPlan{Target: "docker", Summary: "Run Clanker SRE as a small Docker container", Available: available, Warnings: warnings, Commands: commands, Files: []InstallFile{{Path: "docker-compose.sre.yml", Mode: "0644", Content: compose}}, NextSteps: []string{"export " + tokenEnv + "=...", "run the docker command or docker compose -f docker-compose.sre.yml up -d"}} } -func localPlan(discovery Discovery, name string, backendURL string, tokenEnv string, interval time.Duration) InstallPlan { +func localPlan(discovery Discovery, name string, backendURL string, tokenEnv string, provider string, deployID string, interval time.Duration) InstallPlan { warnings := []string{} if backendURL == "" { warnings = append(warnings, "no Cerebro URL configured; local run will auto-detect a desktop backend if one is running") } - command := fmt.Sprintf("CLANKER_CEREBRO_URL=%q %s=\"$%s\" clanker sre run --sre --target local --interval %s", backendURL, tokenEnv, tokenEnv, interval.String()) + command := fmt.Sprintf("%s clanker %s", sreShellEnvPrefix(backendURL, tokenEnv, provider, deployID), sreRunShell("local", interval, provider, deployID)) script := "#!/usr/bin/env sh\nset -eu\nexec " + command + "\n" return InstallPlan{Target: "local", Summary: "Run Clanker SRE in the foreground on this machine", Available: true, Warnings: warnings, Commands: []string{command}, Files: []InstallFile{{Path: name + ".sh", Mode: "0755", Content: script}}, NextSteps: []string{"run the generated script or foreground command"}, Discovery: discovery} } -func launchdPlan(discovery Discovery, name string, backendURL string, tokenEnv string, interval time.Duration) InstallPlan { +func launchdPlan(discovery Discovery, name string, backendURL string, tokenEnv string, provider string, deployID string, interval time.Duration) InstallPlan { available := runtime.GOOS == "darwin" warnings := []string{} if !available { warnings = append(warnings, "launchd target is only available on macOS") } + args := sreRunArgs("launchd", interval, provider, deployID) + argXML := " clanker" + for _, arg := range args { + argXML += fmt.Sprintf("%s", arg) + } + envXML := fmt.Sprintf("CLANKER_CEREBRO_URL%s%s$%s", backendURL, tokenEnv, tokenEnv) + if strings.TrimSpace(provider) != "" { + envXML += fmt.Sprintf("CLANKER_SRE_PROVIDER%s", strings.TrimSpace(provider)) + } + if strings.TrimSpace(deployID) != "" { + envXML += fmt.Sprintf("CLANKER_SRE_DEPLOY_ID%s", strings.TrimSpace(deployID)) + } plist := fmt.Sprintf(` @@ -929,24 +1003,31 @@ func launchdPlan(discovery Discovery, name string, backendURL string, tokenEnv s Labelai.clanker.%s ProgramArguments - clankersrerun--sre--targetlaunchd--interval%s +%s EnvironmentVariables - CLANKER_CEREBRO_URL%s%s$%s + %s RunAtLoad KeepAlive -`, name, interval.String(), backendURL, tokenEnv, tokenEnv) +`, name, argXML, envXML) return InstallPlan{Target: "launchd", Summary: "Install Clanker SRE as a macOS launchd service", Available: available, Warnings: warnings, Commands: []string{"launchctl load ~/Library/LaunchAgents/ai.clanker." + name + ".plist"}, Files: []InstallFile{{Path: "ai.clanker." + name + ".plist", Mode: "0644", Content: plist}}, NextSteps: []string{"copy plist into ~/Library/LaunchAgents", "load it with launchctl"}, Discovery: discovery} } -func systemdPlan(discovery Discovery, name string, backendURL string, tokenEnv string, interval time.Duration) InstallPlan { +func systemdPlan(discovery Discovery, name string, backendURL string, tokenEnv string, provider string, deployID string, interval time.Duration) InstallPlan { available := runtime.GOOS == "linux" warnings := []string{} if !available { warnings = append(warnings, "systemd target is only available on Linux") } + envLines := fmt.Sprintf("Environment=CLANKER_CEREBRO_URL=%s\nEnvironment=%s=${%s}\n", backendURL, tokenEnv, tokenEnv) + if strings.TrimSpace(provider) != "" { + envLines += fmt.Sprintf("Environment=CLANKER_SRE_PROVIDER=%s\n", strings.TrimSpace(provider)) + } + if strings.TrimSpace(deployID) != "" { + envLines += fmt.Sprintf("Environment=CLANKER_SRE_DEPLOY_ID=%s\n", strings.TrimSpace(deployID)) + } unit := fmt.Sprintf(`[Unit] Description=Clanker SRE After=network-online.target @@ -954,24 +1035,29 @@ Wants=network-online.target [Service] Type=simple -Environment=CLANKER_CEREBRO_URL=%s -Environment=%s=${%s} -ExecStart=/usr/bin/env clanker sre run --sre --target systemd --interval %s +%sExecStart=/usr/bin/env clanker %s Restart=always RestartSec=10 [Install] WantedBy=multi-user.target -`, backendURL, tokenEnv, tokenEnv, interval.String()) +`, envLines, sreRunShell("systemd", interval, provider, deployID)) return InstallPlan{Target: "systemd", Summary: "Install Clanker SRE as a Linux systemd service", Available: available, Warnings: warnings, Commands: []string{"sudo cp " + name + ".service /etc/systemd/system/", "sudo systemctl daemon-reload", "sudo systemctl enable --now " + name}, Files: []InstallFile{{Path: name + ".service", Mode: "0644", Content: unit}}, NextSteps: []string{"copy the unit to /etc/systemd/system", "enable the service"}, Discovery: discovery} } -func k8sPlan(discovery Discovery, image string, name string, backendURL string, tokenEnv string, interval time.Duration) InstallPlan { +func k8sPlan(discovery Discovery, image string, name string, backendURL string, tokenEnv string, provider string, deployID string, interval time.Duration) InstallPlan { available := discovery.Kubernetes.Available warnings := []string{} if !available { warnings = append(warnings, "kubernetes was not detected; use this target only when kubeconfig is available") } + extraEnv := "" + if strings.TrimSpace(provider) != "" { + extraEnv += fmt.Sprintf(" - name: CLANKER_SRE_PROVIDER\n value: %q\n", strings.TrimSpace(provider)) + } + if strings.TrimSpace(deployID) != "" { + extraEnv += fmt.Sprintf(" - name: CLANKER_SRE_DEPLOY_ID\n value: %q\n", strings.TrimSpace(deployID)) + } manifest := fmt.Sprintf(`apiVersion: apps/v1 kind: Deployment metadata: @@ -990,7 +1076,7 @@ spec: containers: - name: sre image: %s - args: ["sre", "run", "--sre", "--target", "k8s", "--interval", "%s"] + args: %s env: - name: CLANKER_CEREBRO_URL value: %q @@ -999,18 +1085,18 @@ spec: secretKeyRef: name: clanker-sre key: ingest-token -`, name, name, name, image, interval.String(), backendURL, tokenEnv) +%s`, name, name, name, image, sreRunJSONArgs("k8s", interval, provider, deployID), backendURL, tokenEnv, extraEnv) return InstallPlan{Target: "k8s", Summary: "Run Clanker SRE in an existing Kubernetes cluster", Available: available, Warnings: warnings, Commands: []string{"kubectl create namespace clanker --dry-run=client -o yaml | kubectl apply -f -", "kubectl -n clanker create secret generic clanker-sre --from-literal=ingest-token=\"$" + tokenEnv + "\" --dry-run=client -o yaml | kubectl apply -f -", "kubectl apply -f clanker-sre.yaml"}, Files: []InstallFile{{Path: "clanker-sre.yaml", Mode: "0644", Content: manifest}}, NextSteps: []string{"create the ingest token secret", "apply clanker-sre.yaml"}, Discovery: discovery} } -func cloudVMPlan(discovery Discovery, image string, name string, backendURL string, tokenEnv string, interval time.Duration) InstallPlan { +func cloudVMPlan(discovery Discovery, image string, name string, backendURL string, tokenEnv string, provider string, deployID string, interval time.Duration) InstallPlan { cloudInit := fmt.Sprintf(`#cloud-config packages: - docker.io runcmd: - docker pull %s - - docker run -d --name %s --restart unless-stopped -e CLANKER_CEREBRO_URL=%q -e %s="$%s" %s sre run --sre --target cloud-vm --interval %s -`, image, name, backendURL, tokenEnv, tokenEnv, image, interval.String()) + - docker run -d --name %s --restart unless-stopped %s %s %s +`, image, name, sreDockerEnvArgs(backendURL, tokenEnv, provider, deployID), image, sreRunShell("cloud-vm", interval, provider, deployID)) return InstallPlan{Target: "cloud-vm", Summary: "Run Clanker SRE on a minimal VM in a user-owned provider", Available: true, Warnings: []string{"cloud-vm target generates bootstrap assets only; provision the VM with your chosen provider"}, Commands: []string{"use clanker-sre-cloud-init.yaml as cloud-init user data on a small VM"}, Files: []InstallFile{{Path: "clanker-sre-cloud-init.yaml", Mode: "0644", Content: cloudInit}}, NextSteps: []string{"create the smallest suitable VM", "attach cloud-init user data", "verify heartbeat in Cerebro"}, Discovery: discovery} }