cmd

package
v0.0.0-...-8437796 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 24, 2026 License: Apache-2.0 Imports: 59 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

View Source
var ApprovalsCommand = &cli.Command{Name: "approvals", Usage: "Manage SHA-bound CI approvals", Subcommands: []*cli.Command{{
	Name: "create", Flags: append(apiFlags(),
		&cli.StringFlag{Name: "org", Required: true}, &cli.StringFlag{Name: "project", Required: true},
		&cli.IntFlag{Name: "pr", Required: true}, &cli.StringFlag{Name: "head-repository", Required: true},
		&cli.StringFlag{Name: "head-sha", Required: true}, &cli.StringFlag{Name: "base-sha", Required: true},
		&cli.StringFlag{Name: "policy-revision", Required: true}, &cli.StringFlag{Name: "workflow", Required: true},
		&cli.StringFlag{Name: "profile", Required: true}, &cli.StringFlag{Name: "subject", Required: true},
		&cli.TimestampFlag{Name: "expires-at", Layout: time.RFC3339}),
	Action: func(ctx *cli.Context) error {
		client, err := newAPIClient(ctx)
		if err != nil {
			return err
		}
		request := approvalCreateRequest{Organization: ctx.String("org"), Project: ctx.String("project"),
			PRNumber: ctx.Int("pr"), HeadRepository: ctx.String("head-repository"), HeadSHA: ctx.String("head-sha"),
			BaseSHA: ctx.String("base-sha"), PolicyRevision: ctx.String("policy-revision"), WorkflowScope: ctx.String("workflow"),
			ExecutionProfile: ctx.String("profile"), ApproverSubject: ctx.String("subject"), ExpiresAt: ctx.Timestamp("expires-at")}
		var response struct {
			ApprovalID string `json:"approval_id"`
		}
		if err := client.doJSON(http.MethodPost, "/api/v1/approvals", request, http.StatusCreated, &response); err != nil {
			return err
		}
		fmt.Printf("CI approval created: %s\n", response.ApprovalID)
		return nil
	},
}}}
View Source
var HealthCheckCommand = &cli.Command{
	Name:  "healthcheck",
	Usage: "Check if the coordinator API is healthy (for container health checks)",
	Flags: []cli.Flag{
		&cli.StringFlag{
			Name:    "url",
			Aliases: []string{"u"},
			Value:   "http://localhost:6080/api/v1/health",
			Usage:   "URL to check for health",
			EnvVars: []string{"REACTORCIDE_HEALTH_URL"},
		},
		&cli.IntFlag{
			Name:    "timeout",
			Aliases: []string{"t"},
			Value:   5,
			Usage:   "Timeout in seconds",
			EnvVars: []string{"REACTORCIDE_HEALTH_TIMEOUT"},
		},
	},
	Action: func(ctx *cli.Context) error {
		url := ctx.String("url")
		timeout := time.Duration(ctx.Int("timeout")) * time.Second

		client := &http.Client{
			Timeout: timeout,
		}

		resp, err := client.Get(url)
		if err != nil {
			return fmt.Errorf("health check failed: %w", err)
		}
		defer resp.Body.Close()

		if resp.StatusCode != http.StatusOK {
			return fmt.Errorf("health check failed: status %d", resp.StatusCode)
		}

		return nil
	},
}
View Source
var JobsCommand = &cli.Command{
	Name:  "jobs",
	Usage: "List and control jobs on a Reactorcide coordinator",
	Flags: apiFlags(),
	Subcommands: []*cli.Command{
		{
			Name:  "list",
			Usage: "List jobs",
			Flags: append(append(apiFlags(), paginationFlags()...),
				formatFlag(),
				&cli.StringFlag{Name: "status", Usage: "Filter by job status"},
				&cli.StringFlag{Name: "queue-name", Usage: "Filter by queue name"},
				&cli.StringFlag{Name: "source-type", Usage: "Filter by source type"},
				&cli.StringFlag{Name: "project-id", Usage: "Filter by project ID"},
				&cli.StringFlag{Name: "workflow-id", Usage: "Filter by workflow ID"},
				&cli.StringFlag{Name: "user-id", Usage: "Filter by owning user ID (admins only)"},
			),
			Action: func(ctx *cli.Context) error {
				client, err := newAPIClient(ctx)
				if err != nil {
					return err
				}
				query := pagedQuery(ctx, map[string]string{
					"status":      ctx.String("status"),
					"queue_name":  ctx.String("queue-name"),
					"source_type": ctx.String("source-type"),
					"project_id":  ctx.String("project-id"),
					"workflow_id": ctx.String("workflow-id"),
					"user_id":     ctx.String("user-id"),
				})
				var resp listJobsResponse
				if err := client.doJSON(http.MethodGet, "/api/v1/jobs"+query, nil, http.StatusOK, &resp); err != nil {
					return err
				}
				return render(ctx.String("format"), resp.Jobs, func(w *tabwriter.Writer) {
					fmt.Fprintln(w, "JOB ID\tNAME\tSTATUS\tQUEUE\tCREATED\tCOMPLETED")
					for _, job := range resp.Jobs {
						fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
							job.JobID, job.Name, job.Status, job.QueueName,
							job.CreatedAt.Format(time.RFC3339), timeOrDash(job.CompletedAt))
					}
				})
			},
		},
		{
			Name:      "get",
			Usage:     "Get a job by ID",
			ArgsUsage: "<job-id>",
			Flags:     append(apiFlags(), formatFlag()),
			Action: func(ctx *cli.Context) error {
				return jobAction(ctx, http.MethodGet, "", http.StatusOK)
			},
		},
		{
			Name:      "cancel",
			Usage:     "Cancel a job (graceful, allows cleanup hooks to run)",
			ArgsUsage: "<job-id>",
			Flags:     append(apiFlags(), formatFlag()),
			Action: func(ctx *cli.Context) error {
				return jobAction(ctx, http.MethodPut, "/cancel", http.StatusOK)
			},
		},
		{
			Name:      "kill",
			Usage:     "Kill a job immediately (admin only, no cleanup grace period)",
			ArgsUsage: "<job-id>",
			Flags:     append(apiFlags(), formatFlag()),
			Action: func(ctx *cli.Context) error {
				return jobAction(ctx, http.MethodPost, "/kill", http.StatusOK)
			},
		},
		{
			Name:      "retry",
			Usage:     "Retry a job as a new job in the same workflow node",
			ArgsUsage: "<job-id>",
			Flags:     append(apiFlags(), formatFlag()),
			Action: func(ctx *cli.Context) error {
				return jobAction(ctx, http.MethodPost, "/retry", http.StatusCreated)
			},
		},
		{
			Name:      "delete",
			Usage:     "Delete a job and its telemetry",
			ArgsUsage: "<job-id>",
			Flags:     apiFlags(),
			Action: func(ctx *cli.Context) error {
				jobID, client, err := jobTarget(ctx)
				if err != nil {
					return err
				}
				if err := client.doJSON(http.MethodDelete, "/api/v1/jobs/"+url.PathEscape(jobID), nil, http.StatusNoContent, nil); err != nil {
					return err
				}
				fmt.Printf("Job deleted: %s\n", jobID)
				return nil
			},
		},
		{
			Name:      "metrics",
			Usage:     "Get resource metrics for a job",
			ArgsUsage: "<job-id>",
			Flags: append(apiFlags(),
				formatFlag(),
				&cli.StringSliceFlag{Name: "metric", Usage: "Metric name to include (repeatable, default: all)"},
				&cli.StringFlag{Name: "from", Usage: "Start of the time range (RFC3339)"},
				&cli.StringFlag{Name: "to", Usage: "End of the time range (RFC3339)"},
				&cli.IntFlag{Name: "max-points", Usage: "Maximum points per metric series"},
			),
			Action: func(ctx *cli.Context) error {
				jobID, client, err := jobTarget(ctx)
				if err != nil {
					return err
				}
				values := url.Values{}
				for _, metric := range ctx.StringSlice("metric") {
					values.Add("metric", metric)
				}
				if from := ctx.String("from"); from != "" {
					values.Set("from", from)
				}
				if to := ctx.String("to"); to != "" {
					values.Set("to", to)
				}
				if maxPoints := ctx.Int("max-points"); maxPoints > 0 {
					values.Set("max_points", fmt.Sprint(maxPoints))
				}
				path := "/api/v1/jobs/" + url.PathEscape(jobID) + "/metrics"
				if len(values) > 0 {
					path += "?" + values.Encode()
				}
				var result map[string]interface{}
				if err := client.doJSON(http.MethodGet, path, nil, http.StatusOK, &result); err != nil {
					return err
				}
				format := ctx.String("format")
				if format == "table" {
					format = "json"
				}
				return render(format, result, nil)
			},
		},
	},
}

