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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 个人开源部署项目

在用户本地提供可视化管理服务,帮助用户已有的编码 Agent 部署、检查和维护其跨平台应用资源。这里记录已明确的领域用语;技术选型见 [架构决策](docs/adr/),当前范围见 [PRODUCT.md](PRODUCT.md)。
在用户本地提供管理服务,帮助用户已有的编码 Agent 将应用部署到自有服务器或云平台,并核对发布结果。这里记录已明确的领域用语;技术选型见 [架构决策](docs/adr/),当前范围见 [PRODUCT.md](PRODUCT.md)。

## Language

Expand All @@ -10,6 +10,9 @@ _Avoid_: 用“AI 应用”统称所有受管应用
**资源账号**:用户持有的第三方基础设施服务账号,应用及相关云资源由这些账号承载。
_Avoid_: 将第三方资源账号授权与作者统一账号登录混用

**部署目标**:用户授权 Carry 在其上发布受管应用的服务器或云平台项目。管理应用的发布不等于接管整台服务器或整个资源账号。
_Avoid_: 将应用发布权限称为主机或账号的无限管理权限

**跨平台组合**:同一个受管应用使用不同平台提供的计算、数据库、存储等服务,并由本项目协调它们之间的连接。
_Avoid_: 将“能够选择一个部署平台”称为“跨平台组合”

Expand All @@ -31,7 +34,7 @@ _Avoid_: 将外部编码 Agent 称为本项目内置的聊天助手
**外接资源**:应用通过连接信息使用、但未交由本工具管理配置和生命周期的既有资源。
_Avoid_: 将配置连接信息称为接管资源

**受管资源**:本工具负责管理其配置和生命周期的云资源,可以由工具创建,也可以由用户明确接管。
**受管资源**:本工具负责管理其配置和生命周期的用户资源,可以由工具创建,也可以由用户明确接管;在 VPS 路径中,受管范围先限于授权应用的部署单元。
_Avoid_: 将受管资源与作者拥有的资源混用

