Compare commits
2 Commits
e3b92a3b5d
...
c84d8868d1
| Author | SHA1 | Date | |
|---|---|---|---|
| c84d8868d1 | |||
| fc33bbca54 |
@@ -43,6 +43,7 @@ Canonical homes:
|
||||
|
||||
- project purpose and quickstart: `README.md`
|
||||
- development principles: `docs/policy/architecture.md`
|
||||
- public HTTP API reference: `docs/api.md`
|
||||
- configuration reference: `docs/config.md`
|
||||
- CLI reference: `docs/cli.md`
|
||||
- operations and recovery: `docs/operations.md`
|
||||
@@ -122,6 +123,22 @@ Recommended:
|
||||
- `docs/troubleshooting.md`
|
||||
- validated examples under `examples/`
|
||||
|
||||
### Public HTTP API service
|
||||
|
||||
Required:
|
||||
- `docs/api.md`
|
||||
- `docs/cli.md`, if CLI-based
|
||||
- `docs/config.md`, if config-driven
|
||||
- `docs/operations.md`
|
||||
- `docs/internal/`
|
||||
- `docs/policy/development.md`
|
||||
|
||||
Recommended:
|
||||
- `docs/troubleshooting.md`
|
||||
- `docs/consumers/`, for task-oriented client integration guides
|
||||
- `docs/integrations/`, for upstream/downstream service contracts
|
||||
- validated examples under `examples/`
|
||||
|
||||
### Project with public packages or consumer APIs
|
||||
|
||||
Required:
|
||||
@@ -173,6 +190,32 @@ It should include:
|
||||
|
||||
For small projects, this file may be brief. It may simply state that the project is intentionally narrow, monolithic, and dependency-light.
|
||||
|
||||
### docs/api.md
|
||||
|
||||
**Audience:** external HTTP API consumers, developers, LLM coding agents integrating by HTTP
|
||||
|
||||
Required for projects whose primary public interface is HTTP.
|
||||
|
||||
`docs/api.md` is the canonical public HTTP API contract. It should be normative for external consumers and should not be duplicated by README, operations docs, consumer guides, or integration docs.
|
||||
|
||||
It should include:
|
||||
|
||||
1. base URL conventions;
|
||||
2. authentication and authorization behavior, if implemented;
|
||||
3. response envelope;
|
||||
4. supported media types and content negotiation behavior;
|
||||
5. shared query parameters;
|
||||
6. endpoint reference grouped by route family;
|
||||
7. request parameters and validation rules;
|
||||
8. response fields, units, nullability, and optionality;
|
||||
9. error response shape and status codes;
|
||||
10. pagination, caching, rate-limit, idempotency, and retry behavior, if implemented;
|
||||
11. compact request and response examples.
|
||||
|
||||
It must document only implemented endpoints and behavior. Planned endpoints, proposed fields, future filters, and experimental response shapes belong only under `docs/roadmap/`.
|
||||
|
||||
For HTTP API projects, `docs/consumers/` may provide task-oriented client integration guides, but those guides should link to `docs/api.md` for the authoritative endpoint contract.
|
||||
|
||||
### docs/policy/development.md
|
||||
|
||||
**Audience:** developers, LLM coding agents
|
||||
@@ -264,6 +307,8 @@ Required for projects with public packages, SDKs, client APIs, plugin APIs, or o
|
||||
|
||||
This directory describes how an external codebase should consume the project's public API. It should be task-oriented and copyable where useful. It is not the place for internal implementation details or operator procedures.
|
||||
|
||||
For projects whose public API is HTTP, `docs/consumers/` is not required, and it should not duplicate the endpoint reference in `docs/api.md`. If present, it may provide practical integration workflows, client-specific examples, or migration notes that link back to `docs/api.md`.
|
||||
|
||||
`docs/consumers/api.md` should provide the consumer-facing overview and primary implementation workflow. It should include:
|
||||
|
||||
1. intended consumer audience and use cases;
|
||||
@@ -330,6 +375,8 @@ Required for projects that depend on external CLIs, APIs, services, protocols, o
|
||||
|
||||
This directory contains concise, versioned reference notes for external integration contracts. It should document only the parts of the external system that this project actually uses or exposes.
|
||||
|
||||
For public HTTP API services, `docs/integrations/` should document upstream, downstream, storage, protocol, or runtime contracts that the service depends on or bridges. It should not become a second copy of the public HTTP endpoint reference; that belongs in `docs/api.md`.
|
||||
|
||||
Use one file per integration where useful.
|
||||
|
||||
## Examples Directory
|
||||
@@ -385,9 +432,10 @@ Before merging documentation changes, verify:
|
||||
|
||||
- README is concise and orientation-focused.
|
||||
- `docs/policy/architecture.md` describes development principles.
|
||||
- `docs/api.md` is the canonical HTTP contract for HTTP API services.
|
||||
- Future work appears only under `docs/roadmap/`.
|
||||
- User-facing docs avoid unnecessary internals.
|
||||
- Consumer-facing docs explain public APIs without duplicating integration contracts.
|
||||
- Consumer-facing docs explain public APIs without duplicating HTTP endpoint or integration contracts.
|
||||
- Developer-facing docs preserve boundaries and invariants.
|
||||
- Config examples match the schema.
|
||||
- CLI examples match real commands and flags.
|
||||
|
||||
@@ -170,7 +170,7 @@ func (b *Backend) WriteFrom(ctx context.Context, logicalPath string, r io.Reader
|
||||
return storage.Entry{}, storage.NewError(storage.OpWriteFrom, BackendName, logicalPath, storage.ErrConflict, fmt.Errorf("stream size %d does not match expected size %d", written, opts.Size))
|
||||
}
|
||||
if opts.PreferAtomic {
|
||||
if err := b.client.Rename(writePath, nativePath); err != nil {
|
||||
if err := renamePromotedFile(b.client, writePath, nativePath, opts.Overwrite); err != nil {
|
||||
return storage.Entry{}, b.translateError(storage.OpWriteFrom, logicalPath, err)
|
||||
}
|
||||
cleanup = false
|
||||
@@ -178,6 +178,27 @@ func (b *Backend) WriteFrom(ctx context.Context, logicalPath string, r io.Reader
|
||||
return b.Stat(ctx, logicalPath)
|
||||
}
|
||||
|
||||
type sftpRenamer interface {
|
||||
PosixRename(oldname, newname string) error
|
||||
Rename(oldname, newname string) error
|
||||
Remove(path string) error
|
||||
}
|
||||
|
||||
func renamePromotedFile(client sftpRenamer, oldname, newname string, overwrite bool) error {
|
||||
if !overwrite {
|
||||
return client.Rename(oldname, newname)
|
||||
}
|
||||
if err := client.PosixRename(oldname, newname); err == nil {
|
||||
return nil
|
||||
} else if !isReplaceRenameFallbackError(err) {
|
||||
return err
|
||||
}
|
||||
if err := client.Remove(newname); err != nil && !isNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return client.Rename(oldname, newname)
|
||||
}
|
||||
|
||||
func (b *Backend) Stat(ctx context.Context, logicalPath string) (storage.Entry, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return storage.Entry{}, err
|
||||
@@ -464,6 +485,14 @@ func isNotExist(err error) bool {
|
||||
return errors.Is(err, fs.ErrNotExist) || errors.Is(err, os.ErrNotExist) || errors.Is(err, sftp.ErrSSHFxNoSuchFile)
|
||||
}
|
||||
|
||||
func isReplaceRenameFallbackError(err error) bool {
|
||||
if errors.Is(err, sftp.ErrSSHFxFailure) || errors.Is(err, sftp.ErrSSHFxOpUnsupported) {
|
||||
return true
|
||||
}
|
||||
var statusErr *sftp.StatusError
|
||||
return errors.As(err, &statusErr) && (statusErr.FxCode() == sftp.ErrSSHFxFailure || statusErr.FxCode() == sftp.ErrSSHFxOpUnsupported)
|
||||
}
|
||||
|
||||
func (b *Backend) translateError(op, logicalPath string, err error) error {
|
||||
kind := storage.ErrUnknown
|
||||
switch {
|
||||
|
||||
125
internal/adapters/ssh/backend_test.go
Normal file
125
internal/adapters/ssh/backend_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
)
|
||||
|
||||
func TestRenamePromotedFileUsesPlainRenameWithoutOverwrite(t *testing.T) {
|
||||
client := &recordingRenamer{}
|
||||
if err := renamePromotedFile(client, "temp", "index.html", false); err != nil {
|
||||
t.Fatalf("renamePromotedFile() error = %v", err)
|
||||
}
|
||||
if got, want := client.calls, []string{"rename temp index.html"}; !equalStrings(got, want) {
|
||||
t.Fatalf("calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenamePromotedFileUsesPosixRenameForOverwrite(t *testing.T) {
|
||||
client := &recordingRenamer{}
|
||||
if err := renamePromotedFile(client, "temp", "index.html", true); err != nil {
|
||||
t.Fatalf("renamePromotedFile() error = %v", err)
|
||||
}
|
||||
if got, want := client.calls, []string{"posix temp index.html"}; !equalStrings(got, want) {
|
||||
t.Fatalf("calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenamePromotedFileFallsBackWhenReplaceRenameUnsupported(t *testing.T) {
|
||||
for _, err := range []error{
|
||||
sftp.ErrSSHFxOpUnsupported,
|
||||
sftp.ErrSSHFxFailure,
|
||||
&sftp.StatusError{Code: uint32(sftp.ErrSSHFxOpUnsupported)},
|
||||
&sftp.StatusError{Code: uint32(sftp.ErrSSHFxFailure)},
|
||||
} {
|
||||
t.Run(err.Error(), func(t *testing.T) {
|
||||
client := &recordingRenamer{posixErr: err}
|
||||
if err := renamePromotedFile(client, "temp", "index.html", true); err != nil {
|
||||
t.Fatalf("renamePromotedFile() error = %v", err)
|
||||
}
|
||||
want := []string{"posix temp index.html", "remove index.html", "rename temp index.html"}
|
||||
if got := client.calls; !equalStrings(got, want) {
|
||||
t.Fatalf("calls = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenamePromotedFileIgnoresMissingTargetDuringFallback(t *testing.T) {
|
||||
client := &recordingRenamer{
|
||||
posixErr: sftp.ErrSSHFxOpUnsupported,
|
||||
removeErr: &os.PathError{
|
||||
Op: "remove",
|
||||
Path: "index.html",
|
||||
Err: os.ErrNotExist,
|
||||
},
|
||||
}
|
||||
if err := renamePromotedFile(client, "temp", "index.html", true); err != nil {
|
||||
t.Fatalf("renamePromotedFile() error = %v", err)
|
||||
}
|
||||
want := []string{"posix temp index.html", "remove index.html", "rename temp index.html"}
|
||||
if got := client.calls; !equalStrings(got, want) {
|
||||
t.Fatalf("calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenamePromotedFileDoesNotFallbackForPermissionError(t *testing.T) {
|
||||
client := &recordingRenamer{posixErr: sftp.ErrSSHFxPermissionDenied}
|
||||
if err := renamePromotedFile(client, "temp", "index.html", true); !errors.Is(err, sftp.ErrSSHFxPermissionDenied) {
|
||||
t.Fatalf("renamePromotedFile() error = %v, want permission denied", err)
|
||||
}
|
||||
if got, want := client.calls, []string{"posix temp index.html"}; !equalStrings(got, want) {
|
||||
t.Fatalf("calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenamePromotedFileReturnsRemoveFallbackError(t *testing.T) {
|
||||
client := &recordingRenamer{
|
||||
posixErr: sftp.ErrSSHFxOpUnsupported,
|
||||
removeErr: sftp.ErrSSHFxPermissionDenied,
|
||||
}
|
||||
if err := renamePromotedFile(client, "temp", "index.html", true); !errors.Is(err, sftp.ErrSSHFxPermissionDenied) {
|
||||
t.Fatalf("renamePromotedFile() error = %v, want permission denied", err)
|
||||
}
|
||||
want := []string{"posix temp index.html", "remove index.html"}
|
||||
if got := client.calls; !equalStrings(got, want) {
|
||||
t.Fatalf("calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingRenamer struct {
|
||||
calls []string
|
||||
posixErr error
|
||||
renameErr error
|
||||
removeErr error
|
||||
}
|
||||
|
||||
func (r *recordingRenamer) PosixRename(oldname, newname string) error {
|
||||
r.calls = append(r.calls, "posix "+oldname+" "+newname)
|
||||
return r.posixErr
|
||||
}
|
||||
|
||||
func (r *recordingRenamer) Rename(oldname, newname string) error {
|
||||
r.calls = append(r.calls, "rename "+oldname+" "+newname)
|
||||
return r.renameErr
|
||||
}
|
||||
|
||||
func (r *recordingRenamer) Remove(path string) error {
|
||||
r.calls = append(r.calls, "remove "+path)
|
||||
return r.removeErr
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for index := range a {
|
||||
if a[index] != b[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user