JobsCommand exposes the /api/v1/jobs endpoints. Job creation lives in the separate "submit" command, which also handles overlays and job-file parsing.

View Source
var LocalContextCommand = &cli.Command{
	Name: "local-context", Usage: "Manage synchronized local execution settings",
	Subcommands: []*cli.Command{
		{Name: "sync", Usage: "Synchronize non-secret project settings", Flags: append(apiFlags(),
			&cli.StringFlag{Name: "project", Required: true}, &cli.StringFlag{Name: "name", Required: true},
			&cli.StringFlag{Name: "include-workflow-secrets", Usage: "Workflow file whose referenced secrets are copied to the encrypted local store"},
			&cli.BoolFlag{Name: "replace-secrets", Usage: "Replace local values for selected secret references"}), Action: syncLocalContext},
		{Name: "show", ArgsUsage: "[name]", Flags: []cli.Flag{formatFlag(), &cli.StringFlag{Name: "name"}}, Action: showLocalContext},
		{Name: "remove", ArgsUsage: "[name]", Flags: []cli.Flag{&cli.StringFlag{Name: "name"}}, Action: removeLocalContext},
	},
}
View Source
var LogsCommand = &cli.Command{
	Name:      "logs",
	Usage:     "Get logs for a job from a remote Reactorcide coordinator",
	ArgsUsage: "<job-id>",
	Flags: append(apiFlags(),
		&cli.StringFlag{
			Name:    "stream",
			Aliases: []string{"s"},
			Value:   "combined",
			Usage:   "Log stream to retrieve: stdout, stderr, or combined (default)",
		},
		&cli.StringFlag{
			Name:    "output",
			Aliases: []string{"o"},
			Usage:   "Output file (default: stdout)",
		},
	),
	Action: logsAction,
}

LogsCommand retrieves logs for a job from a remote Reactorcide coordinator API