**资源接管**:用户授权工具通过供应商管理接口,把既有资源纳入所支持的管理范围和状态记录的过程。资源仍运行在原供应商处,账号及费用归属不变;SQL 连接凭证并不自动授予供应商资源管理权限。
Expand Down
183 changes: 39 additions & 144 deletions PRODUCT.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@ Do not paste tokens. If you only have code and no cloud app yet, the agent shoul
**[Get started](https://github.com/unix2dos/carry/blob/main/docs/FIRST-TRY.md)** — install, current limits (macOS Apple Silicon, existing Vercel Hobby app; Neon optional), and the first deploy.

[Alpha notes](docs/ALPHA.md) · [Product scope](PRODUCT.md) · [License](LICENSE)

## VPS path in development

The source tree now has a single-server VPS path for an existing Linux host reached through a known SSH alias. It builds a Dockerfile locally for the host architecture, transfers the image over SSH, and runs the app with a Caddy HTTPS proxy through Docker Compose. The current published Alpha installer still documents Vercel/Railway; this VPS path needs local Docker, remote Docker, a user-owned domain, and a manually configured DNS record.

`carry register --provider vps` binds one app to one host; `status`, `publish`, `reconcile`, `logs`, `check`, and image-only `rollback` use the same local operation history as cloud deployments. Secrets enter through `carry secret save NAME KEY --stdin`, never `--vps-env`. The first real acceptance target and its remaining checks are in [the product plan](PRODUCT.md).
52 changes: 51 additions & 1 deletion cmd/carry/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ func validateReferences(p *Project) error {
return errors.New("Vercel requires explicit team and project IDs")
}
ids = append(ids, p.VercelTeam, p.VercelProject)
case "vps":
if p.hasNeon() || !vpsHostPattern.MatchString(p.VPSHost) || p.VPSPort < 1 || p.VPSPort > 65535 ||
(p.VPSDataPath != "" && !validVPSDataPath(p.VPSDataPath)) || !validVPSEnv(p.VPSEnv) {
return errors.New("VPS requires a safe SSH host alias, container port and optional absolute data path/non-secret environment")
}
default:
return errors.New("unsupported compute provider")
}
Expand Down Expand Up @@ -157,7 +162,8 @@ func bundleSource(source, root string, secrets []string) (dir, digest string, co
return fmt.Errorf("source contains a symlink: %s; alpha does not follow symlinks", rel)
}
if d.IsDir() {
return os.MkdirAll(filepath.Join(dir, rel), 0700)
// The private staging root protects local source; copied image directories must be traversable by non-root app users.
return os.MkdirAll(filepath.Join(dir, rel), 0755)
}
info, e := d.Info()
if e != nil || !info.Mode().IsRegular() {
Expand Down Expand Up @@ -284,6 +290,50 @@ func (e *Engine) executePublish(ctx context.Context, p Project, op *Operation, w
}
return nil
}
func (e *Engine) rollbackVPS(ctx context.Context, p Project, op *Operation) error {
if p.Provider != "vps" {
return e.record(op, "blocked", "rollback currently supports only VPS applications")
}
current, err := e.Providers.vpsDeployments(ctx, p)
if err != nil || len(current) != 1 {
e.record(op, "blocked", "current VPS application could not be identified")
return errors.New("current VPS application could not be identified")
}
operations, err := e.Store.operations(p.Name)
if err != nil {
return err
}
var prior *Operation
for i := range operations {
if operations[i].State == "deployed" && operations[i].Marker != current[0].Meta.Message {
prior = &operations[i]
break
}
}
if prior == nil {
return e.record(op, "blocked", "no earlier deployed image is available to restore")
}
image := vpsImage(p, prior.Marker)
if _, err = e.Providers.vpsCall(ctx, p, "image", "inspect", image, "--format", "{{.Id}}"); err != nil {
e.record(op, "blocked", "earlier image is not available on the VPS")
return err
}
op.SourceHash, op.SourceFiles = prior.SourceHash, prior.SourceFiles
if err = e.record(op, "submitting", "restoring an earlier application image; current environment and data remain in place"); err != nil {
return err
}
submitErr := e.Providers.applyVPSCompose(ctx, p, image, op.Marker)
if err = e.record(op, "unknown", "rollback result needs readback"); err != nil {
return err
}
if err = e.reconcileOnce(ctx, p, op); err != nil {
return err
}
if submitErr != nil && op.DeploymentID == "" {
return errors.New("rollback outcome is unknown; reconcile before another operation")
}
return nil
}
func (e *Engine) reconcileOnce(ctx context.Context, p Project, op *Operation) error {
if op.State == "blocked" {
return nil
Expand Down
119 changes: 110 additions & 9 deletions cmd/carry/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (

var version = "dev"

const help = `carry — alpha, existing Railway or Vercel projects with optional Neon
const help = `carry — local deployment manager

Global flags (before command):
--state-dir PATH Private local records (default: ~/.carry; existing legacy state reused)
Expand All @@ -27,19 +27,25 @@ Global flags (before command):

Commands:
version Print the installed CLI version (also: --version)
register --name NAME --source DIR --url HTTPS_ORIGIN [--provider vercel|railway]
register --name NAME --source DIR --url HTTPS_ORIGIN [--provider vercel|railway|vps]
--vercel-team ID --vercel-project ID
[--neon-org ID --neon-project ID --neon-endpoint ID] (all three or none)
[--allow-publish] [--allow-hobby]
Default: Vercel; --allow-hobby accepts personal noncommercial Hobby conditions
Railway: use --provider railway --workspace ID --railway-project ID
--service ID --environment ID instead of Vercel IDs; [--allow-trial]
VPS: --provider vps --vps-host SSH_ALIAS --vps-port CONTAINER_PORT
[--vps-data-path ABSOLUTE_CONTAINER_PATH] [--vps-env KEY=VALUE]...
Save secrets separately with 'secret save NAME KEY --stdin'
Existing projects keep their bound provider for all subsequent operations
rebind NAME --source DIR Change the bound source directory after verifying it
env NAME KEY VALUE VPS only: change a non-secret application variable
list
status NAME Live read-only ownership, account plan and resource checks
status NAME Live read-only ownership, account plan or VPS container checks
check NAME GET /healthz; also /readyz when Neon is bound; no business writes
logs NAME Last 40 deployment log lines; known credentials redacted
publish NAME [--detach] Upload a captured source directory to the bound service
publish NAME [--detach] Publish captured source to the bound service or VPS
rollback NAME VPS only: restore an earlier application image; keep data and current secrets
reconcile NAME [--wait] Find the existing operation by its deployment marker
history NAME
secret save NAME KEY --stdin Save plaintext in an owner-only local file; no cloud changes
Expand All @@ -50,8 +56,9 @@ Commands:
authorize NAME [--allow-publish=true|false] [--allow-trial=true|false]
serve [--port 0] [--open] Loopback-only local webpage with session authentication

Official CLI login remains a user-owned prerequisite. This alpha does not create,
delete or adopt whole cloud projects, change billing, or migrate databases.
Official cloud CLI login remains a user-owned prerequisite. VPS publishing needs
local Docker and an SSH-bound Docker Engine on the user's server. Carry does not
create cloud accounts, purchase servers, change billing, or migrate databases.
All non-server command outputs are JSON. Business secret values are stored separately
in private local files and never printed. Official CLI login credentials stay with
the official tools. Use one explicit project authorization for regular updates.
Expand Down Expand Up @@ -171,7 +178,7 @@ func run(ctx context.Context, args []string) error {
if settings.NeonConfig == "" {
settings.NeonConfig = filepath.Join(home, ".config", "neon")
}
providers := &Providers{Railway: resolveTool(settings.Railway, "railway", "@railway/cli/bin/railway", "CARRY_RAILWAY_BIN", "SHIP_RAILWAY_BIN", "UPOK_RAILWAY_BIN"), Neon: resolveTool(settings.Neon, "neon", ".bin/neon", "CARRY_NEON_BIN", "SHIP_NEON_BIN", "UPOK_NEON_BIN"), NeonConfig: settings.NeonConfig, Vercel: resolveTool(settings.Vercel, "vercel", ".bin/vercel", "CARRY_VERCEL_BIN", "SHIP_VERCEL_BIN", "UPOK_VERCEL_BIN"), VercelConfig: settings.VercelConfig, Store: store}
providers := &Providers{Railway: resolveTool(settings.Railway, "railway", "@railway/cli/bin/railway", "CARRY_RAILWAY_BIN", "SHIP_RAILWAY_BIN", "UPOK_RAILWAY_BIN"), Neon: resolveTool(settings.Neon, "neon", ".bin/neon", "CARRY_NEON_BIN", "SHIP_NEON_BIN", "UPOK_NEON_BIN"), NeonConfig: settings.NeonConfig, Vercel: resolveTool(settings.Vercel, "vercel", ".bin/vercel", "CARRY_VERCEL_BIN", "SHIP_VERCEL_BIN", "UPOK_VERCEL_BIN"), VercelConfig: settings.VercelConfig, Docker: resolveTool("", "docker", ""), SSH: resolveTool("", "ssh", ""), Store: store}
engine := &Engine{Store: store, Providers: providers}
switch args[0] {
case "secret":
Expand All @@ -190,6 +197,7 @@ func run(ctx context.Context, args []string) error {
case "register":
f := flag.NewFlagSet("register", flag.ContinueOnError)
var p Project
var vpsEnv envFlags
f.StringVar(&p.Provider, "provider", "vercel", "compute provider")
f.StringVar(&p.VercelTeam, "vercel-team", "", "Vercel team ID")
f.StringVar(&p.VercelProject, "vercel-project", "", "Vercel project ID")
Expand All @@ -206,6 +214,10 @@ func run(ctx context.Context, args []string) error {
f.StringVar(&p.NeonEndpoint, "neon-endpoint", "", "Neon endpoint")
f.BoolVar(&p.AllowPublish, "allow-publish", false, "authorize regular source updates to this exact service")
f.BoolVar(&p.AllowTrial, "allow-trial", false, "explicitly accept the current Trial account for internal testing")
f.StringVar(&p.VPSHost, "vps-host", "", "existing SSH host alias")
f.IntVar(&p.VPSPort, "vps-port", 0, "application container port")
f.StringVar(&p.VPSDataPath, "vps-data-path", "", "optional persistent container directory")
f.Var(&vpsEnv, "vps-env", "repeatable non-secret KEY=VALUE")
if err = f.Parse(args[1:]); err != nil {
return err
}
Expand All @@ -215,11 +227,26 @@ func run(ctx context.Context, args []string) error {
if p.Source == "" {
return errors.New("source is required")
}
p.VPSEnv = vpsEnv
if err = validateProject(&p); err != nil {
return err
}
if _, err = providers.inspect(ctx, p); err != nil {
return err
if p.Provider == "vps" {
projects, e := store.projects()
if e != nil {
return e
}
// ponytail: one Carry app per VPS until a second real app justifies a shared HTTPS proxy.
for _, current := range projects {
if current.Provider == "vps" && current.VPSHost == p.VPSHost {
return errors.New("this VPS already has a Carry app; shared-domain routing is not supported yet")
}
}
}
if p.Provider != "vps" {
if _, err = providers.inspect(ctx, p); err != nil {
return err
}
}
if err = store.register(p); err != nil {
return err
Expand Down Expand Up @@ -256,6 +283,65 @@ func run(ctx context.Context, args []string) error {
return errors.New("registered project not found")
}
switch args[0] {
case "env":
if len(args) != 4 || p.Provider != "vps" || !validVPSEnv(map[string]string{args[2]: args[3]}) {
return errors.New("use env NAME NON_SECRET_KEY VALUE for a VPS binding")
}
unlock, err := store.lock(p.Name)
if err != nil {
return err
}
defer unlock()
ops, err := store.operations(p.Name)
if err != nil {
return err
}
for _, op := range ops {
if !op.terminal() {
return errors.New("reconcile the unfinished operation before changing environment")
}
}
if p.VPSEnv == nil {
p.VPSEnv = map[string]string{}
}
p.VPSEnv[args[2]] = args[3]
if err = atomicJSON(filepath.Join(store.Root, "projects", p.Name+".json"), p); err != nil {
return err
}
output(p.VPSEnv)
return nil
case "rebind":
f := flag.NewFlagSet("rebind", flag.ContinueOnError)
source := f.String("source", "", "new source directory")
if err = f.Parse(args[2:]); err != nil {
return err
}
if f.NArg() != 0 || *source == "" {
return errors.New("use rebind NAME --source DIR")
}
p.Source = *source
if err = validateProject(&p); err != nil {
return err
}
unlock, err := store.lock(p.Name)
if err != nil {
return err
}
defer unlock()
ops, err := store.operations(p.Name)
if err != nil {
return err
}
for _, op := range ops {
if !op.terminal() {
return errors.New("reconcile the unfinished operation before changing source")
}
}
if err = atomicJSON(filepath.Join(store.Root, "projects", p.Name+".json"), p); err != nil {
return err
}
output(p)
return nil
case "authorize":
f := flag.NewFlagSet("authorize", flag.ContinueOnError)
f.BoolVar(&p.AllowPublish, "allow-publish", p.AllowPublish, "authorization for future source updates to this exact service")
Expand Down Expand Up @@ -332,6 +418,21 @@ func run(ctx context.Context, args []string) error {
err = operationError(op)
}
return err
case "rollback":
if len(args) != 2 {
return errors.New("use rollback NAME")
}
op, unlock, err := engine.begin(p)
if err != nil {
return err
}
defer unlock()
err = engine.rollbackVPS(ctx, p, op)
output(op)
if err == nil {
err = operationError(op)
}
return err
case "reconcile":
wait := false
if len(args) == 3 && args[2] == "--wait" {
Expand Down
29 changes: 28 additions & 1 deletion cmd/carry/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
Expand Down Expand Up @@ -224,6 +225,32 @@ func TestOwnershipMismatchAndSecretSafeSource(t *testing.T) {
t.Fatal("source symlink followed")
}
}
func TestBundledSourceKeepsPrivateRootAndReadableImageDirectories(t *testing.T) {
e, p := fixture(t)
nested := filepath.Join(p.Source, "web", "dist")
if err := os.MkdirAll(nested, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nested, "app.js"), []byte("ready"), 0644); err != nil {
t.Fatal(err)
}
stage, _, _, err := bundleSource(p.Source, e.Store.Root, nil)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(stage)
rootInfo, err := os.Stat(stage)
if err != nil {
t.Fatal(err)
}
dirInfo, err := os.Stat(filepath.Join(stage, "web", "dist"))
if err != nil {
t.Fatal(err)
}
if rootInfo.Mode().Perm() != 0700 || dirInfo.Mode().Perm()&0005 != 0005 {
t.Fatal("staging root must stay private while image directories allow non-root traversal")
}
}
func TestPrivateStateAndProjectLock(t *testing.T) {
e, p := fixture(t)
if err := validateProject(&p); err != nil {
Expand Down Expand Up @@ -404,7 +431,7 @@ func TestRenameReusesExistingState(t *testing.T) {
t.Fatal(err)
}
got, err := reopened.project(p.Name)
if err != nil || got != p {
if err != nil || !reflect.DeepEqual(got, p) {
t.Fatal("rename lost an existing project or its authorizations")
}
if _, err = os.Stat(carryDir); !os.IsNotExist(err) {
Expand Down
Loading
Loading