View Source
var MigrateCommand = &cli.Command{
	Name:  "migrate",
	Usage: "Runs database migrations",
	Flags: []cli.Flag{
		&cli.StringFlag{
			Name:        "db-uri",
			Aliases:     []string{"db"},
			Value:       "postgresql://devuser:devpass@monodemo-postgresql:5432/monodemopg?sslmode=disable",
			Usage:       "The uri to use to connect to the db",
			Destination: &config.DbUri,
			EnvVars:     []string{"REACTORCIDE_DB_URI", "DB_URI"},
		},
	},
	Action: func(ctx *cli.Context) error {
		return RunMigrations()
	},
}
View Source
var OrgsCommand = &cli.Command{
	Name: "orgs", Usage: "Manage organizations", Flags: apiFlags(),
	Subcommands: []*cli.Command{
		{
			Name: "create", ArgsUsage: "<name>", Flags: append(apiFlags(),
				&cli.StringFlag{Name: "display-name"}, &cli.BoolFlag{Name: "private"}),
			Action: func(ctx *cli.Context) error {
				if ctx.NArg() != 1 {
					return fmt.Errorf("usage: reactorcide orgs create <name>")
				}
				client, err := newAPIClient(ctx)
				if err != nil {
					return err
				}
				body := organizationSummary{Name: ctx.Args().First(), DisplayName: ctx.String("display-name"), IsPrivate: ctx.Bool("private"), Status: "active"}
				var response organizationSummary
				if err := client.doJSON(http.MethodPost, "/api/v1/organizations", body, http.StatusCreated, &response); err != nil {
					return err
				}
				fmt.Printf("Organization created: %s\n", response.Name)
				return nil
			},
		},
		{
			Name: "list", Flags: append(apiFlags(), formatFlag()), Action: func(ctx *cli.Context) error {
				client, err := newAPIClient(ctx)
				if err != nil {
					return err
				}
				var response struct {
					Organizations []organizationSummary `json:"organizations"`
				}
				if err := client.doJSON(http.MethodGet, "/api/v1/organizations", nil, http.StatusOK, &response); err != nil {
					return err
				}
				return render(ctx.String("format"), response.Organizations, func(w *tabwriter.Writer) {
					fmt.Fprintln(w, "NAME\tDISPLAY NAME\tSTATUS\tPRIVATE\tDEFAULT")
					for _, org := range response.Organizations {
						fmt.Fprintf(w, "%s\t%s\t%s\t%t\t%t\n", org.Name, org.DisplayName, org.Status, org.IsPrivate, org.IsDefault)
					}
				})
			},
		},
		{
			Name: "get", ArgsUsage: "<name>", Flags: append(apiFlags(), formatFlag()), Action: func(ctx *cli.Context) error {
				if ctx.NArg() != 1 {
					return fmt.Errorf("usage: reactorcide orgs get <name>")
				}
				client, err := newAPIClient(ctx)
				if err != nil {
					return err
				}
				var response organizationSummary
				if err := client.doJSON(http.MethodGet, "/api/v1/organizations/"+url.PathEscape(ctx.Args().First()), nil, http.StatusOK, &response); err != nil {
					return err
				}
				return render(ctx.String("format"), response, func(w *tabwriter.Writer) {
					fmt.Fprintln(w, "NAME\tDISPLAY NAME\tSTATUS\tPRIVATE\tDEFAULT")
					fmt.Fprintf(w, "%s\t%s\t%s\t%t\t%t\n", response.Name, response.DisplayName, response.Status, response.IsPrivate, response.IsDefault)
				})
			},
		},
		{
			Name: "set-default", ArgsUsage: "<name>", Flags: apiFlags(), Action: func(ctx *cli.Context) error {
				if ctx.NArg() != 1 {
					return fmt.Errorf("usage: reactorcide orgs set-default <name>")
				}
				client, err := newAPIClient(ctx)
				if err != nil {
					return err
				}
				var response organizationSummary
				if err := client.doJSON(http.MethodPut, "/api/v1/organizations/"+url.PathEscape(ctx.Args().First())+"/default", nil, http.StatusOK, &response); err != nil {
					return err
				}
				fmt.Printf("Default organization: %s\n", response.Name)
				return nil
			},
		},
		{
			Name: "update", ArgsUsage: "<name>", Flags: append(apiFlags(),
				&cli.StringFlag{Name: "display-name", Required: true}, &cli.StringFlag{Name: "status", Value: "active"}, &cli.BoolFlag{Name: "private"}),
			Action: func(ctx *cli.Context) error {
				if ctx.NArg() != 1 {
					return fmt.Errorf("usage: reactorcide orgs update <name>")
				}
				client, err := newAPIClient(ctx)
				if err != nil {
					return err
				}
				body := organizationSummary{DisplayName: ctx.String("display-name"), Status: ctx.String("status"), IsPrivate: ctx.Bool("private")}
				var response organizationSummary
				if err := client.doJSON(http.MethodPut, "/api/v1/organizations/"+url.PathEscape(ctx.Args().First()), body, http.StatusOK, &response); err != nil {
					return err
				}
				fmt.Printf("Organization updated: %s\n", response.Name)
				return nil
			},
		},
		{
			Name: "delete", ArgsUsage: "<name>", Flags: append(apiFlags(),
				&cli.StringFlag{Name: "replacement", Required: true, Usage: "Organization that becomes the default"},
				&cli.BoolFlag{Name: "yes", Usage: "Confirm deletion of the organization and all resources that it owns"}),
			Action: func(ctx *cli.Context) error {
				if ctx.NArg() != 1 {
					return fmt.Errorf("usage: reactorcide orgs delete <name> --replacement <name> --yes")
				}
				if !ctx.Bool("yes") {
					return fmt.Errorf("organization deletion requires --yes")
				}
				client, err := newAPIClient(ctx)
				if err != nil {
					return err
				}
				body := struct {
					Replacement string `json:"replacement"`
					Confirm     bool   `json:"confirm"`
				}{Replacement: ctx.String("replacement"), Confirm: true}
				if err := client.doJSON(http.MethodDelete, "/api/v1/organizations/"+url.PathEscape(ctx.Args().First()), body, http.StatusNoContent, nil); err != nil {
					return err
				}
				fmt.Printf("Organization deleted: %s\n", ctx.Args().First())
				return nil
			},
		},
	},
}
View Source
var PolicyCommand = &cli.Command{
	Name:  "policy",
	Usage: "Manage coordinator CI admission policy",
	Flags: apiFlags(),
	Subcommands: []*cli.Command{
		{
			Name:  "get",
			Usage: "Get a project CI policy",
			Flags: []cli.Flag{policyProjectFlag(), formatFlag()},
			Action: func(ctx *cli.Context) error {
				client, projectID, err := policyClientAndProject(ctx)
				if err != nil {
					return err
				}
				resp, err := client.GetCiPolicy(ctx.Context, csilapi.GetCiPolicyRequest{ProjectId: projectID})
				if err != nil {
					return err
				}
				return printPolicy(ctx.String("format"), resp.Policy)
			},
		},
		{
			Name:  "set",
			Usage: "Create or replace a project CI policy",
			Flags: []cli.Flag{policyProjectFlag(), &cli.StringFlag{Name: "file", Aliases: []string{"f"}, Required: true, Usage: "Policy YAML or JSON file; use - for stdin"}, &cli.StringFlag{Name: "expected-revision", Usage: "Reject the update if the current revision differs"}, policyFormatFlag()},
			Action: func(ctx *cli.Context) error {
				document, err := readPolicyInput(ctx.String("file"))
				if err != nil {
					return err
				}
				if _, err := cipolicy.ParseDocument(document); err != nil {
					return err
				}
				client, projectID, err := policyClientAndProject(ctx)
				if err != nil {
					return err
				}
				resp, err := client.PutCiPolicy(ctx.Context, csilapi.PutCiPolicyRequest{ProjectId: projectID, Document: string(document), ExpectedRevision: optionalText(ctx.String("expected-revision"))})
				if err != nil {
					return err
				}
				return printPolicy(ctx.String("format"), resp.Policy)
			},
		},
		{
			Name:  "delete",
			Usage: "Delete a project CI policy",
			Flags: []cli.Flag{policyProjectFlag(), &cli.StringFlag{Name: "expected-revision", Usage: "Reject the delete if the current revision differs"}},
			Action: func(ctx *cli.Context) error {
				client, projectID, err := policyClientAndProject(ctx)
				if err != nil {
					return err
				}
				_, err = client.DeleteCiPolicy(ctx.Context, csilapi.DeleteCiPolicyRequest{ProjectId: projectID, ExpectedRevision: optionalText(ctx.String("expected-revision"))})
				if err != nil {
					return err
				}
				fmt.Printf("CI policy deleted for project %s\n", projectID)
				return nil
			},
		},
		{
			Name:  "validate",
			Usage: "Validate a policy file without changing coordinator state",
			Flags: []cli.Flag{&cli.StringFlag{Name: "file", Aliases: []string{"f"}, Required: true}},
			Action: func(ctx *cli.Context) error {
				document, err := readPolicyInput(ctx.String("file"))
				if err != nil {
					return err
				}
				policy, err := cipolicy.ParseDocument(document)
				if err != nil {
					return err
				}
				fmt.Printf("Policy is valid. Revision: %s\n", policy.Revision)
				return nil
			},
		},
		{
			Name:  "explain",
			Usage: "Evaluate a local policy file against supplied facts",
			Flags: []cli.Flag{&cli.StringFlag{Name: "file", Aliases: []string{"f"}, Required: true},
				&cli.StringFlag{Name: "workflow"}, &cli.StringSliceFlag{Name: "changed-path"}, &cli.StringFlag{Name: "event", Value: "pull_request_updated"},
				&cli.StringFlag{Name: "base-branch"}, &cli.StringFlag{Name: "head-repository", Value: "same"}, &cli.StringSliceFlag{Name: "actor"}, &cli.StringSliceFlag{Name: "approval"}},
			Action: explainPolicy,
		},
	},
}
View Source
var ProfilesCommand = &cli.Command{Name: "profiles", Usage: "Manage organization execution profiles", Subcommands: []*cli.Command{
	{Name: "list", Flags: append(apiFlags(), &cli.StringFlag{Name: "org", Required: true}, formatFlag()), Action: func(ctx *cli.Context) error {
		client, err := newAPIClient(ctx)
		if err != nil {
			return err
		}
		var response struct {
			Profiles []models.ExecutionProfile `json:"profiles" yaml:"profiles"`
		}
		if err := client.doJSON(http.MethodGet, profilePath(ctx.String("org")), nil, http.StatusOK, &response); err != nil {
			return err
		}
		return render(ctx.String("format"), response.Profiles, func(w *tabwriter.Writer) {
			fmt.Fprintln(w, "NAME\tDENY SECRETS\tROOT\tWORKER CLASSES")
			for _, profile := range response.Profiles {
				fmt.Fprintf(w, "%s\t%t\t%t\t%v\n", profile.Name, profile.DenySecrets, profile.MayRunAsRoot, []string(profile.AllowedWorkerClasses))
			}
		})
	}},
	{Name: "get", ArgsUsage: "<name>", Flags: append(apiFlags(), &cli.StringFlag{Name: "org", Required: true}, formatFlag()), Action: func(ctx *cli.Context) error {
		if ctx.NArg() != 1 {
			return fmt.Errorf("usage: reactorcide profiles get --org NAME <name>")
		}
		client, err := newAPIClient(ctx)
		if err != nil {
			return err
		}
		var profile models.ExecutionProfile
		if err := client.doJSON(http.MethodGet, profilePath(ctx.String("org"), ctx.Args().First()), nil, http.StatusOK, &profile); err != nil {
			return err
		}
		return render(ctx.String("format"), profile, func(w *tabwriter.Writer) { fmt.Fprintf(w, "%s\n", profile.Name) })
	}},
	{Name: "apply", ArgsUsage: "<profile.yaml>", Flags: append(apiFlags(), &cli.StringFlag{Name: "org", Required: true}), Action: func(ctx *cli.Context) error {
		if ctx.NArg() != 1 {
			return fmt.Errorf("usage: reactorcide profiles apply --org NAME <profile.yaml>")
		}
		data, err := os.ReadFile(ctx.Args().First())
		if err != nil {
			return err
		}
		var profile models.ExecutionProfile
		decoder := yaml.NewDecoder(bytes.NewReader(data))
		decoder.KnownFields(true)
		if err := decoder.Decode(&profile); err != nil {
			return fmt.Errorf("parse execution profile: %w", err)
		}
		if profile.Name == "" {
			return fmt.Errorf("profile name is required")
		}
		client, err := newAPIClient(ctx)
		if err != nil {
			return err
		}
		method, path, expected := http.MethodPost, profilePath(ctx.String("org")), http.StatusCreated
		var existing models.ExecutionProfile
		getErr := client.doJSON(http.MethodGet, profilePath(ctx.String("org"), profile.Name), nil, http.StatusOK, &existing)
		if getErr == nil {
			method, path, expected = http.MethodPut, profilePath(ctx.String("org"), profile.Name), http.StatusOK
		} else {
			var apiErr *apiError
			if !errors.As(getErr, &apiErr) || apiErr.StatusCode != http.StatusNotFound {
				return getErr
			}
		}
		var response models.ExecutionProfile
		if err := client.doJSON(method, path, profile, expected, &response); err != nil {
			return err
		}
		fmt.Printf("Execution profile applied: %s\n", response.Name)
		return nil
	}},
	{Name: "delete", ArgsUsage: "<name>", Flags: append(apiFlags(), &cli.StringFlag{Name: "org", Required: true}), Action: func(ctx *cli.Context) error {
		if ctx.NArg() != 1 {
			return fmt.Errorf("usage: reactorcide profiles delete --org NAME <name>")
		}
		client, err := newAPIClient(ctx)
		if err != nil {
			return err
		}
		if err := client.doJSON(http.MethodDelete, profilePath(ctx.String("org"), ctx.Args().First()), nil, http.StatusNoContent, nil); err != nil {
			return err
		}
		fmt.Printf("Execution profile deleted: %s\n", ctx.Args().First())
		return nil
	}},
}}
View Source
var ProjectsCommand = &cli.Command{
	Name:  "projects",
	Usage: "Manage projects (repositories) on a Reactorcide coordinator",
	Flags: apiFlags(),
	Subcommands: []*cli.Command{
		{
			Name:  "list",
			Usage: "List projects",
			Flags: append(append(apiFlags(), paginationFlags()...), formatFlag()),
			Action: func(ctx *cli.Context) error {
				client, err := newAPIClient(ctx)
				if err != nil {
					return err
				}
				var resp listProjectsResponse
				if err := client.doJSON(http.MethodGet, "/api/v1/projects"+pagedQuery(ctx, nil), nil, http.StatusOK, &resp); err != nil {
					return err
				}
				return render(ctx.String("format"), resp.Projects, func(w *tabwriter.Writer) {
					fmt.Fprintln(w, "PROJECT ID\tNAME\tREPO URL\tENABLED\tQUEUE")
					for _, p := range resp.Projects {
						fmt.Fprintf(w, "%s\t%s\t%s\t%t\t%s\n",
							p.ProjectID, p.Name, p.RepoURL, p.Enabled, p.DefaultQueueName)
					}
				})
			},
		},
		{
			Name:      "get",
			Usage:     "Get a project by ID",
			ArgsUsage: "<project-id>",
			Flags:     append(apiFlags(), formatFlag()),
			Action: func(ctx *cli.Context) error {
				projectID, client, err := projectTarget(ctx)
				if err != nil {
					return err
				}
				var project projectResponse
				if err := client.doJSON(http.MethodGet, "/api/v1/projects/"+url.PathEscape(projectID), nil, http.StatusOK, &project); err != nil {
					return err
				}
				return renderProject(ctx.String("format"), &project)
			},
		},
		{
			Name:  "create",
			Usage: "Create a project",
			Description: "Supply the project either with flags or with --file, a YAML or JSON " +
				"document holding the same fields as the API request body. Flags override the file.",
			Flags: append(append(apiFlags(), projectSpecFlags()...),
				formatFlag(),
				&cli.StringFlag{Name: "file", Usage: "YAML or JSON file holding the project definition"},
			),
			Action: func(ctx *cli.Context) error {
				client, err := newAPIClient(ctx)
				if err != nil {
					return err
				}
				spec, err := loadProjectSpec(ctx)
				if err != nil {
					return err
				}
				if spec.Name == "" || spec.RepoURL == "" {
					return fmt.Errorf("--name and --repo-url are required")
				}
				var project projectResponse
				if err := client.doJSON(http.MethodPost, "/api/v1/projects", spec, http.StatusCreated, &project); err != nil {
					return err
				}
				return renderProject(ctx.String("format"), &project)
			},
		},
		{
			Name:      "update",
			Usage:     "Update a project",
			ArgsUsage: "<project-id>",
			Description: "Only the fields you supply are changed. Use --file for a YAML or JSON " +
				"document holding the same fields as the API request body.",
			Flags: append(append(apiFlags(), projectSpecFlags()...),
				formatFlag(),
				&cli.StringFlag{Name: "file", Usage: "YAML or JSON file holding the fields to update"},
			),
			Action: func(ctx *cli.Context) error {
				projectID, client, err := projectTarget(ctx)
				if err != nil {
					return err
				}
				spec, err := loadProjectSpec(ctx)
				if err != nil {
					return err
				}
				var project projectResponse
				if err := client.doJSON(http.MethodPut, "/api/v1/projects/"+url.PathEscape(projectID), spec, http.StatusOK, &project); err != nil {
					return err
				}
				return renderProject(ctx.String("format"), &project)
			},
		},
		{
			Name:      "delete",
			Usage:     "Delete a project",
			ArgsUsage: "<project-id>",
			Flags:     apiFlags(),
			Action: func(ctx *cli.Context) error {
				projectID, client, err := projectTarget(ctx)
				if err != nil {
					return err
				}
				if err := client.doJSON(http.MethodDelete, "/api/v1/projects/"+url.PathEscape(projectID), nil, http.StatusNoContent, nil); err != nil {
					return err
				}
				fmt.Printf("Project deleted: %s\n", projectID)
				return nil
			},
		},
	},
}
View Source
var RunLocalCommand = &cli.Command{
	Name:      "run-local",
	Usage:     "Execute a job in a container locally (emulates worker behavior)",
	ArgsUsage: "<job-file>",
	Flags: []cli.Flag{
		&cli.BoolFlag{
			Name:  "dry-run",
			Usage: "Show what would be executed without running",
		},
		&cli.StringFlag{
			Name:  "source-dir",
			Usage: "Application source directory to mount at /job/src (default: current repository)",
		},
		&cli.StringFlag{
			Name:  "ci-dir",
			Usage: "Trusted CI directory to mount read-only at /job/ci (default: current repository)",
		},
		&cli.StringFlag{
			Name:  "backend",
			Usage: "Container runtime backend: docker, containerd, kubernetes, vm (vm is only implemented on darwin/windows)",
			Value: "docker",
		},
		&cli.StringSliceFlag{
			Name:    "input",
			Aliases: []string{"i"},
			Usage:   "Overlay YAML files to merge with job spec (first has highest priority)",
		},
		&cli.BoolFlag{
			Name:  "allow-secret-overrides",
			Usage: "Allow overlays to override secret references with plaintext values",
		},
		&cli.StringFlag{
			Name:  "code-url",
			Usage: "Git URL to clone into /job/src instead of mounting --source-dir. Use it with --code-ref to test another checkout.",
		},
		&cli.StringFlag{
			Name:  "code-ref",
			Usage: "Git ref (branch, tag, or SHA) to checkout from --code-url. Defaults to the remote's default branch.",
		},
		&cli.BoolFlag{
			Name:  "as-runner",
			Usage: "Run the job container as the image's runner uid (1001:1001), matching the worker, instead of the host uid. Gives sudo/HOME parity for jobs that rely on the image's runner user, such as sudo apt-get. Writes to /job are made world-writable since an unprivileged run-local can't chown to 1001. Can also be set per-job via the job YAML's run_local block.",
		},
		&cli.StringFlag{
			Name:  "user",
			Usage: "User to run the job container as: a numeric uid[:gid] (e.g. \"1001:1001\"), or the symbolic name \"runner\" (image runner uid 1001), \"root\", or \"host\" (the invoking user). Overrides the host-uid default. Mutually exclusive with --as-runner.",
		},
		&cli.StringFlag{Name: "event", Value: "push", Usage: "Trigger event to evaluate for a workflow"},
		&cli.StringSliceFlag{Name: "changed-file", Usage: "Changed source path for workflow path rules; repeat for each path"},
		&cli.StringFlag{Name: "eval-image", Value: worker.DefaultRunnerImage, Usage: "Image that evaluates a workflow"},
		&cli.IntFlag{Name: "max-parallel", Value: 1, Usage: "Maximum number of workflow nodes to run at once"},
		&cli.StringFlag{Name: "context", Usage: "Synchronized local context name"},
		&cli.StringFlag{Name: "workflow-vars-file", Hidden: true},
		&cli.StringFlag{Name: "result-dir", Hidden: true},
	},
	Action: runLocalAction,
}

RunLocalCommand executes a job in a container, fulfilling worker behavior. This uses the same JobRunner infrastructure as the worker, ensuring consistent execution between local development and production. Or in outages of some kind.

View Source
var SecretGrantsCommand = &cli.Command{
	Name:  "secret-grants",
	Usage: "Manage API secret grants",
	Flags: append(apiFlags(),
		&cli.StringFlag{
			Name:  "project",
			Usage: "Project scope by ID, name, or repo URL. Omit for org/global grants",
		},
	),
	Subcommands: []*cli.Command{
		{
			Name:  "list",
			Usage: "List secret grants",
			Flags: []cli.Flag{secretGrantFormatFlag()},
			Action: func(ctx *cli.Context) error {
				client, err := newSecretGrantsAPIClient(ctx)
				if err != nil {
					return err
				}
				grants, err := client.List(ctx.String("project"))
				if err != nil {
					return err
				}
				return printSecretGrants(ctx.String("format"), grants)
			},
		},
		{
			Name:      "get",
			Usage:     "Get a secret grant by name or ID",
			ArgsUsage: "<name-or-id>",
			Flags:     []cli.Flag{secretGrantFormatFlag()},
			Action: func(ctx *cli.Context) error {
				if ctx.NArg() < 1 {
					return fmt.Errorf("usage: reactorcide secret-grants get <name-or-id>")
				}
				client, err := newSecretGrantsAPIClient(ctx)
				if err != nil {
					return err
				}
				grant, err := client.Get(ctx.Args().Get(0), ctx.String("project"))
				if err != nil {
					return err
				}
				return printSecretGrants(ctx.String("format"), []models.SecretGrant{*grant})
			},
		},
		{
			Name:      "set",
			Usage:     "Create or update a named secret grant",
			ArgsUsage: "<name>",
			Flags: []cli.Flag{
				&cli.StringFlag{Name: "secret-path", Usage: "Secret path pattern to grant"},
				&cli.StringFlag{Name: "secret-match", Value: models.SecretGrantMatchPrefix, Usage: "Secret path match: exact, prefix, glob, regex"},
				&cli.StringFlag{Name: "job-name", Usage: "Job name pattern. Omit to match any job name"},
				&cli.StringFlag{Name: "job-match", Value: models.SecretGrantMatchAny, Usage: "Job name match: any, exact, prefix, glob, regex"},
				&cli.StringFlag{Name: "description", Usage: "Grant description"},
				&cli.StringSliceFlag{Name: "execution-profile", Usage: "Allowed execution profile; repeatable. Omit for any profile"},
				&cli.StringSliceFlag{Name: "ci-origin", Usage: "Allowed CI origin: base or head; repeatable. Omit for either origin"},
				&cli.BoolFlag{Name: "clear-execution-profiles", Usage: "Remove the execution profile limit"},
				&cli.BoolFlag{Name: "clear-ci-origins", Usage: "Remove the CI origin limit"},
			},
			Action: func(ctx *cli.Context) error {
				if ctx.NArg() < 1 {
					return fmt.Errorf("usage: reactorcide secret-grants set <name> --secret-path <pattern>")
				}
				if strings.TrimSpace(ctx.String("secret-path")) == "" {
					return fmt.Errorf("--secret-path is required")
				}
				req := secretGrantAPIRequest{
					Name:              ctx.Args().Get(0),
					Project:           ctx.String("project"),
					SecretPathMatch:   ctx.String("secret-match"),
					SecretPathPattern: ctx.String("secret-path"),
					JobNameMatch:      ctx.String("job-match"),
					JobNamePattern:    ctx.String("job-name"),
					Description:       ctx.String("description"),
				}
				if ctx.IsSet("execution-profile") && ctx.Bool("clear-execution-profiles") {
					return fmt.Errorf("--execution-profile and --clear-execution-profiles are mutually exclusive")
				}
				if ctx.IsSet("ci-origin") && ctx.Bool("clear-ci-origins") {
					return fmt.Errorf("--ci-origin and --clear-ci-origins are mutually exclusive")
				}
				if ctx.Bool("clear-execution-profiles") {
					req.ExecutionProfiles = []string{}
				} else if ctx.IsSet("execution-profile") {
					req.ExecutionProfiles = ctx.StringSlice("execution-profile")
				}
				if ctx.Bool("clear-ci-origins") {
					req.CIOrigins = []string{}
				} else if ctx.IsSet("ci-origin") {
					req.CIOrigins = ctx.StringSlice("ci-origin")
				}
				if req.JobNamePattern != "" && req.JobNameMatch == models.SecretGrantMatchAny {
					req.JobNameMatch = models.SecretGrantMatchExact
				}
				client, err := newSecretGrantsAPIClient(ctx)
				if err != nil {
					return err
				}
				grant, err := client.Upsert(req)
				if err != nil {
					return err
				}
				return printSecretGrants("table", []models.SecretGrant{*grant})
			},
		},
		{
			Name:      "delete",
			Usage:     "Delete a secret grant by name or ID",
			ArgsUsage: "<name-or-id>",
			Action: func(ctx *cli.Context) error {
				if ctx.NArg() < 1 {
					return fmt.Errorf("usage: reactorcide secret-grants delete <name-or-id>")
				}
				client, err := newSecretGrantsAPIClient(ctx)
				if err != nil {
					return err
				}
				if err := client.Delete(ctx.Args().Get(0), ctx.String("project")); err != nil {
					return err
				}
				fmt.Printf("Secret grant deleted: %s\n", ctx.Args().Get(0))
				return nil
			},
		},
		{
			Name:  "apply",
			Usage: "Apply secret grants from a YAML file",
			Flags: []cli.Flag{

				&cli.StringFlag{Name: "file", Usage: "YAML file to apply"},
				&cli.BoolFlag{Name: "dry-run", Usage: "Compute changes without writing them"},
				&cli.BoolFlag{Name: "prune", Usage: "Delete grants omitted from scopes present in the file"},
				secretGrantFormatFlag(),
			},
			Action: func(ctx *cli.Context) error {
				path := ctx.String("file")
				if path == "" {
					return fmt.Errorf("--file is required")
				}
				req, err := loadSecretGrantApplyFile(path)
				if err != nil {
					return err
				}
				req.DryRun = req.DryRun || ctx.Bool("dry-run")
				req.Prune = req.Prune || ctx.Bool("prune")
				client, err := newSecretGrantsAPIClient(ctx)
				if err != nil {
					return err
				}
				resp, err := client.Apply(req)
				if err != nil {
					return err
				}
				return printSecretGrantApplyResponse(ctx.String("format"), resp)
			},
		},
	},
}
View Source
var SecretsCommand = &cli.Command{
	Name:  "secrets",
	Usage: "Manage secrets locally or through a Reactorcide coordinator API",
	Flags: apiFlags(),
	Subcommands: []*cli.Command{
		{
			Name:  "init",
			Usage: "Initialize secrets storage",
			Flags: []cli.Flag{
				&cli.BoolFlag{
					Name:  "force",
					Usage: "Reinitialize even if already exists",
				},
			},
			Action: func(ctx *cli.Context) error {
				if apiURLConfigured(ctx) {
					client, err := newSecretsAPIClient(ctx)
					if err != nil {
						return err
					}
					if err := client.Init(); err != nil {
						return err
					}
					fmt.Println("Secrets storage initialized")
					return nil
				}

				storage := secrets.NewStorage()

				pw, err := getPasswordConfirm()
				if err != nil {
					return err
				}

				if err := storage.Init(pw, ctx.Bool("force")); err != nil {
					return err
				}

				fmt.Println("Secrets storage initialized")
				return nil
			},
		},
		{
			Name:      "set",
			Usage:     "Set a secret value",
			ArgsUsage: "<path> <key>",
			Flags: []cli.Flag{
				&cli.StringFlag{
					Name:    "value",
					Aliases: []string{"v"},
					Usage:   "Secret value (prompts if not provided)",
				},
				&cli.BoolFlag{
					Name:  "stdin",
					Usage: "Read value from stdin",
				},
			},
			Action: func(ctx *cli.Context) error {
				if ctx.NArg() < 2 {
					return fmt.Errorf("usage: reactorcide secrets set <path> <key>")
				}

				path := ctx.Args().Get(0)
				key := ctx.Args().Get(1)

				var value string
				if ctx.Bool("stdin") {
					content, err := io.ReadAll(os.Stdin)
					if err != nil {
						return fmt.Errorf("failed to read secret value from stdin: %w", err)
					}
					value = string(content)
				} else if ctx.IsSet("value") {
					value = ctx.String("value")
				} else {

					fmt.Fprint(os.Stderr, "Secret value: ")
					if term.IsTerminal(int(os.Stdin.Fd())) {
						valueBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
						fmt.Fprintln(os.Stderr)
						if err != nil {
							return fmt.Errorf("failed to read secret value: %w", err)
						}
						value = string(valueBytes)
					} else {
						reader := bufio.NewReader(os.Stdin)
						v, err := reader.ReadString('\n')
						if err != nil {
							return fmt.Errorf("failed to read secret value: %w", err)
						}
						value = strings.TrimSpace(v)
					}
				}

				if apiURLConfigured(ctx) {
					client, err := newSecretsAPIClient(ctx)
					if err != nil {
						return err
					}
					if err := client.Set(path, key, value); err != nil {
						return err
					}
					fmt.Printf("Secret set: %s:%s\n", path, key)
					return nil
				}

				storage := secrets.NewStorage()

				pw, err := getPassword("Secrets password: ")
				if err != nil {
					return err
				}

				if err := storage.Set(path, key, value, pw); err != nil {
					return err
				}

				fmt.Printf("Secret set: %s:%s\n", path, key)
				return nil
			},
		},
		{
			Name:      "get",
			Usage:     "Get a secret value (outputs only the value for scripting)",
			ArgsUsage: "<path> <key>",
			Action: func(ctx *cli.Context) error {
				if ctx.NArg() < 2 {
					return fmt.Errorf("usage: reactorcide secrets get <path> <key>")
				}

				path := ctx.Args().Get(0)
				key := ctx.Args().Get(1)

				value, err := getSecretValue(ctx, path, key)
				if err != nil {
					return err
				}

				if value == "" {
					return fmt.Errorf("secret not found: %s:%s", path, key)
				}

				fmt.Print(value)
				return nil
			},
		},
		{
			Name:      "delete",
			Usage:     "Delete a secret",
			ArgsUsage: "<path> <key>",
			Action: func(ctx *cli.Context) error {
				if ctx.NArg() < 2 {
					return fmt.Errorf("usage: reactorcide secrets delete <path> <key>")
				}

				path := ctx.Args().Get(0)
				key := ctx.Args().Get(1)

				deleted, err := deleteSecretValue(ctx, path, key)
				if err != nil {
					return err
				}

				if deleted {
					fmt.Printf("Secret deleted: %s:%s\n", path, key)
				} else {
					return fmt.Errorf("secret not found: %s:%s", path, key)
				}
				return nil
			},
		},
		{
			Name:      "list",
			Usage:     "List all keys in a path (values NOT shown)",
			ArgsUsage: "<path>",
			Action: func(ctx *cli.Context) error {
				if ctx.NArg() < 1 {
					return fmt.Errorf("usage: reactorcide secrets list <path>")
				}

				path := ctx.Args().Get(0)

				keys, err := listSecretKeys(ctx, path)
				if err != nil {
					return err
				}

				sort.Strings(keys)
				for _, k := range keys {
					fmt.Println(k)
				}
				return nil
			},
		},
		{
			Name:      "get-multi",
			Usage:     "Get multiple secrets at once (key derivation happens once)",
			ArgsUsage: "<path:key> [path:key...]",
			Flags: []cli.Flag{
				&cli.StringFlag{
					Name:    "format",
					Aliases: []string{"f"},
					Usage:   "Output format: env, json, or lines (default: env)",
					Value:   "env",
				},
			},
			Action: func(ctx *cli.Context) error {
				if ctx.NArg() < 1 {
					return fmt.Errorf("usage: reactorcide secrets get-multi <path:key> [path:key...]")
				}

				refs := make([]secrets.SecretRef, 0, ctx.NArg())
				for i := 0; i < ctx.NArg(); i++ {
					arg := ctx.Args().Get(i)
					parts := strings.SplitN(arg, ":", 2)
					if len(parts) != 2 {
						return fmt.Errorf("invalid format %q, expected path:key", arg)
					}
					refs = append(refs, secrets.SecretRef{Path: parts[0], Key: parts[1]})
				}

				results, err := getMultiSecrets(ctx, refs)
				if err != nil {
					return err
				}

				for _, ref := range refs {
					if results[ref.Key] == "" {
						return fmt.Errorf("secret not found: %s:%s", ref.Path, ref.Key)
					}
				}

				format := ctx.String("format")
				switch format {
				case "env":
					for _, ref := range refs {
						fmt.Printf("%s=%s\n", ref.Key, results[ref.Key])
					}
				case "json":
					jsonBytes, _ := json.MarshalIndent(results, "", "  ")
					fmt.Println(string(jsonBytes))
				case "lines":
					for _, ref := range refs {
						fmt.Println(results[ref.Key])
					}
				default:
					return fmt.Errorf("unknown format: %s", format)
				}

				return nil
			},
		},
		{
			Name:  "list-paths",
			Usage: "List all paths that have secrets",
			Action: func(ctx *cli.Context) error {
				paths, err := listSecretPaths(ctx)
				if err != nil {
					return err
				}

				sort.Strings(paths)
				for _, p := range paths {
					fmt.Println(p)
				}
				return nil
			},
		},
		masterKeysCommand,
	},
}
View Source
var ServeCommand = &cli.Command{
	Name:  "serve",
	Usage: "Run the Server",
	Flags: flags,
	Action: func(ctx *cli.Context) error {
		return Serve()
	},
}
View Source
var Server *http.ServeMux
View Source
var SubmitCommand = &cli.Command{
	Name:      "submit",
	Usage:     "Submit a job to a remote Reactorcide coordinator",
	ArgsUsage: "<job-file>",
	Flags: append(apiFlags(),
		&cli.StringSliceFlag{
			Name:    "overlay",
			Aliases: []string{"o"},
			Usage:   "Overlay file(s) to merge with job definition (can be repeated)",
		},
		&cli.BoolFlag{
			Name:  "allow-secret-overrides",
			Usage: "Allow overlay files to override secret references with plaintext",
		},
		&cli.BoolFlag{
			Name:    "wait",
			Aliases: []string{"w"},
			Usage:   "Wait for job to complete and show final status",
		},
		&cli.IntFlag{
			Name:  "poll-interval",
			Value: 5,
			Usage: "Polling interval in seconds when using --wait",
		},
	),
	Action: submitAction,
}

SubmitCommand submits a job to a remote Reactorcide coordinator API

View Source
var TokenCommand = &cli.Command{
	Name:  "token",
	Usage: "Manage API tokens",
	Subcommands: []*cli.Command{
		{
			Name:  "create",
			Usage: "Create a new API token",
			Description: "Writes straight to the database, so it works before any token exists. " +
				"It needs database access; the other token subcommands use the API.",
			Flags: []cli.Flag{
				&cli.StringFlag{
					Name:     "name",
					Aliases:  []string{"n"},
					Usage:    "Name for the token",
					Required: true,
				},
				&cli.StringFlag{
					Name:  "as-user",
					Usage: "Delegate the token to this username",
				},
				&cli.StringSliceFlag{Name: "org", Usage: "Limit the token to this organization; repeat for more organizations"},
				&cli.StringSliceFlag{Name: "capability", Usage: "Limit the token to this capability; repeat for more capabilities"},
				&cli.StringFlag{
					Name:        "db-uri",
					Aliases:     []string{"db"},
					Usage:       "Database connection URI",
					Destination: &config.DbUri,
					EnvVars:     []string{"REACTORCIDE_DB_URI", "DB_URI"},
				},
			},
			Action: func(ctx *cli.Context) error {
				store.AppStore = postgres_store.PostgresStore
				if _, err := store.AppStore.Initialize(); err != nil {
					return fmt.Errorf("failed to initialize database: %w", err)
				}

				adminStore, ok := store.AppStore.(interface {
					EnsureDefaultOrganization(context.Context) error
					GetOrganizationByName(context.Context, string) (*models.Organization, error)
					GetUserByUsername(context.Context, string) (*models.User, error)
				})
				if !ok {
					return fmt.Errorf("configured store does not support organization token bootstrap")
				}
				if err := adminStore.EnsureDefaultOrganization(context.Background()); err != nil {
					return fmt.Errorf("failed to ensure default organization: %w", err)
				}

				tokenName := ctx.String("name")
				orgNames := ctx.StringSlice("org")
				capabilityNames := ctx.StringSlice("capability")
				capabilitySet, err := tokencaps.New(capabilityNames...)
				if err != nil {
					return err
				}
				orgIDs := make([]string, 0, len(orgNames))
				for _, name := range orgNames {
					org, err := adminStore.GetOrganizationByName(context.Background(), name)
					if err != nil {
						return fmt.Errorf("organization %q: %w", name, err)
					}
					orgIDs = append(orgIDs, org.OrgID)
				}
				subjectType := "instance_token"
				var ownerOrgID *string
				var userID string
				if username := ctx.String("as-user"); username != "" {
					user, err := adminStore.GetUserByUsername(context.Background(), username)
					if err != nil {
						return fmt.Errorf("user %q: %w", username, err)
					}
					userID = user.UserID
					subjectType = "user_token"
				} else if len(orgIDs) > 0 {
					subjectType = "service_token"
					ownerOrgID = &orgIDs[0]
				}

				tokenBytes := make([]byte, 32)
				if _, err := rand.Read(tokenBytes); err != nil {
					return fmt.Errorf("failed to generate token: %w", err)
				}
				tokenString := hex.EncodeToString(tokenBytes)

				tokenHash := checkauth.HashAPIToken(tokenString)

				apiToken := &models.APIToken{
					UserID: userID, TokenHash: tokenHash, Name: tokenName, IsActive: true,
					SubjectType: subjectType, OwnerOrgID: ownerOrgID,
					AllOrganizations: len(orgIDs) == 0, OrganizationIDs: orgIDs,
					AllCapabilities: len(capabilityNames) == 0, Capabilities: capabilitySet.Slice(),
				}

				if err := store.AppStore.CreateAPIToken(context.Background(), apiToken); err != nil {
					return fmt.Errorf("failed to create token: %w", err)
				}

				fmt.Printf("Token created successfully!\n")
				fmt.Printf("Token ID: %s\n", apiToken.TokenID)
				fmt.Printf("Token: %s\n", tokenString)
				fmt.Printf("\nSave this token - it cannot be retrieved again!\n")

				return nil
			},
		},
		{
			Name:  "list",
			Usage: "List the API tokens of the authenticated user",
			Flags: append(apiFlags(), formatFlag()),
			Action: func(ctx *cli.Context) error {
				client, err := newAPIClient(ctx)
				if err != nil {
					return err
				}
				var resp listTokensResponse
				if err := client.doJSON(http.MethodGet, "/api/v1/tokens", nil, http.StatusOK, &resp); err != nil {
					return err
				}
				return render(ctx.String("format"), resp.Tokens, func(w *tabwriter.Writer) {
					fmt.Fprintln(w, "TOKEN ID\tNAME\tACTIVE\tCREATED\tEXPIRES\tLAST USED")
					for _, t := range resp.Tokens {
						fmt.Fprintf(w, "%s\t%s\t%t\t%s\t%s\t%s\n",
							t.TokenID, t.Name, t.IsActive,
							t.CreatedAt.Format(time.RFC3339),
							timeOrDash(t.ExpiresAt), timeOrDash(t.LastUsedAt))
					}
				})
			},
		},
		{
			Name:      "delete",
			Usage:     "Delete an API token",
			ArgsUsage: "<token-id>",
			Flags:     apiFlags(),
			Action: func(ctx *cli.Context) error {
				if ctx.NArg() < 1 {
					return fmt.Errorf("usage: reactorcide token delete <token-id>")
				}
				client, err := newAPIClient(ctx)
				if err != nil {
					return err
				}
				tokenID := ctx.Args().Get(0)
				if err := client.doJSON(http.MethodDelete, "/api/v1/tokens/"+url.PathEscape(tokenID), nil, http.StatusNoContent, nil); err != nil {
					return err
				}
				fmt.Printf("Token deleted: %s\n", tokenID)
				return nil
			},
		},
	},
}
View Source
var VMImageCommand = &cli.Command{
	Name:  "vm-image",
	Usage: "Build, publish, pull, and manage VM images",
	Subcommands: []*cli.Command{
		vmImageBuildCommand,
		vmImagePublishCommand,
		vmImagePullCommand,
		vmImageCacheCommand,
		vmImageRegistryCommand,
	},
}
View Source
var WindowsServiceCommand = &cli.Command{
	Name:  "windows-service",
	Usage: "Manage the native Windows worker service",
	Subcommands: []*cli.Command{
		{
			Name:  "install",
			Usage: "Install the worker service",
			Flags: []cli.Flag{
				&cli.StringFlag{Name: "config", Required: true, Usage: "Path to the worker service JSON file"},
				&cli.StringFlag{Name: "executable", Usage: "Path to reactorcide.exe (default: this executable)"},
			},
			Action: func(ctx *cli.Context) error {
				executable := ctx.String("executable")
				if executable == "" {
					var err error
					executable, err = os.Executable()
					if err != nil {
						return fmt.Errorf("find current executable: %w", err)
					}
				}
				return windowsservice.Install(executable, ctx.String("config"))
			},
		},
		{Name: "start", Usage: "Start the worker service", Action: func(*cli.Context) error { return windowsservice.Start() }},
		{Name: "stop", Usage: "Stop the worker service", Action: func(*cli.Context) error { return windowsservice.Stop() }},
		{
			Name:  "restart",
			Usage: "Restart the worker service",
			Action: func(*cli.Context) error {
				if err := windowsservice.Stop(); err != nil {
					return err
				}
				return windowsservice.Start()
			},
		},
		{
			Name:  "status",
			Usage: "Show the worker service state",
			Action: func(ctx *cli.Context) error {
				status, err := windowsservice.Status()
				if err == nil {
					fmt.Fprintln(ctx.App.Writer, status)
				}
				return err
			},
		},
		{Name: "uninstall", Usage: "Remove the stopped worker service", Action: func(*cli.Context) error { return windowsservice.Uninstall() }},
		{Name: "run", Hidden: true, Flags: []cli.Flag{&cli.StringFlag{Name: "config"}}},
	},
}
View Source
var WorkerCommand = &cli.Command{
	Name:  "worker",
	Usage: "Run a coordinator-mediated job worker",
	Flags: workerFlags,
	Action: func(ctx *cli.Context) error {
		return RunWorker(ctx)
	},
}

WorkerCommand runs a coordinator-mediated worker: it authenticates to a coordinator over CSIL-RPC and pulls work through it (Register -> RequestJob -> run -> AppendLogs -> ReportResult -> Heartbeat, see internal/coordinatorworker). It has no corndogs, Postgres, or object-store dependency of its own -- the coordinator is the only thing that talks to those. The legacy direct-corndogs worker path (which polled corndogs/Postgres directly) has been removed; this is the only worker mode.

View Source
var WorkersAdminCommand = &cli.Command{
	Name:  "workers",
	Usage: "Manage workers and worker routing",
	Flags: apiFlags(),
	Subcommands: []*cli.Command{
		workersListCommand(),
		workersStatusCommand(),
		workersDrainCommand(),
		workerPoolsCommand(),
		workerTokensCommand(),
		workerQueuesCommand(),
		workerClassesCommand(),
	},
}

WorkersAdminCommand exposes the complete worker administration surface. The singular "worker" command runs a worker process. The plural command manages workers, pools, enrollment tokens, queues, and worker classes.

View Source
var WorkflowsCommand = &cli.Command{
	Name:  "workflows",
	Usage: "List and control workflows on a Reactorcide coordinator",
	Flags: apiFlags(),
	Subcommands: []*cli.Command{
		{
			Name:  "list",
			Usage: "List workflows",
			Flags: append(append(apiFlags(), paginationFlags()...),
				formatFlag(),
				&cli.StringFlag{Name: "status", Usage: "Filter by workflow status"},
				&cli.StringFlag{Name: "project-id", Usage: "Filter by project ID"},
				&cli.StringFlag{Name: "user-id", Usage: "Filter by owning user ID (admins only)"},
			),
			Action: func(ctx *cli.Context) error {
				client, err := newAPIClient(ctx)
				if err != nil {
					return err
				}
				query := pagedQuery(ctx, map[string]string{
					"status":     ctx.String("status"),
					"project_id": ctx.String("project-id"),
					"user_id":    ctx.String("user-id"),
				})
				var resp listWorkflowsResponse
				if err := client.doJSON(http.MethodGet, "/api/v1/workflows"+query, nil, http.StatusOK, &resp); err != nil {
					return err
				}
				return render(ctx.String("format"), resp.Workflows, func(w *tabwriter.Writer) {
					fmt.Fprintln(w, "WORKFLOW ID\tNAME\tSTATUS\tJOBS\tRUNNING\tFAILED\tCREATED")
					for _, wf := range resp.Workflows {
						fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%d\t%d\t%s\n",
							wf.WorkflowID, wf.Name, wf.Status, wf.JobCount,
							wf.RunningCount, wf.FailedCount, wf.CreatedAt.Format(time.RFC3339))
					}
				})
			},
		},
		{
			Name:      "get",
			Usage:     "Get a workflow by ID",
			ArgsUsage: "<workflow-id>",
			Flags:     append(apiFlags(), formatFlag()),
			Action: func(ctx *cli.Context) error {
				workflowID, client, err := workflowTarget(ctx)
				if err != nil {
					return err
				}
				var summary models.WorkflowSummary
				if err := client.doJSON(http.MethodGet, "/api/v1/workflows/"+url.PathEscape(workflowID), nil, http.StatusOK, &summary); err != nil {
					return err
				}
				return renderWorkflow(ctx.String("format"), &summary)
			},
		},
		{
			Name:      "cancel",
			Usage:     "Cancel a workflow and its non-terminal jobs",
			ArgsUsage: "<workflow-id>",
			Flags:     append(apiFlags(), formatFlag()),
			Action: func(ctx *cli.Context) error {
				workflowID, client, err := workflowTarget(ctx)
				if err != nil {
					return err
				}
				var instance models.WorkflowInstance
				if err := client.doJSON(http.MethodPut, "/api/v1/workflows/"+url.PathEscape(workflowID)+"/cancel", nil, http.StatusOK, &instance); err != nil {
					return err
				}
				return renderWorkflowInstance(ctx.String("format"), &instance)
			},
		},
		{
			Name:      "retry",
			Usage:     "Retry a workflow as a new instance, leaving the original for history",
			ArgsUsage: "<workflow-id>",
			Flags:     append(apiFlags(), formatFlag()),
			Action: func(ctx *cli.Context) error {
				workflowID, client, err := workflowTarget(ctx)
				if err != nil {
					return err
				}
				var instance models.WorkflowInstance
				if err := client.doJSON(http.MethodPost, "/api/v1/workflows/"+url.PathEscape(workflowID)+"/retry", nil, http.StatusCreated, &instance); err != nil {
					return err
				}
				return renderWorkflowInstance(ctx.String("format"), &instance)
			},
		},
		{
			Name:      "retry-unsuccessful",
			Usage:     "Retry the failed and cancelled jobs of a workflow in place",
			ArgsUsage: "<workflow-id>",
			Flags:     append(apiFlags(), formatFlag()),
			Action: func(ctx *cli.Context) error {
				workflowID, client, err := workflowTarget(ctx)
				if err != nil {
					return err
				}
				var resp retryUnsuccessfulResponse
				if err := client.doJSON(http.MethodPost, "/api/v1/workflows/"+url.PathEscape(workflowID)+"/retry-unsuccessful", nil, http.StatusOK, &resp); err != nil {
					return err
				}

				if err := render(ctx.String("format"), resp.Jobs, func(w *tabwriter.Writer) {
					fmt.Fprintln(w, "JOB ID\tNAME\tSTATUS")
					for _, job := range resp.Jobs {
						fmt.Fprintf(w, "%s\t%s\t%s\n", job.JobID, job.Name, job.Status)
					}
				}); err != nil {
					return err
				}
				if resp.Error != "" {
					return fmt.Errorf("some jobs could not be retried: %s", resp.Error)
				}
				return nil
			},
		},
	},
}

Functions

func NormalizeArgs

func NormalizeArgs(app *cli.App, args []string) []string

NormalizeArgs reorders argv so that flags placed after a positional argument are still parsed.

urfave/cli v2 parses with the stdlib "flag" package, which stops at the first non-flag token and offers no "permute" mode. The natural invocation

reactorcide jobs get <job-id> --format json

therefore drops --format as a trailing positional instead of parsing it. NormalizeArgs walks the command tree to find the command argv resolves to, collecting the flags visible at each level, then moves that command's recognized flags ahead of its positional arguments — the ordering the underlying parser already handles.

Unknown flags are left in place as flags, so a typo still produces the normal "flag provided but not defined" error rather than being silently swallowed.

func RunMigrations

func RunMigrations() error

func RunWorker

func RunWorker(ctx *cli.Context) error

RunWorker wires CLI flags/env into a coordinatorworker.Config and blocks running the coordinator-mediated run loop until SIGINT/SIGTERM.

func Serve

func Serve() error

Types

type CreateJobRequest

type CreateJobRequest struct {
	Name        string `json:"name"`
	Description string `json:"description,omitempty"`

	// Source configuration
	SourceURL  string `json:"source_url,omitempty"`
	SourceRef  string `json:"source_ref,omitempty"`
	SourceType string `json:"source_type"`
	SourcePath string `json:"source_path,omitempty"`

	// CI Source configuration (trusted CI pipeline code)
	CISourceType string `json:"ci_source_type,omitempty"`
	CISourceURL  string `json:"ci_source_url,omitempty"`
	CISourceRef  string `json:"ci_source_ref,omitempty"`

	// Runnerlib configuration
	CodeDir     string `json:"code_dir,omitempty"`
	JobDir      string `json:"job_dir,omitempty"`
	JobCommand  string `json:"job_command"`
	RunnerImage string `json:"runner_image,omitempty"`

	// Environment configuration
	JobEnvVars map[string]string `json:"job_env_vars,omitempty"`
	JobEnvFile string            `json:"job_env_file,omitempty"`

	// Execution settings
	TimeoutSeconds *int   `json:"timeout_seconds,omitempty"`
	Priority       *int   `json:"priority,omitempty"`
	RunAsUser      string `json:"run_as_user,omitempty"`
	QueueName      string `json:"queue_name,omitempty"`

	// ImagePullSecrets lists Kubernetes Secret NAMES for pulling the job
	// image — never credentials.
	ImagePullSecrets []string `json:"image_pull_secrets,omitempty"`

	// Characteristics/Resources. Passed through verbatim from the job spec's
	// top-level `characteristics`/`resources` blocks; the coordinator
	// validates and applies them (queue routing, resource defaults).
	Characteristics map[string]interface{} `json:"characteristics,omitempty"`
	WorkerClass     string                 `json:"worker_class,omitempty"`
	Resources       map[string]interface{} `json:"resources,omitempty"`
}

CreateJobRequest is the API request structure for creating a job

type JobResponse

type JobResponse struct {
	JobID       string    `json:"job_id"`
	Name        string    `json:"name"`
	Description string    `json:"description"`
	Status      string    `json:"status"`
	CreatedAt   time.Time `json:"created_at"`
	UpdatedAt   time.Time `json:"updated_at"`

	// Source info
	SourceURL  string `json:"source_url,omitempty"`
	SourceRef  string `json:"source_ref,omitempty"`
	SourceType string `json:"source_type"`
	SourcePath string `json:"source_path,omitempty"`

	// Execution info
	TimeoutSeconds int        `json:"timeout_seconds"`
	Priority       int        `json:"priority"`
	RunAsUser      string     `json:"run_as_user,omitempty"`
	QueueName      string     `json:"queue_name"`
	StartedAt      *time.Time `json:"started_at,omitempty"`
	CompletedAt    *time.Time `json:"completed_at,omitempty"`
	ExitCode       *int       `json:"exit_code,omitempty"`
}

JobResponse is the API response structure for job operations

Directories

Path Synopsis
This stub keeps `go build ./...` working on every platform/tag combination that is NOT a real smoke target (darwin && vz, or windows).
This stub keeps `go build ./...` working on every platform/tag combination that is NOT a real smoke target (darwin && vz, or windows).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL